1# frozen_string_literal: true 2 3require 'spec_helper' 4 5require 'googleauth' 6 7RSpec.describe Integrations::Prometheus, :use_clean_rails_memory_store_caching, :snowplow do 8 include PrometheusHelpers 9 include ReactiveCachingHelpers 10 11 let_it_be_with_reload(:project) { create(:prometheus_project) } 12 13 let(:integration) { project.prometheus_integration } 14 15 context 'redirects' do 16 it 'does not follow redirects' do 17 redirect_to = 'https://redirected.example.com' 18 redirect_req_stub = stub_prometheus_request(prometheus_query_url('1'), status: 302, headers: { location: redirect_to }) 19 redirected_req_stub = stub_prometheus_request(redirect_to, body: { 'status': 'success' }) 20 21 result = integration.test 22 23 # result = { success: false, result: error } 24 expect(result[:success]).to be_falsy 25 expect(result[:result]).to be_instance_of(Gitlab::PrometheusClient::UnexpectedResponseError) 26 27 expect(redirect_req_stub).to have_been_requested 28 expect(redirected_req_stub).not_to have_been_requested 29 end 30 end 31 32 describe 'Validations' do 33 context 'when manual_configuration is enabled' do 34 before do 35 integration.manual_configuration = true 36 end 37 38 it 'validates presence of api_url' do 39 expect(integration).to validate_presence_of(:api_url) 40 end 41 end 42 43 context 'when manual configuration is disabled' do 44 before do 45 integration.manual_configuration = false 46 end 47 48 it 'does not validate presence of api_url' do 49 expect(integration).not_to validate_presence_of(:api_url) 50 expect(integration.valid?).to eq(true) 51 end 52 53 context 'local connections allowed' do 54 before do 55 stub_application_setting(allow_local_requests_from_web_hooks_and_services: true) 56 end 57 58 it 'does not validate presence of api_url' do 59 expect(integration).not_to validate_presence_of(:api_url) 60 expect(integration.valid?).to eq(true) 61 end 62 end 63 end 64 65 context 'when the api_url domain points to localhost or local network' do 66 let(:domain) { Addressable::URI.parse(integration.api_url).hostname } 67 68 it 'cannot query' do 69 expect(integration.can_query?).to be true 70 71 aggregate_failures do 72 ['127.0.0.1', '192.168.2.3'].each do |url| 73 allow(Addrinfo).to receive(:getaddrinfo).with(domain, any_args).and_return([Addrinfo.tcp(url, 80)]) 74 75 expect(integration.can_query?).to be false 76 end 77 end 78 end 79 80 it 'can query when local requests are allowed' do 81 stub_application_setting(allow_local_requests_from_web_hooks_and_services: true) 82 83 aggregate_failures do 84 ['127.0.0.1', '192.168.2.3'].each do |url| 85 allow(Addrinfo).to receive(:getaddrinfo).with(domain, any_args).and_return([Addrinfo.tcp(url, 80)]) 86 87 expect(integration.can_query?).to be true 88 end 89 end 90 end 91 92 context 'with self-monitoring project and internal Prometheus' do 93 before do 94 integration.api_url = 'http://localhost:9090' 95 96 stub_application_setting(self_monitoring_project_id: project.id) 97 stub_config(prometheus: { enable: true, server_address: 'localhost:9090' }) 98 end 99 100 it 'allows self-monitoring project to connect to internal Prometheus' do 101 aggregate_failures do 102 ['127.0.0.1', '192.168.2.3'].each do |url| 103 allow(Addrinfo).to receive(:getaddrinfo).with(domain, any_args).and_return([Addrinfo.tcp(url, 80)]) 104 105 expect(integration.can_query?).to be true 106 end 107 end 108 end 109 110 it 'does not allow self-monitoring project to connect to other local URLs' do 111 integration.api_url = 'http://localhost:8000' 112 113 aggregate_failures do 114 ['127.0.0.1', '192.168.2.3'].each do |url| 115 allow(Addrinfo).to receive(:getaddrinfo).with(domain, any_args).and_return([Addrinfo.tcp(url, 80)]) 116 117 expect(integration.can_query?).to be false 118 end 119 end 120 end 121 end 122 end 123 end 124 125 describe 'callbacks' do 126 context 'after_create' do 127 let(:project) { create(:project) } 128 let(:integration) { build(:prometheus_integration, project: project) } 129 130 subject(:create_integration) { integration.save! } 131 132 it 'creates default alerts' do 133 expect(Prometheus::CreateDefaultAlertsWorker) 134 .to receive(:perform_async) 135 .with(project.id) 136 137 create_integration 138 end 139 140 context 'no project exists' do 141 let(:integration) { build(:prometheus_integration, :instance) } 142 143 it 'does not create default alerts' do 144 expect(Prometheus::CreateDefaultAlertsWorker) 145 .not_to receive(:perform_async) 146 147 create_integration 148 end 149 end 150 end 151 end 152 153 describe '#test' do 154 before do 155 integration.manual_configuration = true 156 end 157 158 let!(:req_stub) { stub_prometheus_request(prometheus_query_url('1'), body: prometheus_value_body('vector')) } 159 160 context 'success' do 161 it 'reads the discovery endpoint' do 162 expect(integration.test[:result]).to eq('Checked API endpoint') 163 expect(integration.test[:success]).to be_truthy 164 expect(req_stub).to have_been_requested.twice 165 end 166 end 167 168 context 'failure' do 169 let!(:req_stub) { stub_prometheus_request(prometheus_query_url('1'), status: 404) } 170 171 it 'fails to read the discovery endpoint' do 172 expect(integration.test[:success]).to be_falsy 173 expect(req_stub).to have_been_requested 174 end 175 end 176 end 177 178 describe '#prometheus_client' do 179 let(:api_url) { 'http://some_url' } 180 181 before do 182 integration.active = true 183 integration.api_url = api_url 184 integration.manual_configuration = manual_configuration 185 end 186 187 context 'manual configuration is enabled' do 188 let(:manual_configuration) { true } 189 190 it 'calls valid?' do 191 allow(integration).to receive(:valid?).and_call_original 192 193 expect(integration.prometheus_client).not_to be_nil 194 195 expect(integration).to have_received(:valid?) 196 end 197 end 198 199 context 'manual configuration is disabled' do 200 let(:manual_configuration) { false } 201 202 it 'no client provided' do 203 expect(integration.prometheus_client).to be_nil 204 end 205 end 206 207 context 'when local requests are allowed' do 208 let(:manual_configuration) { true } 209 let(:api_url) { 'http://192.168.1.1:9090' } 210 211 before do 212 stub_application_setting(allow_local_requests_from_web_hooks_and_services: true) 213 214 stub_prometheus_request("#{api_url}/api/v1/query?query=1") 215 end 216 217 it 'allows local requests' do 218 expect(integration.prometheus_client).not_to be_nil 219 expect { integration.prometheus_client.ping }.not_to raise_error 220 end 221 end 222 223 context 'when local requests are blocked' do 224 let(:manual_configuration) { true } 225 let(:api_url) { 'http://192.168.1.1:9090' } 226 227 before do 228 stub_application_setting(allow_local_requests_from_web_hooks_and_services: false) 229 230 stub_prometheus_request("#{api_url}/api/v1/query?query=1") 231 end 232 233 it 'blocks local requests' do 234 expect(integration.prometheus_client).to be_nil 235 end 236 237 context 'with self monitoring project and internal Prometheus URL' do 238 before do 239 stub_application_setting(allow_local_requests_from_web_hooks_and_services: false) 240 stub_application_setting(self_monitoring_project_id: project.id) 241 242 stub_config(prometheus: { 243 enable: true, 244 server_address: api_url 245 }) 246 end 247 248 it 'allows local requests' do 249 expect(integration.prometheus_client).not_to be_nil 250 expect { integration.prometheus_client.ping }.not_to raise_error 251 end 252 end 253 end 254 255 context 'behind IAP' do 256 let(:manual_configuration) { true } 257 258 let(:google_iap_service_account) do 259 { 260 type: "service_account", 261 # dummy private key generated only for this test to pass openssl validation 262 private_key: <<~KEY 263 -----BEGIN RSA PRIVATE KEY----- 264 MIIBOAIBAAJAU85LgUY5o6j6j/07GMLCNUcWJOBA1buZnNgKELayA6mSsHrIv31J 265 Y8kS+9WzGPQninea7DcM4hHA7smMgQD1BwIDAQABAkAqKxMy6PL3tn7dFL43p0ex 266 JyOtSmlVIiAZG1t1LXhE/uoLpYi5DnbYqGgu0oih+7nzLY/dXpNpXUmiRMOUEKmB 267 AiEAoTi2rBXbrLSi2C+H7M/nTOjMQQDuZ8Wr4uWpKcjYJTMCIQCFEskL565oFl/7 268 RRQVH+cARrAsAAoJSbrOBAvYZ0PI3QIgIEFwis10vgEF86rOzxppdIG/G+JL0IdD 269 9IluZuXAGPECIGUo7qSaLr75o2VEEgwtAFH5aptIPFjrL5LFCKwtdB4RAiAYZgFV 270 HCMmaooAw/eELuMoMWNYmujZ7VaAnOewGDW0uw== 271 -----END RSA PRIVATE KEY----- 272 KEY 273 } 274 end 275 276 def stub_iap_request 277 integration.google_iap_service_account_json = Gitlab::Json.generate(google_iap_service_account) 278 integration.google_iap_audience_client_id = 'IAP_CLIENT_ID.apps.googleusercontent.com' 279 280 stub_request(:post, 'https://oauth2.googleapis.com/token') 281 .to_return( 282 status: 200, 283 body: '{"id_token": "FOO"}', 284 headers: { 'Content-Type': 'application/json; charset=UTF-8' } 285 ) 286 end 287 288 it 'includes the authorization header' do 289 stub_iap_request 290 291 expect(integration.prometheus_client).not_to be_nil 292 expect(integration.prometheus_client.send(:options)).to have_key(:headers) 293 expect(integration.prometheus_client.send(:options)[:headers]).to eq(authorization: "Bearer FOO") 294 end 295 296 context 'when passed with token_credential_uri', issue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/284819' do 297 let(:malicious_host) { 'http://example.com' } 298 299 where(:param_name) do 300 [ 301 :token_credential_uri, 302 :tokencredentialuri, 303 :Token_credential_uri, 304 :tokenCredentialUri 305 ] 306 end 307 308 with_them do 309 it 'does not make any unexpected HTTP requests' do 310 google_iap_service_account[param_name] = malicious_host 311 stub_iap_request 312 stub_request(:any, malicious_host).to_raise('Making additional HTTP requests is forbidden!') 313 314 expect(integration.prometheus_client).not_to be_nil 315 end 316 end 317 end 318 end 319 end 320 321 describe '#prometheus_available?' do 322 context 'clusters with enabled prometheus' do 323 before do 324 create(:clusters_integrations_prometheus, cluster: cluster) 325 end 326 327 context 'cluster belongs to project' do 328 let(:cluster) { create(:cluster, projects: [project]) } 329 330 it 'returns true' do 331 expect(integration.prometheus_available?).to be(true) 332 end 333 end 334 335 context 'cluster belongs to projects group' do 336 let_it_be(:group) { create(:group) } 337 338 let(:project) { create(:prometheus_project, group: group) } 339 let(:cluster) { create(:cluster_for_group, groups: [group]) } 340 341 it 'returns true' do 342 expect(integration.prometheus_available?).to be(true) 343 end 344 345 it 'avoids N+1 queries' do 346 integration 347 5.times do |i| 348 other_cluster = create(:cluster_for_group, groups: [group], environment_scope: i) 349 create(:clusters_integrations_prometheus, cluster: other_cluster) 350 end 351 expect { integration.prometheus_available? }.not_to exceed_query_limit(1) 352 end 353 end 354 355 context 'cluster belongs to gitlab instance' do 356 let(:cluster) { create(:cluster, :instance) } 357 358 it 'returns true' do 359 expect(integration.prometheus_available?).to be(true) 360 end 361 end 362 end 363 364 context 'clusters with prometheus disabled' do 365 let(:cluster) { create(:cluster, projects: [project]) } 366 let!(:prometheus) { create(:clusters_integrations_prometheus, :disabled, cluster: cluster) } 367 368 it 'returns false' do 369 expect(integration.prometheus_available?).to be(false) 370 end 371 end 372 373 context 'clusters without prometheus' do 374 let(:cluster) { create(:cluster, projects: [project]) } 375 376 it 'returns false' do 377 expect(integration.prometheus_available?).to be(false) 378 end 379 end 380 381 context 'no clusters' do 382 it 'returns false' do 383 expect(integration.prometheus_available?).to be(false) 384 end 385 end 386 end 387 388 describe '#synchronize_service_state before_save callback' do 389 context 'no clusters with prometheus are installed' do 390 context 'when integration is inactive' do 391 before do 392 integration.active = false 393 end 394 395 it 'activates integration when manual_configuration is enabled' do 396 expect { integration.update!(manual_configuration: true) }.to change { integration.active }.from(false).to(true) 397 end 398 399 it 'keeps integration inactive when manual_configuration is disabled' do 400 expect { integration.update!(manual_configuration: false) }.not_to change { integration.active }.from(false) 401 end 402 end 403 404 context 'when integration is active' do 405 before do 406 integration.active = true 407 end 408 409 it 'keeps the integration active when manual_configuration is enabled' do 410 expect { integration.update!(manual_configuration: true) }.not_to change { integration.active }.from(true) 411 end 412 413 it 'inactivates the integration when manual_configuration is disabled' do 414 expect { integration.update!(manual_configuration: false) }.to change { integration.active }.from(true).to(false) 415 end 416 end 417 end 418 419 context 'with prometheus installed in the cluster' do 420 before do 421 allow(integration).to receive(:prometheus_available?).and_return(true) 422 end 423 424 context 'when integration is inactive' do 425 before do 426 integration.active = false 427 end 428 429 it 'activates integration when manual_configuration is enabled' do 430 expect { integration.update!(manual_configuration: true) }.to change { integration.active }.from(false).to(true) 431 end 432 433 it 'activates integration when manual_configuration is disabled' do 434 expect { integration.update!(manual_configuration: false) }.to change { integration.active }.from(false).to(true) 435 end 436 end 437 438 context 'when integration is active' do 439 before do 440 integration.active = true 441 end 442 443 it 'keeps integration active when manual_configuration is enabled' do 444 expect { integration.update!(manual_configuration: true) }.not_to change { integration.active }.from(true) 445 end 446 447 it 'keeps integration active when manual_configuration is disabled' do 448 expect { integration.update!(manual_configuration: false) }.not_to change { integration.active }.from(true) 449 end 450 end 451 end 452 end 453 454 describe '#track_events after_commit callback' do 455 before do 456 allow(integration).to receive(:prometheus_available?).and_return(true) 457 end 458 459 context "enabling manual_configuration" do 460 it "tracks enable event" do 461 integration.update!(manual_configuration: false) 462 integration.update!(manual_configuration: true) 463 464 expect_snowplow_event(category: 'cluster:services:prometheus', action: 'enabled_manual_prometheus') 465 end 466 467 it "tracks disable event" do 468 integration.update!(manual_configuration: true) 469 integration.update!(manual_configuration: false) 470 471 expect_snowplow_event(category: 'cluster:services:prometheus', action: 'disabled_manual_prometheus') 472 end 473 end 474 end 475 476 describe '#editable?' do 477 it 'is editable' do 478 expect(integration.editable?).to be(true) 479 end 480 481 context 'when cluster exists with prometheus enabled' do 482 let(:cluster) { create(:cluster, projects: [project]) } 483 484 before do 485 integration.update!(manual_configuration: false) 486 487 create(:clusters_integrations_prometheus, cluster: cluster) 488 end 489 490 it 'remains editable' do 491 expect(integration.editable?).to be(true) 492 end 493 end 494 end 495 496 describe '#fields' do 497 let(:expected_fields) do 498 [ 499 { 500 type: 'checkbox', 501 name: 'manual_configuration', 502 title: s_('PrometheusService|Active'), 503 help: s_('PrometheusService|Select this checkbox to override the auto configuration settings with your own settings.'), 504 required: true 505 }, 506 { 507 type: 'text', 508 name: 'api_url', 509 title: 'API URL', 510 placeholder: s_('PrometheusService|https://prometheus.example.com/'), 511 help: s_('PrometheusService|The Prometheus API base URL.'), 512 required: true 513 }, 514 { 515 type: 'text', 516 name: 'google_iap_audience_client_id', 517 title: 'Google IAP Audience Client ID', 518 placeholder: s_('PrometheusService|IAP_CLIENT_ID.apps.googleusercontent.com'), 519 help: s_('PrometheusService|The ID of the IAP-secured resource.'), 520 autocomplete: 'off', 521 required: false 522 }, 523 { 524 type: 'textarea', 525 name: 'google_iap_service_account_json', 526 title: 'Google IAP Service Account JSON', 527 placeholder: s_('PrometheusService|{ "type": "service_account", "project_id": ... }'), 528 help: s_('PrometheusService|The contents of the credentials.json file of your service account.'), 529 required: false 530 } 531 ] 532 end 533 534 it 'returns fields' do 535 expect(integration.fields).to eq(expected_fields) 536 end 537 end 538end 539