diff --git a/doc/development/internal_api/index.md b/doc/development/internal_api/index.md index 93b3c7dec6daa5ed9c1ffdd04b02b71aae74251b..30976a88cf62768d9c341d7cbe30091b7a751f07 100644 --- a/doc/development/internal_api/index.md +++ b/doc/development/internal_api/index.md @@ -554,6 +554,46 @@ curl --request POST --header "Gitlab-Kas-Api-Request: <JWT token>" --header "Con --data '{"counters": {"gitops_sync":1}}' "http://localhost:3000/api/v4/internal/kubernetes/usage_metrics" ``` +### GitLab agent events + +Called from GitLab agent server (`kas`) to track events. + +| Attribute | Type | Required | Description | +|:------------------------------------------------------------------------------|:--------------|:---------|:--------------------------------------------------------------------------| +| `events` | hash | no | Hash of events | +| `events["k8s_api_proxy_requests_unique_users_via_ci_access"]` | hash array | no | Array of events for `k8s_api_proxy_requests_unique_users_via_ci_access` | +| `events["k8s_api_proxy_requests_unique_users_via_ci_access"]["user_id"]` | integer | no | The user ID for the event | +| `events["k8s_api_proxy_requests_unique_users_via_ci_access"]["project_id"]` | integer | no | The project ID for the event | +| `events["k8s_api_proxy_requests_unique_users_via_user_access"]` | hash array | no | Array of events for `k8s_api_proxy_requests_unique_users_via_user_access` | +| `events["k8s_api_proxy_requests_unique_users_via_user_access"]["user_id"]` | integer | no | The user ID for the event | +| `events["k8s_api_proxy_requests_unique_users_via_user_access"]["project_id"]` | integer | no | The project ID for the event | +| `events["k8s_api_proxy_requests_unique_users_via_pat_access"]` | hash array | no | Array of events for `k8s_api_proxy_requests_unique_users_via_pat_access` | +| `events["k8s_api_proxy_requests_unique_users_via_pat_access"]["user_id"]` | integer | no | The user ID for the event | +| `events["k8s_api_proxy_requests_unique_users_via_pat_access"]["project_id"]` | integer | no | The project ID for the event | + +```plaintext +POST /internal/kubernetes/agent_events +``` + +Example Request: + +```shell +curl --request POST \ + --url "http://localhost:3000/api/v4/internal/kubernetes/agent_events" \ + --header "Gitlab-Kas-Api-Request: <JWT token>" \ + --header "Content-Type: application/json" \ + --data '{ + "events": { + "k8s_api_proxy_requests_unique_users_via_ci_access": [ + { + "user_id": 1, + "project_id": 1 + } + ] + } + }' +``` + ### Create Starboard vulnerability Called from the GitLab agent server (`kas`) to create a security vulnerability diff --git a/lib/api/helpers/kubernetes/agent_helpers.rb b/lib/api/helpers/kubernetes/agent_helpers.rb index ff80270fc15eac7c38445250d0274a64cfdf44e1..88fd52ddd2b5c6db9034c8b2bf27fd5396c7f049 100644 --- a/lib/api/helpers/kubernetes/agent_helpers.rb +++ b/lib/api/helpers/kubernetes/agent_helpers.rb @@ -54,6 +54,20 @@ def increment_unique_events end end + def track_events + event_lists = params[:events]&.slice( + :k8s_api_proxy_requests_unique_users_via_ci_access, + :k8s_api_proxy_requests_unique_users_via_user_access, + :k8s_api_proxy_requests_unique_users_via_pat_access + ) + return if event_lists.blank? + + users, projects = load_users_and_projects(event_lists) + event_lists.each do |event_name, events| + track_events_for(event_name, events, users, projects) + end + end + def track_unique_user_events events = params[:unique_counters]&.slice( :k8s_api_proxy_requests_unique_users_via_ci_access, @@ -130,6 +144,29 @@ def access_token PersonalAccessToken.find_by_token(params[:access_key]) end strong_memoize_attr :access_token + + private + + def load_users_and_projects(event_lists) + all_events = event_lists.values.flatten + unique_user_ids = all_events.pluck('user_id').compact.uniq # rubocop:disable CodeReuse/ActiveRecord -- this pluck isn't from ActiveRecord, it's from ActiveSupport + unique_project_ids = all_events.pluck('project_id').compact.uniq # rubocop:disable CodeReuse/ActiveRecord -- this pluck isn't from ActiveRecord, it's from ActiveSupport + users = User.id_in(unique_user_ids).index_by(&:id) + projects = Project.id_in(unique_project_ids).index_by(&:id) + [users, projects] + end + + def track_events_for(event_name, events, users, projects) + events.each do |event| + next if event.blank? + + user = users[event[:user_id]] + project = projects[event[:project_id]] + next if user.nil? || project.nil? + + Gitlab::InternalEvents.track_event(event_name, user: user, project: project) + end + end end end end diff --git a/lib/api/internal/kubernetes.rb b/lib/api/internal/kubernetes.rb index c6538e17b88cdb2a4276c38a547ebb3f58fabbd4..d3a4d94f8cac6d15daf06c8ea2a655192e45197d 100644 --- a/lib/api/internal/kubernetes.rb +++ b/lib/api/internal/kubernetes.rb @@ -147,6 +147,33 @@ class Kubernetes < ::API::Base bad_request!(e.message) end end + + namespace 'kubernetes/agent_events' do + desc 'POST agent events' do + detail 'Updates agent events' + end + params do + optional :events, type: Hash, desc: 'Array of events' do + optional :k8s_api_proxy_requests_unique_users_via_ci_access, type: Array, desc: 'An array of events that have interacted with the CI tunnel via `ci_access`' do + optional :user_id, type: Integer, desc: 'User ID' + optional :project_id, type: Integer, desc: 'Project ID' + end + optional :k8s_api_proxy_requests_unique_users_via_user_access, type: Array, desc: 'An array of events that have interacted with the CI tunnel via `ci_access`' do + optional :user_id, type: Integer, desc: 'User ID' + optional :project_id, type: Integer, desc: 'Project ID' + end + optional :k8s_api_proxy_requests_unique_users_via_pat_access, type: Array, desc: 'An array of events that have interacted with the CI tunnel via `ci_access`' do + optional :user_id, type: Integer, desc: 'User ID' + optional :project_id, type: Integer, desc: 'Project ID' + end + end + end + post '/', feature_category: :deployment_management do + track_events + + no_content! + end + end end end end diff --git a/spec/requests/api/internal/kubernetes_spec.rb b/spec/requests/api/internal/kubernetes_spec.rb index f16fdcb9cc73a68ecbc4bd6ddc9ddda5b37473d0..1f0e2a079b83e0eec7b76e683856ebcc53054e4a 100644 --- a/spec/requests/api/internal/kubernetes_spec.rb +++ b/spec/requests/api/internal/kubernetes_spec.rb @@ -162,6 +162,87 @@ def send_request(headers: {}, params: {}) end end + describe 'POST /internal/kubernetes/agent_events', :clean_gitlab_redis_shared_state do + def send_request(headers: {}, params: {}) + post api('/internal/kubernetes/agent_events'), params: params, headers: headers.reverse_merge(jwt_auth_headers) + end + + include_examples 'authorization' + include_examples 'error handling' + + context 'is authenticated for an agent' do + let!(:agent_token) { create(:cluster_agent_token) } + + context 'when events are valid' do + let(:request_count) { 2 } + let(:users) { create_list(:user, 3).index_by(&:id) } + let(:projects) { create_list(:project, 3).index_by(&:id) } + let(:events) do + user_ids = users.keys + project_ids = projects.keys + event_data = Array.new(3) do |i| + { user_id: user_ids[i], project_id: project_ids[i] } + end + { + k8s_api_proxy_requests_unique_users_via_ci_access: event_data, + k8s_api_proxy_requests_unique_users_via_user_access: event_data, + k8s_api_proxy_requests_unique_users_via_pat_access: event_data + } + end + + it 'tracks events and returns no_content', :aggregate_failures do + events.each do |event_name, event_list| + event_list.each do |event| + expect(Gitlab::InternalEvents).to receive(:track_event) + .with(event_name.to_s, user: users[event[:user_id]], project: projects[event[:project_id]]) + .exactly(request_count).times + end + end + + request_count.times do + send_request(params: { events: events }) + end + + expect(response).to have_gitlab_http_status(:no_content) + end + end + + context 'when events are empty' do + let(:events) do + { + k8s_api_proxy_requests_unique_users_via_ci_access: [], + k8s_api_proxy_requests_unique_users_via_user_access: [], + k8s_api_proxy_requests_unique_users_via_pat_access: [] + } + end + + it 'returns no_content for empty events' do + expect(Gitlab::InternalEvents).not_to receive(:track_event) + send_request(params: { events: events }) + + expect(response).to have_gitlab_http_status(:no_content) + end + end + + context 'when events have non-integer values' do + let(:events) do + { + k8s_api_proxy_requests_unique_users_via_ci_access: [ + { user_id: 'string', project_id: 111 } + ] + } + end + + it 'returns 400 for non-integer values' do + expect(Gitlab::InternalEvents).not_to receive(:track_event) + send_request(params: { events: events }) + + expect(response).to have_gitlab_http_status(:bad_request) + end + end + end + end + describe 'POST /internal/kubernetes/agent_configuration' do def send_request(headers: {}, params: {}) post api('/internal/kubernetes/agent_configuration'), params: params, headers: headers.reverse_merge(jwt_auth_headers)