From d044a844567df03249929b926d43ace8a20a669c Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Thu, 27 Mar 2025 12:07:11 +0000 Subject: [PATCH] Add latest changes from gitlab-org/gitlab@master --- GITALY_SERVER_VERSION | 2 +- Gemfile | 4 +- Gemfile.checksum | 11 +- Gemfile.lock | 23 +- Gemfile.next.checksum | 12 +- Gemfile.next.lock | 23 +- ...ere_user_can_import_projects.query.graphql | 2 +- app/assets/javascripts/merge_request_tabs.js | 4 +- .../components/notes/work_item_discussion.vue | 3 + .../components/notes/work_item_note.vue | 6 + app/controllers/glql/base_controller.rb | 2 +- .../concerns/web_hooks/auto_disabling.rb | 77 ++-- app/services/web_hook_service.rb | 4 +- .../web_hooks/log_execution_service.rb | 6 +- app/views/shared/web_hooks/_hook.html.haml | 6 +- .../shared/web_hooks/_hook_errors.html.haml | 12 +- config/initializers/postgres_partitioning.rb | 1 + db/docs/ai_troubleshoot_job_events.yml | 13 + ...55814_create_ai_troubleshoot_job_events.rb | 28 ++ ...rate_old_disabled_web_hook_to_new_state.rb | 21 + ...rate_old_disabled_web_hook_to_new_state.rb | 32 ++ ...web_hooks_for_migrate_disabled_web_hook.rb | 21 + db/schema_migrations/20250312155814 | 1 + db/schema_migrations/20250317021351 | 1 + db/schema_migrations/20250317021451 | 1 + db/schema_migrations/20250317021551 | 1 + db/structure.sql | 38 ++ .../gitlab_duo_self_hosted/troubleshooting.md | 28 ++ doc/user/gitlab_duo_chat/troubleshooting.md | 3 +- doc/user/glql/_index.md | 5 - doc/user/glql/fields.md | 10 +- .../integrations/img/failed_badges_v14_9.png | Bin 15999 -> 0 bytes .../integrations/img/failed_badges_v17_11.png | Bin 0 -> 40445 bytes doc/user/project/integrations/webhooks.md | 35 +- .../packages/nuget/public_endpoints.rb | 2 +- lib/api/helpers.rb | 6 +- lib/api/integrations/slack/request.rb | 4 +- lib/api/npm_project_packages.rb | 2 +- lib/api/nuget_project_packages.rb | 2 +- lib/api/releases.rb | 4 +- .../modules/v1/namespace_packages.rb | 2 +- .../terraform/modules/v1/project_packages.rb | 2 +- .../loggers/filter_parameters.rb | 8 +- lib/tasks/ci/job_tokens_task.rb | 8 +- locale/gitlab.pot | 28 +- qa/gdk/gdk.yml | 2 +- spec/controllers/glql/base_controller_spec.rb | 8 +- spec/db/schema_spec.rb | 1 + spec/factories/project_hooks.rb | 2 +- .../components/import_target_dropdown_spec.js | 15 + spec/frontend/import_entities/mock_data.js | 4 + spec/frontend/merge_request_tabs_spec.js | 39 +- spec/lib/api/api_spec.rb | 2 +- .../loggers/filter_parameters_spec.rb | 6 +- spec/lib/gitlab/utils/batch_loader_spec.rb | 4 +- ...old_disabled_web_hook_to_new_state_spec.rb | 57 +++ spec/models/project_spec.rb | 2 +- spec/requests/api/api_spec.rb | 4 +- spec/requests/api/groups_spec.rb | 4 +- spec/requests/api/helpers_spec.rb | 4 +- spec/requests/api/projects_spec.rb | 4 +- spec/requests/api/suggestions_spec.rb | 4 +- spec/requests/api/topics_spec.rb | 18 +- spec/requests/api/users_spec.rb | 4 +- spec/services/web_hook_service_spec.rb | 2 +- .../web_hooks/log_execution_service_spec.rb | 22 - .../webhook_autodisabling_shared_context.rb | 32 ++ .../auto_disabling_hooks_shared_examples.rb | 436 +++++++++--------- .../unstoppable_hooks_shared_examples.rb | 120 ++--- .../has_web_hooks_shared_examples.rb | 2 +- .../web_hooks/web_hook_shared_examples.rb | 146 +----- .../requests/api/hooks_shared_examples.rb | 4 +- .../api/protection_rules_shared_examples.rb | 4 +- spec/tasks/ci/job_tokens_task_spec.rb | 48 +- .../projects/hooks/edit.html.haml_spec.rb | 4 +- .../projects/hooks/index.html.haml_spec.rb | 18 +- .../merge_request_cleanup_refs_worker_spec.rb | 4 +- 77 files changed, 832 insertions(+), 698 deletions(-) create mode 100644 db/docs/ai_troubleshoot_job_events.yml create mode 100644 db/migrate/20250312155814_create_ai_troubleshoot_job_events.rb create mode 100644 db/post_migrate/20250317021351_add_temporary_index_to_web_hooks_for_migrate_old_disabled_web_hook_to_new_state.rb create mode 100644 db/post_migrate/20250317021451_migrate_old_disabled_web_hook_to_new_state.rb create mode 100644 db/post_migrate/20250317021551_remove_temporary_index_from_web_hooks_for_migrate_disabled_web_hook.rb create mode 100644 db/schema_migrations/20250312155814 create mode 100644 db/schema_migrations/20250317021351 create mode 100644 db/schema_migrations/20250317021451 create mode 100644 db/schema_migrations/20250317021551 delete mode 100644 doc/user/project/integrations/img/failed_badges_v14_9.png create mode 100644 doc/user/project/integrations/img/failed_badges_v17_11.png create mode 100644 spec/migrations/db/post_migrate/20250317021451_migrate_old_disabled_web_hook_to_new_state_spec.rb create mode 100644 spec/support/shared_contexts/models/concerns/webhooks/webhook_autodisabling_shared_context.rb diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 7e5f92272e5..23f930b6bdb 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -4f276256d6ef947d1b8b12671dd6878f9fc513c3 +1841bc63148c1743d4334f8dbeffbd54dee24d83 diff --git a/Gemfile b/Gemfile index 73dba0f0ca8..28d5cf5b00d 100644 --- a/Gemfile +++ b/Gemfile @@ -156,7 +156,7 @@ gem 'gitlab_omniauth-ldap', '~> 2.2.0', require: 'omniauth-ldap', feature_catego gem 'net-ldap', '~> 0.17.1', feature_category: :system_access # API -gem 'grape', '~> 2.0.0', feature_category: :api +gem 'grape', '~> 2.1.0', feature_category: :api gem 'grape-entity', '~> 1.0.1', feature_category: :api gem 'grape-swagger', '~> 2.1.2', group: [:development, :test], feature_category: :api gem 'grape-swagger-entity', '~> 0.5.5', group: [:development, :test], feature_category: :api @@ -756,4 +756,4 @@ gem 'paper_trail', '~> 15.0', feature_category: :shared gem "i18n_data", "~> 0.13.1", feature_category: :system_access -gem "gitlab-cloud-connector", "~> 1.0.0", require: 'gitlab/cloud_connector', feature_category: :cloud_connector +gem "gitlab-cloud-connector", "~> 1.4", require: 'gitlab/cloud_connector', feature_category: :cloud_connector diff --git a/Gemfile.checksum b/Gemfile.checksum index 32bcd7829fb..e3e3ddf24df 100644 --- a/Gemfile.checksum +++ b/Gemfile.checksum @@ -220,7 +220,7 @@ {"name":"gitaly","version":"17.8.4","platform":"ruby","checksum":"196d9735a83f8a7d396baa216b979eb0c801622d8b7573f90010338d5b0c7b4f"}, {"name":"gitlab","version":"4.19.0","platform":"ruby","checksum":"3f645e3e195dbc24f0834fbf83e8ccfb2056d8e9712b01a640aad418a6949679"}, {"name":"gitlab-chronic","version":"0.10.6","platform":"ruby","checksum":"a244d11a1396d2aac6ae9b2f326adf1605ec1ad20c29f06e8b672047d415a9ac"}, -{"name":"gitlab-cloud-connector","version":"1.0.0","platform":"ruby","checksum":"edf2d13c698e1eb8a6828acefa7c12230f1e47d48212710f1617d5f986d051af"}, +{"name":"gitlab-cloud-connector","version":"1.4.0","platform":"ruby","checksum":"01083b3a03f6db3d2da116cc7e8f3a121eaa91240a738a503b80199e6d8ccd90"}, {"name":"gitlab-dangerfiles","version":"4.8.1","platform":"ruby","checksum":"bbad321c9638152a643d27a20b35ba1e2d8eddcc6bdfc4493d7b96e816ecf300"}, {"name":"gitlab-experiment","version":"0.9.1","platform":"ruby","checksum":"f230ee742154805a755d5f2539dc44d93cdff08c5bbbb7656018d61f93d01f48"}, {"name":"gitlab-fog-azure-rm","version":"2.2.0","platform":"ruby","checksum":"31aa7c2170f57874053144e7f716ec9e15f32e71ffbd2c56753dce46e2e78ba9"}, @@ -286,7 +286,7 @@ {"name":"googleapis-common-protos-types","version":"1.18.0","platform":"ruby","checksum":"280d19dcf431f86b9ff7ed8b23994915c5f4f7a0282ad3e870a2d3595976559f"}, {"name":"googleauth","version":"1.8.1","platform":"ruby","checksum":"814adadaaa1221dce72a67131e3ecbd6d23491a161ec84fb15fd353b87d8c9e7"}, {"name":"gpgme","version":"2.0.24","platform":"ruby","checksum":"53eccd7042abb4fd5c78f30bc9ed075b1325e6450eab207f2f6a1e7e28ae3b64"}, -{"name":"grape","version":"2.0.0","platform":"ruby","checksum":"3aeff94c17e84ccead4ff98833df691e7da0c108878cc128ca31f80c1047494a"}, +{"name":"grape","version":"2.1.3","platform":"ruby","checksum":"62973acde2a89ac275baa9efb67adcc9240b83b0016ca561807f81a829a47e37"}, {"name":"grape-entity","version":"1.0.1","platform":"ruby","checksum":"e00f9e94e407aff77aa2945d741f544d07e48501927942988799913151d02634"}, {"name":"grape-path-helpers","version":"2.0.1","platform":"ruby","checksum":"ad5216e52c6e796738a9118087352ab4c962900dbad1d8f8c0f96e093c6702d7"}, {"name":"grape-swagger","version":"2.1.2","platform":"ruby","checksum":"8ad7bd53c8baee704575808875dba8c08d269c457db3cf8f1b8a2a1dbf827294"}, @@ -345,8 +345,8 @@ {"name":"jira-ruby","version":"2.3.0","platform":"ruby","checksum":"abf26e6bff4a8ea40bae06f7df6276a5776905c63fb2070934823ca54f62eb62"}, {"name":"jmespath","version":"1.6.2","platform":"ruby","checksum":"238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1"}, {"name":"js_regex","version":"3.8.0","platform":"ruby","checksum":"7934bcdd5a0e6d5af4a520288fd4684a02a472ae55831d9178ccaf82356344b5"}, -{"name":"json","version":"2.10.1","platform":"java","checksum":"de07233fb74113af2186eb9342f8207c9be0faf289a1e2623c9b0acb8b0b0ee1"}, -{"name":"json","version":"2.10.1","platform":"ruby","checksum":"ddc88ad91a1baf3f0038c174f253af3b086d30dc74db17ca4259bbde982f94dc"}, +{"name":"json","version":"2.10.2","platform":"java","checksum":"fe31faac61ea21ea1448c35450183f84e85c2b94cc6522c241959ba9d1362006"}, +{"name":"json","version":"2.10.2","platform":"ruby","checksum":"34e0eada93022b2a0a3345bb0b5efddb6e9ff5be7c48e409cfb54ff8a36a8b06"}, {"name":"json-jwt","version":"1.16.6","platform":"ruby","checksum":"ab451f9cd8743cecc4137f4170806046c1d8a6d4ee6e8570e0b5c958409b266c"}, {"name":"json_schemer","version":"2.3.0","platform":"ruby","checksum":"9f1fa173b859ca520f15e9e8d08b0892ffca80b78dd8221feb3e360ff4cdeb35"}, {"name":"jsonb_accessor","version":"1.4","platform":"java","checksum":"2c5590d33d89c7b929d5cf38ae3d2c52658bf6f84f03b06ede5c88e9d76f3451"}, @@ -407,7 +407,7 @@ {"name":"multipart-post","version":"2.2.3","platform":"ruby","checksum":"462979de2971b8df33c2ee797fd497731617241f9dcd93960cc3caccb2dd13d8"}, {"name":"murmurhash3","version":"0.1.7","platform":"ruby","checksum":"370a2ce2e9ab0711e51554e530b5f63956927a6554a296855f42a1a4a5ed0936"}, {"name":"mustermann","version":"3.0.0","platform":"ruby","checksum":"6d3569aa3c3b2f048c60626f48d9b2d561cc8d2ef269296943b03da181c08b67"}, -{"name":"mustermann-grape","version":"1.0.2","platform":"ruby","checksum":"6f5309d6a338f801f211c644e8c2d3cc2577a8693f9cd51dadfdb29c1260f5fe"}, +{"name":"mustermann-grape","version":"1.1.0","platform":"ruby","checksum":"8d258a986004c8f01ce4c023c0b037c168a9ed889cf5778068ad54398fa458c5"}, {"name":"mutex_m","version":"0.3.0","platform":"ruby","checksum":"cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751"}, {"name":"nap","version":"1.1.0","platform":"ruby","checksum":"949691660f9d041d75be611bb2a8d2fd559c467537deac241f4097d9b5eea576"}, {"name":"nenv","version":"0.3.0","platform":"ruby","checksum":"d9de6d8fb7072228463bf61843159419c969edb34b3cef51832b516ae7972765"}, @@ -541,7 +541,6 @@ {"name":"racc","version":"1.8.1","platform":"java","checksum":"54f2e6d1e1b91c154013277d986f52a90e5ececbe91465d29172e49342732b98"}, {"name":"racc","version":"1.8.1","platform":"ruby","checksum":"4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f"}, {"name":"rack","version":"2.2.11","platform":"ruby","checksum":"424c49affa19081e9255d65d861f2d7bc7d8388edc0cb608b5e6caf1dd49bb8a"}, -{"name":"rack-accept","version":"0.4.5","platform":"ruby","checksum":"66247b5449db64ebb93ae2ec4af4764b87d1ae8a7463c7c68893ac13fa8d4da2"}, {"name":"rack-attack","version":"6.7.0","platform":"ruby","checksum":"3ca47e8f66cd33b2c96af53ea4754525cd928ed3fa8da10ee6dad0277791d77c"}, {"name":"rack-cors","version":"2.0.2","platform":"ruby","checksum":"415d4e1599891760c5dc9ef0349c7fecdf94f7c6a03e75b2e7c2b54b82adda1b"}, {"name":"rack-oauth2","version":"2.2.1","platform":"ruby","checksum":"c73aa87c508043e2258f02b4fb110cacba9b37d2ccf884e22487d014a120d1a5"}, diff --git a/Gemfile.lock b/Gemfile.lock index 14e81592020..c6149e210ff 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -742,7 +742,7 @@ GEM terminal-table (>= 1.5.1) gitlab-chronic (0.10.6) numerizer (~> 0.2) - gitlab-cloud-connector (1.0.0) + gitlab-cloud-connector (1.4.0) activesupport (~> 7.0) jwt (~> 2.9.3) gitlab-dangerfiles (4.8.1) @@ -922,13 +922,12 @@ GEM signet (>= 0.16, < 2.a) gpgme (2.0.24) mini_portile2 (~> 2.7) - grape (2.0.0) - activesupport (>= 5) - builder + grape (2.1.3) + activesupport (>= 6) dry-types (>= 1.1) - mustermann-grape (~> 1.0.0) - rack (>= 1.3.0) - rack-accept + mustermann-grape (~> 1.1.0) + rack (>= 2) + zeitwerk grape-entity (1.0.1) activesupport (>= 3.0.0) multi_json (>= 1.3.2) @@ -1052,7 +1051,7 @@ GEM character_set (~> 1.4) regexp_parser (~> 2.5) regexp_property_values (~> 1.0) - json (2.10.1) + json (2.10.2) json-jwt (1.16.6) activesupport (>= 4.2) aes_key_wrap @@ -1187,7 +1186,7 @@ GEM murmurhash3 (0.1.7) mustermann (3.0.0) ruby2_keywords (~> 0.0.1) - mustermann-grape (1.0.2) + mustermann-grape (1.1.0) mustermann (>= 1.0.0) mutex_m (0.3.0) nap (1.1.0) @@ -1492,8 +1491,6 @@ GEM raabro (1.4.0) racc (1.8.1) rack (2.2.11) - rack-accept (0.4.5) - rack (>= 0.4) rack-attack (6.7.0) rack (>= 1.0, < 4) rack-cors (2.0.2) @@ -2099,7 +2096,7 @@ DEPENDENCIES gitlab-active-context! gitlab-backup-cli! gitlab-chronic (~> 0.10.5) - gitlab-cloud-connector (~> 1.0.0) + gitlab-cloud-connector (~> 1.4) gitlab-dangerfiles (~> 4.8.0) gitlab-duo-workflow-service-client (~> 0.1)! gitlab-experiment (~> 0.9.1) @@ -2145,7 +2142,7 @@ DEPENDENCIES google-protobuf (~> 3.25, >= 3.25.3) googleauth (~> 1.8.1) gpgme (~> 2.0.24) - grape (~> 2.0.0) + grape (~> 2.1.0) grape-entity (~> 1.0.1) grape-path-helpers (~> 2.0.1) grape-swagger (~> 2.1.2) diff --git a/Gemfile.next.checksum b/Gemfile.next.checksum index 3c9280b26ab..07a86e415d9 100644 --- a/Gemfile.next.checksum +++ b/Gemfile.next.checksum @@ -220,7 +220,7 @@ {"name":"gitaly","version":"17.8.4","platform":"ruby","checksum":"196d9735a83f8a7d396baa216b979eb0c801622d8b7573f90010338d5b0c7b4f"}, {"name":"gitlab","version":"4.19.0","platform":"ruby","checksum":"3f645e3e195dbc24f0834fbf83e8ccfb2056d8e9712b01a640aad418a6949679"}, {"name":"gitlab-chronic","version":"0.10.6","platform":"ruby","checksum":"a244d11a1396d2aac6ae9b2f326adf1605ec1ad20c29f06e8b672047d415a9ac"}, -{"name":"gitlab-cloud-connector","version":"1.0.0","platform":"ruby","checksum":"edf2d13c698e1eb8a6828acefa7c12230f1e47d48212710f1617d5f986d051af"}, +{"name":"gitlab-cloud-connector","version":"1.4.0","platform":"ruby","checksum":"01083b3a03f6db3d2da116cc7e8f3a121eaa91240a738a503b80199e6d8ccd90"}, {"name":"gitlab-dangerfiles","version":"4.8.1","platform":"ruby","checksum":"bbad321c9638152a643d27a20b35ba1e2d8eddcc6bdfc4493d7b96e816ecf300"}, {"name":"gitlab-experiment","version":"0.9.1","platform":"ruby","checksum":"f230ee742154805a755d5f2539dc44d93cdff08c5bbbb7656018d61f93d01f48"}, {"name":"gitlab-fog-azure-rm","version":"2.2.0","platform":"ruby","checksum":"31aa7c2170f57874053144e7f716ec9e15f32e71ffbd2c56753dce46e2e78ba9"}, @@ -286,7 +286,7 @@ {"name":"googleapis-common-protos-types","version":"1.18.0","platform":"ruby","checksum":"280d19dcf431f86b9ff7ed8b23994915c5f4f7a0282ad3e870a2d3595976559f"}, {"name":"googleauth","version":"1.8.1","platform":"ruby","checksum":"814adadaaa1221dce72a67131e3ecbd6d23491a161ec84fb15fd353b87d8c9e7"}, {"name":"gpgme","version":"2.0.24","platform":"ruby","checksum":"53eccd7042abb4fd5c78f30bc9ed075b1325e6450eab207f2f6a1e7e28ae3b64"}, -{"name":"grape","version":"2.0.0","platform":"ruby","checksum":"3aeff94c17e84ccead4ff98833df691e7da0c108878cc128ca31f80c1047494a"}, +{"name":"grape","version":"2.1.3","platform":"ruby","checksum":"62973acde2a89ac275baa9efb67adcc9240b83b0016ca561807f81a829a47e37"}, {"name":"grape-entity","version":"1.0.1","platform":"ruby","checksum":"e00f9e94e407aff77aa2945d741f544d07e48501927942988799913151d02634"}, {"name":"grape-path-helpers","version":"2.0.1","platform":"ruby","checksum":"ad5216e52c6e796738a9118087352ab4c962900dbad1d8f8c0f96e093c6702d7"}, {"name":"grape-swagger","version":"2.1.2","platform":"ruby","checksum":"8ad7bd53c8baee704575808875dba8c08d269c457db3cf8f1b8a2a1dbf827294"}, @@ -348,8 +348,8 @@ {"name":"jira-ruby","version":"2.3.0","platform":"ruby","checksum":"abf26e6bff4a8ea40bae06f7df6276a5776905c63fb2070934823ca54f62eb62"}, {"name":"jmespath","version":"1.6.2","platform":"ruby","checksum":"238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1"}, {"name":"js_regex","version":"3.8.0","platform":"ruby","checksum":"7934bcdd5a0e6d5af4a520288fd4684a02a472ae55831d9178ccaf82356344b5"}, -{"name":"json","version":"2.10.1","platform":"java","checksum":"de07233fb74113af2186eb9342f8207c9be0faf289a1e2623c9b0acb8b0b0ee1"}, -{"name":"json","version":"2.10.1","platform":"ruby","checksum":"ddc88ad91a1baf3f0038c174f253af3b086d30dc74db17ca4259bbde982f94dc"}, +{"name":"json","version":"2.10.2","platform":"java","checksum":"fe31faac61ea21ea1448c35450183f84e85c2b94cc6522c241959ba9d1362006"}, +{"name":"json","version":"2.10.2","platform":"ruby","checksum":"34e0eada93022b2a0a3345bb0b5efddb6e9ff5be7c48e409cfb54ff8a36a8b06"}, {"name":"json-jwt","version":"1.16.6","platform":"ruby","checksum":"ab451f9cd8743cecc4137f4170806046c1d8a6d4ee6e8570e0b5c958409b266c"}, {"name":"json_schemer","version":"2.3.0","platform":"ruby","checksum":"9f1fa173b859ca520f15e9e8d08b0892ffca80b78dd8221feb3e360ff4cdeb35"}, {"name":"jsonb_accessor","version":"1.4","platform":"java","checksum":"2c5590d33d89c7b929d5cf38ae3d2c52658bf6f84f03b06ede5c88e9d76f3451"}, @@ -410,7 +410,7 @@ {"name":"multipart-post","version":"2.2.3","platform":"ruby","checksum":"462979de2971b8df33c2ee797fd497731617241f9dcd93960cc3caccb2dd13d8"}, {"name":"murmurhash3","version":"0.1.7","platform":"ruby","checksum":"370a2ce2e9ab0711e51554e530b5f63956927a6554a296855f42a1a4a5ed0936"}, {"name":"mustermann","version":"3.0.0","platform":"ruby","checksum":"6d3569aa3c3b2f048c60626f48d9b2d561cc8d2ef269296943b03da181c08b67"}, -{"name":"mustermann-grape","version":"1.0.2","platform":"ruby","checksum":"6f5309d6a338f801f211c644e8c2d3cc2577a8693f9cd51dadfdb29c1260f5fe"}, +{"name":"mustermann-grape","version":"1.1.0","platform":"ruby","checksum":"8d258a986004c8f01ce4c023c0b037c168a9ed889cf5778068ad54398fa458c5"}, {"name":"mutex_m","version":"0.3.0","platform":"ruby","checksum":"cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751"}, {"name":"nap","version":"1.1.0","platform":"ruby","checksum":"949691660f9d041d75be611bb2a8d2fd559c467537deac241f4097d9b5eea576"}, {"name":"nenv","version":"0.3.0","platform":"ruby","checksum":"d9de6d8fb7072228463bf61843159419c969edb34b3cef51832b516ae7972765"}, @@ -548,7 +548,6 @@ {"name":"racc","version":"1.8.1","platform":"java","checksum":"54f2e6d1e1b91c154013277d986f52a90e5ececbe91465d29172e49342732b98"}, {"name":"racc","version":"1.8.1","platform":"ruby","checksum":"4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f"}, {"name":"rack","version":"2.2.11","platform":"ruby","checksum":"424c49affa19081e9255d65d861f2d7bc7d8388edc0cb608b5e6caf1dd49bb8a"}, -{"name":"rack-accept","version":"0.4.5","platform":"ruby","checksum":"66247b5449db64ebb93ae2ec4af4764b87d1ae8a7463c7c68893ac13fa8d4da2"}, {"name":"rack-attack","version":"6.7.0","platform":"ruby","checksum":"3ca47e8f66cd33b2c96af53ea4754525cd928ed3fa8da10ee6dad0277791d77c"}, {"name":"rack-cors","version":"2.0.2","platform":"ruby","checksum":"415d4e1599891760c5dc9ef0349c7fecdf94f7c6a03e75b2e7c2b54b82adda1b"}, {"name":"rack-oauth2","version":"2.2.1","platform":"ruby","checksum":"c73aa87c508043e2258f02b4fb110cacba9b37d2ccf884e22487d014a120d1a5"}, @@ -723,7 +722,6 @@ {"name":"state_machines","version":"0.5.0","platform":"ruby","checksum":"23e6249d374a920b528dccade403518b4abbd83841a3e2c9ef13e6f1a009b102"}, {"name":"state_machines-activemodel","version":"0.8.0","platform":"ruby","checksum":"e932dab190d4be044fb5f9cab01a3ea0b092c5f113d4676c6c0a0d49bf738d2c"}, {"name":"state_machines-activerecord","version":"0.8.0","platform":"ruby","checksum":"072fb701b8ab03de0608297f6c55dc34ed096e556fa8f77e556f3c461c71aab6"}, -{"name":"stringio","version":"3.1.6","platform":"java","checksum":"dbdb1ee4e6d75782bbc7e8cc7d84cd05e592df50494f363011cc7cd48153bbf7"}, {"name":"stringio","version":"3.1.6","platform":"ruby","checksum":"292c495d1657adfcdf0a32eecf12a60e6691317a500c3112ad3b2e31068274f5"}, {"name":"strings","version":"0.2.1","platform":"ruby","checksum":"933293b3c95cf85b81eb44b3cf673e3087661ba739bbadfeadf442083158d6fb"}, {"name":"strings-ansi","version":"0.2.0","platform":"ruby","checksum":"90262d760ea4a94cc2ae8d58205277a343409c288cbe7c29416b1826bd511c88"}, diff --git a/Gemfile.next.lock b/Gemfile.next.lock index c0b3f86e0c8..b9d7c8cd53b 100644 --- a/Gemfile.next.lock +++ b/Gemfile.next.lock @@ -754,7 +754,7 @@ GEM terminal-table (>= 1.5.1) gitlab-chronic (0.10.6) numerizer (~> 0.2) - gitlab-cloud-connector (1.0.0) + gitlab-cloud-connector (1.4.0) activesupport (~> 7.0) jwt (~> 2.9.3) gitlab-dangerfiles (4.8.1) @@ -934,13 +934,12 @@ GEM signet (>= 0.16, < 2.a) gpgme (2.0.24) mini_portile2 (~> 2.7) - grape (2.0.0) - activesupport (>= 5) - builder + grape (2.1.3) + activesupport (>= 6) dry-types (>= 1.1) - mustermann-grape (~> 1.0.0) - rack (>= 1.3.0) - rack-accept + mustermann-grape (~> 1.1.0) + rack (>= 2) + zeitwerk grape-entity (1.0.1) activesupport (>= 3.0.0) multi_json (>= 1.3.2) @@ -1069,7 +1068,7 @@ GEM character_set (~> 1.4) regexp_parser (~> 2.5) regexp_property_values (~> 1.0) - json (2.10.1) + json (2.10.2) json-jwt (1.16.6) activesupport (>= 4.2) aes_key_wrap @@ -1204,7 +1203,7 @@ GEM murmurhash3 (0.1.7) mustermann (3.0.0) ruby2_keywords (~> 0.0.1) - mustermann-grape (1.0.2) + mustermann-grape (1.1.0) mustermann (>= 1.0.0) mutex_m (0.3.0) nap (1.1.0) @@ -1515,8 +1514,6 @@ GEM raabro (1.4.0) racc (1.8.1) rack (2.2.11) - rack-accept (0.4.5) - rack (>= 0.4) rack-attack (6.7.0) rack (>= 1.0, < 4) rack-cors (2.0.2) @@ -2133,7 +2130,7 @@ DEPENDENCIES gitlab-active-context! gitlab-backup-cli! gitlab-chronic (~> 0.10.5) - gitlab-cloud-connector (~> 1.0.0) + gitlab-cloud-connector (~> 1.4) gitlab-dangerfiles (~> 4.8.0) gitlab-duo-workflow-service-client (~> 0.1)! gitlab-experiment (~> 0.9.1) @@ -2179,7 +2176,7 @@ DEPENDENCIES google-protobuf (~> 3.25, >= 3.25.3) googleauth (~> 1.8.1) gpgme (~> 2.0.24) - grape (~> 2.0.0) + grape (~> 2.1.0) grape-entity (~> 1.0.1) grape-path-helpers (~> 2.0.1) grape-swagger (~> 2.1.2) diff --git a/app/assets/javascripts/import_entities/import_projects/graphql/queries/search_namespaces_where_user_can_import_projects.query.graphql b/app/assets/javascripts/import_entities/import_projects/graphql/queries/search_namespaces_where_user_can_import_projects.query.graphql index f6be7f42273..51a2e669870 100644 --- a/app/assets/javascripts/import_entities/import_projects/graphql/queries/search_namespaces_where_user_can_import_projects.query.graphql +++ b/app/assets/javascripts/import_entities/import_projects/graphql/queries/search_namespaces_where_user_can_import_projects.query.graphql @@ -1,7 +1,7 @@ query searchNamespacesWhereUserCanImportProjects($search: String) { currentUser { id - groups(permissionScope: IMPORT_PROJECTS, search: $search) { + groups(permissionScope: IMPORT_PROJECTS, search: $search, sort: SIMILARITY) { nodes { id fullPath diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index 4312c8f8eb2..95385a00189 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -210,7 +210,7 @@ export default class MergeRequestTabs { this.pageLayout = document.querySelector('.layout-page'); this.expandSidebar = document.querySelectorAll('.js-expand-sidebar, .js-sidebar-toggle'); this.paddingTop = 16; - this.actionRegex = /\/(commits|diffs|pipelines|reports(\/(.*))?)(\.html)?\/?$/; + this.actionRegex = /\/(commits|diffs|pipelines|reports(?:\/[^/]+)?)(\.html)?\/?$/; this.scrollPositions = {}; @@ -448,7 +448,7 @@ export default class MergeRequestTabs { if ( this.currentAction !== 'show' && this.currentAction !== 'new' && - !/reports\/(.*)$/.test(pathname) + !newStatePathname.endsWith(`/${this.currentAction}`) ) { newStatePathname += `/${this.currentAction}`; } diff --git a/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue b/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue index 1f198bbc0f9..16e0997511f 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue @@ -253,6 +253,7 @@ export default { :work-item-iid="workItemIid" :is-resolving="isResolving" :hide-fullscreen-markdown-button="hideFullscreenMarkdownButton" + :is-group-work-item="isGroupWorkItem" @startEditing="$emit('startEditing')" @resolve="resolveDiscussion" @startReplying="showReplyForm" @@ -288,6 +289,7 @@ export default { :is-discussion-resolvable="isDiscussionResolvable" :is-resolving="isResolving" :hide-fullscreen-markdown-button="hideFullscreenMarkdownButton" + :is-group-work-item="isGroupWorkItem" @startReplying="showReplyForm" @startEditing="$emit('startEditing')" @deleteNote="$emit('deleteNote', note)" @@ -323,6 +325,7 @@ export default { :is-discussion-resolvable="isDiscussionResolvable" :is-resolving="isResolving" :hide-fullscreen-markdown-button="hideFullscreenMarkdownButton" + :is-group-work-item="isGroupWorkItem" @startReplying="showReplyForm" @deleteNote="$emit('deleteNote', reply)" @reportAbuse="$emit('reportAbuse', reply)" diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note.vue b/app/assets/javascripts/work_items/components/notes/work_item_note.vue index 5d4eb801723..9a906852f34 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_note.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_note.vue @@ -116,6 +116,11 @@ export default { required: false, default: false, }, + isGroupWorkItem: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -430,6 +435,7 @@ export default { :has-replies="hasReplies" :full-path="fullPath" :hide-fullscreen-markdown-button="hideFullscreenMarkdownButton" + :is-group-work-item="isGroupWorkItem" class="gl-mt-3" @cancelEditing="cancelEditing" @toggleResolveDiscussion="$emit('resolve')" diff --git a/app/controllers/glql/base_controller.rb b/app/controllers/glql/base_controller.rb index 623ef6096bf..a855798c55f 100644 --- a/app/controllers/glql/base_controller.rb +++ b/app/controllers/glql/base_controller.rb @@ -76,7 +76,7 @@ module Glql return if error_type Gitlab::Metrics::GlqlSlis.record_apdex( - labels: labels, + labels: labels.merge(error_type: nil), success: duration_s <= query_urgency.duration ) end diff --git a/app/models/concerns/web_hooks/auto_disabling.rb b/app/models/concerns/web_hooks/auto_disabling.rb index 3499f0056fd..9ff6b40c266 100644 --- a/app/models/concerns/web_hooks/auto_disabling.rb +++ b/app/models/concerns/web_hooks/auto_disabling.rb @@ -6,9 +6,14 @@ module WebHooks include ::Gitlab::Loggable ENABLED_HOOK_TYPES = %w[ProjectHook].freeze - MAX_FAILURES = 100 - FAILURE_THRESHOLD = 3 - EXCEEDED_FAILURE_THRESHOLD = FAILURE_THRESHOLD + 1 + + TEMPORARILY_DISABLED_FAILURE_THRESHOLD = 3 + # A webhook will be failing and being temporarily disabled for the max backoff of 1 day (`MAX_BACKOFF`) + # for at least 1 month before it becomes permanently disabled on its 40th failure. + # Exactly how quickly this happens depends on how frequently it triggers. + # https://gitlab.com/gitlab-org/gitlab/-/issues/503733#note_2217234805 + PERMANENTLY_DISABLED_FAILURE_THRESHOLD = 39 + INITIAL_BACKOFF = 1.minute.freeze MAX_BACKOFF = 1.day.freeze MAX_BACKOFF_COUNT = 11 @@ -32,40 +37,40 @@ module WebHooks included do delegate :auto_disabling_enabled?, to: :class, private: true - # A hook is disabled if: + # A webhook is disabled if: # - # - we have exceeded the grace FAILURE_THRESHOLD (recent_failures > ?) - # - and either: - # - disabled_until is nil (i.e. this was set by WebHook#fail!) - # - or disabled_until is in the future (i.e. this was set by WebHook#backoff!) - # - OR silent mode is enabled. + # - it has exceeded the grace TEMPORARILY_DISABLED_FAILURE_THRESHOLD (recent_failures > ?) + # - AND the time period it was disabled for has not yet expired (disabled_until >= ?) + # - OR it has reached the PERMANENTLY_DISABLED_FAILURE_THRESHOLD (recent_failures > ?) scope :disabled, -> do return all if Gitlab::SilentMode.enabled? return none unless auto_disabling_enabled? where( - 'recent_failures > ? AND (disabled_until IS NULL OR disabled_until >= ?)', - FAILURE_THRESHOLD, - Time.current + '(recent_failures > ? AND (disabled_until IS NULL OR disabled_until >= ?)) OR recent_failures > ?', + TEMPORARILY_DISABLED_FAILURE_THRESHOLD, + Time.current, + PERMANENTLY_DISABLED_FAILURE_THRESHOLD ) end - # A hook is executable if: + # A webhook is executable if: # - # - we have not yet exceeeded the grace FAILURE_THRESHOLD (recent_failures <= ?) - # - OR we have exceeded the grace FAILURE_THRESHOLD and neither of the following is true: - # - disabled_until is nil (i.e. this was set by WebHook#fail!) - # - disabled_until is in the future (i.e. this was set by WebHook#backoff!) - # - AND silent mode is not enabled. + # - it has not exceeeded the grace TEMPORARILY_DISABLED_FAILURE_THRESHOLD (recent_failures <= ?) + # - OR it has exceeded the grace TEMPORARILY_DISABLED_FAILURE_THRESHOLD and: + # - it was temporarily disabled but can now be triggered again (disabled_until < ?) + # - AND has not reached the PERMANENTLY_DISABLED_FAILURE_THRESHOLD (recent_failures <= ?) scope :executable, -> do return none if Gitlab::SilentMode.enabled? return all unless auto_disabling_enabled? where( - 'recent_failures <= ? OR (recent_failures > ? AND (disabled_until IS NOT NULL) AND (disabled_until < ?))', - FAILURE_THRESHOLD, - FAILURE_THRESHOLD, - Time.current + '(recent_failures <= ? OR (recent_failures > ? AND disabled_until IS NOT NULL AND disabled_until < ?)) ' \ + 'AND recent_failures <= ?', + TEMPORARILY_DISABLED_FAILURE_THRESHOLD, + TEMPORARILY_DISABLED_FAILURE_THRESHOLD, + Time.current, + PERMANENTLY_DISABLED_FAILURE_THRESHOLD ) end end @@ -79,13 +84,18 @@ module WebHooks def temporarily_disabled? return false unless auto_disabling_enabled? - disabled_until.present? && disabled_until >= Time.current && recent_failures > FAILURE_THRESHOLD + disabled_until.present? && disabled_until >= Time.current && + recent_failures.between?(TEMPORARILY_DISABLED_FAILURE_THRESHOLD + 1, PERMANENTLY_DISABLED_FAILURE_THRESHOLD) end def permanently_disabled? return false unless auto_disabling_enabled? - recent_failures > FAILURE_THRESHOLD && disabled_until.blank? + recent_failures > PERMANENTLY_DISABLED_FAILURE_THRESHOLD || + # Keep the old definition of permanently disabled just until we have migrated all records to the new definition + # with `MigrateOldDisabledWebHookToNewState` + # TODO Remove the next line as part of https://gitlab.com/gitlab-org/gitlab/-/issues/525446 + (recent_failures > TEMPORARILY_DISABLED_FAILURE_THRESHOLD && disabled_until.blank?) end def enable! @@ -99,7 +109,7 @@ module WebHooks save(validate: false) end - # Don't actually back-off until a grace level of FAILURE_THRESHOLD failures have been seen + # Don't actually back-off until a grace level of TEMPORARILY_DISABLED_FAILURE_THRESHOLD failures have been seen # tracked in the recent_failures counter def backoff! return unless auto_disabling_enabled? @@ -107,7 +117,7 @@ module WebHooks attrs = { recent_failures: next_failure_count } - if recent_failures >= FAILURE_THRESHOLD + if recent_failures >= TEMPORARILY_DISABLED_FAILURE_THRESHOLD attrs[:backoff_count] = next_backoff_count attrs[:disabled_until] = next_backoff.from_now end @@ -120,17 +130,6 @@ module WebHooks save(validate: false) end - def failed! - return unless auto_disabling_enabled? - return unless recent_failures < MAX_FAILURES - - attrs = { disabled_until: nil, backoff_count: 0, recent_failures: next_failure_count } - - assign_attributes(**attrs) - logger.info(hook_id: id, action: 'disable', **attrs) - save(validate: false) - end - def next_backoff # Optimization to prevent expensive exponentiation and possible overflows return MAX_BACKOFF if backoff_count >= MAX_BACKOFF_COUNT @@ -159,11 +158,11 @@ module WebHooks end def next_failure_count - recent_failures.succ.clamp(1, MAX_FAILURES) + recent_failures.succ.clamp(1, PERMANENTLY_DISABLED_FAILURE_THRESHOLD + 1) end def next_backoff_count - backoff_count.succ.clamp(1, MAX_FAILURES) + backoff_count.succ.clamp(1, PERMANENTLY_DISABLED_FAILURE_THRESHOLD + 1) end end end diff --git a/app/services/web_hook_service.rb b/app/services/web_hook_service.rb index 9fa870c9eac..95f780c0577 100644 --- a/app/services/web_hook_service.rb +++ b/app/services/web_hook_service.rb @@ -202,10 +202,8 @@ class WebHookService def response_category(response) if response.success? || response.redirection? :ok - elsif response.internal_server_error? - :error else - :failed + :error end end diff --git a/app/services/web_hooks/log_execution_service.rb b/app/services/web_hooks/log_execution_service.rb index db614f37db9..dcd6ada4633 100644 --- a/app/services/web_hooks/log_execution_service.rb +++ b/app/services/web_hooks/log_execution_service.rb @@ -52,10 +52,10 @@ module WebHooks case response_category when :ok hook.enable! - when :error + # TODO remove handling of `:failed` as part of + # https://gitlab.com/gitlab-org/gitlab/-/issues/525446 + when :error, :failed hook.backoff! - when :failed - hook.failed! end hook.parent.update_last_webhook_failure(hook) if hook.parent diff --git a/app/views/shared/web_hooks/_hook.html.haml b/app/views/shared/web_hooks/_hook.html.haml index 384e312dce3..8350eb03f18 100644 --- a/app/views/shared/web_hooks/_hook.html.haml +++ b/app/views/shared/web_hooks/_hook.html.haml @@ -11,11 +11,11 @@ = hook.url - if hook.rate_limited? - = gl_badge_tag(_('Disabled'), variant: :danger) + = gl_badge_tag(_('Webhooks|Rate limited'), variant: :danger) - elsif hook.permanently_disabled? - = gl_badge_tag(s_('Webhooks|Failed to connect'), variant: :danger) + = gl_badge_tag(s_('Webhooks|Disabled'), variant: :danger) - elsif hook.temporarily_disabled? - = gl_badge_tag(s_('Webhooks|Fails to connect'), variant: :warning) + = gl_badge_tag(s_('Webhooks|Temporarily disabled'), variant: :warning) %div - hook.class.triggers.each_value do |trigger| diff --git a/app/views/shared/web_hooks/_hook_errors.html.haml b/app/views/shared/web_hooks/_hook_errors.html.haml index b610f56ef17..52402d5a337 100644 --- a/app/views/shared/web_hooks/_hook_errors.html.haml +++ b/app/views/shared/web_hooks/_hook_errors.html.haml @@ -1,5 +1,6 @@ - strong = { strong_start: ''.html_safe, strong_end: ''.html_safe } +- help_link = link_to('', help_page_path('user/project/integrations/webhooks.md', anchor: 'auto-disabled-webhooks'), target: '_blank', rel: 'noopener noreferrer') - if hook.rate_limited? - placeholders = { limit: number_with_delimiter(hook.rate_limit), root_namespace: hook.parent.root_namespace.path } @@ -8,14 +9,15 @@ - c.with_body do = s_("Webhooks|Webhooks for %{root_namespace} are now disabled because they've been triggered more than %{limit} times per minute. These webhooks are re-enabled automatically in the next minute.").html_safe % placeholders - elsif hook.permanently_disabled? - = render Pajamas::AlertComponent.new(title: s_('Webhooks|Webhook failed to connect'), + - failure_count = { failure_count: hook.recent_failures } + = render Pajamas::AlertComponent.new(title: s_('Webhooks|Webhook disabled'), variant: :danger) do |c| - c.with_body do - = safe_format(s_('Webhooks|The webhook failed to connect and is now disabled. To re-enable the webhook, see %{strong_start}Recent events%{strong_end} for more information about the error, then test your settings.'), strong) + = safe_format(s_('Webhooks|The webhook has %{help_link_start}failed%{help_link_end} %{failure_count} times consecutively and has been disabled. To re-enable the webhook, see %{strong_start}Recent events%{strong_end} for more information about the error, then test your settings.'), strong, failure_count, tag_pair(help_link, :help_link_start, :help_link_end)) - elsif hook.temporarily_disabled? - - help_link = link_to('', help_page_path('user/project/integrations/webhooks.md', anchor: 'auto-disabled-webhooks'), target: '_blank', rel: 'noopener noreferrer') - retry_time = { retry_time: time_interval_in_words(hook.disabled_until - Time.now) } - = render Pajamas::AlertComponent.new(title: s_('Webhooks|Webhook fails to connect'), + - failure_count = { failure_count: hook.recent_failures } + = render Pajamas::AlertComponent.new(title: s_('Webhooks|Webhook temporarily disabled'), variant: :warning) do |c| - c.with_body do - = safe_format(s_('Webhooks|The webhook %{help_link_start}failed to connect%{help_link_end} and is scheduled to retry in %{retry_time}. To re-enable the webhook, see %{strong_start}Recent events%{strong_end} for more information about the error, then test your settings.'), retry_time, strong, tag_pair(help_link, :help_link_start, :help_link_end)) + = safe_format(s_('Webhooks|The webhook has %{help_link_start}failed%{help_link_end} %{failure_count} times consecutively and is disabled for %{retry_time}. To re-enable the webhook earlier, see %{strong_start}Recent events%{strong_end} for more information about the error, then test your settings.'), retry_time, strong, failure_count, tag_pair(help_link, :help_link_start, :help_link_end)) diff --git a/config/initializers/postgres_partitioning.rb b/config/initializers/postgres_partitioning.rb index 6ff3cc1b906..b70b4e0ed23 100644 --- a/config/initializers/postgres_partitioning.rb +++ b/config/initializers/postgres_partitioning.rb @@ -51,6 +51,7 @@ if Gitlab.ee? Search::Zoekt::Task, Ai::CodeSuggestionEvent, Ai::DuoChatEvent, + Ai::TroubleshootJobEvent, Vulnerabilities::Archive, Vulnerabilities::ArchivedRecord, Vulnerabilities::ArchiveExport diff --git a/db/docs/ai_troubleshoot_job_events.yml b/db/docs/ai_troubleshoot_job_events.yml new file mode 100644 index 00000000000..a4927bce308 --- /dev/null +++ b/db/docs/ai_troubleshoot_job_events.yml @@ -0,0 +1,13 @@ +--- +table_name: ai_troubleshoot_job_events +classes: +- Ai::TroubleshootJobEvent +feature_categories: +- duo_chat +description: Database storage for raw /troubleshoot usage events. Partitioned by month. +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/184357 +milestone: '17.11' +gitlab_schema: gitlab_main_cell +sharding_key: + project_id: projects +table_size: small diff --git a/db/migrate/20250312155814_create_ai_troubleshoot_job_events.rb b/db/migrate/20250312155814_create_ai_troubleshoot_job_events.rb new file mode 100644 index 00000000000..25069c96a9a --- /dev/null +++ b/db/migrate/20250312155814_create_ai_troubleshoot_job_events.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class CreateAiTroubleshootJobEvents < Gitlab::Database::Migration[2.2] + disable_ddl_transaction! + milestone '17.11' + + def up + # rubocop:disable Migration/Datetime -- "timestamp" is a column name + create_table :ai_troubleshoot_job_events, + options: 'PARTITION BY RANGE (timestamp)', + primary_key: [:id, :timestamp] do |t| + t.bigserial :id, null: false + t.datetime_with_timezone :timestamp, null: false + t.belongs_to :user, null: false + t.references :job, null: false + t.references :project, foreign_key: true, null: false + t.timestamps_with_timezone null: false + t.integer :event, null: false, limit: 2 + t.text :namespace_path, limit: 255 + t.jsonb :payload + end + # rubocop:enable Migration/Datetime + end + + def down + drop_table :ai_troubleshoot_job_events + end +end diff --git a/db/post_migrate/20250317021351_add_temporary_index_to_web_hooks_for_migrate_old_disabled_web_hook_to_new_state.rb b/db/post_migrate/20250317021351_add_temporary_index_to_web_hooks_for_migrate_old_disabled_web_hook_to_new_state.rb new file mode 100644 index 00000000000..07609ee57b6 --- /dev/null +++ b/db/post_migrate/20250317021351_add_temporary_index_to_web_hooks_for_migrate_old_disabled_web_hook_to_new_state.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class AddTemporaryIndexToWebHooksForMigrateOldDisabledWebHookToNewState < Gitlab::Database::Migration[2.2] + milestone '17.11' + + INDEX_NAME = 'tmp_index_web_hooks_on_disabled_until_recent_failures' + TABLE = :web_hooks + COLUMNS = [:id, :recent_failures, :disabled_until] + + disable_ddl_transaction! + + def up + add_concurrent_index TABLE, COLUMNS, where: 'disabled_until is NULL', name: INDEX_NAME + + connection.execute("ANALYZE #{TABLE}") + end + + def down + remove_concurrent_index_by_name TABLE, INDEX_NAME + end +end diff --git a/db/post_migrate/20250317021451_migrate_old_disabled_web_hook_to_new_state.rb b/db/post_migrate/20250317021451_migrate_old_disabled_web_hook_to_new_state.rb new file mode 100644 index 00000000000..6679f25a868 --- /dev/null +++ b/db/post_migrate/20250317021451_migrate_old_disabled_web_hook_to_new_state.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class MigrateOldDisabledWebHookToNewState < Gitlab::Database::Migration[2.2] + BATCH_SIZE = 1000 + TABLE = 'web_hooks' + SCOPE = ->(table) { + table.where('recent_failures > 3').where(disabled_until: nil) + }.freeze + + NEW_RECENT_FAILURES = 40 # WebHooks::AutoDisabling::PERMANENTLY_DISABLED_FAILURE_THRESHOLD + 1 + NEW_BACKOFF_COUNT = 37 # NEW_RECENT_FAILURES - WebHooks::AutoDisabling::FAILURE_THRESHOLD + + disable_ddl_transaction! + restrict_gitlab_migration gitlab_schema: :gitlab_main + milestone '17.11' + + def up + disabled_until = Time.zone.now.to_fs(:db) # Specific time does not matter, just needs to be present + + each_batch(TABLE, connection: connection, scope: SCOPE, of: BATCH_SIZE) do |batch, _batchable_model| + batch.update_all( + recent_failures: NEW_RECENT_FAILURES, + backoff_count: NEW_BACKOFF_COUNT, + disabled_until: disabled_until + ) + end + end + + def down + # no-op + end +end diff --git a/db/post_migrate/20250317021551_remove_temporary_index_from_web_hooks_for_migrate_disabled_web_hook.rb b/db/post_migrate/20250317021551_remove_temporary_index_from_web_hooks_for_migrate_disabled_web_hook.rb new file mode 100644 index 00000000000..b69145b318e --- /dev/null +++ b/db/post_migrate/20250317021551_remove_temporary_index_from_web_hooks_for_migrate_disabled_web_hook.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class RemoveTemporaryIndexFromWebHooksForMigrateDisabledWebHook < Gitlab::Database::Migration[2.2] + milestone '17.11' + + INDEX_NAME = 'tmp_index_web_hooks_on_disabled_until_recent_failures' + TABLE = :web_hooks + COLUMNS = [:id, :recent_failures, :disabled_until] + + disable_ddl_transaction! + + def up + remove_concurrent_index_by_name TABLE, INDEX_NAME + end + + def down + add_concurrent_index TABLE, COLUMNS, where: 'disabled_until is NULL', name: INDEX_NAME + + connection.execute("ANALYZE #{TABLE}") + end +end diff --git a/db/schema_migrations/20250312155814 b/db/schema_migrations/20250312155814 new file mode 100644 index 00000000000..8d42e8e4dd2 --- /dev/null +++ b/db/schema_migrations/20250312155814 @@ -0,0 +1 @@ +8ff13419285a7855b6a75e69ba63f10eb3ecb22655df946bb344d05201f7b6f6 \ No newline at end of file diff --git a/db/schema_migrations/20250317021351 b/db/schema_migrations/20250317021351 new file mode 100644 index 00000000000..44ac91170f8 --- /dev/null +++ b/db/schema_migrations/20250317021351 @@ -0,0 +1 @@ +43a806f0236fcf8d57242c339a90e1afbb7c1ca5950d29423da5648eb4b855ff \ No newline at end of file diff --git a/db/schema_migrations/20250317021451 b/db/schema_migrations/20250317021451 new file mode 100644 index 00000000000..ff5142ed7d6 --- /dev/null +++ b/db/schema_migrations/20250317021451 @@ -0,0 +1 @@ +b90e017fcfdb70ab0d478f0d5fa2803fbcd6ee444c157902b34cab495496684b \ No newline at end of file diff --git a/db/schema_migrations/20250317021551 b/db/schema_migrations/20250317021551 new file mode 100644 index 00000000000..ffaffeff340 --- /dev/null +++ b/db/schema_migrations/20250317021551 @@ -0,0 +1 @@ +93b32fdd10b4eddaad779c8aa8ae8f0a9f0806549ee9539659fefa09e79a87ad \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index af8a054f834..5f30d8f6e2a 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -4459,6 +4459,21 @@ CREATE TABLE ai_duo_chat_events ( ) PARTITION BY RANGE ("timestamp"); +CREATE TABLE ai_troubleshoot_job_events ( + id bigint NOT NULL, + "timestamp" timestamp with time zone NOT NULL, + user_id bigint NOT NULL, + job_id bigint NOT NULL, + project_id bigint NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + event smallint NOT NULL, + namespace_path text, + payload jsonb, + CONSTRAINT check_29d6dbc329 CHECK ((char_length(namespace_path) <= 255)) +) +PARTITION BY RANGE ("timestamp"); + CREATE TABLE audit_events ( id bigint NOT NULL, author_id bigint NOT NULL, @@ -7975,6 +7990,15 @@ CREATE TABLE ai_testing_terms_acceptances ( CONSTRAINT check_5efe98894e CHECK ((char_length(user_email) <= 255)) ); +CREATE SEQUENCE ai_troubleshoot_job_events_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE ai_troubleshoot_job_events_id_seq OWNED BY ai_troubleshoot_job_events.id; + CREATE TABLE ai_user_metrics ( user_id bigint NOT NULL, last_duo_activity_on date NOT NULL @@ -26462,6 +26486,8 @@ ALTER TABLE ONLY ai_self_hosted_models ALTER COLUMN id SET DEFAULT nextval('ai_s ALTER TABLE ONLY ai_settings ALTER COLUMN id SET DEFAULT nextval('ai_settings_id_seq'::regclass); +ALTER TABLE ONLY ai_troubleshoot_job_events ALTER COLUMN id SET DEFAULT nextval('ai_troubleshoot_job_events_id_seq'::regclass); + ALTER TABLE ONLY ai_vectorizable_files ALTER COLUMN id SET DEFAULT nextval('ai_vectorizable_files_id_seq'::regclass); ALTER TABLE ONLY alert_management_alert_assignees ALTER COLUMN id SET DEFAULT nextval('alert_management_alert_assignees_id_seq'::regclass); @@ -28451,6 +28477,9 @@ ALTER TABLE ONLY ai_settings ALTER TABLE ONLY ai_testing_terms_acceptances ADD CONSTRAINT ai_testing_terms_acceptances_pkey PRIMARY KEY (user_id); +ALTER TABLE ONLY ai_troubleshoot_job_events + ADD CONSTRAINT ai_troubleshoot_job_events_pkey PRIMARY KEY (id, "timestamp"); + ALTER TABLE ONLY ai_user_metrics ADD CONSTRAINT ai_user_metrics_pkey PRIMARY KEY (user_id); @@ -33329,6 +33358,12 @@ CREATE INDEX index_ai_settings_on_duo_workflow_service_account_user_id ON ai_set CREATE UNIQUE INDEX index_ai_settings_on_singleton ON ai_settings USING btree (singleton); +CREATE INDEX index_ai_troubleshoot_job_events_on_job_id ON ONLY ai_troubleshoot_job_events USING btree (job_id); + +CREATE INDEX index_ai_troubleshoot_job_events_on_project_id ON ONLY ai_troubleshoot_job_events USING btree (project_id); + +CREATE INDEX index_ai_troubleshoot_job_events_on_user_id ON ONLY ai_troubleshoot_job_events USING btree (user_id); + CREATE INDEX index_ai_vectorizable_files_on_project_id ON ai_vectorizable_files USING btree (project_id); CREATE INDEX index_alert_assignees_on_alert_id ON alert_management_alert_assignees USING btree (alert_id); @@ -43713,6 +43748,9 @@ ALTER TABLE ONLY boards_epic_board_positions ALTER TABLE ONLY external_status_checks ADD CONSTRAINT fk_rails_1f5a8aa809 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; +ALTER TABLE ai_troubleshoot_job_events + ADD CONSTRAINT fk_rails_1fb7e812da FOREIGN KEY (project_id) REFERENCES projects(id); + ALTER TABLE ONLY dora_daily_metrics ADD CONSTRAINT fk_rails_1fd07aff6f FOREIGN KEY (environment_id) REFERENCES environments(id) ON DELETE CASCADE; diff --git a/doc/administration/gitlab_duo_self_hosted/troubleshooting.md b/doc/administration/gitlab_duo_self_hosted/troubleshooting.md index c6a6404b9dc..e04c37559bc 100644 --- a/doc/administration/gitlab_duo_self_hosted/troubleshooting.md +++ b/doc/administration/gitlab_duo_self_hosted/troubleshooting.md @@ -493,6 +493,34 @@ You might get an error that states This error occurs when an unknown error occurs in ReAct agent. Try your request again. If the problem persists, report the issue to the GitLab support team. +## Feature not accessible or feature button not visible + +If a feature is not working or a feature button (for example, **`/troubleshoot`**) is not visible: + +1. Check if the feature's `unit_primitive` is listed in the [self-hosted models unit primitives list in the `gitlab-cloud-connector` gem configuration](https://gitlab.com/gitlab-org/cloud-connector/gitlab-cloud-connector/-/blob/main/config/services/self_hosted_models.yml). + + If the feature is missing from this file, that could be the reason it's not accessible. + +1. Optional. If the feature is not listed, you can verify this is the cause of the issue by setting the following in your GitLab instance: + + ```shell + CLOUD_CONNECTOR_SELF_SIGN_TOKENS=1 + ``` + + Then restart GitLab and check if the feature becomes accessible. + + > **Important**: After troubleshooting, restart GitLab **without** this flag set. + + {{< alert type="warning" >}} + + **Do not use `CLOUD_CONNECTOR_SELF_SIGN_TOKENS=1` in production.** Development environments should closely mirror production, with no hidden flags or internal-only workarounds. + + {{< /alert >}} + +1. To resolve this issue: + - If you're a GitLab team member, contact the Custom Models team through the [`#g_custom_models` Slack channel](https://gitlab.enterprise.slack.com/archives/C06DCB3N96F). + - If you're a customer, report the issue through [GitLab Support](https://about.gitlab.com/support/). + ## Related topics - [GitLab Duo troubleshooting](../../user/gitlab_duo_chat/troubleshooting.md) diff --git a/doc/user/gitlab_duo_chat/troubleshooting.md b/doc/user/gitlab_duo_chat/troubleshooting.md index a53e16a8ea1..973fe49eacc 100644 --- a/doc/user/gitlab_duo_chat/troubleshooting.md +++ b/doc/user/gitlab_duo_chat/troubleshooting.md @@ -12,8 +12,7 @@ When working with GitLab Duo Chat, you might encounter the following issues. If the button is not visible in the upper-right of the UI, ensure GitLab Duo Chat [is enabled](turn_on_off.md). -The **GitLab Duo Chat** button is not displayed on personal projects, -as well as +The **GitLab Duo Chat** button is not displayed on [groups and projects with GitLab Duo features disabled](turn_on_off.md). After you enable GitLab Duo Chat, it might take a few minutes for the diff --git a/doc/user/glql/_index.md b/doc/user/glql/_index.md index 6b51aad5600..81f1df6155a 100644 --- a/doc/user/glql/_index.md +++ b/doc/user/glql/_index.md @@ -36,11 +36,6 @@ Use it to filter and embed content from anywhere in the platform, using familiar Embed queries in Markdown code blocks. The rendered output of this query is called a view. -To test GLQL views: - -- On GitLab Self-Managed, ask your administrator to enable the `glql_integration` feature flag on your instance. -- On GitLab.com, contact your account representative. - Share your feedback in the [GLQL beta feedback issue](https://gitlab.com/gitlab-org/gitlab/-/issues/509791). ## Query syntax diff --git a/doc/user/glql/fields.md b/doc/user/glql/fields.md index 8315a47194d..ef6715c268d 100644 --- a/doc/user/glql/fields.md +++ b/doc/user/glql/fields.md @@ -30,14 +30,12 @@ This feature is available for testing, but not ready for production use. {{< /alert >}} -In a GitLab Query Language (GLQL) [query](_index.md#query-syntax), a field is the leftmost part -of the expression. -In queries, fields follow the syntax of ` and ...`, +With GitLab Query Language (GLQL), fields are used to: -In a [GLQL view](_index.md#glql-views), fields are included as a comma-separated list of tokens in -the `fields:` option. +- Filter the results returned from a [GLQL query](_index.md#query-syntax). +- Control the details displayed in a [GLQL view](_index.md#presentation-syntax). -This page lists fields available to use as filters when querying issues or work items. +The following fields are available: ## Type diff --git a/doc/user/project/integrations/img/failed_badges_v14_9.png b/doc/user/project/integrations/img/failed_badges_v14_9.png deleted file mode 100644 index 5a1f481e54c3f992035ef08b634d3960ab34d831..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15999 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 literal 0 HcmV?d00001 diff --git a/doc/user/project/integrations/webhooks.md b/doc/user/project/integrations/webhooks.md index 8229ef648c5..f1afbfdc9ae 100644 --- a/doc/user/project/integrations/webhooks.md +++ b/doc/user/project/integrations/webhooks.md @@ -481,6 +481,8 @@ To optimize your webhook receivers: - [Generally available](https://gitlab.com/gitlab-org/gitlab/-/issues/329849) for project webhooks in GitLab 15.7. Feature flag `web_hooks_disable_failed` removed. - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/385902) for group webhooks in GitLab 15.10. - [Disabled on GitLab Self-Managed](https://gitlab.com/gitlab-org/gitlab/-/issues/390157) in GitLab 15.10 [with a flag](../../../administration/feature_flags.md) named `auto_disabling_web_hooks`. +- **Fails to connect** and **Failing to connect** [renamed](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/166329) to **Disabled** and **Temporarily disabled** in GitLab 17.11. +- [Changed](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/166329) to become permanently disabled after 40 consecutive failures in GitLab 17.11. {{< /history >}} @@ -500,24 +502,34 @@ To view auto-disabled webhooks: In the webhook list, auto-disabled webhooks display as: -- **Fails to connect** for [temporarily disabled](#temporarily-disabled-webhooks) webhooks -- **Failed to connect** for [permanently disabled](#permanently-disabled-webhooks) webhooks +- **Temporarily disabled** for [temporarily disabled](#temporarily-disabled-webhooks) webhooks +- **Disabled** for [permanently disabled](#permanently-disabled-webhooks) webhooks -![Badges on failing webhooks](img/failed_badges_v14_9.png) +![Badges on failing webhooks](img/failed_badges_v17_11.png) #### Temporarily disabled webhooks -Webhooks are temporarily disabled if they: +Webhooks are temporarily disabled if they fail four consecutive times. +If webhooks fail 40 consecutive times, they become [permanently disabled](#permanently-disabled-webhooks). -- Return response codes in the `5xx` range. -- Experience a [timeout](../../gitlab_com/_index.md#webhooks). -- Encounter other HTTP errors. +Failure occurs when: -These webhooks are initially disabled for one minute, with the duration extending on subsequent failures up to 24 hours. +- The [webhook receiver](#webhook-receiver-requirements) returns a response code in the `4xx` or `5xx` range. +- The webhook experiences a [timeout](../../gitlab_com/_index.md#webhooks) when attempting to connect to the webhook receiver. +- The webhook encounters other HTTP errors. + +Temporarily disabled webhooks are initially disabled for one minute, +with the duration extending on subsequent failures up to 24 hours. +After this period has elapsed, these webhooks are automatically re-enabled. #### Permanently disabled webhooks -Webhooks are permanently disabled if they return response codes in the `4xx` range, indicating a misconfiguration. +Webhooks are permanently disabled if they fail 40 consecutive times. +Unlike [temporarily disabled webhooks](#temporarily-disabled-webhooks), these webhooks are not automatically re-enabled. + +Webhooks that were permanently disabled in GitLab 17.10 and earlier underwent a data migration. +These webhooks might display four failures in [**Recent events**](#view-webhook-request-history) +even though the UI might state they have 40 failures. #### Re-enable disabled webhooks @@ -528,10 +540,7 @@ Webhooks are permanently disabled if they return response codes in the `4xx` ran {{< /history >}} -To re-enable a temporarily or permanently disabled webhook: - -- [Send a test request](#test-a-webhook) to the webhook. - +To re-enable a disabled webhook, [send a test request](#test-a-webhook). The webhook is re-enabled if the test request returns a response code in the `2xx` range. ### Delivery headers diff --git a/lib/api/concerns/packages/nuget/public_endpoints.rb b/lib/api/concerns/packages/nuget/public_endpoints.rb index 2cb7e9914c9..4dde27c216d 100644 --- a/lib/api/concerns/packages/nuget/public_endpoints.rb +++ b/lib/api/concerns/packages/nuget/public_endpoints.rb @@ -74,7 +74,7 @@ module API documentation: { example: 'k813f89485474661234z7109cve5709eFFFFFFFF' } requires :same_file_name, same_as: :file_name end - get '*file_name/*signature/*same_file_name', format: false, urgency: :low do + get '*file_name/*signature/*same_file_name', format: true, urgency: :low do bad_request!('Missing checksum header') if headers['Symbolchecksum'].blank? project_or_group_without_auth diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb index b0e4ac230f8..2d777568b26 100644 --- a/lib/api/helpers.rb +++ b/lib/api/helpers.rb @@ -631,7 +631,9 @@ module API end def render_api_error!(message, status) - render_structured_api_error!({ 'message' => message }, status) + error_message = message.is_a?(ActiveModel::Errors) ? message.to_hash : message + + render_structured_api_error!({ 'message' => error_message }, status) end def render_structured_api_error!(hash, status) @@ -675,7 +677,7 @@ module API '500 Internal Server Error' end - rack_response({ 'message' => response_message }.to_json, 500) + error!({ 'message' => response_message }, 500) end # project helpers diff --git a/lib/api/integrations/slack/request.rb b/lib/api/integrations/slack/request.rb index ef57d8bd9b3..77f06d84d12 100644 --- a/lib/api/integrations/slack/request.rb +++ b/lib/api/integrations/slack/request.rb @@ -5,8 +5,8 @@ module API module Slack module Request VERIFICATION_VERSION = 'v0' - VERIFICATION_TIMESTAMP_HEADER = 'X-Slack-Request-Timestamp' - VERIFICATION_SIGNATURE_HEADER = 'X-Slack-Signature' + VERIFICATION_TIMESTAMP_HEADER = 'x-slack-request-timestamp' + VERIFICATION_SIGNATURE_HEADER = 'x-slack-signature' VERIFICATION_DELIMITER = ':' VERIFICATION_HMAC_ALGORITHM = 'sha256' VERIFICATION_TIMESTAMP_EXPIRY = 1.minute.to_i diff --git a/lib/api/npm_project_packages.rb b/lib/api/npm_project_packages.rb index e04063761a1..7c870299a76 100644 --- a/lib/api/npm_project_packages.rb +++ b/lib/api/npm_project_packages.rb @@ -63,7 +63,7 @@ module API route_setting :authentication, job_token_allowed: true, deploy_token_allowed: true route_setting :authorization, job_token_policies: :read_packages, allow_public_access_for_enabled_project_features: :package_registry - get '*package_name/-/*file_name', format: false do + get '*package_name/-/*file_name', format: true do authorize_read_package!(project) package = ::Packages::Npm::Package diff --git a/lib/api/nuget_project_packages.rb b/lib/api/nuget_project_packages.rb index 7927dad2910..d6a6191d323 100644 --- a/lib/api/nuget_project_packages.rb +++ b/lib/api/nuget_project_packages.rb @@ -377,7 +377,7 @@ module API requires :package_version, type: String, allow_blank: false, desc: 'The NuGet package version', regexp: Gitlab::Regex.nuget_version_regex, documentation: { example: '1.0.1' } end - delete '*package_name/*package_version', format: false, urgency: :low do + delete '*package_name/*package_version', format: true, urgency: :low do authorize_destroy_package!(project_or_group) destroy_conditionally!(find_package) do |package| diff --git a/lib/api/releases.rb b/lib/api/releases.rb index 3a99271ca19..07a01c14327 100644 --- a/lib/api/releases.rb +++ b/lib/api/releases.rb @@ -166,7 +166,7 @@ module API route_setting :authentication, job_token_allowed: true route_setting :authorization, job_token_policies: :read_releases, allow_public_access_for_enabled_project_features: [:repository, :releases] - get ':id/releases/:tag_name/downloads/*direct_asset_path', format: false, requirements: RELEASE_ENDPOINT_REQUIREMENTS do + get ':id/releases/:tag_name/downloads/*direct_asset_path', format: true, requirements: RELEASE_ENDPOINT_REQUIREMENTS do authorize_read_code! not_found! unless release @@ -196,7 +196,7 @@ module API route_setting :authentication, job_token_allowed: true route_setting :authorization, job_token_policies: :read_releases, allow_public_access_for_enabled_project_features: [:repository, :releases] - get ':id/releases/permalink/latest(/)(*suffix_path)', format: false, requirements: RELEASE_ENDPOINT_REQUIREMENTS do + get ':id/releases/permalink/latest(/)(*suffix_path)', format: true, requirements: RELEASE_ENDPOINT_REQUIREMENTS do authorize_read_code! # Try to find the latest release diff --git a/lib/api/terraform/modules/v1/namespace_packages.rb b/lib/api/terraform/modules/v1/namespace_packages.rb index ac4ae3af99a..bfa13e89195 100644 --- a/lib/api/terraform/modules/v1/namespace_packages.rb +++ b/lib/api/terraform/modules/v1/namespace_packages.rb @@ -234,7 +234,7 @@ module API ] tags %w[terraform_registry] end - get format: false do + get format: true do presenter = ::Terraform::ModuleVersionPresenter.new(package, params[:module_system]) present presenter, with: ::API::Entities::Terraform::ModuleVersion end diff --git a/lib/api/terraform/modules/v1/project_packages.rb b/lib/api/terraform/modules/v1/project_packages.rb index 934b6995bbc..18743bf07b2 100644 --- a/lib/api/terraform/modules/v1/project_packages.rb +++ b/lib/api/terraform/modules/v1/project_packages.rb @@ -114,7 +114,7 @@ module API params do use :terraform_get end - get format: false do + get format: true do present_package_file end diff --git a/lib/gitlab/grape_logging/loggers/filter_parameters.rb b/lib/gitlab/grape_logging/loggers/filter_parameters.rb index ae9df203544..2d12556b109 100644 --- a/lib/gitlab/grape_logging/loggers/filter_parameters.rb +++ b/lib/gitlab/grape_logging/loggers/filter_parameters.rb @@ -13,15 +13,15 @@ module Gitlab def safe_parameters(request) loggable_params = super - settings = request.env[Grape::Env::API_ENDPOINT]&.route&.settings + options = request.env[Grape::Env::API_ENDPOINT]&.route&.options - return loggable_params unless settings&.key?(:log_safety) + return loggable_params unless options&.dig(:settings, :log_safety) - settings[:log_safety][:safe].each do |key| + options[:settings][:log_safety][:safe].each do |key| loggable_params[key] = request.params[key] if loggable_params.key?(key) end - settings[:log_safety][:unsafe].each do |key| + options[:settings][:log_safety][:unsafe].each do |key| loggable_params[key] = @replacement if loggable_params.key?(key) end diff --git a/lib/tasks/ci/job_tokens_task.rb b/lib/tasks/ci/job_tokens_task.rb index 34c0fb07d99..bc6bfa2d832 100644 --- a/lib/tasks/ci/job_tokens_task.rb +++ b/lib/tasks/ci/job_tokens_task.rb @@ -120,7 +120,7 @@ module Tasks row << [ "`#{route_path(route)}`", - route.description + route.options[:description] ] markdown_row(row) @@ -155,15 +155,15 @@ module Tasks end def allowed_route?(route) - route.settings.dig(:authentication, :job_token_allowed) + route.options.dig(:settings, :authentication, :job_token_allowed) end def skip_route?(route) - route.settings.dig(:authorization, :skip_job_token_policies) + route.options.dig(:settings, :authorization, :skip_job_token_policies) end def policies_for(route) - Array(route.settings.dig(:authorization, :job_token_policies)) + Array(route.options.dig(:settings, :authorization, :job_token_policies)) end end end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 27e3604d0b1..48af8dc39c5 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -66072,6 +66072,9 @@ msgstr "" msgid "Webhooks|Description (optional)" msgstr "" +msgid "Webhooks|Disabled" +msgstr "" + msgid "Webhooks|Do not show sensitive data such as tokens in the UI." msgstr "" @@ -66084,12 +66087,6 @@ msgstr "" msgid "Webhooks|Enable SSL verification" msgstr "" -msgid "Webhooks|Failed to connect" -msgstr "" - -msgid "Webhooks|Fails to connect" -msgstr "" - msgid "Webhooks|Feature flag events" msgstr "" @@ -66150,6 +66147,9 @@ msgstr "" msgid "Webhooks|Project or group access token events" msgstr "" +msgid "Webhooks|Rate limited" +msgstr "" + msgid "Webhooks|Regular expression" msgstr "" @@ -66189,6 +66189,9 @@ msgstr "" msgid "Webhooks|Tag push events" msgstr "" +msgid "Webhooks|Temporarily disabled" +msgstr "" + msgid "Webhooks|The URL must be percent-encoded if it contains one or more special characters." msgstr "" @@ -66198,10 +66201,10 @@ msgstr "" msgid "Webhooks|The secret token is cleared on save unless it is updated." msgstr "" -msgid "Webhooks|The webhook %{help_link_start}failed to connect%{help_link_end} and is scheduled to retry in %{retry_time}. To re-enable the webhook, see %{strong_start}Recent events%{strong_end} for more information about the error, then test your settings." +msgid "Webhooks|The webhook has %{help_link_start}failed%{help_link_end} %{failure_count} times consecutively and has been disabled. To re-enable the webhook, see %{strong_start}Recent events%{strong_end} for more information about the error, then test your settings." msgstr "" -msgid "Webhooks|The webhook failed to connect and is now disabled. To re-enable the webhook, see %{strong_start}Recent events%{strong_end} for more information about the error, then test your settings." +msgid "Webhooks|The webhook has %{help_link_start}failed%{help_link_end} %{failure_count} times consecutively and is disabled for %{retry_time}. To re-enable the webhook earlier, see %{strong_start}Recent events%{strong_end} for more information about the error, then test your settings." msgstr "" msgid "Webhooks|Trigger" @@ -66219,15 +66222,12 @@ msgstr "" msgid "Webhooks|Webhook disabled" msgstr "" -msgid "Webhooks|Webhook failed to connect" -msgstr "" - -msgid "Webhooks|Webhook fails to connect" -msgstr "" - msgid "Webhooks|Webhook rate limit has been reached" msgstr "" +msgid "Webhooks|Webhook temporarily disabled" +msgstr "" + msgid "Webhooks|Webhooks for %{root_namespace} are now disabled because they've been triggered more than %{limit} times per minute. These webhooks are re-enabled automatically in the next minute." msgstr "" diff --git a/qa/gdk/gdk.yml b/qa/gdk/gdk.yml index a8ab00ce5d4..db35124bdcd 100644 --- a/qa/gdk/gdk.yml +++ b/qa/gdk/gdk.yml @@ -45,4 +45,4 @@ tracer: enabled: false # https://gitlab.com/gitlab-org/gitlab/-/issues/471172 gitlab_http_router: - enabled: true + enabled: false diff --git a/spec/controllers/glql/base_controller_spec.rb b/spec/controllers/glql/base_controller_spec.rb index db1e4eccbb4..a455ceda4b2 100644 --- a/spec/controllers/glql/base_controller_spec.rb +++ b/spec/controllers/glql/base_controller_spec.rb @@ -41,7 +41,7 @@ RSpec.describe Glql::BaseController, feature_category: :integrations do it 'tracks SLI metrics for each successful glql query' do expect(Gitlab::Metrics::GlqlSlis).to receive(:record_apdex).with({ - labels: qlql_sli_labels, + labels: qlql_sli_labels.merge(error_type: nil), success: true }) @@ -52,6 +52,12 @@ RSpec.describe Glql::BaseController, feature_category: :integrations do execute_request end + + it 'does not fail when SLIs were initialized' do + Gitlab::Metrics::GlqlSlis.initialize_slis! + + expect { execute_request }.not_to raise_error + end end context 'when a single ActiveRecord::QueryAborted error occurs' do diff --git a/spec/db/schema_spec.rb b/spec/db/schema_spec.rb index fb41a42ef48..996ae882443 100644 --- a/spec/db/schema_spec.rb +++ b/spec/db/schema_spec.rb @@ -33,6 +33,7 @@ RSpec.describe 'Database schema', abuse_report_notes: %w[discussion_id], ai_code_suggestion_events: %w[user_id], ai_duo_chat_events: %w[user_id organization_id], + ai_troubleshoot_job_events: %w[user_id job_id], application_settings: %w[performance_bar_allowed_group_id slack_app_id snowplow_app_id eks_account_id eks_access_key_id], approvals: %w[user_id project_id], diff --git a/spec/factories/project_hooks.rb b/spec/factories/project_hooks.rb index 8691a79a158..482cec1195d 100644 --- a/spec/factories/project_hooks.rb +++ b/spec/factories/project_hooks.rb @@ -39,7 +39,7 @@ FactoryBot.define do end trait :permanently_disabled do - recent_failures { WebHooks::AutoDisabling::FAILURE_THRESHOLD + 1 } + recent_failures { WebHooks::AutoDisabling::PERMANENTLY_DISABLED_FAILURE_THRESHOLD + 1 } end end end diff --git a/spec/frontend/import_entities/components/import_target_dropdown_spec.js b/spec/frontend/import_entities/components/import_target_dropdown_spec.js index ba0bb0b0f74..d885ff9d24c 100644 --- a/spec/frontend/import_entities/components/import_target_dropdown_spec.js +++ b/spec/frontend/import_entities/components/import_target_dropdown_spec.js @@ -137,4 +137,19 @@ describe('ImportTargetDropdown', () => { { text: 'match2', value: 'match2' }, ]); }); + + it('sorts namespaces based on similarity to input', async () => { + createComponent(); + + findListbox().vm.$emit('search', 'sortme'); + + await waitForQuery(); + + expect(findListboxGroupsItems()).toEqual([ + { text: 'sortme', value: 'sortme' }, + { text: 'sortmea', value: 'sortmea' }, + { text: 'sortmeaa', value: 'sortmeaa' }, + { text: 'sortmeab', value: 'sortmeab' }, + ]); + }); }); diff --git a/spec/frontend/import_entities/mock_data.js b/spec/frontend/import_entities/mock_data.js index de44824002b..3ef35e3fe86 100644 --- a/spec/frontend/import_entities/mock_data.js +++ b/spec/frontend/import_entities/mock_data.js @@ -12,6 +12,10 @@ export const mockAvailableNamespaces = [ mockGroupFactory('match1'), mockGroupFactory('unrelated'), mockGroupFactory('match2'), + mockGroupFactory('sortme'), + mockGroupFactory('sortmea'), + mockGroupFactory('sortmeaa'), + mockGroupFactory('sortmeab'), ]; export const mockNamespacesResponse = { diff --git a/spec/frontend/merge_request_tabs_spec.js b/spec/frontend/merge_request_tabs_spec.js index e0ce216f0d8..9a96bf44540 100644 --- a/spec/frontend/merge_request_tabs_spec.js +++ b/spec/frontend/merge_request_tabs_spec.js @@ -310,6 +310,30 @@ describe('MergeRequestTabs', () => { expect(testContext.subject('show')).toBe('/foo/bar/-/merge_requests/1'); }); + + it.each` + pathname | action | expected + ${'/group/reports/project/-/merge_requests/1'} | ${'show'} | ${'/group/reports/project/-/merge_requests/1'} + ${'/group/reports/project/-/merge_requests/1'} | ${'reports'} | ${'/group/reports/project/-/merge_requests/1/reports'} + ${'/group/reports/project/-/merge_requests/1/reports'} | ${'reports'} | ${'/group/reports/project/-/merge_requests/1/reports'} + ${'/group/reports/project/-/merge_requests/1/reports'} | ${'show'} | ${'/group/reports/project/-/merge_requests/1'} + ${'/group/project/-/merge_requests/1/diffs'} | ${'commits'} | ${'/group/project/-/merge_requests/1/commits'} + ${'/group/project/-/merge_requests/1/commits'} | ${'diffs'} | ${'/group/project/-/merge_requests/1/diffs'} + ${'/group/project/-/merge_requests/1/reports/security'} | ${'show'} | ${'/group/project/-/merge_requests/1'} + ${'/group/project/-/merge_requests/1/reports/security'} | ${'reports'} | ${'/group/project/-/merge_requests/1/reports'} + ${'/group/project/-/merge_requests/1/commits'} | ${'commits'} | ${'/group/project/-/merge_requests/1/commits'} + ${'/group/project/-/merge_requests/1/diffs/'} | ${'show'} | ${'/group/project/-/merge_requests/1'} + ${'/group/project/-/merge_requests/1/commits.html'} | ${'show'} | ${'/group/project/-/merge_requests/1'} + `( + 'updates URL to $expected if current URL is $pathname and new action is $action', + ({ pathname, action, expected }) => { + setLocation({ + pathname, + }); + + expect(testContext.subject(action)).toBe(expected); + }, + ); }); describe('expandViewContainer', () => { @@ -559,13 +583,14 @@ describe('MergeRequestTabs', () => { describe('getActionFromHref', () => { it.each` - pathName | action - ${'/user/pipelines/-/merge_requests/1/diffs'} | ${'diffs'} - ${'/user/diffs/-/merge_requests/1/pipelines'} | ${'pipelines'} - ${'/user/pipelines/-/merge_requests/1/commits'} | ${'commits'} - ${'/user/pipelines/1/-/merge_requests/1/diffs'} | ${'diffs'} - ${'/user/pipelines/-/merge_requests/1'} | ${'show'} - ${'/user/pipelines/-/merge_requests/1/reports'} | ${'reports'} + pathName | action + ${'/user/pipelines/-/merge_requests/1/diffs'} | ${'diffs'} + ${'/user/diffs/-/merge_requests/1/pipelines'} | ${'pipelines'} + ${'/user/pipelines/-/merge_requests/1/commits'} | ${'commits'} + ${'/user/pipelines/1/-/merge_requests/1/diffs'} | ${'diffs'} + ${'/user/pipelines/-/merge_requests/1'} | ${'show'} + ${'/user/pipelines/-/merge_requests/1/reports'} | ${'reports'} + ${'/group/reports/project/-/merge_requests/1/reports'} | ${'reports'} `('returns $action for $location', ({ pathName, action }) => { expect(getActionFromHref(pathName)).toBe(action); }); diff --git a/spec/lib/api/api_spec.rb b/spec/lib/api/api_spec.rb index cf08eaa7653..a6068d81aa9 100644 --- a/spec/lib/api/api_spec.rb +++ b/spec/lib/api/api_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe API::API do describe '.prefix' do it 'has a prefix defined' do - expect(described_class.prefix).to eq :api + expect(described_class.prefix).to eq 'api' end end diff --git a/spec/lib/gitlab/grape_logging/loggers/filter_parameters_spec.rb b/spec/lib/gitlab/grape_logging/loggers/filter_parameters_spec.rb index 15c842c9f44..bf5cd320a11 100644 --- a/spec/lib/gitlab/grape_logging/loggers/filter_parameters_spec.rb +++ b/spec/lib/gitlab/grape_logging/loggers/filter_parameters_spec.rb @@ -6,7 +6,7 @@ RSpec.describe Gitlab::GrapeLogging::Loggers::FilterParameters do subject { described_class.new } describe ".parameters" do - let(:route) { instance_double('Grape::Router::Route', settings: settings) } + let(:route) { instance_double('Grape::Router::Route', options: options) } let(:endpoint) { instance_double('Grape::Endpoint', route: route) } let(:env) do @@ -24,7 +24,7 @@ RSpec.describe Gitlab::GrapeLogging::Loggers::FilterParameters do end context 'when the log_safety setting is provided' do - let(:settings) { { log_safety: { safe: %w[foo bar key], unsafe: %w[oof rab value] } } } + let(:options) { { settings: { log_safety: { safe: %w[foo bar key], unsafe: %w[oof rab value] } } } } it 'includes safe parameters, and filters unsafe ones' do data = subject.parameters(mock_request, nil) @@ -42,7 +42,7 @@ RSpec.describe Gitlab::GrapeLogging::Loggers::FilterParameters do end context 'when the log_safety is not provided' do - let(:settings) { {} } + let(:options) { {} } it 'behaves like the normal parameter filter' do data = subject.parameters(mock_request, nil) diff --git a/spec/lib/gitlab/utils/batch_loader_spec.rb b/spec/lib/gitlab/utils/batch_loader_spec.rb index c1f6d6df07a..87cd6288928 100644 --- a/spec/lib/gitlab/utils/batch_loader_spec.rb +++ b/spec/lib/gitlab/utils/batch_loader_spec.rb @@ -3,7 +3,7 @@ require 'fast_spec_helper' require 'batch-loader' -RSpec.describe Gitlab::Utils::BatchLoader do +RSpec.describe Gitlab::Utils::BatchLoader, feature_category: :shared do let(:stubbed_loader) do double( # rubocop:disable RSpec/VerifiedDoubles 'Loader', @@ -54,6 +54,7 @@ RSpec.describe Gitlab::Utils::BatchLoader do described_class.clear_key(:my_batch_name) + # .to_i triggers loading of lazy value test_module.lazy_method(4).to_i test_module.lazy_method_same_batch_key(5).to_i test_module.lazy_method_other_batch_key(6).to_i @@ -64,6 +65,7 @@ RSpec.describe Gitlab::Utils::BatchLoader do end it 'clears loaded values which match the specified batch key' do + # .to_i triggers loading of lazy value test_module.lazy_method(1).to_i test_module.lazy_method_same_batch_key(2).to_i test_module.lazy_method_other_batch_key(3).to_i diff --git a/spec/migrations/db/post_migrate/20250317021451_migrate_old_disabled_web_hook_to_new_state_spec.rb b/spec/migrations/db/post_migrate/20250317021451_migrate_old_disabled_web_hook_to_new_state_spec.rb new file mode 100644 index 00000000000..ad26f6e3076 --- /dev/null +++ b/spec/migrations/db/post_migrate/20250317021451_migrate_old_disabled_web_hook_to_new_state_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! + +RSpec.describe MigrateOldDisabledWebHookToNewState, :freeze_time, feature_category: :webhooks, migration_version: 20250206114301 do + let!(:web_hooks) { table(:web_hooks) } + + let!(:non_disabled_webhook) { web_hooks.create!(recent_failures: 3, backoff_count: 1) } + let!(:legacy_permanently_disabled_webhook) { web_hooks.create!(recent_failures: 4, backoff_count: 1) } + let!(:temporarily_disabled_webhook) do + web_hooks.create!(recent_failures: 4, backoff_count: 1, disabled_until: Time.current + 1.hour) + end + + describe '#up' do + it 'migrates legacy permanently disabled web hooks to new permanently disabled state' do + migrate! + + [non_disabled_webhook, temporarily_disabled_webhook, legacy_permanently_disabled_webhook].each(&:reload) + + expect(non_disabled_webhook.recent_failures).to eq(3) + expect(non_disabled_webhook.backoff_count).to eq(1) + expect(non_disabled_webhook.disabled_until).to be_nil + + expect(temporarily_disabled_webhook.recent_failures).to eq(4) + expect(temporarily_disabled_webhook.backoff_count).to eq(1) + expect(temporarily_disabled_webhook.disabled_until).to eq(Time.current + 1.hour) + + expect(legacy_permanently_disabled_webhook.recent_failures).to eq(40) + expect(legacy_permanently_disabled_webhook.backoff_count).to eq(37) + expect(legacy_permanently_disabled_webhook.disabled_until).to eq(Time.current) + + expect(web_hooks.where(disabled_until: nil).where('recent_failures > 3').count).to eq(0) + + expect(ProjectHook.executable.pluck_primary_key).to contain_exactly(non_disabled_webhook.id) + expect(ProjectHook.disabled.pluck_primary_key).to contain_exactly( + temporarily_disabled_webhook.id, + legacy_permanently_disabled_webhook.id + ) + end + + it 'migrates in batches' do + web_hooks.create!(recent_failures: 4, backoff_count: 1) + web_hooks.create!(recent_failures: 4, backoff_count: 1) + + stub_const("#{described_class}::BATCH_SIZE", 2) + disabled_until = Time.zone.now.to_fs(:db) + + expect do + migrate! + end.to make_queries_matching( + /UPDATE "web_hooks" SET "recent_failures" = 40, "backoff_count" = 37, "disabled_until" = '#{disabled_until}'/, + 2 + ) + end + end +end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 656f9a50708..61e68ffb50f 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -6439,7 +6439,7 @@ RSpec.describe Project, factory_default: :keep, feature_category: :groups_and_pr it 'executes hooks which were backed off and are no longer backed off' do project = create(:project) hook = create(:project_hook, project: project, push_events: true) - WebHooks::AutoDisabling::FAILURE_THRESHOLD.succ.times { hook.backoff! } + WebHooks::AutoDisabling::TEMPORARILY_DISABLED_FAILURE_THRESHOLD.succ.times { hook.backoff! } expect_any_instance_of(ProjectHook).to receive(:async_execute).once diff --git a/spec/requests/api/api_spec.rb b/spec/requests/api/api_spec.rb index cd891f20a9f..2bca38151f0 100644 --- a/spec/requests/api/api_spec.rb +++ b/spec/requests/api/api_spec.rb @@ -582,8 +582,10 @@ RSpec.describe API::API, feature_category: :system_access do end describe 'Grape::Exceptions::Base handler' do + let_it_be(:user) { create(:user) } + it 'returns 400 on JSON parse errors' do - post api('/projects'), + post api('/projects', user), params: '{"test":"random_\$escaped/symbols\;here"}', headers: { 'content-type' => 'application/json' } diff --git a/spec/requests/api/groups_spec.rb b/spec/requests/api/groups_spec.rb index 482b85a2585..93aa450a260 100644 --- a/spec/requests/api/groups_spec.rb +++ b/spec/requests/api/groups_spec.rb @@ -3451,10 +3451,10 @@ RSpec.describe API::Groups, :with_current_organization, feature_category: :group end.to change { shared_group.shared_with_group_links.count }.by(-1) end - it 'requires the group id to be an integer' do + it 'returns a 404 error when the group id is not an integer' do delete api("/groups/#{shared_group.id}/share/foo", user) - expect(response).to have_gitlab_http_status(:bad_request) + expect(response).to have_gitlab_http_status(:not_found) end it 'returns a 404 error when group link does not exist' do diff --git a/spec/requests/api/helpers_spec.rb b/spec/requests/api/helpers_spec.rb index 45879ce1e80..3b85180d49c 100644 --- a/spec/requests/api/helpers_spec.rb +++ b/spec/requests/api/helpers_spec.rb @@ -42,7 +42,7 @@ RSpec.describe API::Helpers, :enable_admin_mode, feature_category: :system_acces env['warden'] = warden end - def error!(message, status, header) + def error!(message, status, header = {}) raise StandardError, "#{status} - #{message}" end @@ -336,7 +336,7 @@ RSpec.describe API::Helpers, :enable_admin_mode, feature_category: :system_acces describe '.handle_api_exception' do before do - allow_any_instance_of(self.class).to receive(:rack_response) + allow_any_instance_of(self.class).to receive(:error!) stub_sentry_settings diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index 86c2588623f..d8a052ea551 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -4016,10 +4016,10 @@ RSpec.describe API::Projects, :aggregate_failures, feature_category: :groups_and end end - it 'returns a 400 when group id is not an integer' do + it 'returns a 404 when group id is not an integer' do delete api("/projects/#{project.id}/share/foo", user) - expect(response).to have_gitlab_http_status(:bad_request) + expect(response).to have_gitlab_http_status(:not_found) end it 'returns a 404 error when group link does not exist' do diff --git a/spec/requests/api/suggestions_spec.rb b/spec/requests/api/suggestions_spec.rb index f4994324059..6e4b046c32e 100644 --- a/spec/requests/api/suggestions_spec.rb +++ b/spec/requests/api/suggestions_spec.rb @@ -111,8 +111,8 @@ RSpec.describe API::Suggestions, feature_category: :code_review_workflow do put api(url, user) - expect(response).to have_gitlab_http_status(:bad_request) - expect(json_response).to eq({ 'error' => 'id is invalid' }) + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response).to eq({ 'error' => '404 Not Found' }) end end diff --git a/spec/requests/api/topics_spec.rb b/spec/requests/api/topics_spec.rb index 17b8034c291..c7a1b5d637c 100644 --- a/spec/requests/api/topics_spec.rb +++ b/spec/requests/api/topics_spec.rb @@ -129,11 +129,11 @@ RSpec.describe API::Topics, :aggregate_failures, :with_current_organization, fea expect(response).to have_gitlab_http_status(:not_found) end - it 'returns 400 for invalid `id` parameter' do + it 'returns 404 for invalid `id` parameter' do get api('/topics/invalid') - expect(response).to have_gitlab_http_status(:bad_request) - expect(json_response['error']).to eql('id is invalid') + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response['error']).to eql('404 Not Found') end end @@ -245,11 +245,11 @@ RSpec.describe API::Topics, :aggregate_failures, :with_current_organization, fea expect(response).to have_gitlab_http_status(:not_found) end - it 'returns 400 for invalid `id` parameter' do + it 'returns 404 for invalid `id` parameter' do put api('/topics/invalid', admin, admin_mode: true), params: params - expect(response).to have_gitlab_http_status(:bad_request) - expect(json_response['error']).to eql('id is invalid') + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response['error']).to eql('404 Not Found') end context 'with blank avatar' do @@ -318,11 +318,11 @@ RSpec.describe API::Topics, :aggregate_failures, :with_current_organization, fea expect(response).to have_gitlab_http_status(:not_found) end - it 'returns 400 for invalid `id` parameter' do + it 'returns 404 for invalid `id` parameter' do delete api('/topics/invalid', admin, admin_mode: true), params: params - expect(response).to have_gitlab_http_status(:bad_request) - expect(json_response['error']).to eql('id is invalid') + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response['error']).to eql('404 Not Found') end end diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb index 73e9a808467..51bcbd4e3de 100644 --- a/spec/requests/api/users_spec.rb +++ b/spec/requests/api/users_spec.rb @@ -3865,10 +3865,10 @@ RSpec.describe API::Users, :with_current_organization, :aggregate_failures, feat expect(response).to have_gitlab_http_status(:unauthorized) end - it "returns 400 for invalid ID" do + it "returns 404 for invalid ID" do delete api("/user/emails/ASDF", admin, admin_mode: true) - expect(response).to have_gitlab_http_status(:bad_request) + expect(response).to have_gitlab_http_status(:not_found) end end diff --git a/spec/services/web_hook_service_spec.rb b/spec/services/web_hook_service_spec.rb index e1532656a18..2b561ba2018 100644 --- a/spec/services/web_hook_service_spec.rb +++ b/spec/services/web_hook_service_spec.rb @@ -627,7 +627,7 @@ RSpec.describe WebHookService, :request_store, :clean_gitlab_redis_shared_state, response_status: 400 ).deep_stringify_keys ), - 'failed', + 'error', '' ) diff --git a/spec/services/web_hooks/log_execution_service_spec.rb b/spec/services/web_hooks/log_execution_service_spec.rb index ff54508f1af..c2f0b8f67e5 100644 --- a/spec/services/web_hooks/log_execution_service_spec.rb +++ b/spec/services/web_hooks/log_execution_service_spec.rb @@ -116,28 +116,6 @@ RSpec.describe WebHooks::LogExecutionService, feature_category: :webhooks do end end - context 'when response_category is :failed' do - let(:response_category) { :failed } - - before do - data[:response_status] = '400' - end - - it 'increments the failure count' do - expect { service.execute }.to change { project_hook.recent_failures }.by(1) - end - - it 'does not change the disabled_until attribute' do - expect { service.execute }.not_to change { project_hook.disabled_until } - end - - it 'does not allow the failure count to overflow' do - project_hook.update!(recent_failures: 32767) - - expect { service.execute }.not_to change { project_hook.recent_failures } - end - end - context 'when response_category is :error' do let(:response_category) { :error } diff --git a/spec/support/shared_contexts/models/concerns/webhooks/webhook_autodisabling_shared_context.rb b/spec/support/shared_contexts/models/concerns/webhooks/webhook_autodisabling_shared_context.rb new file mode 100644 index 00000000000..b559374ced9 --- /dev/null +++ b/spec/support/shared_contexts/models/concerns/webhooks/webhook_autodisabling_shared_context.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +RSpec.shared_context 'with webhook auto-disabling failure thresholds' do + where(:recent_failures, :disabled_until, :executable) do + past = 1.minute.ago + now = Time.current + future = 1.minute.from_now + + [ + # At 3 failures the hook is always executable + [3, nil, true], + [3, past, true], + [3, now, true], + [3, future, true], + # At 4 failures the hook is executable only when disabled_until is in the past + [4, nil, false], + [4, past, true], + [4, now, true], + [4, future, false], + # At 39 failures the logic should be the same as with 4 failures (testing the boundary of 40) + [39, nil, false], + [39, past, true], + [39, now, true], + [39, future, false], + # At 40 failures the hook is always disabled + [40, nil, false], + [40, past, false], + [40, now, false], + [40, future, false] + ] + end +end diff --git a/spec/support/shared_examples/models/concerns/auto_disabling_hooks_shared_examples.rb b/spec/support/shared_examples/models/concerns/auto_disabling_hooks_shared_examples.rb index 33b62564e5f..465209541a0 100644 --- a/spec/support/shared_examples/models/concerns/auto_disabling_hooks_shared_examples.rb +++ b/spec/support/shared_examples/models/concerns/auto_disabling_hooks_shared_examples.rb @@ -8,144 +8,64 @@ RSpec.shared_examples 'a hook that gets automatically disabled on failure' do allow(logger).to receive(:info) end - shared_examples 'is tolerant of invalid records' do - specify do - hook.url = nil + describe '.executable and .disabled', :freeze_time do + include_context 'with webhook auto-disabling failure thresholds' - expect(hook).to be_invalid - run_expectation - end - end - - describe '.executable/.disabled', :freeze_time do - let!(:not_executable) do - [ - [4, nil], # Exceeded the grace period, set by #fail! - [4, 1.second.from_now], # Exceeded the grace period, set by #backoff! - [4, Time.current] # Exceeded the grace period, set by #backoff!, edge-case - ].map do |(recent_failures, disabled_until)| - create( - hook_factory, - **default_factory_arguments, + with_them do + let(:web_hook) do + factory_arguments = default_factory_arguments.merge( recent_failures: recent_failures, disabled_until: disabled_until ) - end - end - let!(:executables) do - expired = 1.second.ago - borderline = Time.current - suspended = 1.second.from_now - - [ - # Most of these are impossible states, but are included for completeness - [0, nil], - [1, nil], - [3, nil], - [4, expired], - - # Impossible cases: - [3, suspended], - [3, expired], - [3, borderline], - [1, suspended], - [1, expired], - [1, borderline], - [0, borderline], - [0, suspended], - [0, expired] - ].map do |(recent_failures, disabled_until)| - create( - hook_factory, - **default_factory_arguments, - recent_failures: recent_failures, - disabled_until: disabled_until - ) - end - end - - it 'finds the correct set of project hooks' do - expect(find_hooks.executable).to match_array executables - expect(find_hooks.executable).to all(be_executable) - - # As expected, and consistent - expect(find_hooks.disabled).to match_array not_executable - expect(find_hooks.disabled.map(&:executable?)).not_to include(true) - - # Nothing is missing - expect(find_hooks.executable.to_a + find_hooks.disabled.to_a).to match_array(find_hooks.to_a) - end - - context 'when the flag is disabled' do - before do - stub_feature_flags(auto_disabling_web_hooks: false) + create(hook_factory, **factory_arguments) end - it 'causes all hooks to be considered executable' do - expect(find_hooks.executable.count).to eq(16) + it 'scopes correctly' do + if executable + expect(find_hooks.executable).to match_array([web_hook]) + expect(find_hooks.disabled).to be_empty + else + expect(find_hooks.executable).to be_empty + expect(find_hooks.disabled).to match_array([web_hook]) + end end - it 'causes no hooks to be considered disabled' do - expect(find_hooks.disabled).to be_empty - end - end + context 'when the flag is disabled' do + before do + stub_feature_flags(auto_disabling_web_hooks: false) + end - context 'when silent mode is enabled' do - before do - stub_application_setting(silent_mode_enabled: true) + it 'causes all hooks to be scoped as executable' do + expect(find_hooks.executable).to match_array([web_hook]) + expect(find_hooks.disabled).to be_empty + end end - it 'causes no hooks to be considered executable' do - expect(find_hooks.executable).to be_empty - end + context 'when silent mode is enabled' do + before do + stub_application_setting(silent_mode_enabled: true) + end - it 'causes all hooks to be considered disabled' do - expect(find_hooks.disabled.count).to eq(16) + it 'causes all hooks to be scoped as disabled' do + expect(find_hooks.executable).to be_empty + expect(find_hooks.disabled).to match_array([web_hook]) + end end end end describe '#executable?', :freeze_time do - let(:web_hook) { create(hook_factory, **default_factory_arguments) } - - where(:recent_failures, :not_until, :executable) do - [ - [0, :not_set, true], - [0, :past, true], - [0, :future, true], - [0, :now, true], - [1, :not_set, true], - [1, :past, true], - [1, :future, true], - [3, :not_set, true], - [3, :past, true], - [3, :future, true], - [4, :not_set, false], - [4, :past, true], # expired suspension - [4, :now, false], # active suspension - [4, :future, false] # active suspension - ] - end + include_context 'with webhook auto-disabling failure thresholds' with_them do - # Phasing means we cannot put these values in the where block, - # which is not subject to the frozen time context. - let(:disabled_until) do - case not_until - when :not_set - nil - when :past - 1.minute.ago - when :future - 1.minute.from_now - when :now - Time.current - end - end + let(:web_hook) do + factory_arguments = default_factory_arguments.merge( + recent_failures: recent_failures, + disabled_until: disabled_until + ) - before do - web_hook.update!(recent_failures: recent_failures, disabled_until: disabled_until) + create(hook_factory, **factory_arguments) end it 'has the correct state' do @@ -164,24 +84,17 @@ RSpec.shared_examples 'a hook that gets automatically disabled on failure' do end end - describe '#enable!' do - it 'makes a hook executable if it was marked as failed' do - hook.recent_failures = 1000 - - expect { hook.enable! }.to change { hook.executable? }.from(false).to(true) + describe '#enable!', :freeze_time do + before do + hook.recent_failures = WebHooks::AutoDisabling::TEMPORARILY_DISABLED_FAILURE_THRESHOLD + hook.backoff! end - it 'makes a hook executable if it is currently backed off' do - hook.recent_failures = 1000 - hook.disabled_until = 1.hour.from_now - + it 'makes a hook executable' do expect { hook.enable! }.to change { hook.executable? }.from(false).to(true) end it 'logs relevant information' do - hook.recent_failures = 1000 - hook.disabled_until = 1.hour.from_now - expect(logger) .to receive(:info) .with(a_hash_including( @@ -196,32 +109,50 @@ RSpec.shared_examples 'a hook that gets automatically disabled on failure' do end it 'does not update hooks unless necessary' do - hook + hook.recent_failures = 0 + hook.backoff_count = 0 + hook.disabled_until = nil sql_count = ActiveRecord::QueryRecorder.new { hook.enable! }.count expect(sql_count).to eq(0) end - include_examples 'is tolerant of invalid records' do - def run_expectation - hook.recent_failures = 1000 + it 'is tolerant of invalid records' do + hook.url = nil - expect { hook.enable! }.to change { hook.executable? }.from(false).to(true) - end + expect(hook).to be_invalid + expect { hook.enable! }.to change { hook.executable? }.from(false).to(true) end end - describe '#backoff!', :freeze_time do + describe '#backoff!' do + around do |example| + if example.metadata[:skip_freeze_time] + example.run + else + freeze_time { example.run } + end + end + context 'when we have not backed off before' do it 'does not disable the hook' do expect { hook.backoff! }.not_to change { hook.executable? }.from(true) + expect(hook.class.executable).to include(hook) end it 'increments recent_failures' do expect { hook.backoff! }.to change { hook.recent_failures }.from(0).to(1) end + it 'does not increment backoff_count' do + expect { hook.backoff! }.not_to change { hook.backoff_count }.from(0) + end + + it 'does not set disabled_until' do + expect { hook.backoff! }.not_to change { hook.disabled_until }.from(nil) + end + it 'logs relevant information' do expect(logger) .to receive(:info) @@ -233,19 +164,29 @@ RSpec.shared_examples 'a hook that gets automatically disabled on failure' do end end - context 'when we have exhausted the grace period' do + context 'when failures exceed the threshold' do before do - hook.update!(recent_failures: WebHooks::AutoDisabling::FAILURE_THRESHOLD) + hook.update!(recent_failures: WebHooks::AutoDisabling::TEMPORARILY_DISABLED_FAILURE_THRESHOLD) end - it 'disables the hook' do + it 'temporarily disables the hook' do expect { hook.backoff! }.to change { hook.executable? }.from(true).to(false) + expect(hook).to be_temporarily_disabled + expect(hook).not_to be_permanently_disabled + expect(hook.class.executable).not_to include(hook) end it 'increments backoff_count' do expect { hook.backoff! }.to change { hook.backoff_count }.from(0).to(1) end + it 'increments recent_failures' do + expect { hook.backoff! }.to change { + hook.recent_failures + }.from(WebHooks::AutoDisabling::TEMPORARILY_DISABLED_FAILURE_THRESHOLD) + .to(WebHooks::AutoDisabling::TEMPORARILY_DISABLED_FAILURE_THRESHOLD + 1) + end + it 'sets disabled_until' do expect { hook.backoff! }.to change { hook.disabled_until }.from(nil).to(1.minute.from_now) end @@ -256,7 +197,7 @@ RSpec.shared_examples 'a hook that gets automatically disabled on failure' do .with(a_hash_including( hook_id: hook.id, action: 'backoff', - recent_failures: WebHooks::AutoDisabling::FAILURE_THRESHOLD + 1, + recent_failures: WebHooks::AutoDisabling::TEMPORARILY_DISABLED_FAILURE_THRESHOLD + 1, disabled_until: 1.minute.from_now, backoff_count: 1 )) @@ -264,23 +205,71 @@ RSpec.shared_examples 'a hook that gets automatically disabled on failure' do hook.backoff! end + it 'is no longer disabled after the backoff time has elapsed', :skip_freeze_time do + hook.backoff! + + expect(hook).to be_temporarily_disabled + expect(hook).not_to be_permanently_disabled + expect(hook.class.executable).not_to include(hook) + + travel_to(hook.disabled_until + 1.second) do + expect(hook).not_to be_temporarily_disabled + expect(hook).not_to be_permanently_disabled + expect(hook.class.executable).to include(hook) + end + end + + it 'increases the backoff time exponentially', :skip_freeze_time do + hook.backoff! + + expect(hook).to have_attributes( + recent_failures: (WebHooks::AutoDisabling::TEMPORARILY_DISABLED_FAILURE_THRESHOLD + 1), + backoff_count: 1, + disabled_until: be_like_time(Time.zone.now + 1.minute) + ) + + travel_to(hook.disabled_until + 1.second) do + hook.backoff! + + expect(hook).to have_attributes( + recent_failures: (WebHooks::AutoDisabling::TEMPORARILY_DISABLED_FAILURE_THRESHOLD + 2), + backoff_count: 2, + disabled_until: be_like_time(Time.zone.now + 2.minutes) + ) + end + + travel_to(hook.disabled_until + 1.second) do + hook.backoff! + + expect(hook).to have_attributes( + recent_failures: (WebHooks::AutoDisabling::TEMPORARILY_DISABLED_FAILURE_THRESHOLD + 3), + backoff_count: 3, + disabled_until: be_like_time(Time.zone.now + 4.minutes) + ) + end + end + context 'when the hook is permanently disabled' do before do allow(hook).to receive(:permanently_disabled?).and_return(true) end - it 'does not set disabled_until' do - expect { hook.backoff! }.not_to change { hook.disabled_until } - end + it 'does not do anything' do + sql_count = ActiveRecord::QueryRecorder.new { hook.backoff! }.count - it 'does not increment the backoff count' do - expect { hook.backoff! }.not_to change { hook.backoff_count } + expect(sql_count).to eq(0) end end - include_examples 'is tolerant of invalid records' do - def run_expectation - expect { hook.backoff! }.to change { hook.backoff_count }.by(1) + context 'when the hook is temporarily disabled' do + before do + allow(hook).to receive(:temporarily_disabled?).and_return(true) + end + + it 'does not do anything' do + sql_count = ActiveRecord::QueryRecorder.new { hook.backoff! }.count + + expect(sql_count).to eq(0) end end @@ -289,80 +278,66 @@ RSpec.shared_examples 'a hook that gets automatically disabled on failure' do stub_feature_flags(auto_disabling_web_hooks: false) end - it 'does not increment backoff count' do - expect { hook.failed! }.not_to change { hook.backoff_count } - end - end - end - end - - describe '#failed!' do - include_examples 'is tolerant of invalid records' do - def run_expectation - expect { hook.failed! }.to change { hook.recent_failures }.by(1) - end - - context 'when the flag is disabled' do - before do - stub_feature_flags(auto_disabling_web_hooks: false) - end - - it 'does not increment recent failure count' do - expect { hook.failed! }.not_to change { hook.recent_failures } - end - end - end - end - - describe '#temporarily_disabled?' do - it 'is false when not temporarily disabled' do - expect(hook).not_to be_temporarily_disabled - end - - it 'allows FAILURE_THRESHOLD initial failures before we back-off' do - WebHooks::AutoDisabling::FAILURE_THRESHOLD.times do - hook.backoff! - expect(hook).not_to be_temporarily_disabled - end - - hook.backoff! - expect(hook).to be_temporarily_disabled - end - - context 'when hook has been told to back off' do - before do - hook.update!(recent_failures: WebHooks::AutoDisabling::FAILURE_THRESHOLD) - hook.backoff! - end - - it 'is true' do - expect(hook).to be_temporarily_disabled - end - - context 'when the flag is disabled' do - before do - stub_feature_flags(auto_disabling_web_hooks: false) - end - - it 'is false' do + it 'does not disable the hook' do + expect { hook.backoff! }.not_to change { hook.executable? }.from(true) expect(hook).not_to be_temporarily_disabled + expect(hook).not_to be_permanently_disabled + expect(hook.class.executable).to include(hook) end end + + it 'is tolerant of invalid records' do + hook.url = nil + + expect(hook).to be_invalid + expect { hook.backoff! }.to change { hook.backoff_count }.by(1) + end end end - describe '#permanently_disabled?' do - it 'is false when not disabled' do + describe '#temporarily_disabled? and #permanently_disabled?', :freeze_time do + it 'is initially not disabled at all' do + expect(hook).not_to be_temporarily_disabled expect(hook).not_to be_permanently_disabled end - context 'when hook has been disabled' do - before do - hook.update!(recent_failures: WebHooks::AutoDisabling::EXCEEDED_FAILURE_THRESHOLD) + it 'becomes temporarily disabled after a threshold of failures has been exceeded' do + WebHooks::AutoDisabling::TEMPORARILY_DISABLED_FAILURE_THRESHOLD.times do + hook.backoff! + + expect(hook).not_to be_temporarily_disabled + expect(hook).not_to be_permanently_disabled end - it 'is true' do + hook.backoff! + + expect(hook).to be_temporarily_disabled + expect(hook).not_to be_permanently_disabled + end + + context 'when the flag is disabled' do + before do + stub_feature_flags(auto_disabling_web_hooks: false) + end + + it 'is not disabled at all' do + hook.update!(recent_failures: WebHooks::AutoDisabling::TEMPORARILY_DISABLED_FAILURE_THRESHOLD) + hook.backoff! + + expect(hook).not_to be_temporarily_disabled + expect(hook).not_to be_permanently_disabled + end + end + + context 'when hook exceeds the permanently disabled threshold' do + before do + hook.update!(recent_failures: WebHooks::AutoDisabling::PERMANENTLY_DISABLED_FAILURE_THRESHOLD) + hook.backoff! + end + + it 'is permanently disabled' do expect(hook).to be_permanently_disabled + expect(hook).not_to be_temporarily_disabled end context 'when the flag is disabled' do @@ -370,24 +345,48 @@ RSpec.shared_examples 'a hook that gets automatically disabled on failure' do stub_feature_flags(auto_disabling_web_hooks: false) end - it 'is false' do + it 'is not disabled at all' do + expect(hook).not_to be_temporarily_disabled expect(hook).not_to be_permanently_disabled end end end + + # TODO Remove as part of https://gitlab.com/gitlab-org/gitlab/-/issues/525446 + context 'when hook has no disabled_until set and exceeds TEMPORARILY_DISABLED_FAILURE_THRESHOLD (legacy state)' do + before do + hook.update!(recent_failures: WebHooks::AutoDisabling::TEMPORARILY_DISABLED_FAILURE_THRESHOLD + 1) + end + + it 'is permanently disabled' do + expect(hook).to be_permanently_disabled + expect(hook).not_to be_temporarily_disabled + end + + context 'when the flag is disabled' do + before do + stub_feature_flags(auto_disabling_web_hooks: false) + end + + it 'is not disabled at all' do + expect(hook).not_to be_permanently_disabled + expect(hook).not_to be_temporarily_disabled + end + end + end end describe '#alert_status' do subject(:status) { hook.alert_status } - it { is_expected.to eq :executable } + it { is_expected.to eq(:executable) } - context 'when hook has been disabled' do + context 'when hook has been permanently disabled' do before do - hook.update!(recent_failures: WebHooks::AutoDisabling::EXCEEDED_FAILURE_THRESHOLD) + allow(hook).to receive(:permanently_disabled?).and_return(true) end - it { is_expected.to eq :disabled } + it { is_expected.to eq(:disabled) } context 'when the flag is disabled' do before do @@ -398,13 +397,12 @@ RSpec.shared_examples 'a hook that gets automatically disabled on failure' do end end - context 'when hook has been backed off' do + context 'when hook has been temporarily disabled' do before do - hook.update!(recent_failures: WebHooks::AutoDisabling::EXCEEDED_FAILURE_THRESHOLD) - hook.disabled_until = 1.hour.from_now + allow(hook).to receive(:temporarily_disabled?).and_return(true) end - it { is_expected.to eq :temporarily_disabled } + it { is_expected.to eq(:temporarily_disabled) } context 'when the flag is disabled' do before do diff --git a/spec/support/shared_examples/models/concerns/unstoppable_hooks_shared_examples.rb b/spec/support/shared_examples/models/concerns/unstoppable_hooks_shared_examples.rb index cce52fd5fbd..05943e563b6 100644 --- a/spec/support/shared_examples/models/concerns/unstoppable_hooks_shared_examples.rb +++ b/spec/support/shared_examples/models/concerns/unstoppable_hooks_shared_examples.rb @@ -1,97 +1,57 @@ # frozen_string_literal: true RSpec.shared_examples 'a hook that does not get automatically disabled on failure' do + let(:exeeded_failure_threshold) { WebHooks::AutoDisabling::TEMPORARILY_DISABLED_FAILURE_THRESHOLD + 1 } + describe '.executable/.disabled', :freeze_time do - let(:attributes_for_webhooks) do - attributes_list = attributes_for_list(hook_factory, 13) + include_context 'with webhook auto-disabling failure thresholds' - merged_attributes = attributes_list.zip([ - [0, Time.current], - [0, 1.minute.from_now], - [1, 1.minute.from_now], - [3, 1.minute.from_now], - [4, nil], - [4, 1.day.ago], - [4, 1.minute.from_now], - [0, nil], - [0, 1.day.ago], - [1, nil], - [1, 1.day.ago], - [3, nil], - [3, 1.day.ago] - ]) + with_them do + let(:web_hook) do + factory_arguments = default_factory_arguments.merge( + recent_failures: recent_failures, + disabled_until: disabled_until + ) - merged_attributes.map do |attributes, (recent_failures, disabled_until)| - attributes.merge(**default_factory_arguments, recent_failures: recent_failures, disabled_until: disabled_until) - end - end - - let(:webhooks) { described_class.create!(attributes_for_webhooks) } - - it 'finds the correct set of project hooks' do - expect(find_hooks).to all(be_executable) - expect(find_hooks.executable).to match_array(webhooks) - expect(find_hooks.disabled).to be_empty - end - - context 'when silent mode is enabled' do - before do - stub_application_setting(silent_mode_enabled: true) + create(hook_factory, **factory_arguments) end - it 'causes no hooks to be considered executable' do - expect(find_hooks.executable).to be_empty + it 'is always enabled' do + expect(find_hooks).to all(be_executable) + expect(find_hooks.executable).to match_array(find_hooks) + expect(find_hooks.disabled).to be_empty end - it 'causes all hooks to be considered disabled' do - expect(find_hooks.disabled).to match_array(webhooks) + context 'when silent mode is enabled' do + before do + stub_application_setting(silent_mode_enabled: true) + end + + it 'causes no hooks to be considered executable' do + expect(find_hooks.executable).to be_empty + end + + it 'causes all hooks to be considered disabled' do + expect(find_hooks.disabled).to match_array(find_hooks) + end end end end describe '#executable?', :freeze_time do - let(:web_hook) { build(hook_factory, **default_factory_arguments) } - - where(:recent_failures, :not_until) do - [ - [0, :not_set], - [0, :past], - [0, :future], - [0, :now], - [1, :not_set], - [1, :past], - [1, :future], - [3, :not_set], - [3, :past], - [3, :future], - [4, :not_set], - [4, :past], # expired suspension - [4, :now], # active suspension - [4, :future] # active suspension - ] - end + include_context 'with webhook auto-disabling failure thresholds' with_them do - # Phasing means we cannot put these values in the where block, - # which is not subject to the frozen time context. - let(:disabled_until) do - case not_until - when :not_set - nil - when :past - 1.minute.ago - when :future - 1.minute.from_now - when :now - Time.current - end + let(:web_hook) do + factory_arguments = default_factory_arguments.merge( + recent_failures: recent_failures, + disabled_until: disabled_until + ) + + build(hook_factory, **factory_arguments) end - before do - web_hook.update!(recent_failures: recent_failures, disabled_until: disabled_until) - end - - it 'has the correct state' do + it 'is always executable' do expect(web_hook).to be_executable end end @@ -129,7 +89,7 @@ RSpec.shared_examples 'a hook that does not get automatically disabled on failur context 'when we have exhausted the grace period' do before do - hook.update!(recent_failures: WebHooks::AutoDisabling::FAILURE_THRESHOLD) + hook.update!(recent_failures: WebHooks::AutoDisabling::TEMPORARILY_DISABLED_FAILURE_THRESHOLD) end it 'does not disable the hook' do @@ -144,7 +104,7 @@ RSpec.shared_examples 'a hook that does not get automatically disabled on failur expect(hook).not_to be_temporarily_disabled # Backing off - WebHooks::AutoDisabling::FAILURE_THRESHOLD.times do + WebHooks::AutoDisabling::TEMPORARILY_DISABLED_FAILURE_THRESHOLD.times do hook.backoff! expect(hook).not_to be_temporarily_disabled end @@ -159,7 +119,7 @@ RSpec.shared_examples 'a hook that does not get automatically disabled on failur # Initially expect(hook).not_to be_permanently_disabled - hook.update!(recent_failures: WebHooks::AutoDisabling::EXCEEDED_FAILURE_THRESHOLD) + hook.update!(recent_failures: exeeded_failure_threshold) expect(hook).not_to be_permanently_disabled end @@ -172,7 +132,7 @@ RSpec.shared_examples 'a hook that does not get automatically disabled on failur context 'when hook has been disabled' do before do - hook.update!(recent_failures: WebHooks::AutoDisabling::EXCEEDED_FAILURE_THRESHOLD) + hook.update!(recent_failures: exeeded_failure_threshold) end it { is_expected.to eq :executable } @@ -180,7 +140,7 @@ RSpec.shared_examples 'a hook that does not get automatically disabled on failur context 'when hook has been backed off' do before do - hook.update!(recent_failures: WebHooks::AutoDisabling::EXCEEDED_FAILURE_THRESHOLD) + hook.update!(recent_failures: exeeded_failure_threshold) hook.disabled_until = 1.hour.from_now end diff --git a/spec/support/shared_examples/models/concerns/web_hooks/has_web_hooks_shared_examples.rb b/spec/support/shared_examples/models/concerns/web_hooks/has_web_hooks_shared_examples.rb index 113dcc266fc..841fe04a035 100644 --- a/spec/support/shared_examples/models/concerns/web_hooks/has_web_hooks_shared_examples.rb +++ b/spec/support/shared_examples/models/concerns/web_hooks/has_web_hooks_shared_examples.rb @@ -19,7 +19,7 @@ RSpec.shared_examples 'something that has web-hooks' do context 'when there is a failed hook' do before do hook = create_hook - hook.update!(recent_failures: WebHooks::AutoDisabling::FAILURE_THRESHOLD + 1) + hook.update!(recent_failures: WebHooks::AutoDisabling::TEMPORARILY_DISABLED_FAILURE_THRESHOLD + 1) end it { is_expected.to eq(true) } diff --git a/spec/support/shared_examples/models/concerns/web_hooks/web_hook_shared_examples.rb b/spec/support/shared_examples/models/concerns/web_hooks/web_hook_shared_examples.rb index 7ec0564eafb..78698946b88 100644 --- a/spec/support/shared_examples/models/concerns/web_hooks/web_hook_shared_examples.rb +++ b/spec/support/shared_examples/models/concerns/web_hooks/web_hook_shared_examples.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.shared_examples 'a webhook' do |factory:, auto_disabling: true| +RSpec.shared_examples 'a webhook' do |factory:| include AfterNextHelpers let(:hook) { build(factory) } @@ -560,148 +560,4 @@ RSpec.shared_examples 'a webhook' do |factory:, auto_disabling: true| it { expect(hook.masked_token).to eq described_class::SECRET_MASK } end end - - describe '#backoff!', if: auto_disabling do - context 'when we have not backed off before' do - it 'increments the recent_failures count but does not disable the hook yet' do - expect { hook.backoff! }.to change { hook.recent_failures }.to(1) - expect(hook.class.executable).to include(hook) - end - end - - context 'when hook is at the failure threshold' do - before do - WebHooks::AutoDisabling::FAILURE_THRESHOLD.times { hook.backoff! } - end - - it 'is not yet disabled' do - expect(hook.class.executable).to include(hook) - expect(hook).to have_attributes( - recent_failures: WebHooks::AutoDisabling::FAILURE_THRESHOLD, - backoff_count: 0, - disabled_until: nil - ) - end - - context 'when hook is next told to backoff' do - before do - hook.backoff! - end - - it 'causes the hook to become disabled for initial backoff period' do - expect(hook.class.executable).not_to include(hook) - expect(hook).to have_attributes( - recent_failures: (WebHooks::AutoDisabling::FAILURE_THRESHOLD + 1), - backoff_count: 1, - disabled_until: 1.minute.from_now - ) - end - - context 'when the backoff time has elapsed', :skip_freeze_time do - it 'is no longer disabled' do - travel_to(hook.disabled_until + 1.minute) do - expect(hook.class.executable).to include(hook) - end - end - - context 'when the hook is next told to backoff' do - it 'disables the hook again, increasing the backoff time exponentially' do - travel_to(hook.disabled_until + 1.minute) do - hook.backoff! - - expect(hook.class.executable).not_to include(hook) - expect(hook).to have_attributes( - recent_failures: (WebHooks::AutoDisabling::FAILURE_THRESHOLD + 2), - backoff_count: 2, - disabled_until: 2.minutes.from_now - ) - end - end - end - end - end - end - - it 'does not do anything if the hook is currently temporarily disabled' do - allow(hook).to receive(:temporarily_disabled?).and_return(true) - - sql_count = ActiveRecord::QueryRecorder.new { hook.backoff! }.count - - expect(sql_count).to eq(0) - end - - it 'does not do anything if the hook is currently permanently disabled' do - allow(hook).to receive(:permanently_disabled?).and_return(true) - - sql_count = ActiveRecord::QueryRecorder.new { hook.backoff! }.count - - expect(sql_count).to eq(0) - end - - context 'when the counter are above MAX_FAILURES' do - let(:max_failures) { WebHooks::AutoDisabling::MAX_FAILURES } - - before do - hook.update!( - recent_failures: (max_failures + 1), - backoff_count: (max_failures + 1), - disabled_until: 1.hour.ago - ) - end - - it 'reduces the counter to MAX_FAILURES' do - hook.backoff! - - expect(hook).to have_attributes( - recent_failures: max_failures, - backoff_count: max_failures - ) - end - end - end - - describe '#failed!', if: auto_disabling do - it 'increments the recent_failures count but does not disable the hook yet' do - expect { hook.failed! }.to change { hook.recent_failures }.to(1) - expect(hook.class.executable).to include(hook) - end - - context 'when hook is at the failure threshold' do - before do - WebHooks::AutoDisabling::FAILURE_THRESHOLD.times { hook.failed! } - end - - it 'is not yet disabled' do - expect(hook.class.executable).to include(hook) - expect(hook).to have_attributes( - recent_failures: WebHooks::AutoDisabling::FAILURE_THRESHOLD, - backoff_count: 0, - disabled_until: nil - ) - end - - context 'when hook is next failed' do - before do - hook.failed! - end - - it 'causes the hook to become disabled' do - expect(hook.class.executable).not_to include(hook) - expect(hook).to have_attributes( - recent_failures: (WebHooks::AutoDisabling::FAILURE_THRESHOLD + 1), - backoff_count: 0, - disabled_until: nil - ) - end - end - end - - it 'does not do anything if recent_failures is at MAX_FAILURES' do - hook.recent_failures = WebHooks::AutoDisabling::MAX_FAILURES - - sql_count = ActiveRecord::QueryRecorder.new { hook.failed! }.count - - expect(sql_count).to eq(0) - end - end end diff --git a/spec/support/shared_examples/requests/api/hooks_shared_examples.rb b/spec/support/shared_examples/requests/api/hooks_shared_examples.rb index 45637423c9c..af119d0cc4d 100644 --- a/spec/support/shared_examples/requests/api/hooks_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/hooks_shared_examples.rb @@ -223,7 +223,7 @@ RSpec.shared_examples 'web-hook API endpoints' do |prefix| context 'the hook is disabled' do before do - hook.update!(recent_failures: hook.class::EXCEEDED_FAILURE_THRESHOLD) + hook.update!(recent_failures: hook.class::TEMPORARILY_DISABLED_FAILURE_THRESHOLD + 1) end it "has the correct alert status", :aggregate_failures do @@ -237,7 +237,7 @@ RSpec.shared_examples 'web-hook API endpoints' do |prefix| context 'the hook is backed-off' do before do - WebHooks::AutoDisabling::FAILURE_THRESHOLD.times { hook.backoff! } + WebHooks::AutoDisabling::TEMPORARILY_DISABLED_FAILURE_THRESHOLD.times { hook.backoff! } hook.backoff! end diff --git a/spec/support/shared_examples/requests/api/protection_rules_shared_examples.rb b/spec/support/shared_examples/requests/api/protection_rules_shared_examples.rb index cea4293b395..0204d3809aa 100644 --- a/spec/support/shared_examples/requests/api/protection_rules_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/protection_rules_shared_examples.rb @@ -28,8 +28,8 @@ RSpec.shared_examples 'rejecting protection rules request when handling rule ids let(:url) { "/projects/#{project_id}/#{path}" } where(:project_id, :protection_rule_id, :status) do - ref(:valid_project_id) | 'invalid' | :bad_request - ref(:valid_project_id) | non_existing_record_id | :not_found + ref(:valid_project_id) | 'invalid' | :not_found + ref(:valid_project_id) | non_existing_record_id | :not_found ref(:other_project_id) | ref(:valid_protection_rule_id) | :not_found end diff --git a/spec/tasks/ci/job_tokens_task_spec.rb b/spec/tasks/ci/job_tokens_task_spec.rb index 939459b1bed..9a3d9c02549 100644 --- a/spec/tasks/ci/job_tokens_task_spec.rb +++ b/spec/tasks/ci/job_tokens_task_spec.rb @@ -190,59 +190,69 @@ RSpec.describe Tasks::Ci::JobTokensTask, :silence_stdout, feature_category: :per def allowed_route_without_policies instance_double(Grape::Router::Route, - settings: { - authentication: { job_token_allowed: true } + options: { + description: 'route description', + settings: { + authentication: { job_token_allowed: true } + } }, request_method: 'GET', - description: 'route description', origin: 'path/to/allowed_route_without_policies' ) end def allowed_route_with_invalid_policies instance_double(Grape::Router::Route, - settings: { - authentication: { job_token_allowed: true }, - authorization: { job_token_policies: :invalid_policy } + options: { + description: 'route description', + settings: { + authentication: { job_token_allowed: true }, + authorization: { job_token_policies: :invalid_policy } + } }, request_method: 'GET', - description: 'route description', origin: 'path/to/allowed_route_with_invalid_policies' ) end def allowed_route_with_valid_policies instance_double(Grape::Router::Route, - settings: { - authentication: { job_token_allowed: true }, - authorization: { job_token_policies: :read_packages } + options: { + description: 'route description', + settings: { + authentication: { job_token_allowed: true }, + authorization: { job_token_policies: :read_packages } + } }, request_method: 'GET', - description: 'route description', origin: 'path/to/allowed_route_with_valid_policies' ) end def allowed_route_with_skipped_policies instance_double(Grape::Router::Route, - settings: { - authentication: { job_token_allowed: true }, - authorization: { skip_job_token_policies: true } + options: { + description: 'route description', + settings: { + authentication: { job_token_allowed: true }, + authorization: { skip_job_token_policies: true } + } }, request_method: 'GET', - description: 'route description', origin: 'path/to/allowed_route_with_skipped_policies' ) end def not_allowed_route instance_double(Grape::Router::Route, - settings: { - authentication: { job_token_allowed: false }, - authorization: { job_token_policies: :read_packages } + options: { + description: 'route description', + settings: { + authentication: { job_token_allowed: false }, + authorization: { job_token_policies: :read_packages } + } }, request_method: 'GET', - description: 'route description', origin: 'path/to/route' ) end diff --git a/spec/views/projects/hooks/edit.html.haml_spec.rb b/spec/views/projects/hooks/edit.html.haml_spec.rb index 68dd6faeb76..06c4598a867 100644 --- a/spec/views/projects/hooks/edit.html.haml_spec.rb +++ b/spec/views/projects/hooks/edit.html.haml_spec.rb @@ -39,7 +39,7 @@ RSpec.describe 'projects/hooks/edit' do it 'renders alert' do render - expect(rendered).to have_text(s_('Webhooks|Webhook failed to connect')) + expect(rendered).to have_text(s_('Webhooks|Webhook disabled')) end end @@ -52,7 +52,7 @@ RSpec.describe 'projects/hooks/edit' do it 'renders alert' do render - expect(rendered).to have_text(s_('Webhooks|Webhook fails to connect')) + expect(rendered).to have_text(s_('Webhooks|Webhook temporarily disabled')) end end end diff --git a/spec/views/projects/hooks/index.html.haml_spec.rb b/spec/views/projects/hooks/index.html.haml_spec.rb index e876ace3fcb..a6551ad86a0 100644 --- a/spec/views/projects/hooks/index.html.haml_spec.rb +++ b/spec/views/projects/hooks/index.html.haml_spec.rb @@ -19,9 +19,9 @@ RSpec.describe 'projects/hooks/index' do expect(rendered).to have_css('.gl-heading-2', text: _('Webhooks')) expect(rendered).to have_text('Webhooks') - expect(rendered).not_to have_css('.gl-badge', text: _('Disabled')) - expect(rendered).not_to have_css('.gl-badge', text: s_('Webhooks|Failed to connect')) - expect(rendered).not_to have_css('.gl-badge', text: s_('Webhooks|Fails to connect')) + expect(rendered).not_to have_css('.gl-badge', text: s_('Webhooks|Rate limited')) + expect(rendered).not_to have_css('.gl-badge', text: s_('Webhooks|Disabled')) + expect(rendered).not_to have_css('.gl-badge', text: s_('Webhooks|Temporarily disabled')) end context 'webhook is rate limited' do @@ -29,10 +29,10 @@ RSpec.describe 'projects/hooks/index' do allow(existing_hook).to receive(:rate_limited?).and_return(true) end - it 'renders "Disabled" badge' do + it 'renders "Rate limited" badge' do render - expect(rendered).to have_css('.gl-badge', text: _('Disabled')) + expect(rendered).to have_css('.gl-badge', text: _('Webhooks|Rate limited')) end end @@ -41,10 +41,10 @@ RSpec.describe 'projects/hooks/index' do allow(existing_hook).to receive(:permanently_disabled?).and_return(true) end - it 'renders "Failed to connect" badge' do + it 'renders "Disabled" badge' do render - expect(rendered).to have_css('.gl-badge', text: s_('Webhooks|Failed to connect')) + expect(rendered).to have_css('.gl-badge', text: s_('Webhooks|Disabled')) end end @@ -53,10 +53,10 @@ RSpec.describe 'projects/hooks/index' do allow(existing_hook).to receive(:temporarily_disabled?).and_return(true) end - it 'renders "Fails to connect" badge' do + it 'renders "Temporarily disabled" badge' do render - expect(rendered).to have_css('.gl-badge', text: s_('Webhooks|Fails to connect')) + expect(rendered).to have_css('.gl-badge', text: s_('Webhooks|Temporarily disabled')) end end end diff --git a/spec/workers/merge_request_cleanup_refs_worker_spec.rb b/spec/workers/merge_request_cleanup_refs_worker_spec.rb index 6c87b6827a8..0b96fe66ac3 100644 --- a/spec/workers/merge_request_cleanup_refs_worker_spec.rb +++ b/spec/workers/merge_request_cleanup_refs_worker_spec.rb @@ -30,8 +30,8 @@ RSpec.describe MergeRequestCleanupRefsWorker, feature_category: :code_review_wor expect(cleanup_schedule.completed_at).to be_nil end - context "and cleanup schedule has already failed #{WebHooks::AutoDisabling::FAILURE_THRESHOLD} times" do - let(:failed_count) { WebHooks::AutoDisabling::FAILURE_THRESHOLD } + context "and cleanup schedule has already failed #{described_class::FAILURE_THRESHOLD} times" do + let(:failed_count) { described_class::FAILURE_THRESHOLD } it 'marks the cleanup schedule as failed and track the failure' do expect(cleanup_schedule.reload).to be_failed