From 4f03c7274bae9bd667efc01be2997cbbc151902c Mon Sep 17 00:00:00 2001 From: Timo Furrer Date: Wed, 17 Dec 2025 12:45:23 +0100 Subject: [PATCH] Implement internal endpoint to retrieve applicable runner controllers This change set introduces a new internal REST API endpoint for KAS (Job Router) to retrieve applicable runner controllers for admission control given a runner. Currently, all registered runner controllers are applicable since we don't have any scoping yet (will be introduced with https://gitlab.com/gitlab-org/gitlab/-/issues/582825). The endpoint is behind the feature flag for the job router and additionally a new one specifically for admission control. Refs https://gitlab.com/gitlab-org/gitlab/-/issues/582799 --- ee/app/models/ci/runner_controller.rb | 2 + .../beta/job_router_admission_control.yml | 10 + ee/lib/api/internal/ci/job_router.rb | 68 ++++++ ee/lib/ee/api/api.rb | 1 + ee/spec/factories/ci/runner_controllers.rb | 4 + ee/spec/models/ci/runner_controller_spec.rb | 36 +++ .../api/internal/ci/job_router_spec.rb | 211 ++++++++++++++++++ 7 files changed, 332 insertions(+) create mode 100644 ee/config/feature_flags/beta/job_router_admission_control.yml create mode 100644 ee/lib/api/internal/ci/job_router.rb create mode 100644 ee/spec/requests/api/internal/ci/job_router_spec.rb diff --git a/ee/app/models/ci/runner_controller.rb b/ee/app/models/ci/runner_controller.rb index 3f79eef9f3650a..776558918dfa5a 100644 --- a/ee/app/models/ci/runner_controller.rb +++ b/ee/app/models/ci/runner_controller.rb @@ -8,5 +8,7 @@ class RunnerController < Ci::ApplicationRecord has_many :tokens, class_name: 'Ci::RunnerControllerToken', inverse_of: :runner_controller + + scope :enabled, -> { where(enabled: true) } end end diff --git a/ee/config/feature_flags/beta/job_router_admission_control.yml b/ee/config/feature_flags/beta/job_router_admission_control.yml new file mode 100644 index 00000000000000..2e1679529195b4 --- /dev/null +++ b/ee/config/feature_flags/beta/job_router_admission_control.yml @@ -0,0 +1,10 @@ +--- +name: job_router_admission_control +description: Enable Admission Control in the Job Router. The Job Router must be enabled for this. Use the runner as an actor. +feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/582799 +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/216815 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/584394 +milestone: '18.8' +group: group::runner core +type: beta +default_enabled: false diff --git a/ee/lib/api/internal/ci/job_router.rb b/ee/lib/api/internal/ci/job_router.rb new file mode 100644 index 00000000000000..1bda9b625646eb --- /dev/null +++ b/ee/lib/api/internal/ci/job_router.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module API + module Internal + module Ci + class JobRouter < ::API::Base + feature_category :continuous_integration + urgency :low + + helpers ::API::Helpers::KasHelpers + + before do + authenticate_gitlab_kas_request! + end + + helpers do + def ensure_job_router_enabled_for_runner!(runner) + return if Gitlab::Kas.enabled? && job_router_enabled?(runner) + + render_api_error!('Job Router is not available. Please contact your administrator.', 501) + end + + def ensure_admission_control_enabled_for_runner!(runner) + return if Feature.enabled?(:job_router_admission_control, runner) + + render_api_error!('Admission Control is not available. Please contact your administrator.', 501) + end + end + + namespace 'internal' do + namespace 'ci' do + namespace 'job_router' do + namespace 'runner_controllers' do + helpers ::API::Ci::Helpers::Runner + + before do + authenticate_runner_from_header! + Gitlab::ApplicationContext.push(runner: current_runner_from_header) + end + + desc 'Get applicable runner controllers to use for job admission control' do + detail 'Retrieves all applicable runner controller to use for job admission control in the job router' + success code: 200 + failure [ + { code: 401, message: '401 Unauthorized' }, + { code: 501, message: '501 Not Implemented' } + ] + tags %w[jobs job_router] + end + get '/job_admission' do + ensure_job_router_enabled_for_runner!(current_runner_from_header) + ensure_admission_control_enabled_for_runner!(current_runner_from_header) + + controllers = ::Ci::RunnerController.enabled.select(:id) + + status 200 + { + runner_controllers: controllers.map { |c| { id: c.id } } + } + end + end + end + end + end + end + end + end +end diff --git a/ee/lib/ee/api/api.rb b/ee/lib/ee/api/api.rb index a4e86b32cbb4b5..f6908886eb6c47 100644 --- a/ee/lib/ee/api/api.rb +++ b/ee/lib/ee/api/api.rb @@ -104,6 +104,7 @@ module API mount ::API::VirtualRegistries::Packages::Maven::Endpoints mount ::API::Internal::AppSec::Dast::SiteValidations + mount ::API::Internal::Ci::JobRouter mount ::API::Internal::Search::Zoekt mount ::API::Internal::Ai::XRay::Scan mount ::API::Internal::Observability diff --git a/ee/spec/factories/ci/runner_controllers.rb b/ee/spec/factories/ci/runner_controllers.rb index 96ab2c9b1ae7bc..2bf15cbfc2bfbe 100644 --- a/ee/spec/factories/ci/runner_controllers.rb +++ b/ee/spec/factories/ci/runner_controllers.rb @@ -3,5 +3,9 @@ FactoryBot.define do factory :ci_runner_controller, class: 'Ci::RunnerController' do description { "Controller for managing runner" } + + trait :enabled do + enabled { true } + end end end diff --git a/ee/spec/models/ci/runner_controller_spec.rb b/ee/spec/models/ci/runner_controller_spec.rb index dfa66c373ca6d5..4803e35f72e060 100644 --- a/ee/spec/models/ci/runner_controller_spec.rb +++ b/ee/spec/models/ci/runner_controller_spec.rb @@ -29,4 +29,40 @@ expect(controller.enabled).to be false end end + + describe '.enabled' do + subject(:enabled) { described_class.enabled } + + context 'when enabled and disabled controllers exist' do + let!(:enabled_controller) { create(:ci_runner_controller, :enabled) } + let!(:disabled_controller) { create(:ci_runner_controller) } + + it 'returns only enabled runner controllers' do + is_expected.to include(enabled_controller) + is_expected.not_to include(disabled_controller) + end + end + + context 'when no enabled controllers exist' do + before do + create(:ci_runner_controller) + end + + it 'returns empty collection' do + is_expected.to be_empty + end + end + + context 'when multiple enabled controllers exist' do + let!(:enabled_controllers) { create_list(:ci_runner_controller, 3, :enabled) } + + before do + create(:ci_runner_controller) + end + + it 'returns all enabled controllers' do + is_expected.to match_array(enabled_controllers) + end + end + end end diff --git a/ee/spec/requests/api/internal/ci/job_router_spec.rb b/ee/spec/requests/api/internal/ci/job_router_spec.rb new file mode 100644 index 00000000000000..48602c259076b5 --- /dev/null +++ b/ee/spec/requests/api/internal/ci/job_router_spec.rb @@ -0,0 +1,211 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::Internal::Ci::JobRouter, feature_category: :continuous_integration do + let_it_be(:runner) { create(:ci_runner, :instance) } + let_it_be(:runner_controllers) { create_list(:ci_runner_controller, 3, :enabled) } + let_it_be(:disabled_runner_controllers) { create_list(:ci_runner_controller, 2) } + + let(:jwt_secret) { SecureRandom.random_bytes(Gitlab::Kas::SECRET_LENGTH) } + let(:jwt_token) do + JWT.encode( + { 'iss' => Gitlab::Kas::JWT_ISSUER, 'aud' => Gitlab::Kas::JWT_AUDIENCE }, + jwt_secret, + 'HS256' + ) + end + + let(:kas_headers) { { Gitlab::Kas::INTERNAL_API_KAS_REQUEST_HEADER => jwt_token } } + let(:runner_headers) { { API::Ci::Helpers::Runner::RUNNER_TOKEN_HEADER => runner.token } } + let(:headers) { kas_headers.merge(runner_headers) } + + before do + allow(Gitlab::Kas).to receive_messages(enabled?: true, secret: jwt_secret) + end + + describe 'GET /internal/ci/job_router/runner_controllers/job_admission' do + subject(:perform_request) { get api('/internal/ci/job_router/runner_controllers/job_admission'), headers: headers } + + shared_examples 'returns enabled runner controller ids' do + it 'returns 200 with enabled runner controller ids' do + perform_request + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to eq( + 'runner_controllers' => runner_controllers.map { |c| { 'id' => c.id } } + ) + end + end + + context 'when authenticated' do + it_behaves_like 'returns enabled runner controller ids' + + context 'when no runner controllers exist' do + let_it_be(:runner_controllers) { [] } + + before do + Ci::RunnerController.delete_all + end + + it 'returns empty array' do + perform_request + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to eq({ 'runner_controllers' => [] }) + end + end + end + + context 'when not authenticated' do + context 'without KAS authentication' do + let(:headers) { runner_headers } + + it 'returns 401' do + perform_request + + expect(response).to have_gitlab_http_status(:unauthorized) + end + end + + context 'without runner authentication' do + let(:headers) { kas_headers } + + it 'returns 403' do + perform_request + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + + context 'with invalid runner token' do + let(:runner_headers) { { API::Ci::Helpers::Runner::RUNNER_TOKEN_HEADER => 'invalid' } } + + it 'returns 403' do + perform_request + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + end + + context 'when feature flags are disabled' do + context 'when Gitlab::Kas is disabled' do + before do + allow(Gitlab::Kas).to receive(:enabled?).and_return(false) + end + + it 'returns 501' do + perform_request + + expect(response).to have_gitlab_http_status(:not_implemented) + expect(json_response['message']).to eq('Job Router is not available. Please contact your administrator.') + end + end + + context 'when job_router and job_router_instance_runners feature flags are disabled' do + before do + stub_feature_flags(job_router: false, job_router_instance_runners: false) + end + + it 'returns 501' do + perform_request + + expect(response).to have_gitlab_http_status(:not_implemented) + expect(json_response['message']).to eq('Job Router is not available. Please contact your administrator.') + end + end + + context 'when job_router_admission_control feature flag is disabled' do + before do + stub_feature_flags(job_router_admission_control: false) + end + + it 'returns 501' do + perform_request + + expect(response).to have_gitlab_http_status(:not_implemented) + expect(json_response['message']).to eq( + 'Admission Control is not available. Please contact your administrator.' + ) + end + end + + context 'when job_router, job_router_instance_runners and job_router_admission_control FFs are disabled' do + before do + stub_feature_flags( + job_router: false, + job_router_instance_runners: false, + job_router_admission_control: false + ) + end + + it 'returns 501 for job_router first' do + perform_request + + expect(response).to have_gitlab_http_status(:not_implemented) + expect(json_response['message']).to eq('Job Router is not available. Please contact your administrator.') + end + end + end + + context 'with different runner types' do + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, group: group) } + + context 'with instance runner' do + let_it_be(:runner) { create(:ci_runner, :instance) } + + it_behaves_like 'returns enabled runner controller ids' + + context 'when job_router and job_router_instance_runners feature flags are disabled' do + before do + stub_feature_flags(job_router_instance_runners: false, job_router: false) + end + + it 'returns 501' do + perform_request + + expect(response).to have_gitlab_http_status(:not_implemented) + end + end + end + + context 'with group runner' do + let_it_be(:runner) { create(:ci_runner, :group, groups: [group]) } + + it_behaves_like 'returns enabled runner controller ids' + + context 'when job_router is disabled for the group' do + before do + stub_feature_flags(job_router: false) + end + + it 'returns 501' do + perform_request + + expect(response).to have_gitlab_http_status(:not_implemented) + end + end + end + + context 'with project runner' do + let_it_be(:runner) { create(:ci_runner, :project, projects: [project]) } + + it_behaves_like 'returns enabled runner controller ids' + + context 'when job_router is disabled for the project' do + before do + stub_feature_flags(job_router: false) + end + + it 'returns 501' do + perform_request + + expect(response).to have_gitlab_http_status(:not_implemented) + end + end + end + end + end +end -- GitLab