From 3a4d1cd4a765a758976e0428af8588debf20fd19 Mon Sep 17 00:00:00 2001 From: Jose Ivan Vargas Date: Wed, 13 Aug 2025 10:14:17 -0600 Subject: [PATCH 1/2] Add job_created subscription Add a new job subscription for when jobs are created using the `after_commit` on create hook. This is behind the `ci_job_created` feature flag --- app/graphql/graphql_triggers.rb | 6 ++ .../subscriptions/ci/jobs/job_created.rb | 40 +++++++++++ app/graphql/types/subscription_type.rb | 4 ++ app/models/ci/build.rb | 5 ++ .../ci_job_created_subscription.yml | 10 +++ spec/graphql/graphql_triggers_spec.rb | 30 +++++++++ .../subscriptions/ci/jobs/job_created_spec.rb | 67 +++++++++++++++++++ spec/graphql/types/subscription_type_spec.rb | 1 + spec/models/ci/build_spec.rb | 10 +++ 9 files changed, 173 insertions(+) create mode 100644 app/graphql/subscriptions/ci/jobs/job_created.rb create mode 100644 config/feature_flags/gitlab_com_derisk/ci_job_created_subscription.yml create mode 100644 spec/graphql/subscriptions/ci/jobs/job_created_spec.rb diff --git a/app/graphql/graphql_triggers.rb b/app/graphql/graphql_triggers.rb index 42b066c1690a04..ff05d659decfcb 100644 --- a/app/graphql/graphql_triggers.rb +++ b/app/graphql/graphql_triggers.rb @@ -1,6 +1,12 @@ # frozen_string_literal: true module GraphqlTriggers + def self.ci_job_created(job) + return unless Feature.enabled?(:ci_job_created_subscription, job.project) + + GitlabSchema.subscriptions.trigger(:ci_job_created, { project_id: job.project.to_gid }, job) + end + def self.ci_job_status_updated(job) GitlabSchema.subscriptions.trigger(:ci_job_status_updated, { job_id: job.to_gid }, job) end diff --git a/app/graphql/subscriptions/ci/jobs/job_created.rb b/app/graphql/subscriptions/ci/jobs/job_created.rb new file mode 100644 index 00000000000000..5800c5242b0cab --- /dev/null +++ b/app/graphql/subscriptions/ci/jobs/job_created.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Subscriptions + module Ci + module Jobs + class JobCreated < Subscriptions::BaseSubscription + include Gitlab::Graphql::Laziness + + argument :project_id, + ::Types::GlobalIDType[::Project], + required: true, + description: 'Global ID of the project.' + + payload_type Types::Ci::JobType + + def authorized?(project_id:) + project = force(GitlabSchema.find_by_gid(project_id)) + + unauthorized! unless project + unauthorized! unless Ability.allowed?(current_user, :read_build, project) + + true + end + + def update(project_id:) + updated_job = object + + return NO_UPDATE unless updated_job + + project = force(GitlabSchema.find_by_gid(project_id)) + + return NO_UPDATE unless project && updated_job.project_id == project.id + return NO_UPDATE unless Ability.allowed?(current_user, :read_build, updated_job) + + updated_job + end + end + end + end +end diff --git a/app/graphql/types/subscription_type.rb b/app/graphql/types/subscription_type.rb index efe34d14225ddc..bac1ab5ba98ae2 100644 --- a/app/graphql/types/subscription_type.rb +++ b/app/graphql/types/subscription_type.rb @@ -4,6 +4,10 @@ module Types class SubscriptionType < ::Types::BaseObject graphql_name 'Subscription' + field :ci_job_created, + subscription: Subscriptions::Ci::Jobs::JobCreated, null: true, + description: 'Triggered when a job is created.' + field :ci_job_status_updated, subscription: Subscriptions::Ci::Jobs::StatusUpdated, null: true, description: 'Triggered when a job status is updated.' diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 1aad54a7d40a9d..2d7780e644b365 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -245,6 +245,7 @@ class Build < Ci::Processable run_after_commit { build.execute_hooks } end + after_commit :trigger_job_create_subscription, on: :create after_commit :track_ci_secrets_management_id_tokens_usage, on: :create, if: :id_tokens? after_commit :track_ci_build_created_event, on: :create after_commit :trigger_job_status_change_subscription, if: :saved_change_to_status? @@ -452,6 +453,10 @@ def trigger_job_status_change_subscription GraphqlTriggers.ci_job_status_updated(self) end + def trigger_job_create_subscription + GraphqlTriggers.ci_job_created(self) + end + # A Ci::Bridge may transition to `canceling` as a result of strategy: :depend # but only a Ci::Build will transition to `canceling`` via `.cancel` def supports_canceling? diff --git a/config/feature_flags/gitlab_com_derisk/ci_job_created_subscription.yml b/config/feature_flags/gitlab_com_derisk/ci_job_created_subscription.yml new file mode 100644 index 00000000000000..0927fbef9cdf25 --- /dev/null +++ b/config/feature_flags/gitlab_com_derisk/ci_job_created_subscription.yml @@ -0,0 +1,10 @@ +--- +name: ci_job_created_subscription +description: +feature_issue_url: +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/201350 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/562507 +milestone: '18.3' +group: group::pipeline execution +type: gitlab_com_derisk +default_enabled: false diff --git a/spec/graphql/graphql_triggers_spec.rb b/spec/graphql/graphql_triggers_spec.rb index cd6c1fe3b9894e..7e7076b8cf8ce3 100644 --- a/spec/graphql/graphql_triggers_spec.rb +++ b/spec/graphql/graphql_triggers_spec.rb @@ -280,4 +280,34 @@ end end end + + describe '.ci_job_created' do + let_it_be(:job) { create(:ci_build) } + + it 'triggers the ci_job_created subscription' do + expect(GitlabSchema.subscriptions).to receive(:trigger).with( + :ci_job_created, + { project_id: job.project.to_gid }, + job + ) + + described_class.ci_job_created(job) + end + + describe 'when ci_job_created_subscription is disabled' do + before do + stub_feature_flags(ci_job_created_subscription: false) + end + + it 'does not trigger the ci_job_created subscription' do + expect(GitlabSchema.subscriptions).not_to receive(:trigger).with( + :ci_job_created, + { job_id: job.project.to_gid }, + job + ) + + described_class.ci_job_created(job) + end + end + end end diff --git a/spec/graphql/subscriptions/ci/jobs/job_created_spec.rb b/spec/graphql/subscriptions/ci/jobs/job_created_spec.rb new file mode 100644 index 00000000000000..7e653e1592dbdd --- /dev/null +++ b/spec/graphql/subscriptions/ci/jobs/job_created_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Subscriptions::Ci::Jobs::JobCreated, feature_category: :continuous_integration do + include GraphqlHelpers + + it { expect(described_class).to have_graphql_arguments(:project_id) } + it { expect(described_class.payload_type).to eq(Types::Ci::JobType) } + + describe '#resolve' do + let_it_be(:unauthorized_user) { create(:user) } + let_it_be(:project) { create(:project) } + let_it_be(:job) { create(:ci_build, project: project) } + + let(:current_user) { job.project.owners.first } + let(:project_id) { project.to_gid } + + subject(:subscription) { resolver.resolve_with_support(project_id: project_id) } + + context 'when initially subscribing to a projects jobs' do + let(:resolver) { resolver_instance(described_class, ctx: query_context, subscription_update: false) } + + it 'returns nil' do + expect(subscription).to be_nil + end + + context 'when the user is not authorized' do + let(:current_user) { unauthorized_user } + + it 'raises an exception' do + expect { subscription }.to raise_error(GraphQL::ExecutionError) + end + end + + context 'when the job does not exist' do + let(:project_id) { GlobalID.parse("gid://gitlab/Project/#{non_existing_record_id}") } + + it 'raises an exception' do + expect { subscription }.to raise_error(GraphQL::ExecutionError) + end + end + end + + context 'with subscription updates' do + let(:resolver) do + resolver_instance(described_class, obj: project, ctx: query_context, subscription_update: true) + end + + it 'returns the resolved object' do + expect(subscription).to eq(project) + end + + context 'when a user can not read the job' do + before do + allow(Ability).to receive(:allowed?) + .with(current_user, :read_build, project) + .and_return(false) + end + + it 'unsubscribes the user' do + expect(subscription).to be_an(GraphQL::Execution::Skip) + end + end + end + end +end diff --git a/spec/graphql/types/subscription_type_spec.rb b/spec/graphql/types/subscription_type_spec.rb index 9214807dcda341..8f566c03742db2 100644 --- a/spec/graphql/types/subscription_type_spec.rb +++ b/spec/graphql/types/subscription_type_spec.rb @@ -5,6 +5,7 @@ RSpec.describe GitlabSchema.types['Subscription'], feature_category: :subscription_management do it 'has the expected fields' do expected_fields = %i[ + ci_job_created ci_pipeline_schedule_status_updated ci_job_status_updated ci_pipeline_status_updated diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index a69971480eba28..d6779e412fae71 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -225,6 +225,16 @@ end end end + + describe 'job creation subscription trigger' do + it 'triggers GraphQL subscription ciJobCreated' do + expect_next(described_class).to receive(:execute_hooks) + + expect(GraphqlTriggers).to receive(:ci_job_created).with(instance_of(described_class)) + + create(:ci_build, pipeline: pipeline) + end + end end describe 'status' do -- GitLab From 11f321a8ade3a2775c95b0e1fd365686497f0a0e Mon Sep 17 00:00:00 2001 From: Jose Ivan Vargas Date: Mon, 8 Sep 2025 13:05:10 -0600 Subject: [PATCH 2/2] Add RateLimiter to the subscription hook --- .../subscriptions/ci/jobs/job_created.rb | 10 ++-- app/models/ci/build.rb | 7 +++ .../ci_job_created_subscription.yml | 2 +- lib/gitlab/application_rate_limiter.rb | 1 + .../subscriptions/ci/jobs/job_created_spec.rb | 4 +- spec/models/ci/build_spec.rb | 47 +++++++++++++++++-- 6 files changed, 59 insertions(+), 12 deletions(-) diff --git a/app/graphql/subscriptions/ci/jobs/job_created.rb b/app/graphql/subscriptions/ci/jobs/job_created.rb index 5800c5242b0cab..445a58dc9120cd 100644 --- a/app/graphql/subscriptions/ci/jobs/job_created.rb +++ b/app/graphql/subscriptions/ci/jobs/job_created.rb @@ -23,16 +23,16 @@ def authorized?(project_id:) end def update(project_id:) - updated_job = object + created_job = object - return NO_UPDATE unless updated_job + return NO_UPDATE unless created_job project = force(GitlabSchema.find_by_gid(project_id)) - return NO_UPDATE unless project && updated_job.project_id == project.id - return NO_UPDATE unless Ability.allowed?(current_user, :read_build, updated_job) + return NO_UPDATE unless project && created_job.project_id == project.id + return NO_UPDATE unless Ability.allowed?(current_user, :read_build, created_job) - updated_job + created_job end end end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 2d7780e644b365..5faefbbfcd7800 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -454,6 +454,13 @@ def trigger_job_status_change_subscription end def trigger_job_create_subscription + return unless Feature.enabled?(:ci_job_created_subscription, project) + + return if Gitlab::ApplicationRateLimiter.throttled?( + :ci_job_created_subscription, + scope: project + ) + GraphqlTriggers.ci_job_created(self) end diff --git a/config/feature_flags/gitlab_com_derisk/ci_job_created_subscription.yml b/config/feature_flags/gitlab_com_derisk/ci_job_created_subscription.yml index 0927fbef9cdf25..d4b6e08bffaaf5 100644 --- a/config/feature_flags/gitlab_com_derisk/ci_job_created_subscription.yml +++ b/config/feature_flags/gitlab_com_derisk/ci_job_created_subscription.yml @@ -1,7 +1,7 @@ --- name: ci_job_created_subscription description: -feature_issue_url: +feature_issue_url: https://gitlab.com/groups/gitlab-org/-/epics/16394 introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/201350 rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/562507 milestone: '18.3' diff --git a/lib/gitlab/application_rate_limiter.rb b/lib/gitlab/application_rate_limiter.rb index 9e881c9bcedf51..2ee7b821c1b1c5 100644 --- a/lib/gitlab/application_rate_limiter.rb +++ b/lib/gitlab/application_rate_limiter.rb @@ -25,6 +25,7 @@ def rate_limits # rubocop:disable Metrics/AbcSize autocomplete_users_unauthenticated: { threshold: -> { application_settings.autocomplete_users_unauthenticated_limit }, interval: 1.minute }, bulk_delete_todos: { threshold: 6, interval: 1.minute }, bulk_import: { threshold: 6, interval: 1.minute }, + ci_job_created_subscription: { threshold: 50, interval: 1.minute }, code_suggestions_api_endpoint: { threshold: -> { application_settings.code_suggestions_api_rate_limit }, interval: 1.minute }, create_organization_api: { threshold: -> { application_settings.create_organization_api_limit }, interval: 1.minute }, delete_all_todos: { threshold: 1, interval: 5.minutes }, diff --git a/spec/graphql/subscriptions/ci/jobs/job_created_spec.rb b/spec/graphql/subscriptions/ci/jobs/job_created_spec.rb index 7e653e1592dbdd..bfe4f03cf2642b 100644 --- a/spec/graphql/subscriptions/ci/jobs/job_created_spec.rb +++ b/spec/graphql/subscriptions/ci/jobs/job_created_spec.rb @@ -44,11 +44,11 @@ context 'with subscription updates' do let(:resolver) do - resolver_instance(described_class, obj: project, ctx: query_context, subscription_update: true) + resolver_instance(described_class, obj: job, ctx: query_context, subscription_update: true) end it 'returns the resolved object' do - expect(subscription).to eq(project) + expect(subscription).to eq(job) end context 'when a user can not read the job' do diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index d6779e412fae71..b1bad9bca95485 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -227,12 +227,51 @@ end describe 'job creation subscription trigger' do - it 'triggers GraphQL subscription ciJobCreated' do - expect_next(described_class).to receive(:execute_hooks) + context 'with rate limiting enabled' do + before do + allow(Gitlab::ApplicationRateLimiter).to receive(:throttled?) + .with(:ci_job_created_subscription, scope: build.project) + .and_return(true) + end - expect(GraphqlTriggers).to receive(:ci_job_created).with(instance_of(described_class)) + it 'does not trigger GraphQL subscription when rate limited' do + expect_next(described_class).to receive(:execute_hooks) + expect(GraphqlTriggers).not_to receive(:ci_job_created) - create(:ci_build, pipeline: pipeline) + create(:ci_build, pipeline: pipeline) + end + end + + context 'without rate limiting enabled' do + before do + allow(Gitlab::ApplicationRateLimiter).to receive(:throttled?) + .with(:ci_job_created_subscription, scope: build.project) + .and_return(false) + end + + context 'with feature flag enabled' do + it 'triggers GraphQL subscription ciJobCreated' do + expect_next(described_class).to receive(:execute_hooks) + + expect(GraphqlTriggers).to receive(:ci_job_created).with(instance_of(described_class)) + + create(:ci_build, pipeline: pipeline) + end + end + + context 'with feature flag turned off' do + before do + stub_feature_flags(ci_job_created_subscription: false) + end + + it 'does not trigger GraphQL subscription ciJobCreated' do + expect_next(described_class).to receive(:execute_hooks) + + expect(GraphqlTriggers).not_to receive(:ci_job_created).with(instance_of(described_class)) + + create(:ci_build, pipeline: pipeline) + end + end end end end -- GitLab