diff --git a/app/finders/ci/jobs_finder.rb b/app/finders/ci/jobs_finder.rb index b35637d0e4f16b96c66d10bdfd1c340baba3b5ef..efacd8143bc88af543c392851ca716abe1d528cc 100644 --- a/app/finders/ci/jobs_finder.rb +++ b/app/finders/ci/jobs_finder.rb @@ -16,9 +16,7 @@ def initialize(current_user:, pipeline: nil, project: nil, runner: nil, params: def execute builds = init_collection.order_id_desc - builds = filter_by_with_artifacts(builds) - builds = filter_by_runner_types(builds) - filter_by_scope(builds) + filter_builds(builds) rescue Gitlab::Access::AccessDeniedError type.none end @@ -59,6 +57,13 @@ def pipeline_jobs params[:include_retried] ? jobs_scope : jobs_scope.latest end + # Overriden in EE + def filter_builds(builds) + builds = filter_by_with_artifacts(builds) + builds = filter_by_runner_types(builds) + filter_by_scope(builds) + end + def filter_by_scope(builds) return filter_by_statuses!(builds) if params[:scope].is_a?(Array) @@ -80,16 +85,15 @@ def filter_by_runner_types(builds) builds.with_runner_type(params[:runner_type]) end + # Overriden in EE def use_runner_type_filter? params[:runner_type].present? && Feature.enabled?(:admin_jobs_filter_runner_type, project, type: :ops) end def filter_by_with_artifacts(builds) - if params[:with_artifacts] - builds.with_any_artifacts - else - builds - end + return builds.with_any_artifacts if params[:with_artifacts] + + builds end def filter_by_statuses!(builds) @@ -111,3 +115,5 @@ def jobs_by_type(relation, type) end end end + +Ci::JobsFinder.prepend_mod diff --git a/app/graphql/resolvers/ci/all_jobs_resolver.rb b/app/graphql/resolvers/ci/all_jobs_resolver.rb index 85b0f8da8772e660e028bf4ef5e3ebdd8313ecf8..3012a7defa63f94065d495d74ba2b0610655a9b9 100644 --- a/app/graphql/resolvers/ci/all_jobs_resolver.rb +++ b/app/graphql/resolvers/ci/all_jobs_resolver.rb @@ -17,15 +17,18 @@ class AllJobsResolver < BaseResolver description: 'Filter jobs by runner type if ' \ 'feature flag `:admin_jobs_filter_runner_type` is enabled.' - def resolve_with_lookahead(statuses: nil, runner_types: nil) - jobs = ::Ci::JobsFinder.new(current_user: current_user, -params: { scope: statuses, runner_type: runner_types }).execute + def resolve_with_lookahead(**args) + jobs = ::Ci::JobsFinder.new(current_user: current_user, params: params_data(args)).execute apply_lookahead(jobs) end private + def params_data(args) + { scope: args[:statuses], runner_type: args[:runner_types] } + end + def preloads { previous_stage_jobs_or_needs: [:needs, :pipeline], @@ -55,3 +58,5 @@ def nested_preloads end end end + +Resolvers::Ci::AllJobsResolver.prepend_mod diff --git a/app/graphql/types/ci/job_failure_reason_enum.rb b/app/graphql/types/ci/job_failure_reason_enum.rb new file mode 100644 index 0000000000000000000000000000000000000000..3b9c13536d68db26fc952ee733f6edd57bd273c7 --- /dev/null +++ b/app/graphql/types/ci/job_failure_reason_enum.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module Ci + class JobFailureReasonEnum < BaseEnum + graphql_name 'CiJobFailureReason' + + ::Enums::Ci::CommitStatus.failure_reasons.each_key do |reason| + value reason.to_s.upcase, + description: "A job that failed due to #{reason.to_s.tr('_', ' ')}.", + value: reason + end + end + end +end diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index fe88e2e4129ca13184960a7d13ee993ed695da0d..2f88921757505207dec74c0503698cf70cfe24bd 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -462,6 +462,7 @@ four standard [pagination arguments](#connection-pagination-arguments): | Name | Type | Description | | ---- | ---- | ----------- | +| `failureReason` **{warning-solid}** | [`CiJobFailureReason`](#cijobfailurereason) | **Introduced** in 16.4. This feature is an Experiment. It can be changed or removed at any time. Filter jobs by failure reason. Currently only `RUNNER_SYSTEM_FAILURE` together with `runnerTypes: INSTANCE_TYPE` is supported. | | `runnerTypes` **{warning-solid}** | [`[CiRunnerType!]`](#cirunnertype) | **Introduced** in 16.4. This feature is an Experiment. It can be changed or removed at any time. Filter jobs by runner type if feature flag `:admin_jobs_filter_runner_type` is enabled. | | `statuses` | [`[CiJobStatus!]`](#cijobstatus) | Filter jobs by status. | @@ -26259,6 +26260,47 @@ Values for sorting inherited variables. | `KEY_ASC` | Key by ascending order. | | `KEY_DESC` | Key by descending order. | +### `CiJobFailureReason` + +| Value | Description | +| ----- | ----------- | +| `API_FAILURE` | A job that failed due to api failure. | +| `ARCHIVED_FAILURE` | A job that failed due to archived failure. | +| `BRIDGE_PIPELINE_IS_CHILD_PIPELINE` | A job that failed due to bridge pipeline is child pipeline. | +| `BUILDS_DISABLED` | A job that failed due to builds disabled. | +| `CI_QUOTA_EXCEEDED` | A job that failed due to ci quota exceeded. | +| `DATA_INTEGRITY_FAILURE` | A job that failed due to data integrity failure. | +| `DEPLOYMENT_REJECTED` | A job that failed due to deployment rejected. | +| `DOWNSTREAM_BRIDGE_PROJECT_NOT_FOUND` | A job that failed due to downstream bridge project not found. | +| `DOWNSTREAM_PIPELINE_CREATION_FAILED` | A job that failed due to downstream pipeline creation failed. | +| `ENVIRONMENT_CREATION_FAILURE` | A job that failed due to environment creation failure. | +| `FAILED_OUTDATED_DEPLOYMENT_JOB` | A job that failed due to failed outdated deployment job. | +| `FORWARD_DEPLOYMENT_FAILURE` | A job that failed due to forward deployment failure. | +| `INSUFFICIENT_BRIDGE_PERMISSIONS` | A job that failed due to insufficient bridge permissions. | +| `INSUFFICIENT_UPSTREAM_PERMISSIONS` | A job that failed due to insufficient upstream permissions. | +| `INVALID_BRIDGE_TRIGGER` | A job that failed due to invalid bridge trigger. | +| `IP_RESTRICTION_FAILURE` | A job that failed due to ip restriction failure. | +| `JOB_EXECUTION_TIMEOUT` | A job that failed due to job execution timeout. | +| `MISSING_DEPENDENCY_FAILURE` | A job that failed due to missing dependency failure. | +| `NO_MATCHING_RUNNER` | A job that failed due to no matching runner. | +| `PIPELINE_LOOP_DETECTED` | A job that failed due to pipeline loop detected. | +| `PROJECT_DELETED` | A job that failed due to project deleted. | +| `PROTECTED_ENVIRONMENT_FAILURE` | A job that failed due to protected environment failure. | +| `REACHED_MAX_DESCENDANT_PIPELINES_DEPTH` | A job that failed due to reached max descendant pipelines depth. | +| `REACHED_MAX_PIPELINE_HIERARCHY_SIZE` | A job that failed due to reached max pipeline hierarchy size. | +| `RUNNER_SYSTEM_FAILURE` | A job that failed due to runner system failure. | +| `RUNNER_UNSUPPORTED` | A job that failed due to runner unsupported. | +| `SCHEDULER_FAILURE` | A job that failed due to scheduler failure. | +| `SCRIPT_FAILURE` | A job that failed due to script failure. | +| `SECRETS_PROVIDER_NOT_FOUND` | A job that failed due to secrets provider not found. | +| `STALE_SCHEDULE` | A job that failed due to stale schedule. | +| `STUCK_OR_TIMEOUT_FAILURE` | A job that failed due to stuck or timeout failure. | +| `TRACE_SIZE_EXCEEDED` | A job that failed due to trace size exceeded. | +| `UNKNOWN_FAILURE` | A job that failed due to unknown failure. | +| `UNMET_PREREQUISITES` | A job that failed due to unmet prerequisites. | +| `UPSTREAM_BRIDGE_PROJECT_NOT_FOUND` | A job that failed due to upstream bridge project not found. | +| `USER_BLOCKED` | A job that failed due to user blocked. | + ### `CiJobKind` | Value | Description | diff --git a/ee/app/finders/ee/ci/jobs_finder.rb b/ee/app/finders/ee/ci/jobs_finder.rb new file mode 100644 index 0000000000000000000000000000000000000000..377bdc82adee1019922384cf061578e0860472c6 --- /dev/null +++ b/ee/app/finders/ee/ci/jobs_finder.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module EE + module Ci + module JobsFinder + extend ::Gitlab::Utils::Override + + private + + override :filter_builds + def filter_builds(builds) + filter_by_failure_reason(super) + end + + override :use_runner_type_filter? + def use_runner_type_filter? + # we don't need to use the runner_type scope if we know that the user only cares about instance runners + # with a job failure reason + return false if use_failure_reason_filter? + + super + end + + def filter_by_failure_reason(builds) + return builds unless use_failure_reason_filter? + + builds.recently_failed_on_instance_runner(params[:failure_reason]) # currently limited to instance runners + end + + def use_failure_reason_filter? + failure_reason = params[:failure_reason] + runner_type = params[:runner_type] + + if failure_reason.present? + unless failure_reason == :runner_system_failure + raise ArgumentError, 'failure_reason only supports runner_system_failure' + end + + unless runner_type == %w[instance_type] + raise ArgumentError, 'failure_reason can only be used together with runner_type: instance_type' + end + + return true + end + + false + end + end + end +end diff --git a/ee/app/graphql/ee/resolvers/ci/all_jobs_resolver.rb b/ee/app/graphql/ee/resolvers/ci/all_jobs_resolver.rb new file mode 100644 index 0000000000000000000000000000000000000000..09694a7e938de4ccb0f7b82ac26a3ee946b0c81d --- /dev/null +++ b/ee/app/graphql/ee/resolvers/ci/all_jobs_resolver.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module EE + module Resolvers + module Ci + module AllJobsResolver + extend ActiveSupport::Concern + extend ::Gitlab::Utils::Override + + prepended do + argument :failure_reason, ::Types::Ci::JobFailureReasonEnum, + required: false, + alpha: { milestone: '16.4' }, + description: 'Filter jobs by failure reason. Currently only `RUNNER_SYSTEM_FAILURE` together with ' \ + '`runnerTypes: INSTANCE_TYPE` is supported.' + end + + override :resolve_with_lookahead + def resolve_with_lookahead(**args) + super + rescue ArgumentError => e + raise ::Gitlab::Graphql::Errors::ArgumentError, e.message + end + + private + + override :params_data + def params_data(args) + super.merge(failure_reason: args[:failure_reason]) + end + end + end + end +end diff --git a/ee/app/models/ci/instance_runner_failed_jobs.rb b/ee/app/models/ci/instance_runner_failed_jobs.rb index b7e8c6a4fd6cae9e93d04d161abd41490c00a478..ab3fd09a7688d1575ab4981e8c02c95f6ad68675 100644 --- a/ee/app/models/ci/instance_runner_failed_jobs.rb +++ b/ee/app/models/ci/instance_runner_failed_jobs.rb @@ -5,6 +5,7 @@ class InstanceRunnerFailedJobs # Safety margin for situations where there is a mismatch between the async insert order and the finished_at value JOB_LIMIT_MARGIN = 10 JOB_LIMIT = 100 + SUPPORTED_FAILURE_REASONS = %i[runner_system_failure].freeze class << self def track(build) @@ -18,15 +19,23 @@ def track(build) end end - def recent_jobs + def recent_jobs(failure_reason:) + unless SUPPORTED_FAILURE_REASONS.include?(failure_reason.to_sym) + raise ArgumentError, "The only failure reason(s) supported are #{SUPPORTED_FAILURE_REASONS.join(', ')}" + end + return Ci::Build.none unless License.feature_available?(:runner_performance_insights) job_ids = with_redis do |redis| - # Fetch a few more jobs in case there is a mismatch between the async insert order and the finished_at value + # Fetch a few more jobs in case there is a mismatch between the async insert order and + # the finished_at value redis.lrange(key, 0, max_admissible_job_count - 1) end - Ci::Build.id_in(job_ids).order(finished_at: :desc, id: :desc).limit(JOB_LIMIT) + Ci::Build.id_in(job_ids) + .where(failure_reason: failure_reason) + .reorder(finished_at: :desc, id: :desc) + .limit(JOB_LIMIT) end private @@ -45,7 +54,7 @@ def max_admissible_job_count def track_job?(build) License.feature_available?(:runner_performance_insights) && - build.failure_reason == 'runner_system_failure' && + SUPPORTED_FAILURE_REASONS.include?(build.failure_reason.to_sym) && build.runner.instance_type? end end diff --git a/ee/app/models/ee/ci/build.rb b/ee/app/models/ee/ci/build.rb index be8690467c4854b0daad65dddac0bc7a1aabb5db..32131bc1e3b726893f0c5708910fd0eb9c937a22 100644 --- a/ee/app/models/ee/ci/build.rb +++ b/ee/app/models/ee/ci/build.rb @@ -52,6 +52,10 @@ module Build .for_project_paths(project_path) end + scope :recently_failed_on_instance_runner, -> (failure_reason) do + merge(::Ci::InstanceRunnerFailedJobs.recent_jobs(failure_reason: failure_reason)) + end + state_machine :status do after_transition any => [:success, :failed, :canceled] do |build| build.run_after_commit do diff --git a/ee/spec/finders/ee/ci/jobs_finder_spec.rb b/ee/spec/finders/ee/ci/jobs_finder_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..c4d0307c5f352b2bcdf45e90e561fd2c5a108d7d --- /dev/null +++ b/ee/spec/finders/ee/ci/jobs_finder_spec.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::JobsFinder, '#execute', feature_category: :continuous_integration do + let_it_be(:admin) { create(:user, :admin) } + + let(:params) { {} } + + context 'when project, pipeline, and runner are blank' do + subject(:finder_execute) { described_class.new(current_user: current_user, params: params).execute } + + context 'when current user is an admin' do + let(:current_user) { admin } + + context 'when admin mode is enabled', :enable_admin_mode do + let_it_be(:instance_runner) { create(:ci_runner, :instance) } + let_it_be(:group_runner) { create(:ci_runner, :group) } + let_it_be(:project_runner) { create(:ci_runner, :project) } + let_it_be(:job_args) { { runner: instance_runner, failure_reason: :runner_system_failure } } + let_it_be(:job1) { create(:ci_build, :failed, finished_at: 1.minute.ago, **job_args) } + let_it_be(:job2) { create(:ci_build, :failed, finished_at: 2.minutes.ago, **job_args) } + let_it_be(:job7) { create(:ci_build, :failed, finished_at: 2.minutes.ago, **job_args) } + let_it_be(:job3) { create(:ci_build, :failed, finished_at: 2.minutes.ago, runner: group_runner) } + let_it_be(:job4) { create(:ci_build, :failed, finished_at: 2.minutes.ago, runner: project_runner) } + let_it_be(:job5) do + create(:ci_build, :failed, finished_at: 2.minutes.ago, runner: group_runner, + failure_reason: :runner_system_failure) + end + + let_it_be(:job6) do + create(:ci_build, :failed, finished_at: 2.minutes.ago, runner: project_runner, + failure_reason: :runner_system_failure) + end + + before do + stub_licensed_features(runner_performance_insights: runner_performance_insights) + + ::Ci::InstanceRunnerFailedJobs.track(job1) + ::Ci::InstanceRunnerFailedJobs.track(job2) + end + + context 'with param `failure_reason` set to :runner_system_failure', :clean_gitlab_redis_shared_state, + feature_category: :runner_fleet do + let(:params) { { failure_reason: :runner_system_failure } } + + context 'without runner_performance_insights license' do + let(:runner_performance_insights) { false } + + it 'raises ArgumentError due to lack of runner_type' do + expect(::Ci::Build).not_to receive(:recently_failed_on_instance_runner) + + expect { finder_execute }.to raise_error( + ArgumentError, 'failure_reason can only be used together with runner_type: instance_type' + ) + end + + context 'with param :runner_type set to [instance_type]' do + let(:params) { { failure_reason: :runner_system_failure, runner_type: %w[instance_type] } } + + it 'returns no jobs due to lack of license' do + expect(::Ci::Build).to receive(:recently_failed_on_instance_runner) + .with(:runner_system_failure).once.and_call_original + + expect(finder_execute.ids).to be_empty + end + end + end + + context 'with runner_performance_insights license' do + let(:runner_performance_insights) { true } + + context 'with param :runner_type set to [instance_type]' do + let(:params) { { failure_reason: :runner_system_failure, runner_type: %w[instance_type] } } + + it 'returns builds tracked by InstanceRunnerFailedJobs' do + expect(::Ci::Build).to receive(:recently_failed_on_instance_runner) + .with(:runner_system_failure).once.and_call_original + + expect(finder_execute.ids).to contain_exactly(job1.id, job2.id) + end + end + + context 'with param :runner_type set to multiple runner types', :aggregate_failures do + let(:params) { { failure_reason: :runner_system_failure, runner_type: %w[instance_type group_type] } } + + it 'raises ArgumentError due to multiple runner types' do + expect(::Ci::Build).not_to receive(:recently_failed_on_instance_runner) + + expect { finder_execute }.to raise_error( + ArgumentError, 'failure_reason can only be used together with runner_type: instance_type' + ) + end + + context 'with feature flag :admin_jobs_filter_runner_type disabled' do + before do + stub_feature_flags(admin_jobs_filter_runner_type: false) + end + + it 'raises ArgumentError due to multiple runner types' do + expect(::Ci::Build).not_to receive(:recently_failed_on_instance_runner) + + expect { finder_execute }.to raise_error( + ArgumentError, 'failure_reason can only be used together with runner_type: instance_type' + ) + end + end + end + end + end + + context 'with param `failure_reason` not set to :runner_system_failure', :clean_gitlab_redis_shared_state, + feature_category: :runner_fleet do + let(:params) { { failure_reason: :runner_unsupported } } + + context 'with runner_performance_insights license' do + let(:runner_performance_insights) { true } + + context 'with param :runner_type set to [instance_type]' do + let(:params) { { failure_reason: :runner_unsupported, runner_type: %w[instance_type] } } + + it 'raises ArgumentError due to unsupported failure_reason' do + expect(::Ci::Build).not_to receive(:recently_failed_on_instance_runner) + + expect { finder_execute } + .to raise_error(ArgumentError, 'failure_reason only supports runner_system_failure') + end + end + end + end + end + end + end +end diff --git a/ee/spec/models/ci/build_spec.rb b/ee/spec/models/ci/build_spec.rb index 162a7875d29aa30ab62c738c15ccf4566f0fbe3a..0b1da54037b15650b080043127074da7cd43fa39 100644 --- a/ee/spec/models/ci/build_spec.rb +++ b/ee/spec/models/ci/build_spec.rb @@ -670,6 +670,55 @@ end end + describe '.recently_failed_on_instance_runner', :clean_gitlab_redis_shared_state, feature_category: :runner_fleet do + subject(:recently_failed_on_instance_runner) do + described_class.recently_failed_on_instance_runner(failure_reason) + end + + before do + stub_licensed_features(runner_performance_insights: true) + end + + let_it_be(:instance_runner) { create(:ci_runner, :instance) } + let_it_be(:job_args) { { runner: instance_runner, failure_reason: :runner_system_failure } } + let_it_be(:job1) { create(:ci_build, :failed, finished_at: 1.minute.ago, **job_args) } + let_it_be(:job2) { create(:ci_build, :failed, finished_at: 2.minutes.ago, **job_args) } + + context 'with failure_reason set to :runner_system_failure' do + let(:failure_reason) { :runner_system_failure } + + it 'returns no builds' do + is_expected.to be_empty + end + + context 'with 2 jobs tracked' do + before do + ::Ci::InstanceRunnerFailedJobs.track(job2) + ::Ci::InstanceRunnerFailedJobs.track(job1) + end + + it 'returns builds tracked by InstanceRunnerFailedJobs' do + is_expected.to match([ + an_object_having_attributes(id: job1.id), + an_object_having_attributes(id: job2.id) + ]) + end + + it 'overrides the order of returned builds' do + expect(described_class.order(id: :asc).recently_failed_on_instance_runner(failure_reason)).to match([ + an_object_having_attributes(id: job1.id), + an_object_having_attributes(id: job2.id) + ]) + + expect(described_class.order(id: :desc).recently_failed_on_instance_runner(failure_reason)).to match([ + an_object_having_attributes(id: job1.id), + an_object_having_attributes(id: job2.id) + ]) + end + end + end + end + describe 'ci_secrets_management_available?' do subject { job.ci_secrets_management_available? } diff --git a/ee/spec/models/ci/instance_runner_failed_jobs_spec.rb b/ee/spec/models/ci/instance_runner_failed_jobs_spec.rb index 99df1913b090f8f5e7a6acd68bd1f9e2b76f614d..54ebec167d2f0bc1091dc485a2140ed03c104e75 100644 --- a/ee/spec/models/ci/instance_runner_failed_jobs_spec.rb +++ b/ee/spec/models/ci/instance_runner_failed_jobs_spec.rb @@ -66,7 +66,11 @@ end describe '.recent_jobs' do - subject(:recent_jobs) { described_class.recent_jobs } + def recent_jobs + described_class.recent_jobs(failure_reason: failure_reason) + end + + subject(:scope) { recent_jobs } let_it_be(:runner) { create(:ci_runner, :instance) } let_it_be(:job_args) { { runner: runner, failure_reason: :runner_system_failure } } @@ -77,57 +81,71 @@ context 'with runner_performance_insights licensed feature' do let(:runner_performance_insights) { true } - context 'when content is not set' do - it { is_expected.to be_empty } - end + context 'when failure_reason is not runner_system_failure' do + let(:failure_reason) { :runner_unsupported } - context 'when jobs are added' do - before do - described_class.track(job) + it 'raises an error' do + expect { recent_jobs }.to raise_error( + ArgumentError, 'The only failure reason(s) supported are runner_system_failure' + ) end + end - it 'returns 3 most recently finished jobs' do - expect(described_class.recent_jobs).to contain_exactly(an_object_having_attributes(id: job.id)) - - described_class.track(job2) - described_class.track(job3) + context 'when failure_reason is runner_system_failure' do + let(:failure_reason) { :runner_system_failure } - expect(described_class.recent_jobs).to match([ - an_object_having_attributes(id: job.id), - an_object_having_attributes(id: job2.id), - an_object_having_attributes(id: job3.id) - ]) + context 'when content is not set' do + it { is_expected.to be_empty } end - context 'when jobs are added in different order' do + context 'when jobs are added' do + before do + described_class.track(job) + end + it 'returns 3 most recently finished jobs' do - expect(described_class.recent_jobs).to contain_exactly(an_object_having_attributes(id: job.id)) + expect(recent_jobs).to contain_exactly(an_object_having_attributes(id: job.id)) - described_class.track(job3) described_class.track(job2) + described_class.track(job3) - expect(described_class.recent_jobs).to match([ + expect(recent_jobs).to match([ an_object_having_attributes(id: job.id), an_object_having_attributes(id: job2.id), an_object_having_attributes(id: job3.id) ]) end - end - context 'when trimming is required' do - before do - stub_const("#{described_class}::JOB_LIMIT", 1) - stub_const("#{described_class}::JOB_LIMIT_MARGIN", 1) + context 'when jobs are added in different order' do + it 'returns 3 most recently finished jobs' do + expect(recent_jobs).to contain_exactly(an_object_having_attributes(id: job.id)) + + described_class.track(job3) + described_class.track(job2) + + expect(recent_jobs).to match([ + an_object_having_attributes(id: job.id), + an_object_having_attributes(id: job2.id), + an_object_having_attributes(id: job3.id) + ]) + end end - it 'returns 2 most recently finished jobs and purges the rest', :aggregate_failures do - described_class.track(job3) - described_class.track(job2) + context 'when trimming is required' do + before do + stub_const("#{described_class}::JOB_LIMIT", 1) + stub_const("#{described_class}::JOB_LIMIT_MARGIN", 1) + end + + it 'returns 2 most recently finished jobs and purges the rest', :aggregate_failures do + described_class.track(job3) + described_class.track(job2) - # Only the last 2 jobs saved will be retained - expect(redis_stored_job_ids).to eq(formatted_job_ids_for(job2, job3)) - # and of those 2, the most recently finished will be returned (JOB_LIMIT) - expect(described_class.recent_jobs).to contain_exactly(an_object_having_attributes(id: job2.id)) + # Only the last 2 jobs saved will be retained + expect(redis_stored_job_ids).to eq(formatted_job_ids_for(job2, job3)) + # and of those 2, the most recently finished will be returned (JOB_LIMIT) + is_expected.to contain_exactly(an_object_having_attributes(id: job2.id)) + end end end end @@ -136,7 +154,11 @@ context 'without runner_performance_insights licensed feature' do let(:runner_performance_insights) { false } - it { is_expected.to be_empty } + context 'when failure_reason is runner_system_failure' do + let(:failure_reason) { :runner_system_failure } + + it { is_expected.to be_empty } + end end end diff --git a/ee/spec/requests/api/graphql/ci/jobs_spec.rb b/ee/spec/requests/api/graphql/ci/jobs_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..4f30cf0159d772de5844ea15b533569027cde5e1 --- /dev/null +++ b/ee/spec/requests/api/graphql/ci/jobs_spec.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Query.jobs', feature_category: :continuous_integration do + include GraphqlHelpers + + let_it_be(:instance_runner) { create(:ci_runner, :instance) } + + let_it_be(:successful_job) { create(:ci_build, :success, name: 'successful_job') } + let_it_be(:failed_job) { create(:ci_build, :failed, name: 'failed_job') } + let_it_be(:pending_job) { create(:ci_build, :pending, name: 'pending_job') } + let_it_be(:system_failure_job) do + create(:ci_build, :failed, failure_reason: :runner_system_failure, runner: instance_runner, + name: 'system_failure_job') + end + + let(:query_path) do + [ + [:jobs, query_jobs_args], + [:nodes] + ] + end + + let(:query) do + wrap_fields(query_graphql_path(query_path, 'id')) + end + + let(:jobs_graphql_data) { graphql_data_at(:jobs, :nodes) } + + subject(:request) { post_graphql(query, current_user: current_user) } + + context 'when current user is an admin' do + let_it_be(:current_user) { create(:admin) } + + context "with argument `failure_reason`", feature_category: :runner_fleet do + let(:query_jobs_args) do + graphql_args(failure_reason: failure_reason) + end + + let_it_be(:_system_failure_on_project_runner) do + project_runner = create(:ci_runner, :project) + + create(:ci_build, :failed, failure_reason: :runner_system_failure, runner: project_runner, + name: 'system_failure_job2') + end + + before do + stub_licensed_features(runner_performance_insights: true) + + Ci::Build.all.each { |build| ::Ci::InstanceRunnerFailedJobs.track(build) } + end + + context 'as RUNNER_SYSTEM_FAILURE' do + let(:failure_reason) { :RUNNER_SYSTEM_FAILURE } + + it 'generates an error' do + request + + expect_graphql_errors_to_include 'failure_reason can only be used together with runner_type: instance_type' + end + + context 'with argument `runnerTypes`' do + let(:query_jobs_args) do + graphql_args(runner_types: runner_types, failure_reason: failure_reason) + end + + context 'as INSTANCE_TYPE' do + let(:runner_types) { [:INSTANCE_TYPE] } + + it_behaves_like 'a working graphql query that returns data' do + before do + request + end + + it { expect(jobs_graphql_data).to contain_exactly(a_graphql_entity_for(system_failure_job)) } + end + end + end + end + + context 'as RUNNER_UNSUPPORTED' do + let(:failure_reason) { :RUNNER_UNSUPPORTED } + + context 'with argument `runnerTypes`' do + let(:query_jobs_args) do + graphql_args(runner_types: runner_types, failure_reason: failure_reason) + end + + context 'as INSTANCE_TYPE' do + let(:runner_types) { [:INSTANCE_TYPE] } + + it 'generates an error' do + request + + expect_graphql_errors_to_include 'failure_reason only supports runner_system_failure' + end + end + end + end + end + end +end diff --git a/spec/finders/ci/jobs_finder_spec.rb b/spec/finders/ci/jobs_finder_spec.rb index e86ac65df61dd963f52ea1a5eaed4b9009123e7c..57046baafab7d024440b34b789d9e30428c99995 100644 --- a/spec/finders/ci/jobs_finder_spec.rb +++ b/spec/finders/ci/jobs_finder_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Ci::JobsFinder, '#execute', feature_category: :runner_fleet do +RSpec.describe Ci::JobsFinder, '#execute', feature_category: :continuous_integration do let_it_be(:user) { create(:user) } let_it_be(:admin) { create(:user, :admin) } let_it_be(:project) { create(:project, :private, public_builds: false) } @@ -13,8 +13,8 @@ let(:params) { {} } - context 'when project, pipeline or runner are blank' do - subject { described_class.new(current_user: current_user, params: params).execute } + context 'when project, pipeline, and runner are blank' do + subject(:finder_execute) { described_class.new(current_user: current_user, params: params).execute } context 'with admin' do let(:current_user) { admin } @@ -278,28 +278,30 @@ let_it_be(:runner) { create(:ci_runner, :project, projects: [project]) } let_it_be(:job_4) { create(:ci_build, :success, runner: runner) } - subject { described_class.new(current_user: user, runner: runner, params: params).execute } + subject(:execute) { described_class.new(current_user: user, runner: runner, params: params).execute } - context 'with admin and admin mode enabled', :enable_admin_mode do + context 'when current user is an admin' do let(:user) { admin } - it 'returns jobs for the specified project' do - expect(subject).to match_array([job_4]) - end + context 'when admin mode is enabled', :enable_admin_mode do + it 'returns jobs for the specified project' do + expect(subject).to contain_exactly job_4 + end - context "with params" do - using RSpec::Parameterized::TableSyntax + context 'with params' do + using RSpec::Parameterized::TableSyntax - where(:param_runner_type, :param_scope, :expected_jobs) do - 'project_type' | 'success' | lazy { [job_4] } - 'instance_type' | nil | lazy { [] } - nil | 'pending' | lazy { [] } - end + where(:param_runner_type, :param_scope, :expected_jobs) do + 'project_type' | 'success' | lazy { [job_4] } + 'instance_type' | nil | lazy { [] } + nil | 'pending' | lazy { [] } + end - with_them do - let(:params) { { runner_type: param_runner_type, scope: param_scope } } + with_them do + let(:params) { { runner_type: param_runner_type, scope: param_scope } } - it { is_expected.to match_array(expected_jobs) } + it { is_expected.to match_array(expected_jobs) } + end end end end diff --git a/spec/graphql/resolvers/ci/all_jobs_resolver_spec.rb b/spec/graphql/resolvers/ci/all_jobs_resolver_spec.rb index 933abf31470d46707ddfe5e332f06e895f6d39fb..6b9e3a484b14fab5d938457c6df3ba7efadffbd6 100644 --- a/spec/graphql/resolvers/ci/all_jobs_resolver_spec.rb +++ b/spec/graphql/resolvers/ci/all_jobs_resolver_spec.rb @@ -5,6 +5,7 @@ RSpec.describe Resolvers::Ci::AllJobsResolver, feature_category: :continuous_integration do include GraphqlHelpers + let_it_be(:instance_runner) { create(:ci_runner, :instance) } let_it_be(:successful_job) { create(:ci_build, :success, name: 'successful_job') } let_it_be(:successful_job_two) { create(:ci_build, :success, name: 'successful_job_two') } let_it_be(:failed_job) { create(:ci_build, :failed, name: 'failed_job') } @@ -12,11 +13,11 @@ let(:args) { {} } - subject { resolve_jobs(args) } - describe '#resolve' do - context 'with admin' do - let(:current_user) { create(:admin) } + subject(:request) { resolve_jobs(args) } + + context 'when current user is an admin' do + let_it_be(:current_user) { create(:admin) } shared_examples 'executes as admin' do context "with argument `statuses`" do @@ -40,8 +41,7 @@ context "with argument `runner_types`" do let_it_be(:successful_job_with_instance_runner) do - create(:ci_build, :success, name: 'successful_job_with_instance_runner', - runner: create(:ci_runner, :instance)) + create(:ci_build, :success, name: 'successful_job_with_instance_runner', runner: instance_runner) end context 'with feature flag :admin_jobs_filter_runner_type enabled' do @@ -80,7 +80,7 @@ :ci_build, :success, name: 'successful_job_with_instance_runner', - runner: create(:ci_runner, :instance) + runner: instance_runner ) end @@ -132,7 +132,9 @@ end context 'with unauthorized user' do - let(:current_user) { nil } + let_it_be(:unauth_user) { create(:user) } + + let(:current_user) { unauth_user } it { is_expected.to be_empty } end