diff --git a/config/feature_flags/gitlab_com_derisk/pipeline_execution_policy_empty_pipeline_behavior.yml b/config/feature_flags/gitlab_com_derisk/pipeline_execution_policy_empty_pipeline_behavior.yml new file mode 100644 index 0000000000000000000000000000000000000000..e41fdbe95ecc6adc344b33ec3d8c82b7b627326d --- /dev/null +++ b/config/feature_flags/gitlab_com_derisk/pipeline_execution_policy_empty_pipeline_behavior.yml @@ -0,0 +1,10 @@ +--- +name: pipeline_execution_policy_empty_pipeline_behavior +description: Enable configurable empty_pipeline_behavior option for Pipeline Execution Policies +feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/work_items/582196 +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/214258 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/work_items/583127 +milestone: "18.8" +group: group::security policies +type: gitlab_com_derisk +default_enabled: false diff --git a/ee/app/models/security/pipeline_execution_policy/config.rb b/ee/app/models/security/pipeline_execution_policy/config.rb index c75b704c17b094a5fd1789b9eba98fd8e516ba0b..6962a904526b340f0f70146c7425e964593d78d4 100644 --- a/ee/app/models/security/pipeline_execution_policy/config.rb +++ b/ee/app/models/security/pipeline_execution_policy/config.rb @@ -10,10 +10,11 @@ class Config DEFAULT_SUFFIX_STRATEGY = 'on_conflict' SUFFIX_STRATEGIES = { on_conflict: 'on_conflict', never: 'never' }.freeze DEFAULT_SKIP_CI_STRATEGY = { allowed: false }.freeze + DEFAULT_APPLY_ON_EMPTY_PIPELINE = 'always' POLICY_JOB_SUFFIX = ':policy' attr_reader :content, :config_strategy, :suffix_strategy, :policy_project_id, :policy_index, :name, - :skip_ci_strategy, :variables_override_strategy, :policy_config, :policy_sha + :skip_ci_strategy, :variables_override_strategy, :policy_config, :policy_sha, :apply_on_empty_pipeline delegate :experiment_enabled?, to: :policy_config @@ -22,7 +23,7 @@ def initialize(policy:, policy_config:, policy_index:) @policy_config = policy_config @policy_project_id = policy_config.security_policy_management_project_id @policy_index = policy_index - @config_strategy = policy.fetch(:pipeline_config_strategy).to_sym + parse_pipeline_config_strategy(policy.fetch(:pipeline_config_strategy)) @suffix_strategy = policy[:suffix] || DEFAULT_SUFFIX_STRATEGY @name = policy.fetch(:name) @skip_ci_strategy = policy[:skip_ci].presence || DEFAULT_SKIP_CI_STRATEGY @@ -55,6 +56,20 @@ def suffix_on_conflict? def skip_ci_allowed?(user_id) skip_ci_allowed_for_strategy?(skip_ci_strategy, user_id) end + + private + + def parse_pipeline_config_strategy(strategy_config) + if strategy_config.is_a?(Hash) + @config_strategy = strategy_config.fetch(:type).to_sym + @apply_on_empty_pipeline = strategy_config.fetch( + :apply_on_empty_pipeline, DEFAULT_APPLY_ON_EMPTY_PIPELINE + ).to_s + else + @config_strategy = strategy_config.to_sym + @apply_on_empty_pipeline = DEFAULT_APPLY_ON_EMPTY_PIPELINE + end + end end end end diff --git a/ee/app/validators/json_schemas/pipeline_execution_policy_content.json b/ee/app/validators/json_schemas/pipeline_execution_policy_content.json index 22b34c2c82a4030b3c282f04122b03b82b2957c6..7348b187da2d1e355d34fcf779c2fd9234c1931c 100644 --- a/ee/app/validators/json_schemas/pipeline_execution_policy_content.json +++ b/ee/app/validators/json_schemas/pipeline_execution_policy_content.json @@ -38,12 +38,44 @@ "additionalProperties": false }, "pipeline_config_strategy": { - "description": "Defines the method for merging the policy configuration with the project pipeline. `inject_ci` preserves the project CI configuration and injects additional jobs from the policy. Having multiple policies enabled injects all jobs additively. `inject_policy` behaves like `inject_ci`, but allows custom policy stages to be injected too. `override_project_ci` replaces the project CI configuration and keeps only the policy jobs in the pipeline.", - "type": "string", - "enum": [ - "inject_ci", - "inject_policy", - "override_project_ci" + "description": "Defines the method for merging the policy configuration with the project pipeline. Can be a string or an object with type and apply_on_empty_pipeline options.", + "oneOf": [ + { + "type": "string", + "enum": [ + "inject_ci", + "inject_policy", + "override_project_ci" + ], + "description": "Simple string format for backward compatibility. `inject_ci` preserves the project CI configuration and injects additional jobs from the policy. Having multiple policies enabled injects all jobs additively. `inject_policy` behaves like `inject_ci`, but allows custom policy stages to be injected too. `override_project_ci` replaces the project CI configuration and keeps only the policy jobs in the pipeline." + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "inject_ci", + "inject_policy", + "override_project_ci" + ], + "description": "The strategy type for merging policy configuration." + }, + "apply_on_empty_pipeline": { + "type": "string", + "enum": [ + "always", + "if_no_config", + "never" + ], + "description": "Controls when policies apply to empty pipelines. `always`: Always applies (current default). `if_no_config`: Only applies when project has no CI config. `never`: Never applies a fallback pipeline." + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } ] }, "suffix": { diff --git a/ee/app/validators/json_schemas/security_orchestration_policy.json b/ee/app/validators/json_schemas/security_orchestration_policy.json index 785013f42d4405bbb4eebc93159aeaa6383c1a0c..ddb961a03fb3bdde2c35f7e12dd0328a6d2d460a 100644 --- a/ee/app/validators/json_schemas/security_orchestration_policy.json +++ b/ee/app/validators/json_schemas/security_orchestration_policy.json @@ -279,12 +279,44 @@ "$ref": "#/$defs/pipeline_execution_content" }, "pipeline_config_strategy": { - "description": "Defines the method for merging the policy configuration with the project pipeline. `inject_ci` preserves the project CI configuration and injects additional jobs from the policy. Having multiple policies enabled injects all jobs additively. `inject_policy` behaves like `inject_ci`, but allows custom policy stages to be injected too. `override_project_ci` replaces the project CI configuration and keeps only the policy jobs in the pipeline.", - "type": "string", - "enum": [ - "inject_ci", - "inject_policy", - "override_project_ci" + "description": "Defines the method for merging the policy configuration with the project pipeline. Can be a string or an object with type and apply_on_empty_pipeline options.", + "oneOf": [ + { + "type": "string", + "enum": [ + "inject_ci", + "inject_policy", + "override_project_ci" + ], + "description": "Simple string format for backward compatibility. `inject_ci` preserves the project CI configuration and injects additional jobs from the policy. Having multiple policies enabled injects all jobs additively. `inject_policy` behaves like `inject_ci`, but allows custom policy stages to be injected too. `override_project_ci` replaces the project CI configuration and keeps only the policy jobs in the pipeline." + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "inject_ci", + "inject_policy", + "override_project_ci" + ], + "description": "The strategy type for merging policy configuration." + }, + "apply_on_empty_pipeline": { + "type": "string", + "enum": [ + "always", + "if_no_config", + "never" + ], + "description": "Controls when policies apply to empty pipelines. `always`: Always applies (current default). `if_no_config`: Only applies when project has no CI config. `never`: Never applies a fallback pipeline." + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } ] }, "suffix": { diff --git a/ee/lib/ee/gitlab/ci/pipeline/chain/evaluate_workflow_rules.rb b/ee/lib/ee/gitlab/ci/pipeline/chain/evaluate_workflow_rules.rb index 298c16ce84fbf5e6a2df57bcb216676f0f3d4d3f..6eccbc4c9000745e018c33a140c07f01909cad5f 100644 --- a/ee/lib/ee/gitlab/ci/pipeline/chain/evaluate_workflow_rules.rb +++ b/ee/lib/ee/gitlab/ci/pipeline/chain/evaluate_workflow_rules.rb @@ -12,7 +12,7 @@ module EvaluateWorkflowRules override :force_pipeline_creation_to_continue? def force_pipeline_creation_to_continue? - command.pipeline_policy_context.pipeline_execution_context.has_execution_policy_pipelines? + command.pipeline_policy_context.pipeline_execution_context.force_pipeline_creation?(pipeline) end end end diff --git a/ee/lib/ee/gitlab/ci/pipeline/chain/pipeline_execution_policies/apply_policies.rb b/ee/lib/ee/gitlab/ci/pipeline/chain/pipeline_execution_policies/apply_policies.rb index e891b820b99be9b8e910a988505c4079287e1ba3..0a963a324f937b64a9a2b6b740652424d21fdaf8 100644 --- a/ee/lib/ee/gitlab/ci/pipeline/chain/pipeline_execution_policies/apply_policies.rb +++ b/ee/lib/ee/gitlab/ci/pipeline/chain/pipeline_execution_policies/apply_policies.rb @@ -19,6 +19,7 @@ module Chain module PipelineExecutionPolicies module ApplyPolicies include ::Gitlab::Ci::Pipeline::Chain::Helpers + include ::Gitlab::Utils::StrongMemoize def perform! policy_context = command.pipeline_policy_context.pipeline_execution_context @@ -59,7 +60,7 @@ def clear_project_pipeline end def merge_policy_jobs - command.pipeline_policy_context.pipeline_execution_context.policy_pipelines.each do |policy| + applicable_policy_pipelines.each do |policy| # Return `nil` is equivalent to "never" otherwise provide the new name. on_conflict = ->(job_name) { job_name + policy.suffix if policy.suffix_on_conflict? } @@ -79,16 +80,28 @@ def merge_policy_jobs end end + def applicable_policy_pipelines + policy_context = command.pipeline_policy_context.pipeline_execution_context + + # If the pipeline has jobs from project CI config, all policies apply. + # Otherwise, filter policies based on their apply_on_empty_pipeline setting. + return policy_context.policy_pipelines unless command.pipeline_creation_forced_to_continue + + policy_context.empty_pipeline_applicable_policy_pipelines(pipeline) + end + strong_memoize_attr :applicable_policy_pipelines + def declared_stages command.yaml_processor_result.stages end def usage_tracking - @usage_tracking ||= ::Security::PipelineExecutionPolicy::UsageTracking.new( + ::Security::PipelineExecutionPolicy::UsageTracking.new( project: project, - policy_pipelines: command.pipeline_policy_context.pipeline_execution_context.policy_pipelines + policy_pipelines: applicable_policy_pipelines ) end + strong_memoize_attr :usage_tracking end end end diff --git a/ee/lib/ee/gitlab/ci/pipeline/chain/populate.rb b/ee/lib/ee/gitlab/ci/pipeline/chain/populate.rb new file mode 100644 index 0000000000000000000000000000000000000000..70b695529d06c901d8123fddfb7a20f4fda4277d --- /dev/null +++ b/ee/lib/ee/gitlab/ci/pipeline/chain/populate.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module EE + module Gitlab + module Ci + module Pipeline + module Chain + module Populate + extend ::Gitlab::Utils::Override + + private + + override :force_pipeline_creation_to_continue? + def force_pipeline_creation_to_continue? + # If there are security policy pipelines that force pipeline creation, + # they will be merged onto the pipeline in PipelineExecutionPolicies::ApplyPolicies + super || command.pipeline_policy_context + .pipeline_execution_context + .force_pipeline_creation?(pipeline) + end + end + end + end + end + end +end diff --git a/ee/lib/gitlab/ci/pipeline/pipeline_execution_policies/pipeline_context.rb b/ee/lib/gitlab/ci/pipeline/pipeline_execution_policies/pipeline_context.rb index 6cdec159a07f14bf13e7d3b13bfa88512cbffe46..52770c1c111f2dbe3ea1496b85694aea311be040 100644 --- a/ee/lib/gitlab/ci/pipeline/pipeline_execution_policies/pipeline_context.rb +++ b/ee/lib/gitlab/ci/pipeline/pipeline_execution_policies/pipeline_context.rb @@ -15,6 +15,10 @@ class PipelineContext all_pipelines: :gitlab_security_policies_pipeline_execution_policy_build_policy_pipelines }.freeze + APPLY_ON_EMPTY_PIPELINE_ALWAYS = 'always' + APPLY_ON_EMPTY_PIPELINE_IF_NO_CONFIG = 'if_no_config' + APPLY_ON_EMPTY_PIPELINE_NEVER = 'never' + attr_reader :policy_pipelines, :override_policy_stages, :injected_policy_stages # rubocop:disable Metrics/ParameterLists -- Explicit parameters needed to replace command object delegation @@ -79,6 +83,30 @@ def scheduled_execution_policy_pipeline? source == ::Security::PipelineExecutionPolicies::RunScheduleWorker::PIPELINE_SOURCE end + def force_pipeline_creation?(pipeline) + return false unless has_execution_policy_pipelines? + + strong_memoize_with(:force_pipeline_creation, pipeline) do + break true unless Feature.enabled?(:pipeline_execution_policy_empty_pipeline_behavior, + pipeline.project) + + !empty_pipeline_applicable_policy_pipelines(pipeline).empty? + end + end + + # Returns the policy pipelines that should apply based on their apply_on_empty_pipeline setting. + # This method assumes the pipeline is already determined to be "empty" (no jobs from project CI). + def empty_pipeline_applicable_policy_pipelines(pipeline) + strong_memoize_with(:empty_pipeline_applicable_policy_pipelines, pipeline) do + break policy_pipelines if Feature.disabled?(:pipeline_execution_policy_empty_pipeline_behavior, + pipeline.project) + + policy_pipelines.select do |policy_pipeline| + should_apply_to_empty_pipeline?(policy_pipeline, pipeline) + end + end + end + def skip_ci_allowed? return true unless has_execution_policy_pipelines? @@ -257,6 +285,32 @@ def stages_compatible?(stages, target_stages) stages == target_stages & stages end + def should_apply_to_empty_pipeline?(policy_pipeline, pipeline) + apply_on_empty_pipeline = policy_apply_on_empty_pipeline(policy_pipeline) + + case apply_on_empty_pipeline + when APPLY_ON_EMPTY_PIPELINE_ALWAYS + true + when APPLY_ON_EMPTY_PIPELINE_NEVER + false + when APPLY_ON_EMPTY_PIPELINE_IF_NO_CONFIG + # Only apply if we're using the fallback config source (no CI config found) + pipeline.pipeline_execution_policy_forced? && ( + # For projects with no CI config we prefer MR pipelines over branch to avoid duplicates + pipeline.merge_request? || (pipeline.branch? && pipeline.open_merge_requests_refs.empty?) + ) + else + # Unknown behavior or experiment not enabled defaults to always apply + true + end + end + + def policy_apply_on_empty_pipeline(policy_pipeline) + return unless policy_pipeline.policy_config.experiment_enabled?(:apply_on_empty_pipeline_option) + + policy_pipeline.policy_config.apply_on_empty_pipeline + end + delegate :measure, to: ::Security::SecurityOrchestrationPolicies::ObserveHistogramsService end end diff --git a/ee/spec/factories/security/pipeline_execution_policy_configs.rb b/ee/spec/factories/security/pipeline_execution_policy_configs.rb index 812e678b1ed448247182cdbdb1c623e00b8185fc..5e9d61aa958c1da953d0c355831aa7d59228119a 100644 --- a/ee/spec/factories/security/pipeline_execution_policy_configs.rb +++ b/ee/spec/factories/security/pipeline_execution_policy_configs.rb @@ -5,15 +5,41 @@ :pipeline_execution_policy_config, class: '::Security::PipelineExecutionPolicy::Config' ) do - policy factory: :pipeline_execution_policy - policy_config factory: :security_orchestration_policy_configuration, security_policy_management_project_id: 123456 policy_index { 0 } + transient do + apply_on_empty_pipeline { nil } + end + + policy do |evaluator| + base_policy = association(:pipeline_execution_policy) + if evaluator.apply_on_empty_pipeline + strategy = base_policy[:pipeline_config_strategy] + strategy_type = strategy.is_a?(Hash) ? strategy[:type] : strategy + base_policy[:pipeline_config_strategy] = { + type: strategy_type, + apply_on_empty_pipeline: evaluator.apply_on_empty_pipeline.to_s + } + end + + base_policy + end + + policy_config do |evaluator| + config = association(:security_orchestration_policy_configuration, security_policy_management_project_id: 123456) + if evaluator.apply_on_empty_pipeline + config.experiments = { 'apply_on_empty_pipeline_option' => { 'enabled' => true } } + end + + config + end + skip_create initialize_with do policy = attributes[:policy] policy[:content] = attributes[:content] if attributes[:content].present? policy_config = attributes[:policy_config] + allow(policy_config).to receive(:configuration_sha).and_return(attributes[:policy_sha] || 'policy_sha') new(policy: policy, policy_config: policy_config, policy_index: attributes[:policy_index]) end @@ -45,5 +71,20 @@ trait :variables_override_disallowed do policy factory: [:pipeline_execution_policy, :variables_override_disallowed] end + + trait :apply_on_empty_pipeline_always do + policy factory: [:pipeline_execution_policy, :apply_on_empty_pipeline_always] + policy_config factory: [:security_orchestration_policy_configuration, :with_apply_on_empty_pipeline_experiment] + end + + trait :apply_on_empty_pipeline_if_no_config do + policy factory: [:pipeline_execution_policy, :apply_on_empty_pipeline_if_no_config] + policy_config factory: [:security_orchestration_policy_configuration, :with_apply_on_empty_pipeline_experiment] + end + + trait :apply_on_empty_pipeline_never do + policy factory: [:pipeline_execution_policy, :apply_on_empty_pipeline_never] + policy_config factory: [:security_orchestration_policy_configuration, :with_apply_on_empty_pipeline_experiment] + end end end diff --git a/ee/spec/factories/security/pipeline_execution_policy_pipeline.rb b/ee/spec/factories/security/pipeline_execution_policy_pipeline.rb index 3fda9348ecf699ce18f00e454f3893e56877090f..2ee27692ae80371d421f1b7a081531cc825e22b4 100644 --- a/ee/spec/factories/security/pipeline_execution_policy_pipeline.rb +++ b/ee/spec/factories/security/pipeline_execution_policy_pipeline.rb @@ -8,31 +8,63 @@ class: '::Security::PipelineExecutionPolicy::Pipeline' ) do pipeline factory: :ci_empty_pipeline - policy_config factory: :pipeline_execution_policy_config + + transient do + job_script { nil } + apply_on_empty_pipeline { nil } + end + + policy_config do |evaluator| + if evaluator.apply_on_empty_pipeline + association(:pipeline_execution_policy_config, apply_on_empty_pipeline: evaluator.apply_on_empty_pipeline) + else + association(:pipeline_execution_policy_config) + end + end skip_create initialize_with do - new(**attributes) + new(**attributes.except(:apply_on_empty_pipeline)) end trait :override_project_ci do - policy_config factory: [:pipeline_execution_policy_config, :override_project_ci] + policy_config do |evaluator| + association(:pipeline_execution_policy_config, :override_project_ci, + apply_on_empty_pipeline: evaluator.apply_on_empty_pipeline) + end end trait :suffix_never do - policy_config factory: [:pipeline_execution_policy_config, :suffix_never] + policy_config do |evaluator| + association(:pipeline_execution_policy_config, :suffix_never, + apply_on_empty_pipeline: evaluator.apply_on_empty_pipeline) + end end trait :skip_ci_allowed do - policy_config factory: [:pipeline_execution_policy_config, :skip_ci_allowed] + policy_config do |evaluator| + association(:pipeline_execution_policy_config, :skip_ci_allowed, + apply_on_empty_pipeline: evaluator.apply_on_empty_pipeline) + end end trait :skip_ci_disallowed do - policy_config factory: [:pipeline_execution_policy_config, :skip_ci_disallowed] + policy_config do |evaluator| + association(:pipeline_execution_policy_config, :skip_ci_disallowed, + apply_on_empty_pipeline: evaluator.apply_on_empty_pipeline) + end end - transient do - job_script { nil } + trait :apply_on_empty_pipeline_always do + apply_on_empty_pipeline { 'always' } + end + + trait :apply_on_empty_pipeline_if_no_config do + apply_on_empty_pipeline { 'if_no_config' } + end + + trait :apply_on_empty_pipeline_never do + apply_on_empty_pipeline { 'never' } end after(:build) do |instance, evaluator| diff --git a/ee/spec/factories/security/policies.rb b/ee/spec/factories/security/policies.rb index 18ba09efd47658567a4630e507b1a95e3b0202b2..cb7cf8a4f01b97dd927c9b8dfb619d0df10eb015 100644 --- a/ee/spec/factories/security/policies.rb +++ b/ee/spec/factories/security/policies.rb @@ -326,6 +326,10 @@ skip_ci { { allowed: false } } variables_override { nil } + transient do + apply_on_empty_pipeline { nil } + end + trait :override_project_ci do pipeline_config_strategy { 'override_project_ci' } end @@ -334,6 +338,32 @@ pipeline_config_strategy { 'inject_policy' } end + trait :inject_ci do + pipeline_config_strategy { 'inject_ci' } + end + + trait :apply_on_empty_pipeline_always do + apply_on_empty_pipeline { 'always' } + end + + trait :apply_on_empty_pipeline_if_no_config do + apply_on_empty_pipeline { 'if_no_config' } + end + + trait :apply_on_empty_pipeline_never do + apply_on_empty_pipeline { 'never' } + end + + after(:build) do |policy, evaluator| + if evaluator.apply_on_empty_pipeline + strategy_type = policy[:pipeline_config_strategy] + policy[:pipeline_config_strategy] = { + type: strategy_type, + apply_on_empty_pipeline: evaluator.apply_on_empty_pipeline + } + end + end + trait :suffix_on_conflict do suffix { 'on_conflict' } end diff --git a/ee/spec/factories/security_orchestration_policy_configurations.rb b/ee/spec/factories/security_orchestration_policy_configurations.rb index 5fb4673fdbbfe322b5d9b29d3cfa2df9d689d10c..71150dab4edd7e180648f0e15dc0d9bbc83c34dc 100644 --- a/ee/spec/factories/security_orchestration_policy_configurations.rb +++ b/ee/spec/factories/security_orchestration_policy_configurations.rb @@ -10,5 +10,9 @@ project { nil } namespace end + + trait :with_apply_on_empty_pipeline_experiment do + experiments { { apply_on_empty_pipeline_option: { enabled: true } } } + end end end diff --git a/ee/spec/lib/ee/gitlab/ci/pipeline/chain/evaluate_workflow_rules_spec.rb b/ee/spec/lib/ee/gitlab/ci/pipeline/chain/evaluate_workflow_rules_spec.rb index e845017bf43ed326d8727ad1314695ca1f7ec6fe..bd4dfec24f746855db4d4b536d0c8c4863ff6c9f 100644 --- a/ee/spec/lib/ee/gitlab/ci/pipeline/chain/evaluate_workflow_rules_spec.rb +++ b/ee/spec/lib/ee/gitlab/ci/pipeline/chain/evaluate_workflow_rules_spec.rb @@ -103,19 +103,70 @@ end context 'with execution_policy_pipelines' do + let(:pipeline_execution_context) do + instance_double(Gitlab::Ci::Pipeline::PipelineExecutionPolicies::PipelineContext) + end + before do allow(command) - .to receive_message_chain(:pipeline_policy_context, :pipeline_execution_context, - :has_execution_policy_pipelines?).and_return(true) - step.perform! + .to receive_message_chain(:pipeline_policy_context, :pipeline_execution_context) + .and_return(pipeline_execution_context) end - it_behaves_like 'pipeline not skipped' + context 'when force_pipeline_creation? returns true' do + before do + allow(pipeline_execution_context).to receive(:force_pipeline_creation?).with(pipeline).and_return(true) + end - it 'clears the jobs from the main pipeline in the yaml_processor_result' do - expect(yaml_processor_result).to receive(:clear_jobs!) + it_behaves_like 'pipeline not skipped' do + before do + step.perform! + end + end - step.perform! + it 'clears the jobs from the main pipeline in the yaml_processor_result' do + expect(yaml_processor_result).to receive(:clear_jobs!) + + step.perform! + end + + it 'sets pipeline_creation_forced_to_continue flag on command' do + step.perform! + + expect(command.pipeline_creation_forced_to_continue).to be(true) + end + end + + context 'when force_pipeline_creation? returns false' do + before do + allow(pipeline_execution_context).to receive(:force_pipeline_creation?).with(pipeline).and_return(false) + step.perform! + end + + it 'does not save the pipeline' do + expect(pipeline).not_to be_persisted + end + + it 'breaks the chain' do + expect(step.break?).to be true + end + + it 'attaches an error to the pipeline' do + expect(pipeline.errors[:base]).to include(sanitize_message(Ci::Pipeline.workflow_rules_failure_message)) + end + + it 'saves workflow_rules_result' do + expect(command.workflow_rules_result.variables).to eq(workflow_rules_variables) + end + + it 'sets the failure reason', :aggregate_failures do + expect(pipeline).to be_failed + expect(pipeline).to be_filtered_by_workflow_rules + end + + it 'does not set pipeline_creation_forced_to_continue flag on command' do + expect(command.pipeline_creation_forced_to_continue).to be_nil + end end end end diff --git a/ee/spec/lib/ee/gitlab/ci/pipeline/chain/pipeline_execution_policies/apply_policies_spec.rb b/ee/spec/lib/ee/gitlab/ci/pipeline/chain/pipeline_execution_policies/apply_policies_spec.rb index e4adf86b27abd4d300f50fc66efe7eb5bffe365e..2fa608d54e755d36b1b2fd3696e738528e076955 100644 --- a/ee/spec/lib/ee/gitlab/ci/pipeline/chain/pipeline_execution_policies/apply_policies_spec.rb +++ b/ee/spec/lib/ee/gitlab/ci/pipeline/chain/pipeline_execution_policies/apply_policies_spec.rb @@ -424,6 +424,69 @@ end end + context 'when pipeline creation was forced to continue (empty pipeline scenario)' do + let(:config) { nil } + + let(:always_policy_pipeline) do + build(:pipeline_execution_policy_pipeline, :apply_on_empty_pipeline_always, + pipeline: build_mock_policy_pipeline({ 'build' => ['always_job'] })) + end + + let(:never_policy_pipeline) do + build(:pipeline_execution_policy_pipeline, :apply_on_empty_pipeline_never, + pipeline: build_mock_policy_pipeline({ 'test' => ['never_job'] })) + end + + let(:execution_policy_pipelines) { [always_policy_pipeline, never_policy_pipeline] } + + before do + command.pipeline_creation_forced_to_continue = true + allow(command.pipeline_policy_context.pipeline_execution_context) + .to receive(:empty_pipeline_applicable_policy_pipelines) + .with(pipeline) + .and_return([always_policy_pipeline]) + end + + it 'only applies policy pipelines that should apply based on their apply_on_empty_pipeline setting', + :aggregate_failures do + run_chain + + expect(pipeline.stages.map(&:name)).to contain_exactly('build') + + build_stage = pipeline.stages.find { |stage| stage.name == 'build' } + expect(build_stage.statuses.map(&:name)).to contain_exactly('always_job') + end + + it 'tracks only the applicable policy pipelines' do + expect(Security::PipelineExecutionPolicy::UsageTracking) + .to receive(:new) + .with(project: project, policy_pipelines: [always_policy_pipeline]) + .and_call_original + + run_chain + end + end + + context 'when pipeline creation was not forced (non-empty pipeline scenario)' do + let(:execution_policy_pipelines) do + [ + build(:pipeline_execution_policy_pipeline, :apply_on_empty_pipeline_never, + pipeline: build_mock_policy_pipeline({ 'build' => ['policy_job'] })) + ] + end + + before do + command.pipeline_creation_forced_to_continue = false + end + + it 'applies all policy pipelines regardless of apply_on_empty_pipeline setting', :aggregate_failures do + run_chain + + build_stage = pipeline.stages.find { |stage| stage.name == 'build' } + expect(build_stage.statuses.map(&:name)).to include('policy_job') + end + end + context 'when creating_policy_pipeline? is true' do let(:config) do { stages: %w[test policy-test], diff --git a/ee/spec/lib/ee/gitlab/ci/pipeline/chain/populate_spec.rb b/ee/spec/lib/ee/gitlab/ci/pipeline/chain/populate_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..0b1365fa608d13cd554e2048698e9dc2e1b812ee --- /dev/null +++ b/ee/spec/lib/ee/gitlab/ci/pipeline/chain/populate_spec.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::Pipeline::Chain::Populate, feature_category: :pipeline_composition do + include Ci::PipelineMessageHelpers + + let_it_be(:project) { create(:project, :repository) } + let_it_be(:user) { create(:user) } + + let(:pipeline) do + build(:ci_empty_pipeline, project: project, ref: 'master', user: user) + end + + let(:command) do + Gitlab::Ci::Pipeline::Chain::Command.new( + project: project, + current_user: user, + origin_ref: 'master', + pipeline_policy_context: instance_double( + Gitlab::Ci::Pipeline::ExecutionPolicies::PipelineContext, + pipeline_execution_context: instance_double( + Gitlab::Ci::Pipeline::PipelineExecutionPolicies::PipelineContext, + valid_stage?: true, + applying_config_override?: false, + has_execution_policy_pipelines?: true, + job_options: {}, + creating_policy_pipeline?: true, + force_pipeline_creation?: force_pipeline_creation + ), + job_options: {} + ), + seeds_block: nil + ) + end + + let(:force_pipeline_creation) { true } + + let(:dependencies) do + [ + Gitlab::Ci::Pipeline::Chain::Config::Content.new(pipeline, command), + Gitlab::Ci::Pipeline::Chain::Config::Process.new(pipeline, command), + Gitlab::Ci::Pipeline::Chain::EvaluateWorkflowRules.new(pipeline, command), + Gitlab::Ci::Pipeline::Chain::SeedBlock.new(pipeline, command), + Gitlab::Ci::Pipeline::Chain::Seed.new(pipeline, command) + ] + end + + let(:step) do + result = described_class.new(pipeline, command) + + dependencies.each do |dependency| + dependency.perform! + + # Sanity check. All the dependencies are required for the step object to be valid + raise "Dependency #{dependency.class} failed with #{pipeline.errors.full_messages}" if dependency.break? + end + + result + end + + let(:config) do + { rspec: { + script: 'ls', + only: ['something'] + } } + end + + before do + stub_ci_pipeline_yaml_file(config.to_yaml) + + allow(command.pipeline_policy_context.pipeline_execution_context).to( + receive(:enforce_stages!) { |config:| config } + ) + end + + context 'when pipeline is empty and there are policy pipelines' do + it 'does not break the chain' do + step.perform! + + expect(step.break?).to be false + end + + it 'does not append an error' do + step.perform! + + expect(pipeline.errors).to be_empty + end + + it 'sets pipeline_creation_forced_to_continue flag on command' do + step.perform! + + expect(command.pipeline_creation_forced_to_continue).to be(true) + end + + context 'when execution is not forced' do + let(:force_pipeline_creation) { false } + + it 'breaks the chain' do + step.perform! + + expect(step.break?).to be true + end + + it 'appends an error about no stages/jobs' do + step.perform! + + expect(pipeline.errors.to_a) + .to include sanitize_message(::Ci::Pipeline.rules_failure_message) + end + + it 'does not set pipeline_creation_forced_to_continue flag on command' do + step.perform! + + expect(command.pipeline_creation_forced_to_continue).to be_nil + end + end + end +end diff --git a/ee/spec/lib/gitlab/ci/pipeline/chain/populate_spec.rb b/ee/spec/lib/gitlab/ci/pipeline/chain/populate_spec.rb deleted file mode 100644 index 8ce752bf29a10843f0a4a671ac416efec48d1d6d..0000000000000000000000000000000000000000 --- a/ee/spec/lib/gitlab/ci/pipeline/chain/populate_spec.rb +++ /dev/null @@ -1,58 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Gitlab::Ci::Pipeline::Chain::Populate, feature_category: :pipeline_composition do - let_it_be(:project) { create(:project, :repository) } - let_it_be(:user) { create(:user) } - - let(:pipeline) do - build(:ci_pipeline, project: project, ref: 'master', user: user) - end - - let(:command) do - Gitlab::Ci::Pipeline::Chain::Command.new( - project: project, - current_user: user, - origin_ref: 'master', - seeds_block: nil) - end - - let(:step) { described_class.new(pipeline, command) } - - before do - stub_ci_pipeline_yaml_file(YAML.dump(config)) - end - - context 'when pipeline is empty and there are policy_pipelines' do - let(:command) do - Gitlab::Ci::Pipeline::Chain::Command.new( - project: project, - current_user: user, - origin_ref: 'master', - pipeline_policy_context: instance_double( - Gitlab::Ci::Pipeline::ExecutionPolicies::PipelineContext, - pipeline_execution_context: instance_double( - Gitlab::Ci::Pipeline::PipelineExecutionPolicies::PipelineContext, - policy_pipelines: [build(:pipeline_execution_policy_pipeline)] - ) - ), - seeds_block: nil) - end - - let(:config) do - { rspec: { - script: 'ls', - only: ['something'] - } } - end - - it 'does not break the chain' do - expect(step.break?).to be false - end - - it 'does not append an error' do - expect(pipeline.errors).to be_empty - end - end -end diff --git a/ee/spec/lib/gitlab/ci/pipeline/pipeline_execution_policies/pipeline_context_spec.rb b/ee/spec/lib/gitlab/ci/pipeline/pipeline_execution_policies/pipeline_context_spec.rb index 1692ebb7f97c98052e06d78c47bf7d74f5ecf26f..9f0aa9b07fde40e5ad6e5e9dbf3ac1f22e0eb0c1 100644 --- a/ee/spec/lib/gitlab/ci/pipeline/pipeline_execution_policies/pipeline_context_spec.rb +++ b/ee/spec/lib/gitlab/ci/pipeline/pipeline_execution_policies/pipeline_context_spec.rb @@ -671,6 +671,282 @@ end end + describe '#force_pipeline_creation?' do + subject(:force_creation) { context.force_pipeline_creation?(pipeline) } + + include_context 'with mocked policy_pipelines' + + it { is_expected.to eq(false) } + + context 'with policy_pipelines' do + context 'when feature flag is disabled' do + let(:policy_pipelines) { build_list(:pipeline_execution_policy_pipeline, 1, :apply_on_empty_pipeline_never) } + + before do + stub_feature_flags(pipeline_execution_policy_empty_pipeline_behavior: false) + end + + it 'always forces pipeline creation (default behavior)' do + expect(force_creation).to eq(true) + end + end + + context 'when feature flag is enabled' do + context 'when apply_on_empty_pipeline is an unexpected value' do + let(:policy_pipelines) do + build_list(:pipeline_execution_policy_pipeline, 1, apply_on_empty_pipeline: :something_invalid) + end + + it { is_expected.to eq(true) } + end + + context 'when apply_on_empty_pipeline is "always"' do + let(:policy_pipelines) { build_list(:pipeline_execution_policy_pipeline, 1, :apply_on_empty_pipeline_always) } + + it { is_expected.to eq(true) } + end + + context 'when apply_on_empty_pipeline is "never"' do + let(:policy_pipelines) { build_list(:pipeline_execution_policy_pipeline, 1, :apply_on_empty_pipeline_never) } + + it { is_expected.to eq(false) } + end + + context 'when apply_on_empty_pipeline is "if_no_config"' do + let(:policy_pipelines) do + build_list(:pipeline_execution_policy_pipeline, 1, :apply_on_empty_pipeline_if_no_config) + end + + context 'when project has no CI config (pipeline has no stages)' do + before do + allow(pipeline).to receive_messages(stages: [], pipeline_execution_policy_forced?: true) + end + + context 'when pipeline is a merge request pipeline' do + before do + allow(pipeline).to receive_messages(merge_request?: true, branch?: false) + end + + it 'forces pipeline creation' do + expect(force_creation).to eq(true) + end + end + + context 'when pipeline is a branch pipeline' do + before do + allow(pipeline).to receive_messages(merge_request?: false, branch?: true) + end + + context 'when there are no open merge requests for the branch' do + before do + allow(pipeline).to receive(:open_merge_requests_refs).and_return([]) + end + + it 'forces pipeline creation' do + expect(force_creation).to eq(true) + end + end + + context 'when there are open merge requests for the branch' do + before do + allow(pipeline).to receive(:open_merge_requests_refs).and_return(['refs/merge-requests/1/head']) + end + + it 'does not force pipeline creation to avoid duplicates' do + expect(force_creation).to eq(false) + end + end + end + end + + context 'when pipeline is not using fallback config source' do + before do + allow(pipeline).to receive_messages( + stages: [], + pipeline_execution_policy_forced?: false + ) + end + + it 'does not force pipeline creation' do + expect(force_creation).to eq(false) + end + end + end + + context 'when multiple policies have different apply_on_empty_pipeline settings' do + let(:policy_pipelines) do + [ + build(:pipeline_execution_policy_pipeline, :apply_on_empty_pipeline_always), + build(:pipeline_execution_policy_pipeline, :apply_on_empty_pipeline_never) + ] + end + + it 'forces creation if any policy applies (always policy applies)' do + expect(force_creation).to eq(true) + end + end + + context 'when policies have if_no_config and always' do + let(:policy_pipelines) do + [ + build(:pipeline_execution_policy_pipeline, :apply_on_empty_pipeline_always), + build(:pipeline_execution_policy_pipeline, :apply_on_empty_pipeline_if_no_config) + ] + end + + it 'forces creation because always policy applies' do + expect(force_creation).to eq(true) + end + end + + context 'when policies have if_no_config and never' do + let(:policy_pipelines) do + [ + build(:pipeline_execution_policy_pipeline, :apply_on_empty_pipeline_if_no_config), + build(:pipeline_execution_policy_pipeline, :apply_on_empty_pipeline_never) + ] + end + + context 'when pipeline has no CI config' do + before do + allow(pipeline).to receive_messages( + stages: [], + pipeline_execution_policy_forced?: true, + merge_request?: true, + branch?: false + ) + end + + it 'forces creation because if_no_config policy applies' do + expect(force_creation).to eq(true) + end + end + + context 'when pipeline has CI config' do + before do + allow(pipeline).to receive_messages( + stages: [], + pipeline_execution_policy_forced?: false + ) + end + + it 'does not force creation because neither policy applies' do + expect(force_creation).to eq(false) + end + end + end + + context 'when all policies have never' do + let(:policy_pipelines) do + [ + build(:pipeline_execution_policy_pipeline, :apply_on_empty_pipeline_never), + build(:pipeline_execution_policy_pipeline, :apply_on_empty_pipeline_never) + ] + end + + it 'does not force pipeline creation' do + expect(force_creation).to eq(false) + end + end + + context 'when experiment is disabled' do + let(:policy_config_without_experiment) do + build(:security_orchestration_policy_configuration) + end + + let(:policy_pipelines) do + [ + build(:pipeline_execution_policy_pipeline, + policy_config: build(:pipeline_execution_policy_config, :apply_on_empty_pipeline_never, + policy_config: policy_config_without_experiment)) + ] + end + + it 'ignores apply_on_empty_pipeline and forces creation (default behavior)' do + expect(force_creation).to eq(true) + end + end + end + end + end + + describe '#empty_pipeline_applicable_policy_pipelines' do + subject(:applicable_pipelines) { context.empty_pipeline_applicable_policy_pipelines(pipeline) } + + include_context 'with mocked policy_pipelines' + + context 'when feature flag is disabled' do + let(:policy_pipelines) { build_list(:pipeline_execution_policy_pipeline, 2, :apply_on_empty_pipeline_never) } + + before do + stub_feature_flags(pipeline_execution_policy_empty_pipeline_behavior: false) + end + + it 'returns all policies (default behavior)' do + expect(applicable_pipelines).to eq(policy_pipelines) + end + end + + context 'when all policies have apply_on_empty_pipeline "always"' do + let(:policy_pipelines) { build_list(:pipeline_execution_policy_pipeline, 2, :apply_on_empty_pipeline_always) } + + it 'returns all policies' do + expect(applicable_pipelines).to eq(policy_pipelines) + end + end + + context 'when policies have mixed apply_on_empty_pipeline settings' do + let(:always_policy) { build(:pipeline_execution_policy_pipeline, :apply_on_empty_pipeline_always) } + let(:never_policy) { build(:pipeline_execution_policy_pipeline, :apply_on_empty_pipeline_never) } + let(:policy_pipelines) { [always_policy, never_policy] } + + it 'returns only the always policy' do + expect(applicable_pipelines).to contain_exactly(always_policy) + end + end + + context 'when all policies have apply_on_empty_pipeline "never"' do + let(:policy_pipelines) { build_list(:pipeline_execution_policy_pipeline, 2, :apply_on_empty_pipeline_never) } + + it 'returns no policies' do + expect(applicable_pipelines).to be_empty + end + end + + context 'when policies have if_no_config and never' do + let(:if_no_config_policy) do + build(:pipeline_execution_policy_pipeline, :apply_on_empty_pipeline_if_no_config) + end + + let(:never_policy) { build(:pipeline_execution_policy_pipeline, :apply_on_empty_pipeline_never) } + let(:policy_pipelines) { [if_no_config_policy, never_policy] } + + context 'when pipeline has no CI config' do + before do + allow(pipeline).to receive_messages( + pipeline_execution_policy_forced?: true, + merge_request?: true, + branch?: false + ) + end + + it 'returns only the if_no_config policy' do + expect(applicable_pipelines).to contain_exactly(if_no_config_policy) + end + end + + context 'when pipeline has CI config' do + before do + allow(pipeline).to receive_messages(pipeline_execution_policy_forced?: false) + end + + it 'returns no policies' do + expect(applicable_pipelines).to be_empty + end + end + end + end + describe '#skip_ci_allowed?' do subject { context.skip_ci_allowed? } diff --git a/ee/spec/models/security/pipeline_execution_policy/config_spec.rb b/ee/spec/models/security/pipeline_execution_policy/config_spec.rb index 70d88eb57e6445b394b4b33a835f0275dea972df..014cbf0b88a62dce5f7b9d3a5f99df446b7deff3 100644 --- a/ee/spec/models/security/pipeline_execution_policy/config_spec.rb +++ b/ee/spec/models/security/pipeline_execution_policy/config_spec.rb @@ -46,6 +46,39 @@ end end + describe '#apply_on_empty_pipeline' do + subject(:apply_on_empty_pipeline_value) { config.apply_on_empty_pipeline } + + context 'with string strategy' do + let(:policy) { build(:pipeline_execution_policy, pipeline_config_strategy: 'inject_ci') } + + it 'defaults to always' do + expect(apply_on_empty_pipeline_value).to eq('always') + end + end + + context 'with object strategy and apply_on_empty_pipeline specified' do + let(:policy) do + build(:pipeline_execution_policy, + pipeline_config_strategy: { type: 'inject_policy', apply_on_empty_pipeline: 'if_no_config' }) + end + + it 'returns the specified apply_on_empty_pipeline value' do + expect(apply_on_empty_pipeline_value).to eq('if_no_config') + end + end + + context 'with object strategy and no apply_on_empty_pipeline specified' do + let(:policy) do + build(:pipeline_execution_policy, pipeline_config_strategy: { type: 'inject_policy' }) + end + + it 'defaults to always' do + expect(apply_on_empty_pipeline_value).to eq('always') + end + end + end + describe '#suffix' do subject { config.suffix } diff --git a/ee/spec/services/ci/create_pipeline_service/pipeline_execution_policy_spec.rb b/ee/spec/services/ci/create_pipeline_service/pipeline_execution_policy_spec.rb index e9b2acd8c130c4c604e1da3de155818e26960074..16cca9df770548108d66eeb89ab49b86fd00e138 100644 --- a/ee/spec/services/ci/create_pipeline_service/pipeline_execution_policy_spec.rb +++ b/ee/spec/services/ci/create_pipeline_service/pipeline_execution_policy_spec.rb @@ -1479,6 +1479,302 @@ end end + # rubocop:disable RSpec/MultipleMemoizedHelpers -- table-driven tests require multiple helpers + describe 'apply_on_empty_pipeline configuration' do + before do + namespace_configuration.update!(experiments: { apply_on_empty_pipeline_option: { enabled: true } }) + project_configuration.update!(experiments: { apply_on_empty_pipeline_option: { enabled: true } }) + end + + def policy_include(file) + { + include: [{ + project: compliance_project.full_path, + file: file, + ref: compliance_project.default_branch_or_main + }] + } + end + + let(:no_ci_config) { nil } + + let(:ci_yml) do + <<~YAML + build: + stage: build + script: + - echo 'build' + YAML + end + + let(:ci_yml_workflow_rules) do + <<~YAML + workflow: + rules: + - if: '$CI_COMMIT_BRANCH == "non-existent-branch"' + build: + stage: build + script: + - echo 'build' + YAML + end + + let(:ci_yml_job_rules) do + <<~YAML + build: + stage: build + rules: + - if: '$CI_COMMIT_BRANCH == "non-existent-branch"' + script: + - echo 'build' + YAML + end + + where(:project_ci_yaml, :strategy, :apply_on_empty_pipeline, :expected_outcome, :expected_builds, + :expected_failure_reason, :expected_config_source) do + forced = 'pipeline_execution_policy_forced' + repo = 'repository_source' + [ + # No CI config - inject_policy strategy (default) + [ref(:no_ci_config), :inject_policy, 'always', :success, 2, nil, forced], + [ref(:no_ci_config), :inject_policy, 'if_no_config', :success, 2, nil, forced], + [ref(:no_ci_config), :inject_policy, 'never', :error, 0, :filtered_by_rules, nil], + # Has CI config - inject_policy strategy (default) + [ref(:ci_yml), :inject_policy, 'always', :success, 3, nil, repo], + [ref(:ci_yml), :inject_policy, 'if_no_config', :success, 3, nil, repo], + [ref(:ci_yml), :inject_policy, 'never', :success, 3, nil, repo], + # Workflow rules filter out pipeline - inject_policy strategy (default) + [ref(:ci_yml_workflow_rules), :inject_policy, 'always', :success, 2, nil, repo], + [ref(:ci_yml_workflow_rules), :inject_policy, 'if_no_config', :error, 0, :filtered_by_workflow_rules, + nil], + [ref(:ci_yml_workflow_rules), :inject_policy, 'never', :error, 0, :filtered_by_workflow_rules, nil], + # Job rules filter out all jobs - inject_policy strategy (default) + [ref(:ci_yml_job_rules), :inject_policy, 'always', :success, 2, nil, repo], + [ref(:ci_yml_job_rules), :inject_policy, 'if_no_config', :error, 0, :filtered_by_rules, nil], + [ref(:ci_yml_job_rules), :inject_policy, 'never', :error, 0, :filtered_by_rules, nil], + # No CI config - override_project_ci strategy + [ref(:no_ci_config), :override_project_ci, 'always', :success, 2, nil, forced], + [ref(:no_ci_config), :override_project_ci, 'if_no_config', :success, 2, nil, forced], + [ref(:no_ci_config), :override_project_ci, 'never', :error, 0, :filtered_by_rules, nil], + # Has CI config - override_project_ci strategy + [ref(:ci_yml), :override_project_ci, 'always', :success, 2, nil, forced], + [ref(:ci_yml), :override_project_ci, 'if_no_config', :success, 2, nil, forced], + [ref(:ci_yml), :override_project_ci, 'never', :error, 0, :filtered_by_rules, nil] + ] + end + + with_them do + let(:behaviour_trait) { behaviour } + + let(:namespace_policy) do + build(:pipeline_execution_policy, strategy, apply_on_empty_pipeline: apply_on_empty_pipeline, + content: policy_include(namespace_policy_file)) + end + + let(:project_policy) do + build(:pipeline_execution_policy, strategy, apply_on_empty_pipeline: apply_on_empty_pipeline, + content: policy_include(project_policy_file)) + end + + if params[:expected_outcome] == :success + it 'creates pipeline', :aggregate_failures do + expect { execute }.to change { Ci::Build.count }.from(0).to(expected_builds) + expect(execute).to be_success + expect(execute.payload).to be_persisted + expect(execute.payload.config_source).to eq(expected_config_source) + end + else + it 'does not create pipeline', :aggregate_failures do + expect { execute }.not_to change { Ci::Build.count } + expect(execute).to be_error + expect(execute.payload).not_to be_persisted + expect(execute.payload.failure_reason).to eq(expected_failure_reason.to_s) + end + end + end + + describe 'if_no_config by pipeline source' do + let(:project_ci_yaml) { nil } + + let(:namespace_policy) do + build(:pipeline_execution_policy, :apply_on_empty_pipeline_if_no_config, + content: policy_include(namespace_policy_file)) + end + + let(:project_policy) do + build(:pipeline_execution_policy, :apply_on_empty_pipeline_if_no_config, + content: policy_include(project_policy_file)) + end + + let(:namespace_policy_content) do + { + workflow: { rules: [{ when: 'always' }] }, + namespace_policy_job: { stage: 'build', script: 'namespace script' } + } + end + + let(:project_policy_content) do + { + workflow: { rules: [{ when: 'always' }] }, + project_policy_job: { stage: 'build', script: 'project script' } + } + end + + context 'when branch pipeline' do + context 'with an open MR' do + let(:params) { { ref: 'feature' } } + + before do + create(:merge_request, source_project: project, source_branch: 'feature', target_branch: 'master') + end + + it 'does not create branch pipeline to avoid duplicate with MR pipeline', :aggregate_failures do + expect { execute }.not_to change { Ci::Build.count } + + expect(execute).to be_error + expect(execute.payload).not_to be_persisted + end + end + + context 'without open MR' do + let(:params) { { ref: 'feature' } } + + it 'creates branch pipeline when no open MR exists', :aggregate_failures do + expect { execute }.to change { Ci::Build.count }.from(0).to(2) + + expect(execute).to be_success + expect(execute.payload).to be_persisted + expect(execute.payload.config_source).to eq('pipeline_execution_policy_forced') + end + end + end + + context 'when merge request pipeline' do + let(:source) { :merge_request_event } + + let(:merge_request) do + create(:merge_request, source_project: project, target_project: project, + source_branch: 'feature', target_branch: 'master') + end + + let(:opts) { { merge_request: merge_request } } + let(:params) do + { ref: merge_request.ref_path, + source_sha: merge_request.source_branch_sha, + target_sha: merge_request.target_branch_sha, + checkout_sha: merge_request.diff_head_sha } + end + + before do + stub_licensed_features(security_orchestration_policies: true) + end + + it 'creates MR pipeline for project without CI config', :aggregate_failures do + expect { execute }.to change { Ci::Build.count }.from(0).to(2) + + expect(execute).to be_success + expect(execute.payload).to be_persisted + expect(execute.payload.merge_request).to eq(merge_request) + expect(execute.payload.config_source).to eq('pipeline_execution_policy_forced') + end + end + end + + context 'with mixed behaviours across policies' do + let(:project_ci_yaml) { nil } + + let(:namespace_policy) do + build(:pipeline_execution_policy, :apply_on_empty_pipeline_always, + content: policy_include(namespace_policy_file)) + end + + let(:project_policy) do + build(:pipeline_execution_policy, :apply_on_empty_pipeline_never, + content: policy_include(project_policy_file)) + end + + it 'applies only policies that should apply to empty pipeline (always policy only)', :aggregate_failures do + expect { execute }.to change { Ci::Build.count }.from(0).to(1) + + expect(execute).to be_success + expect(execute.payload).to be_persisted + + stages = execute.payload.stages + expect(stages.find_by(name: 'build').builds.map(&:name)).to contain_exactly('namespace_policy_job') + end + end + + context 'when feature flag is disabled' do + before do + stub_feature_flags(pipeline_execution_policy_empty_pipeline_behavior: false) + end + + let(:project_ci_yaml) { nil } + + let(:namespace_policy) do + build(:pipeline_execution_policy, :apply_on_empty_pipeline_never, + content: { include: [{ + project: compliance_project.full_path, + file: namespace_policy_file, + ref: compliance_project.default_branch_or_main + }] }) + end + + let(:project_policy) do + build(:pipeline_execution_policy, :apply_on_empty_pipeline_never, + content: { include: [{ + project: compliance_project.full_path, + file: project_policy_file, + ref: compliance_project.default_branch_or_main + }] }) + end + + it 'ignores apply_on_empty_pipeline and uses default always behaviour', :aggregate_failures do + expect { execute }.to change { Ci::Build.count }.from(0).to(2) + + expect(execute).to be_success + expect(execute.payload).to be_persisted + expect(execute.payload.config_source).to eq('pipeline_execution_policy_forced') + end + end + + context 'when experiment is disabled' do + before do + namespace_configuration.update!(experiments: {}) + project_configuration.update!(experiments: {}) + end + + let(:project_ci_yaml) { nil } + + let(:namespace_policy) do + build(:pipeline_execution_policy, :apply_on_empty_pipeline_never, + content: { include: [{ + project: compliance_project.full_path, + file: namespace_policy_file, + ref: compliance_project.default_branch_or_main + }] }) + end + + let(:project_policy) do + build(:pipeline_execution_policy, :apply_on_empty_pipeline_never, + content: { include: [{ + project: compliance_project.full_path, + file: project_policy_file, + ref: compliance_project.default_branch_or_main + }] }) + end + + it 'ignores apply_on_empty_pipeline and uses default always behaviour', :aggregate_failures do + expect { execute }.to change { Ci::Build.count }.from(0).to(2) + + expect(execute).to be_success + expect(execute.payload).to be_persisted + expect(execute.payload.config_source).to eq('pipeline_execution_policy_forced') + end + end + end + # rubocop:enable RSpec/MultipleMemoizedHelpers + private def get_job_variable(job, key) diff --git a/ee/spec/validators/json_schemas/pipeline_execution_policy_content_spec.rb b/ee/spec/validators/json_schemas/pipeline_execution_policy_content_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..ee3d4528ee901cfc7daa87e29db4882896b1cf7e --- /dev/null +++ b/ee/spec/validators/json_schemas/pipeline_execution_policy_content_spec.rb @@ -0,0 +1,196 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' + +RSpec.describe 'pipeline_execution_policy_content.json', feature_category: :security_policy_management do + let(:schema_path) do + Rails.root.join("ee/app/validators/json_schemas/pipeline_execution_policy_content.json") + end + + let(:schema) { JSONSchemer.schema(schema_path) } + let(:base_policy) do + { + content: { include: [{ project: "compliance-project", file: "compliance-pipeline.yml" }] } + } + end + + describe 'pipeline_config_strategy' do + context 'with string format' do + context 'with valid strategy' do + %w[inject_ci inject_policy override_project_ci].each do |strategy| + it "accepts #{strategy}" do + policy = base_policy.merge(pipeline_config_strategy: strategy) + expect(schema.valid?(policy)).to be true + end + end + end + + context 'with invalid strategy' do + it 'rejects unknown strategy' do + policy = base_policy.merge(pipeline_config_strategy: 'invalid_strategy') + expect(schema.valid?(policy)).to be false + end + end + end + + context 'with object format' do + context 'with valid type and apply_on_empty_pipeline' do + it 'accepts inject_policy with apply_on_empty_pipeline always' do + policy = base_policy.merge( + pipeline_config_strategy: { type: 'inject_policy', apply_on_empty_pipeline: 'always' } + ) + expect(schema.valid?(policy)).to be true + end + + it 'accepts inject_policy with apply_on_empty_pipeline if_no_config' do + policy = base_policy.merge( + pipeline_config_strategy: { type: 'inject_policy', apply_on_empty_pipeline: 'if_no_config' } + ) + expect(schema.valid?(policy)).to be true + end + + it 'accepts inject_policy with apply_on_empty_pipeline never' do + policy = base_policy.merge( + pipeline_config_strategy: { type: 'inject_policy', apply_on_empty_pipeline: 'never' } + ) + expect(schema.valid?(policy)).to be true + end + + it 'accepts inject_ci with apply_on_empty_pipeline' do + policy = base_policy.merge( + pipeline_config_strategy: { type: 'inject_ci', apply_on_empty_pipeline: 'if_no_config' } + ) + expect(schema.valid?(policy)).to be true + end + + it 'accepts override_project_ci with apply_on_empty_pipeline' do + policy = base_policy.merge( + pipeline_config_strategy: { type: 'override_project_ci', apply_on_empty_pipeline: 'never' } + ) + expect(schema.valid?(policy)).to be true + end + end + + context 'with type only (no apply_on_empty_pipeline)' do + it 'accepts type without apply_on_empty_pipeline' do + policy = base_policy.merge( + pipeline_config_strategy: { type: 'inject_policy' } + ) + expect(schema.valid?(policy)).to be true + end + end + + context 'with invalid values' do + it 'rejects invalid type' do + policy = base_policy.merge( + pipeline_config_strategy: { type: 'invalid_type', apply_on_empty_pipeline: 'always' } + ) + expect(schema.valid?(policy)).to be false + end + + it 'rejects invalid apply_on_empty_pipeline' do + policy = base_policy.merge( + pipeline_config_strategy: { type: 'inject_policy', apply_on_empty_pipeline: 'invalid_behaviour' } + ) + expect(schema.valid?(policy)).to be false + end + + it 'rejects object without type' do + policy = base_policy.merge( + pipeline_config_strategy: { apply_on_empty_pipeline: 'always' } + ) + expect(schema.valid?(policy)).to be false + end + + it 'rejects object with additional properties' do + policy = base_policy.merge( + pipeline_config_strategy: { + type: 'inject_policy', apply_on_empty_pipeline: 'always', extra: 'value' + } + ) + expect(schema.valid?(policy)).to be false + end + end + end + end + + describe 'suffix' do + context 'with valid values' do + it 'accepts on_conflict' do + policy = base_policy.merge(pipeline_config_strategy: 'inject_ci', suffix: 'on_conflict') + expect(schema.valid?(policy)).to be true + end + + it 'accepts never' do + policy = base_policy.merge(pipeline_config_strategy: 'inject_ci', suffix: 'never') + expect(schema.valid?(policy)).to be true + end + + it 'accepts null' do + policy = base_policy.merge(pipeline_config_strategy: 'inject_ci', suffix: nil) + expect(schema.valid?(policy)).to be true + end + end + + context 'with invalid values' do + it 'rejects invalid suffix' do + policy = base_policy.merge(pipeline_config_strategy: 'inject_ci', suffix: 'invalid') + expect(schema.valid?(policy)).to be false + end + end + end + + describe 'skip_ci' do + context 'with valid configuration' do + it 'accepts allowed: true' do + policy = base_policy.merge(pipeline_config_strategy: 'inject_ci', skip_ci: { allowed: true }) + expect(schema.valid?(policy)).to be true + end + + it 'accepts allowed: false with allowlist' do + policy = base_policy.merge( + pipeline_config_strategy: 'inject_ci', + skip_ci: { allowed: false, allowlist: { users: [{ id: 123 }] } } + ) + expect(schema.valid?(policy)).to be true + end + end + + context 'with invalid configuration' do + it 'rejects skip_ci without allowed' do + policy = base_policy.merge(pipeline_config_strategy: 'inject_ci', skip_ci: { allowlist: {} }) + expect(schema.valid?(policy)).to be false + end + end + end + + describe 'variables_override' do + context 'with valid configuration' do + it 'accepts allowed: true' do + policy = base_policy.merge( + pipeline_config_strategy: 'inject_ci', + variables_override: { allowed: true } + ) + expect(schema.valid?(policy)).to be true + end + + it 'accepts allowed: false with exceptions' do + policy = base_policy.merge( + pipeline_config_strategy: 'inject_ci', + variables_override: { allowed: false, exceptions: %w[VAR1 VAR2] } + ) + expect(schema.valid?(policy)).to be true + end + end + + context 'with invalid configuration' do + it 'rejects variables_override without allowed' do + policy = base_policy.merge( + pipeline_config_strategy: 'inject_ci', + variables_override: { exceptions: %w[VAR1] } + ) + expect(schema.valid?(policy)).to be false + end + end + end +end diff --git a/lib/gitlab/ci/pipeline/chain/command.rb b/lib/gitlab/ci/pipeline/chain/command.rb index 9ec28f2e7b482d7adb627837e027a3b7d48ec6e3..5fae32ef4923df32bc6a834f267e17678e2dfbf6 100644 --- a/lib/gitlab/ci/pipeline/chain/command.rb +++ b/lib/gitlab/ci/pipeline/chain/command.rb @@ -13,7 +13,7 @@ module Chain :chat_data, :mirror_update, :bridge, :content, :dry_run, :linting, :logger, :pipeline_policy_context, # These attributes are set by Chains during processing: :config_content, :yaml_processor_result, :workflow_rules_result, :pipeline_seed, - :pipeline_config, :partition_id, :inputs, :gitaly_context, + :pipeline_config, :partition_id, :inputs, :gitaly_context, :pipeline_creation_forced_to_continue, keyword_init: true ) do include Gitlab::Utils::StrongMemoize diff --git a/lib/gitlab/ci/pipeline/chain/evaluate_workflow_rules.rb b/lib/gitlab/ci/pipeline/chain/evaluate_workflow_rules.rb index 8897339092ef57e5064a9a998208c1c0a9a75fd6..f9994d53d731987241c44be9af8c921a3f0f43dd 100644 --- a/lib/gitlab/ci/pipeline/chain/evaluate_workflow_rules.rb +++ b/lib/gitlab/ci/pipeline/chain/evaluate_workflow_rules.rb @@ -23,6 +23,7 @@ def perform! # Example: With Pipeline Execution Policies we want to inject policy # jobs even if the project pipeline is filtered out by workflow:rules. @command.yaml_processor_result&.clear_jobs! + @command.pipeline_creation_forced_to_continue = true return end diff --git a/lib/gitlab/ci/pipeline/chain/populate.rb b/lib/gitlab/ci/pipeline/chain/populate.rb index c7425199dbde31ca3f4f177f9aa60605d2d3d243..01d23cc8d53322ebb74ebb63001fd21e72cd373e 100644 --- a/lib/gitlab/ci/pipeline/chain/populate.rb +++ b/lib/gitlab/ci/pipeline/chain/populate.rb @@ -18,10 +18,14 @@ def perform! pipeline.stages = @command.pipeline_seed.stages if no_pipeline_to_create? - return error( - ::Ci::Pipeline.rules_failure_message, - failure_reason: :filtered_by_rules - ) + if force_pipeline_creation_to_continue? + @command.pipeline_creation_forced_to_continue = true + else + return error( + ::Ci::Pipeline.rules_failure_message, + failure_reason: :filtered_by_rules + ) + end end return error('Failed to build the pipeline!') if pipeline.invalid? @@ -36,13 +40,7 @@ def break? private def no_pipeline_to_create? - # If there are security policy pipelines, - # they will be merged onto the pipeline in PipelineExecutionPolicies::ApplyPolicies - stage_names.empty? && !has_execution_policy_pipelines? - end - - def has_execution_policy_pipelines? - @command.pipeline_policy_context&.pipeline_execution_context&.has_execution_policy_pipelines? + stage_names.empty? end def stage_names @@ -51,8 +49,15 @@ def stage_names # https://gitlab.com/gitlab-org/gitlab/issues/198518 pipeline.stages.map(&:name) - ::Gitlab::Ci::Config::Stages::EDGES end + + # Overridden in EE + def force_pipeline_creation_to_continue? + false + end end end end end end + +Gitlab::Ci::Pipeline::Chain::Populate.prepend_mod