diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue index 76480ea1c12d7399a77b635e8d391f8c06cec1d7..f1f574c642483842c95dbbae41a283e22aa429f0 100644 --- a/app/assets/javascripts/integrations/edit/components/integration_form.vue +++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue @@ -293,6 +293,7 @@ export default { :key="`${currentKey}-${field.name}`" v-bind="field" :is-validated="isValidated" + :data-qa-selector="`${field.name}_div`" /> </div> </div> diff --git a/app/assets/javascripts/integrations/edit/components/sections/configuration.vue b/app/assets/javascripts/integrations/edit/components/sections/configuration.vue index 9e1ad24ae9fc62f4e88b94d8f2b516e9d192723e..b8fd8995744104b4526c8866c36895b882c7fa0a 100644 --- a/app/assets/javascripts/integrations/edit/components/sections/configuration.vue +++ b/app/assets/javascripts/integrations/edit/components/sections/configuration.vue @@ -33,6 +33,7 @@ export default { :key="`${currentKey}-${field.name}`" v-bind="field" :is-validated="isValidated" + :data-qa-selector="`${field.name}_div`" /> </div> </template> diff --git a/db/post_migrate/20220630050050_index_vulnerability_reads_on_casted_cluster_agent_id_full.rb b/db/post_migrate/20220630050050_index_vulnerability_reads_on_casted_cluster_agent_id_full.rb new file mode 100644 index 0000000000000000000000000000000000000000..58b6342e30f5b3cd6f2147f5e6659bb5990e7fd2 --- /dev/null +++ b/db/post_migrate/20220630050050_index_vulnerability_reads_on_casted_cluster_agent_id_full.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class IndexVulnerabilityReadsOnCastedClusterAgentIdFull < Gitlab::Database::Migration[2.0] + disable_ddl_transaction! + + INDEX_NAME = 'index_vuln_reads_on_casted_cluster_agent_id_where_it_is_null' + + def up + add_concurrent_index :vulnerability_reads, + :casted_cluster_agent_id, + name: INDEX_NAME, + where: 'casted_cluster_agent_id IS NOT NULL' + end + + def down + remove_concurrent_index_by_name :vulnerability_reads, INDEX_NAME + end +end diff --git a/db/schema_migrations/20220630050050 b/db/schema_migrations/20220630050050 new file mode 100644 index 0000000000000000000000000000000000000000..2ec998847eb05307e8956973f6999e9edf93e168 --- /dev/null +++ b/db/schema_migrations/20220630050050 @@ -0,0 +1 @@ +dfb314ef76efc54a2464e6b84e71753caf58bc8508f9e64b403066ea4847fe56 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index f39b7270eed37e59b63c04ace38a07fe13f8a09e..097d49d4bb0839b22c67d4466101b7c2707d60a5 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -29912,6 +29912,8 @@ COMMENT ON INDEX index_verification_codes_on_phone_and_visitor_id_code IS 'JiHu- CREATE UNIQUE INDEX index_vuln_historical_statistics_on_project_id_and_date ON vulnerability_historical_statistics USING btree (project_id, date); +CREATE INDEX index_vuln_reads_on_casted_cluster_agent_id_where_it_is_null ON vulnerability_reads USING btree (casted_cluster_agent_id) WHERE (casted_cluster_agent_id IS NOT NULL); + CREATE INDEX index_vuln_reads_on_project_id_state_severity_and_vuln_id ON vulnerability_reads USING btree (project_id, state, severity, vulnerability_id DESC); CREATE INDEX index_vulnerabilites_common_finder_query ON vulnerabilities USING btree (project_id, state, report_type, severity, id); diff --git a/ee/app/services/geo/container_repository_sync.rb b/ee/app/services/geo/container_repository_sync.rb index 5e78203b66aa7e5f3c87c268d616b6a42c8bb80d..88b6cf480e25622b38193766a19f602242bb2559 100644 --- a/ee/app/services/geo/container_repository_sync.rb +++ b/ee/app/services/geo/container_repository_sync.rb @@ -41,30 +41,48 @@ def sync_tag(tag) case manifest_parsed['mediaType'] when *ATOMIC_MANIFESTS - push_manifest_blobs(manifest_parsed) + sync_manifest_blobs(manifest_parsed) when *LIST_MANIFESTS - manifest_parsed['manifests'].each do |submanifest| - image_info_raw = client.repository_raw_manifest(repository_path, submanifest['digest']) - image_info = Gitlab::Json.parse(image_info_raw) - push_manifest_blobs(image_info) - container_repository.push_manifest(submanifest['digest'], image_info_raw, image_info['mediaType']) + if buildkit_oci_incompatible_index?(manifest_parsed['manifests']) + sync_manifest_blobs(manifest_parsed) + else + manifest_parsed['manifests'].each do |submanifest| + image_info_raw = client.repository_raw_manifest(repository_path, submanifest['digest']) + image_info = Gitlab::Json.parse(image_info_raw) + sync_manifest_blobs(image_info) + container_repository.push_manifest(submanifest['digest'], image_info_raw, image_info['mediaType']) + end end else raise "Unexpected mediaType: #{manifest_parsed['mediaType']}" end + container_repository.push_manifest(tag[:name], manifest, manifest_parsed['mediaType']) end - def push_manifest_blobs(manifest) + # Buildkit-cache images have special oci-spec-invalid structure where fat manifests reference + # blobs directly. Normal OCI fat manifest only references other manifests + # Issue https://github.com/moby/buildkit/issues/2251 + def buildkit_oci_incompatible_index?(manifests) + manifests.any? do |manifest| + manifest['mediaType'].include?('application/vnd.buildkit.cacheconfig') + end + end + + def sync_manifest_blobs(manifest) list_blobs(manifest).each do |digest| - next if container_repository.blob_exists?(digest) + sync_blob(digest) + end + end - file = client.pull_blob(repository_path, digest) - begin - container_repository.push_blob(digest, file.path) - ensure - file.unlink - end + def sync_blob(digest) + return if container_repository.blob_exists?(digest) + + file = client.pull_blob(repository_path, digest) + begin + container_repository.push_blob(digest, file.path) + ensure + file.unlink end end @@ -72,8 +90,11 @@ def remove_tag(tag) container_repository.delete_tag_by_digest(tag[:digest]) end + # Normally "layers" is the only reference to blobs related to particular manifest + # This method is also used for build cache images where fat manifest references blobs directly + # in this case "manifests" attribute is a way of refering to them. def list_blobs(manifest) - layers = manifest['layers'].filter_map do |layer| + layers = (manifest['layers'] || manifest['manifests']).filter_map do |layer| layer['digest'] unless foreign_layer?(layer) end diff --git a/ee/spec/services/geo/container_repository_sync_spec.rb b/ee/spec/services/geo/container_repository_sync_spec.rb index fdf328f707985a742fe6bed04595458f5c6161eb..2dc75dbb25c7f2d4800dea1bc8ff1ef56081eb73 100644 --- a/ee/spec/services/geo/container_repository_sync_spec.rb +++ b/ee/spec/services/geo/container_repository_sync_spec.rb @@ -5,10 +5,10 @@ RSpec.describe Geo::ContainerRepositorySync, :geo do let_it_be(:group) { create(:group, name: 'group') } let_it_be(:project) { create(:project, path: 'test', group: group) } - let_it_be(:container_repository) { create(:container_repository, name: 'my_image', project: project) } + let(:container_repository) { create(:container_repository, name: 'my_image', project: project) } let(:primary_api_url) { 'http://primary.registry.gitlab' } - let(:secondary_api_url) { 'http://registry.gitlab' } + let(:secondary_api_url) { 'http://secondary.registry.gitlab' } let(:primary_repository_url) { "#{primary_api_url}/v2/#{container_repository.path}" } let(:secondary_repository_url ) { "#{secondary_api_url}/v2/#{container_repository.path}" } @@ -26,20 +26,6 @@ "}" end - let(:oci_manifest) do - %Q( - { - "schemaVersion":2, - "mediaType":"application/vnd.oci.image.manifest.v1+json", - "layers":[ - {"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","size":3333,"digest":"sha256:3333"}, - {"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","size":4444,"digest":"sha256:4444"}, - {"mediaType":"application/vnd.docker.image.rootfs.foreign.diff.tar.gzip","size":5555,"digest":"sha256:5555","urls":["https://foo.bar/v2/zoo/blobs/sha256:5555"]} - ] - } - ) - end - let(:manifest_list) do %Q( { @@ -60,45 +46,12 @@ ) end - let(:oci_manifest_list) do - %Q( - { - "schemaVersion":2, - "mediaType":"application/vnd.oci.image.index.v1+json", - "manifests":[ - { - "mediaType":"application/vnd.oci.image.manifest.v1+json", - "size":6666, - "digest":"sha256:6666", - "platform": - { - "architecture":"arm64","os":"linux" - } - } - ] - } - ) - end - before do stub_container_registry_config(enabled: true, api_url: secondary_api_url) stub_registry_replication_config(enabled: true, primary_api_url: primary_api_url) end - def stub_primary_repository_tags_requests(repository_url, tags) - stub_request(:get, "#{repository_url}/tags/list") - .to_return( - status: 200, - body: Gitlab::Json.dump(tags: tags.keys), - headers: { 'Content-Type' => 'application/json' }) - - tags.each do |tag, digest| - stub_request(:head, "#{repository_url}/manifests/#{tag}") - .to_return(status: 200, body: "", headers: { DependencyProxy::Manifest::DIGEST_HEADER => digest }) - end - end - - def stub_secondary_repository_tags_requests(repository_url, tags) + def stub_repository_tags_requests(repository_url, tags) stub_request(:get, "#{repository_url}/tags/list") .to_return( status: 200, @@ -111,22 +64,17 @@ def stub_secondary_repository_tags_requests(repository_url, tags) end end - def stub_primary_raw_manifest_request(repository_url, tag, manifest) + def stub_raw_manifest_request(repository_url, tag, manifest) stub_request(:get, "#{repository_url}/manifests/#{tag}") .to_return(status: 200, body: manifest, headers: {}) end - def stub_secondary_raw_manifest_request(repository_url, tag, manifest) - stub_request(:get, "#{repository_url}/manifests/#{tag}") - .to_return(status: 200, body: manifest, headers: {}) - end - - def stub_primary_raw_manifest_list_request(repository_url, tag, manifest) + def stub_raw_manifest_list_request(repository_url, tag, manifest_list) stub_request(:get, "#{repository_url}/manifests/#{tag}") .to_return(status: 200, body: manifest_list, headers: {}) end - def stub_secondary_push_manifest_request(repository_url, tag, manifest) + def stub_push_manifest_request(repository_url, tag, manifest) stub_request(:put, "#{repository_url}/manifests/#{tag}") .with(body: manifest) .to_return(status: 200, body: "", headers: {}) @@ -149,11 +97,11 @@ def stub_missing_blobs_requests(primary_repository_url, secondary_repository_url context 'single manifest' do it 'determines list of tags to sync and to remove correctly' do - stub_primary_repository_tags_requests(primary_repository_url, { 'tag-to-sync' => 'sha256:1111' }) - stub_secondary_repository_tags_requests(secondary_repository_url, { 'tag-to-remove' => 'sha256:2222' }) - stub_primary_raw_manifest_request(primary_repository_url, 'tag-to-sync', manifest) + stub_repository_tags_requests(primary_repository_url, { 'tag-to-sync' => 'sha256:1111' }) + stub_repository_tags_requests(secondary_repository_url, { 'tag-to-remove' => 'sha256:2222' }) + stub_raw_manifest_request(primary_repository_url, 'tag-to-sync', manifest) stub_missing_blobs_requests(primary_repository_url, secondary_repository_url, { 'sha256:3333' => true, 'sha256:4444' => false }) - stub_secondary_push_manifest_request(secondary_repository_url, 'tag-to-sync', manifest) + stub_push_manifest_request(secondary_repository_url, 'tag-to-sync', manifest) expect(container_repository).to receive(:push_blob).with('sha256:3333', anything) expect(container_repository).not_to receive(:push_blob).with('sha256:4444', anything) @@ -165,8 +113,8 @@ def stub_missing_blobs_requests(primary_repository_url, secondary_repository_url context 'when primary repository has no tags' do it 'removes secondary tags and does not fail' do - stub_primary_repository_tags_requests(primary_repository_url, {}) - stub_secondary_repository_tags_requests(secondary_repository_url, { 'tag-to-remove' => 'sha256:2222' }) + stub_repository_tags_requests(primary_repository_url, {}) + stub_repository_tags_requests(secondary_repository_url, { 'tag-to-remove' => 'sha256:2222' }) expect(container_repository).to receive(:delete_tag_by_digest).with('sha256:2222') @@ -177,35 +125,104 @@ def stub_missing_blobs_requests(primary_repository_url, secondary_repository_url context 'manifest list' do it 'pushes the correct blobs and manifests' do - stub_primary_repository_tags_requests(primary_repository_url, { 'tag-to-sync' => 'sha256:1111' }) - stub_secondary_repository_tags_requests(secondary_repository_url, {}) - stub_primary_raw_manifest_list_request(primary_repository_url, 'tag-to-sync', manifest_list) - stub_primary_raw_manifest_request(primary_repository_url, 'sha256:6666', manifest) - stub_secondary_raw_manifest_request(secondary_repository_url, 'sha256:6666', manifest) + stub_repository_tags_requests(primary_repository_url, { 'tag-to-sync' => 'sha256:1111' }) + stub_repository_tags_requests(secondary_repository_url, {}) + stub_raw_manifest_list_request(primary_repository_url, 'tag-to-sync', manifest_list) + stub_raw_manifest_request(primary_repository_url, 'sha256:6666', manifest) stub_missing_blobs_requests(primary_repository_url, secondary_repository_url, { 'sha256:3333' => true, 'sha256:4444' => false }) expect(container_repository).to receive(:push_blob).with('sha256:3333', anything) expect(container_repository).to receive(:push_manifest).with('sha256:6666', anything, anything) expect(container_repository).to receive(:push_manifest).with('tag-to-sync', anything, anything) - expect(container_repository).to receive(:delete_tag_by_digest).with('sha256:2222') subject.execute end end context 'oci manifest list' do + let(:oci_manifest) do + %Q( + { + "schemaVersion":2, + "mediaType":"application/vnd.oci.image.manifest.v1+json", + "layers":[ + {"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","size":3333,"digest":"sha256:3333"}, + {"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","size":4444,"digest":"sha256:4444"}, + {"mediaType":"application/vnd.docker.image.rootfs.foreign.diff.tar.gzip","size":5555,"digest":"sha256:5555","urls":["https://foo.bar/v2/zoo/blobs/sha256:5555"]} + ] + } + ) + end + + let(:oci_manifest_list) do + %Q( + { + "schemaVersion":2, + "mediaType":"application/vnd.oci.image.index.v1+json", + "manifests":[ + { + "mediaType":"application/vnd.oci.image.manifest.v1+json", + "size":6666, + "digest":"sha256:6666", + "platform": + { + "architecture":"arm64","os":"linux" + } + } + ] + } + ) + end + it 'pushes the correct blobs and manifests' do - stub_primary_repository_tags_requests(primary_repository_url, { 'tag-to-sync' => 'sha256:1111' }) - stub_secondary_repository_tags_requests(secondary_repository_url, {}) - stub_primary_raw_manifest_list_request(primary_repository_url, 'tag-to-sync', oci_manifest_list) - stub_primary_raw_manifest_request(primary_repository_url, 'sha256:6666', oci_manifest) - stub_secondary_raw_manifest_request(secondary_repository_url, 'sha256:6666', manifest) + stub_repository_tags_requests(primary_repository_url, { 'tag-to-sync' => 'sha256:1111' }) + stub_repository_tags_requests(secondary_repository_url, {}) + stub_raw_manifest_list_request(primary_repository_url, 'tag-to-sync', oci_manifest_list) + stub_raw_manifest_request(primary_repository_url, 'sha256:6666', oci_manifest) stub_missing_blobs_requests(primary_repository_url, secondary_repository_url, { 'sha256:3333' => true, 'sha256:4444' => false }) expect(container_repository).to receive(:push_blob).with('sha256:3333', anything) expect(container_repository).to receive(:push_manifest).with('sha256:6666', anything, anything) expect(container_repository).to receive(:push_manifest).with('tag-to-sync', anything, anything) - expect(container_repository).to receive(:delete_tag_by_digest).with('sha256:2222') + + subject.execute + end + end + + context 'buildkit cache images' do + let(:buildcache_manifest_list) do + %Q( + { + "schemaVersion":2, + "mediaType":"application/vnd.oci.image.index.v1+json", + "manifests":[ + { + "mediaType":"application/vnd.oci.image.layer.v1.tar+gzip", + "digest":"sha256:3333", + "size":24803024, + "annotations":{ + "buildkit/createdat":"2022-06-17T16:44:22.638028085Z", + "containerd.io/uncompressed":"sha256:65feea9638f81cb1fab4ede714f970bb8453cd1a2aa23860d2bb3fdcb960068b" + } + }, + { + "mediaType":"application/vnd.buildkit.cacheconfig.v0", + "digest":"sha256:4444", + "size":1753 + } + ] + } + ) + end + + it 'pushes the correct blobs and manifests' do + stub_repository_tags_requests(primary_repository_url, { 'tag-to-sync' => 'sha256:1111' }) + stub_repository_tags_requests(secondary_repository_url, {}) + stub_raw_manifest_list_request(primary_repository_url, 'tag-to-sync', buildcache_manifest_list) + stub_missing_blobs_requests(primary_repository_url, secondary_repository_url, { 'sha256:3333' => true, 'sha256:4444' => false }) + + expect(container_repository).to receive(:push_blob).with('sha256:3333', anything) + expect(container_repository).to receive(:push_manifest).with('tag-to-sync', anything, anything) subject.execute end diff --git a/package.json b/package.json index e33699b0402c63b54f98bce3c6385a4b4ea707ec..34027119cbc96be7be0e8d99eb91ecb77635c62e 100644 --- a/package.json +++ b/package.json @@ -170,7 +170,7 @@ "sortablejs": "^1.10.2", "string-hash": "1.1.3", "style-loader": "^2.0.0", - "swagger-ui-dist": "4.8.0", + "swagger-ui-dist": "4.12.0", "three": "^0.84.0", "three-orbit-controls": "^82.1.0", "three-stl-loader": "^1.0.4", diff --git a/qa/qa/page/project/settings/integrations.rb b/qa/qa/page/project/settings/integrations.rb index 420dcb6391878220329e96938827302d66c06388..0d5515aacdf8bdcf4c577a39548a827c5bbea562 100644 --- a/qa/qa/page/project/settings/integrations.rb +++ b/qa/qa/page/project/settings/integrations.rb @@ -8,12 +8,17 @@ class Integrations < QA::Page::Base view 'app/assets/javascripts/integrations/index/components/integrations_table.vue' do element :prometheus_link, %q(:data-qa-selector="`${item.name}_link`") # rubocop:disable QA/ElementWithPattern element :jira_link, %q(:data-qa-selector="`${item.name}_link`") # rubocop:disable QA/ElementWithPattern + element :pipelines_email_link, %q(:data-qa-selector="`${item.name}_link`") # rubocop:disable QA/ElementWithPattern end def click_on_prometheus_integration click_element :prometheus_link end + def click_pipelines_email_link + click_element :pipelines_email_link + end + def click_jira_link click_element :jira_link end diff --git a/qa/qa/page/project/settings/services/pipeline_status_emails.rb b/qa/qa/page/project/settings/services/pipeline_status_emails.rb new file mode 100644 index 0000000000000000000000000000000000000000..2f78577e3d5fc415229296976b0bd474ee394fc5 --- /dev/null +++ b/qa/qa/page/project/settings/services/pipeline_status_emails.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module QA + module Page + module Project + module Settings + module Services + class PipelineStatusEmails < QA::Page::Base + view 'app/assets/javascripts/integrations/edit/components/integration_form.vue' do + element :recipients_div, %q(:data-qa-selector="`${field.name}_div`") # rubocop:disable QA/ElementWithPattern + element :notify_only_broken_pipelines_div, %q(:data-qa-selector="`${field.name}_div`") # rubocop:disable QA/ElementWithPattern + element :save_changes_button + end + + def set_recipients(emails) + within_element :recipients_div do + fill_in 'Recipients', with: emails.join(',') + end + end + + def toggle_notify_broken_pipelines + within_element :notify_only_broken_pipelines_div do + uncheck 'Notify only broken pipelines', allow_label_click: true + end + end + + def click_save_button + click_element(:save_changes_button) + end + end + end + end + end + end +end diff --git a/qa/qa/resource/user.rb b/qa/qa/resource/user.rb index 1b848feb50da3589658c5783a4fe0a203239095b..2fb8b18b71f41b5a02eff09fedbb7fbae1750e8d 100644 --- a/qa/qa/resource/user.rb +++ b/qa/qa/resource/user.rb @@ -104,12 +104,6 @@ def exists? false end - def api_delete - super - - QA::Runtime::Logger.debug("Deleted user '#{username}'") - end - def api_delete_path "/users/#{id}?hard_delete=#{hard_delete_on_api_removal}" rescue NoValueError diff --git a/qa/qa/specs/features/browser_ui/1_manage/project/dashboard_images_spec.rb b/qa/qa/specs/features/browser_ui/1_manage/project/dashboard_images_spec.rb index 44cae31f5d81dc560369527aefd2b70f63850319..b1d59b90e9c2552b0e1b0503b7b09825ff2f35a1 100644 --- a/qa/qa/specs/features/browser_ui/1_manage/project/dashboard_images_spec.rb +++ b/qa/qa/specs/features/browser_ui/1_manage/project/dashboard_images_spec.rb @@ -2,49 +2,45 @@ module QA RSpec.describe 'Manage' do - describe 'Check for broken images', :requires_admin do - before(:context) do - @api_client = Runtime::API::Client.as_admin - @new_user = Resource::User.fabricate_via_api! do |user| - user.api_client = @api_client - end - @new_admin = Resource::User.fabricate_via_api! do |user| - user.admin = true - user.api_client = @api_client - end + shared_examples 'loads all images' do |admin| + let(:api_client) { Runtime::API::Client.as_admin } - Page::Main::Menu.perform(&:sign_out_if_signed_in) + let(:user) do + Resource::User.fabricate_via_api! do |resource| + resource.admin = admin + resource.api_client = api_client + end end - after(:context) do - @new_user.remove_via_api! - @new_admin.remove_via_api! + after do + user.remove_via_api! end - shared_examples 'loads all images' do - it 'loads all images' do - Runtime::Browser.visit(:gitlab, Page::Main::Login) - Page::Main::Login.perform { |login| login.sign_in_using_credentials(user: new_user) } + it 'loads all images' do + Flow::Login.sign_in(as: user) - Page::Dashboard::Welcome.perform do |welcome| - expect(welcome).to have_welcome_title("Welcome to GitLab") + Page::Dashboard::Welcome.perform do |welcome| + expect(welcome).to have_welcome_title("Welcome to GitLab") - # This would be better if it were a visual validation test - expect(welcome).to have_loaded_all_images - end + # This would be better if it were a visual validation test + expect(welcome).to have_loaded_all_images end end + end - context 'when logged in as a new user', testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/347885' do - it_behaves_like 'loads all images' do - let(:new_user) { @new_user } - end + describe 'Check for broken images', :requires_admin, :reliable do + context( + 'when logged in as a new user', + testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/347885' + ) do + it_behaves_like 'loads all images', false end - context 'when logged in as a new admin', testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/347884' do - it_behaves_like 'loads all images' do - let(:new_user) { @new_admin } - end + context( + 'when logged in as a new admin', + testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/347884' + ) do + it_behaves_like 'loads all images', true end end end diff --git a/qa/qa/specs/features/browser_ui/2_plan/email/trigger_email_notification_spec.rb b/qa/qa/specs/features/browser_ui/2_plan/email/trigger_email_notification_spec.rb index f41e59856226b3ddc6312b699b3df7161bbacd93..b815186cd493e2cbb7ad7a589e226ac65a7a68b3 100644 --- a/qa/qa/specs/features/browser_ui/2_plan/email/trigger_email_notification_spec.rb +++ b/qa/qa/specs/features/browser_ui/2_plan/email/trigger_email_notification_spec.rb @@ -46,19 +46,22 @@ def mailhog_json total = mailhog_data.dig('total') subjects = mailhog_data.dig('items') .map(&method(:mailhog_item_subject)) - .join("\n") Runtime::Logger.debug(%Q[Total number of emails: #{total}]) - Runtime::Logger.debug(%Q[Subjects:\n#{subjects}]) + Runtime::Logger.debug(%Q[Subjects:\n#{subjects.join("\n")}]) # Expect at least two invitation messages: group and project - mailhog_data if total >= 2 + mailhog_data if mailhog_project_message_count(subjects) >= 1 end end def mailhog_item_subject(item) item.dig('Content', 'Headers', 'Subject', 0) end + + def mailhog_project_message_count(subjects) + subjects.count { |subject| subject.include?('project was granted') } + end end end end diff --git a/qa/qa/specs/features/browser_ui/4_verify/pipeline/pipeline_status_emails_spec.rb b/qa/qa/specs/features/browser_ui/4_verify/pipeline/pipeline_status_emails_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..f4794b3a904a39827ba0d27088e7860f560ad9af --- /dev/null +++ b/qa/qa/specs/features/browser_ui/4_verify/pipeline/pipeline_status_emails_spec.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +module QA + RSpec.shared_examples 'notifies on a pipeline' do |exit_code| + before do + push_commit(exit_code: exit_code) + end + + it 'sends an email' do + meta = exit_code_meta(exit_code) + + project.visit! + Flow::Pipeline.wait_for_latest_pipeline(status: meta[:status]) + + messages = mail_hog_messages(mail_hog) + subjects = messages.map(&:subject) + targets = messages.map(&:to) + + aggregate_failures do + expect(subjects).to include(meta[:email_subject]) + expect(subjects).to include(/#{Regexp.escape(project.name)}/) + expect(targets).to include(*emails) + end + end + end + + RSpec.describe 'Verify', :orchestrated, :runner, :requires_admin, :smtp do + describe 'Pipeline status emails' do + let(:executor) { "qa-runner-#{Time.now.to_i}" } + let(:emails) { %w[foo@bar.com baz@buzz.com] } + + let(:project) do + Resource::Project.fabricate_via_api! do |project| + project.name = 'pipeline-status-project' + end + end + + let!(:runner) do + Resource::Runner.fabricate! do |runner| + runner.project = project + runner.name = executor + runner.tags = [executor] + end + end + + let(:mail_hog) { Vendor::MailHog::API.new } + + before(:all) do + Runtime::ApplicationSettings.set_application_settings(allow_local_requests_from_web_hooks_and_services: true) + end + + before do + setup_pipeline_emails(emails) + end + + describe 'when pipeline passes', testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/366240' do + include_examples 'notifies on a pipeline', 0 + end + + describe 'when pipeline fails', testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/366241' do + include_examples 'notifies on a pipeline', 1 + end + + def push_commit(exit_code: 0) + Resource::Repository::Commit.fabricate_via_api! do |commit| + commit.project = project + commit.commit_message = 'Add .gitlab-ci.yml' + commit.add_files( + [ + { + file_path: '.gitlab-ci.yml', + content: gitlab_ci_yaml(exit_code: exit_code) + } + ] + ) + end + end + + def setup_pipeline_emails(emails) + page.visit Runtime::Scenario.gitlab_address + Flow::Login.sign_in_unless_signed_in + + project.visit! + + Page::Project::Menu.perform(&:go_to_integrations_settings) + QA::Page::Project::Settings::Integrations.perform(&:click_pipelines_email_link) + + QA::Page::Project::Settings::Services::PipelineStatusEmails.perform do |pipeline_status_emails| + pipeline_status_emails.toggle_notify_broken_pipelines # notify on pass and fail + pipeline_status_emails.set_recipients(emails) + pipeline_status_emails.click_save_button + end + end + + def gitlab_ci_yaml(exit_code: 0, tag: executor) + <<~YAML + test-pipeline-email: + tags: + - #{tag} + script: sleep 5; exit #{exit_code}; + YAML + end + + private + + def exit_code_meta(exit_code) + { + 0 => { status: 'passed', email_subject: /Successful pipeline/ }, + 1 => { status: 'failed', email_subject: /Failed pipeline/ } + }[exit_code] + end + + def mail_hog_messages(mail_hog_api) + Support::Retrier.retry_until(sleep_interval: 1) do + Runtime::Logger.debug('Fetching email...') + + messages = mail_hog_api.fetch_messages + logs = messages.map { |m| "#{m.to}: #{m.subject}" } + + Runtime::Logger.debug("MailHog Logs: #{logs.join("\n")}") + + # for failing pipelines we have three messages + # one for the owner + # and one for each recipient + messages if mail_hog_pipeline_count(messages) >= 2 + end + end + + def mail_hog_pipeline_count(messages) + messages.count { |message| message.subject.include?('pipeline') } + end + end + end +end diff --git a/qa/qa/vendor/mail_hog/api.rb b/qa/qa/vendor/mail_hog/api.rb new file mode 100644 index 0000000000000000000000000000000000000000..85eb06316247c8a4ece6cc0ca1a41e70fe6837fe --- /dev/null +++ b/qa/qa/vendor/mail_hog/api.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module QA + module Vendor + module MailHog + # Represents a Set of messages from a MailHog response + class Messages + include Enumerable + + attr_reader :data + + def initialize(data) + @data = data + end + + def total + data.dig('total') + end + + def each + data.dig('items')&.each do |item| + yield MessageItem.new(item) + end + end + end + + # Represents an email item from a MailHog response + class MessageItem + attr_reader :data + + def initialize(data) + @data = data + end + + def to + data.dig('Content', 'Headers', 'To', 0) + end + + def subject + data.dig('Content', 'Headers', 'Subject', 0) + end + end + + class API + include Support::API + + attr_reader :hostname + + def initialize(hostname: QA::Runtime::Env.mailhog_hostname || 'localhost') + @hostname = hostname + end + + def base_url + "http://#{hostname}:8025" + end + + def api_messages_url(version: 2) + "#{base_url}/api/v#{version}/messages" + end + + def delete_messages + delete(api_messages_url(version: 1)) + end + + def fetch_messages + Messages.new(JSON.parse(fetch_messages_json)) + end + + def fetch_messages_json + get(api_messages_url).body + end + end + end + end +end diff --git a/yarn.lock b/yarn.lock index e5b5cf166cac1ae63c20adadd6237a5f390d3a62..056c6ac2b0f14f1ee59432d12c23bf5cb1e20a44 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11951,10 +11951,10 @@ svg-tags@^1.0.0: resolved "https://registry.yarnpkg.com/svg-tags/-/svg-tags-1.0.0.tgz#58f71cee3bd519b59d4b2a843b6c7de64ac04764" integrity sha1-WPcc7jvVGbWdSyqEO2x95krAR2Q= -swagger-ui-dist@4.8.0: - version "4.8.0" - resolved "https://registry.yarnpkg.com/swagger-ui-dist/-/swagger-ui-dist-4.8.0.tgz#5f39a038a02ffbd5defb8e1921a9ac1620d779ae" - integrity sha512-jdcO4XcbwkAtrwvHp90Usjx3d4JZMjaiS02CxBFfuSxr6G8DBXPcK471+N6BcBkwZK7VTgpUBFAyyarsAvKYFQ== +swagger-ui-dist@4.12.0: + version "4.12.0" + resolved "https://registry.yarnpkg.com/swagger-ui-dist/-/swagger-ui-dist-4.12.0.tgz#986d90f05e81fb9db3ca40372278a5d8ce71db3a" + integrity sha512-B0Iy2ueXtbByE6OOyHTi3lFQkpPi/L7kFOKFeKTr44za7dJIELa9kzaca6GkndCgpK1QTjArnoXG+aUy0XQp1w== symbol-observable@^1.0.4: version "1.2.0"