diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 9301f33e7c00812f04a386c65abe9731f480f5f3..515e7966cc3c4af35ec5653354928fc9a42c0403 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -410,6 +410,10 @@ def auto_retry_allowed? auto_retry.allowed? end + def exit_code=(value) + ensure_metadata.exit_code = value + end + def auto_retry_expected? failed? && auto_retry_allowed? end @@ -1030,7 +1034,7 @@ def drop_with_exit_code!(failure_reason, exit_code) end def exit_codes_defined? - options.dig(:allow_failure_criteria, :exit_codes).present? + options.dig(:allow_failure_criteria, :exit_codes).present? || options.dig(:retry, :exit_codes).present? end def create_queuing_entry! diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index 3a9b1465682fbd602261c891dcbcb85836351ef6..7ec3210f703ebad06366e8875ed85cbc9a648699 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -194,6 +194,7 @@ class CommitStatus < Ci::ApplicationRecord commit_status.failure_reason = reason.failure_reason_enum commit_status.allow_failure = true if reason.force_allow_failure? + commit_status.exit_code = reason.exit_code if Feature.enabled?(:ci_retry_on_exit_codes, Feature.current_request) end before_transition [:skipped, :manual] => :created do |commit_status, transition| @@ -338,6 +339,9 @@ def stage_name ci_stage&.name end + # Handled only by ci_build + def exit_code=(value); end + # For AiAction def to_ability_name 'build' diff --git a/app/models/concerns/ci/metadatable.rb b/app/models/concerns/ci/metadatable.rb index 3f48df080169dcbed72824a2e96648a6395cbf38..6aef24c45d052776b9911f2ede2fa0614879935d 100644 --- a/app/models/concerns/ci/metadatable.rb +++ b/app/models/concerns/ci/metadatable.rb @@ -24,6 +24,7 @@ module Metadatable delegate :environment_auto_stop_in, to: :metadata, prefix: false, allow_nil: true delegate :set_cancel_gracefully, to: :metadata, prefix: false, allow_nil: false delegate :id_tokens, to: :metadata, allow_nil: true + delegate :exit_code, to: :metadata, allow_nil: true before_validation :ensure_metadata, on: :create diff --git a/db/migrate/20240130201017_add_exit_code_to_builds_metadata.rb b/db/migrate/20240130201017_add_exit_code_to_builds_metadata.rb new file mode 100644 index 0000000000000000000000000000000000000000..d6cb62a1863d41a0bd97b847215caa996ef21923 --- /dev/null +++ b/db/migrate/20240130201017_add_exit_code_to_builds_metadata.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AddExitCodeToBuildsMetadata < Gitlab::Database::Migration[2.2] + enable_lock_retries! + + milestone '16.10' + + def change + add_column :p_ci_builds_metadata, :exit_code, :smallint, null: true + end +end diff --git a/db/schema_migrations/20240130201017 b/db/schema_migrations/20240130201017 new file mode 100644 index 0000000000000000000000000000000000000000..b97d0b3d17f4aa7e070f8cbd59ac054fe9c0a24c --- /dev/null +++ b/db/schema_migrations/20240130201017 @@ -0,0 +1 @@ +580d6e209c374c25af9b2cde9792ed13c6f9a3538845236ea7b7387c4ce44469 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 8fa6662d49c0d53516b334e6e8f3e2f5c7a40111..ba0f8565f21a3b74ca2004e3cd6f9503d715b606 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -885,7 +885,8 @@ CREATE TABLE p_ci_builds_metadata ( runtime_runner_features jsonb DEFAULT '{}'::jsonb NOT NULL, id_tokens jsonb DEFAULT '{}'::jsonb NOT NULL, partition_id bigint NOT NULL, - debug_trace_enabled boolean DEFAULT false NOT NULL + debug_trace_enabled boolean DEFAULT false NOT NULL, + exit_code smallint ) PARTITION BY LIST (partition_id); @@ -5780,7 +5781,8 @@ CREATE TABLE ci_builds_metadata ( runtime_runner_features jsonb DEFAULT '{}'::jsonb NOT NULL, id_tokens jsonb DEFAULT '{}'::jsonb NOT NULL, partition_id bigint NOT NULL, - debug_trace_enabled boolean DEFAULT false NOT NULL + debug_trace_enabled boolean DEFAULT false NOT NULL, + exit_code smallint ); CREATE TABLE ci_builds_runner_session ( diff --git a/lib/gitlab/ci/build/auto_retry.rb b/lib/gitlab/ci/build/auto_retry.rb index 453c293f6cd45c679f1db2fa0081e26ae028c6ca..95469b05f5bae93b5b1755ad3bd54e9978000022 100644 --- a/lib/gitlab/ci/build/auto_retry.rb +++ b/lib/gitlab/ci/build/auto_retry.rb @@ -43,20 +43,31 @@ def max_allowed_retries end def options_retry_max - Integer(options_retry[:max], exception: false) if retry_on_reason_or_always? + Integer(options_retry[:max], exception: false) if retry_on_reason_or_always? || retry_on_exit_code? end def options_retry_when - default = ['always'] + default = options_retry_exit_codes.nil? ? ['always'] : [] options_retry.fetch(:when, default) || default end + def options_retry_exit_codes + options_retry.fetch(:exit_codes, nil) + end + def retry_on_reason_or_always? options_retry_when.include?(failure_reason.to_s) || options_retry_when.include?('always') end + def retry_on_exit_code? + return false unless Feature.enabled?(:ci_retry_on_exit_codes, Feature.current_request) + return false unless @build.exit_code + + options_retry_exit_codes&.include?(@build.exit_code) + end + # The format of the retry option changed in GitLab 11.5: Before it was # integer only, after it is a hash. New builds are created with the new # format, but builds created before GitLab 11.5 and saved in database still diff --git a/spec/lib/gitlab/ci/build/auto_retry_spec.rb b/spec/lib/gitlab/ci/build/auto_retry_spec.rb index 0b275e7d5645267e986a0586593f621d94a90587..15b053c47b0e4b02b0b40ba4ce604001b88ec382 100644 --- a/spec/lib/gitlab/ci/build/auto_retry_spec.rb +++ b/spec/lib/gitlab/ci/build/auto_retry_spec.rb @@ -12,21 +12,36 @@ subject { auto_retry.allowed? } - where(:description, :retry_count, :options, :failure_reason, :result) do - "retries are disabled" | 0 | { max: 0 } | nil | false - "max equals count" | 2 | { max: 2 } | nil | false - "max is higher than count" | 1 | { max: 2 } | nil | true - "max is a string" | 1 | { max: '2' } | nil | true - "matching failure reason" | 0 | { when: %w[api_failure], max: 2 } | :api_failure | true - "not matching with always" | 0 | { when: %w[always], max: 2 } | :api_failure | true - "not matching reason" | 0 | { when: %w[script_error], max: 2 } | :api_failure | false - "scheduler failure override" | 1 | { when: %w[scheduler_failure], max: 1 } | :scheduler_failure | false - "default for scheduler failure" | 1 | {} | :scheduler_failure | true - "quota is exceeded" | 0 | { max: 2 } | :ci_quota_exceeded | false - "no matching runner" | 0 | { max: 2 } | :no_matching_runner | false - "missing dependencies" | 0 | { max: 2 } | :missing_dependency_failure | false - "forward deployment failure" | 0 | { max: 2 } | :forward_deployment_failure | false - "environment creation failure" | 0 | { max: 2 } | :environment_creation_failure | false + where(:description, :retry_count, :options, :failure_reason, :exit_code, :result) do + # retry:max + "retries are disabled" | 0 | { max: 0 } | nil | nil | false + "max equals count" | 2 | { max: 2 } | nil | nil | false + "max is higher than count" | 1 | { max: 2 } | nil | nil | true + "max is a string" | 1 | { max: '2' } | nil | nil | true + # retry:when + "matching failure reason" | 0 | { when: %w[api_failure], max: 2 } | :api_failure | nil | true + "not matching with always" | 0 | { when: %w[always], max: 2 } | :api_failure | nil | true + "not matching reason" | 0 | { when: %w[script_error], max: 2 } | :api_failure | nil | false + "scheduler failure override" | 1 | { when: %w[scheduler_failure], max: 1 } | :scheduler_failure | nil | false + # retry:exit_codes + "matching exit code" | 0 | { exit_codes: [255, 137], max: 2 } | nil | 137 | true + "matching exit code simple" | 0 | { exit_codes: [255], max: 2 } | nil | 255 | true + "not matching exit code" | 0 | { exit_codes: [255], max: 2 } | nil | 1 | false + "exit_code is not an integer" | 0 | { exit_codes: ["137"], max: 2 } | nil | 137 | false + # retry:when && retry:exit_codes + # EC: exit_codes + # FR: failure_reason + "matching EC & FR" | 0 | { exit_codes: [3], when: %w[script_failure], max: 2 } | :script_failure | 3 | true + "matching EC only" | 0 | { exit_codes: [3], when: %w[script_failure], max: 2 } | :api_failure | 3 | true + "matching FR only" | 0 | { exit_codes: [1], when: %w[script_failure], max: 2 } | :script_failure | 137 | true + "not matching EC & FR" | 0 | { exit_codes: [1], when: %w[script_failure], max: 2 } | :api_failure | 137 | false + # other + "default for scheduler failure" | 1 | {} | :scheduler_failure | nil | true + "quota is exceeded" | 0 | { max: 2 } | :ci_quota_exceeded | nil | false + "no matching runner" | 0 | { max: 2 } | :no_matching_runner | nil | false + "missing dependencies" | 0 | { max: 2 } | :missing_dependency_failure | nil | false + "forward deployment failure" | 0 | { max: 2 } | :forward_deployment_failure | nil | false + "environment creation failure" | 0 | { max: 2 } | :environment_creation_failure | nil | false end with_them do @@ -35,12 +50,44 @@ build.options[:retry] = options build.failure_reason = failure_reason + build.exit_code = exit_code allow(build).to receive(:retryable?).and_return(true) end it { is_expected.to eq(result) } end + context 'when ci_retry_on_exit_codes feature flag is disabled' do + where(:description, :retry_count, :options, :failure_reason, :exit_code, :result) do + "matching exit code" | 0 | { exit_codes: [255, 137], max: 2 } | nil | 137 | false + "not matching exit code" | 0 | { exit_codes: [255], max: 2 } | nil | 1 | false + "exit_code is not an integer" | 0 | { exit_codes: ["137"], max: 2 } | nil | 137 | false + # retry:when && retry:exit_codes + # EC: exit_codes + # FR: failure_reason + "matching EC & FR" | 0 | { exit_codes: [3], when: %w[script_failure], max: 2 } | :script_failure | 3 | true + "matching EC only" | 0 | { exit_codes: [3], when: %w[script_failure], max: 2 } | :api_failure | 3 | false + "matching FR only" | 0 | { exit_codes: [1], when: %w[script_failure], max: 2 } | :script_failure | 137 | true + "not matching EC & FR" | 0 | { exit_codes: [1], when: %w[script_failure], max: 2 } | :api_failure | 137 | false + end + + with_them do + before do + # shouldn't retry on matching exit code + stub_feature_flags(ci_retry_on_exit_codes: false) + + allow(build).to receive(:retries_count) { retry_count } + + build.options[:retry] = options + build.failure_reason = failure_reason + build.metadata.exit_code = exit_code + allow(build).to receive(:retryable?).and_return(true) + end + + it { is_expected.to eq(result) } + end + end + context 'when build is not retryable' do before do allow(build).to receive(:retryable?).and_return(false) diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index 8e21a1f7cb5b782c72981ecb31c95b7b42df45b7..7d1199dfc258e957a87e9b474594f1d2b2733e39 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -5075,6 +5075,11 @@ def run_job_without_exception build.drop_with_exit_code!(:unknown_failure, exit_code) end + it 'correctly sets the exit code' do + expect { drop_with_exit_code } + .to change { build.reload.metadata&.exit_code }.from(nil).to(1) + end + shared_examples 'drops the build without changing allow_failure' do it 'does not change allow_failure' do expect { drop_with_exit_code } @@ -5175,38 +5180,93 @@ def run_job_without_exception build.exit_codes_defined? end - context 'without allow_failure_criteria' do + context 'without allow_failure_criteria nor retry' do it { is_expected.to be_falsey } end - context 'when exit_codes is nil' do - let(:options) do - { - allow_failure_criteria: { - exit_codes: nil + context 'with allow_failure_critera' do + context 'when exit_codes is nil' do + let(:options) do + { + allow_failure_criteria: { + exit_codes: nil + } } - } + end + + it { is_expected.to be_falsey } end - it { is_expected.to be_falsey } + context 'when exit_codes is an empty array' do + let(:options) do + { + allow_failure_criteria: { + exit_codes: [] + } + } + end + + it { is_expected.to be_falsey } + end + + context 'when exit_codes are defined' do + let(:options) do + { + allow_failure_criteria: { + exit_codes: [5, 6] + } + } + end + + it { is_expected.to be_truthy } + end end - context 'when exit_codes is an empty array' do - let(:options) do - { - allow_failure_criteria: { - exit_codes: [] + context 'with retry' do + context 'when exit_codes is nil' do + let(:options) do + { + retry: { + exit_codes: nil + } } - } + end + + it { is_expected.to be_falsey } end - it { is_expected.to be_falsey } + context 'when exit_codes is an empty array' do + let(:options) do + { + retry: { + exit_codes: [] + } + } + end + + it { is_expected.to be_falsey } + end + + context 'when exit_codes are defined' do + let(:options) do + { + retry: { + exit_codes: [5, 6] + } + } + end + + it { is_expected.to be_truthy } + end end - context 'when exit_codes are defined' do + context "with exit_codes defined for retry and allow_failure_criteria" do let(:options) do { allow_failure_criteria: { + exit_codes: [1] + }, + retry: { exit_codes: [5, 6] } }