From 1ab98e892c57b409d5ac3d643fdebc93de5a08dc Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Fri, 14 Oct 2022 15:11:00 +0000 Subject: [PATCH] Add latest changes from gitlab-org/gitlab@master --- .rubocop_todo/gitlab/service_response.yml | 4 + app/assets/javascripts/groups_select.js | 12 +- .../issues/show/components/edited.vue | 4 +- .../issues/show/components/header_actions.vue | 2 +- .../runner_bulk_delete_checkbox.vue | 5 + .../components/confidentiality_badge.vue | 2 +- .../components/group_select/utils.js | 15 ++ .../components/work_item_description.vue | 17 ++ .../work_item_widgets.fragment.graphql | 5 + app/helpers/issuables_helper.rb | 4 +- app/models/integrations/datadog.rb | 153 +++++++-------- .../issue_type/_details_header.html.haml | 7 +- bin/diagnostic-reports-uploader | 29 +++ .../gitlab_diagnostic_reports_uploader.yml | 8 - config/initializers/diagnostic_reports.rb | 9 - doc/user/admin_area/license_file.md | 4 + doc/user/packages/dependency_proxy/index.md | 2 +- doc/user/packages/package_registry/index.md | 2 +- .../deploy_tokens/img/deploy_tokens_ui.png | Bin 35336 -> 0 bytes doc/user/project/deploy_tokens/index.md | 180 +++++++++--------- doc/user/tasks.md | 16 ++ .../memory/diagnostic_reports_logger.rb | 19 ++ lib/gitlab/memory/reports_uploader.rb | 39 ++-- .../memory/upload_and_cleanup_reports.rb | 45 ++--- lib/gitlab/request_endpoints.rb | 1 + locale/gitlab.pot | 9 + spec/bin/diagnostic_reports_uploader_spec.rb | 86 +++++++++ .../diagnostic_reports/uploader_smoke_spec.rb | 83 ++++++++ .../product_analytics_tracking_spec.rb | 33 ++-- spec/features/admin/admin_runners_spec.rb | 16 ++ .../components/group_select/utils_spec.js | 24 +++ .../components/work_item_description_spec.js | 31 ++- spec/frontend/work_items/mock_data.js | 11 ++ spec/initializers/diagnostic_reports_spec.rb | 37 ---- .../memory/diagnostic_reports_logger_spec.rb | 22 +++ .../gitlab/memory/reports_uploader_spec.rb | 77 +++++++- .../memory/upload_and_cleanup_reports_spec.rb | 162 ++++++---------- spec/models/integrations/datadog_spec.rb | 4 + 38 files changed, 757 insertions(+), 422 deletions(-) create mode 100644 app/assets/javascripts/vue_shared/components/group_select/utils.js create mode 100755 bin/diagnostic-reports-uploader delete mode 100644 config/feature_flags/ops/gitlab_diagnostic_reports_uploader.yml delete mode 100644 doc/user/project/deploy_tokens/img/deploy_tokens_ui.png create mode 100644 lib/gitlab/memory/diagnostic_reports_logger.rb create mode 100644 spec/bin/diagnostic_reports_uploader_spec.rb create mode 100644 spec/commands/diagnostic_reports/uploader_smoke_spec.rb create mode 100644 spec/frontend/vue_shared/components/group_select/utils_spec.js create mode 100644 spec/lib/gitlab/memory/diagnostic_reports_logger_spec.rb diff --git a/.rubocop_todo/gitlab/service_response.yml b/.rubocop_todo/gitlab/service_response.yml index f5be713cdd1..ccf934e09b3 100644 --- a/.rubocop_todo/gitlab/service_response.yml +++ b/.rubocop_todo/gitlab/service_response.yml @@ -15,6 +15,7 @@ Gitlab/ServiceResponse: - 'app/services/ci/retry_pipeline_service.rb' - 'app/services/ci/runners/assign_runner_service.rb' - 'app/services/ci/runners/register_runner_service.rb' + - 'app/services/ci/runners/set_runner_associated_projects_service.rb' - 'app/services/concerns/alert_management/responses.rb' - 'app/services/concerns/services/return_service_responses.rb' - 'app/services/container_expiration_policies/update_service.rb' @@ -64,15 +65,18 @@ Gitlab/ServiceResponse: - 'ee/app/services/vulnerability_issue_links/create_service.rb' - 'ee/app/services/vulnerability_issue_links/delete_service.rb' - 'ee/spec/graphql/mutations/security/finding/dismiss_spec.rb' + - 'spec/controllers/boards/issues_controller_spec.rb' - 'spec/controllers/import/bulk_imports_controller_spec.rb' - 'spec/controllers/import/fogbugz_controller_spec.rb' - 'spec/controllers/projects/alerting/notifications_controller_spec.rb' + - 'spec/controllers/projects/issues_controller_spec.rb' - 'spec/controllers/projects/pipelines_controller_spec.rb' - 'spec/controllers/projects/prometheus/alerts_controller_spec.rb' - 'spec/lib/gitlab/import_export/snippet_repo_restorer_spec.rb' - 'spec/requests/api/ci/pipelines_spec.rb' - 'spec/requests/api/ci/runner/runners_post_spec.rb' - 'spec/requests/api/group_export_spec.rb' + - 'spec/requests/api/issues/issues_spec.rb' - 'spec/requests/api/project_export_spec.rb' - 'spec/requests/api/project_import_spec.rb' - 'spec/requests/projects/incident_management/pagerduty_incidents_spec.rb' diff --git a/app/assets/javascripts/groups_select.js b/app/assets/javascripts/groups_select.js index 64bba91eb4d..34e984a9bb9 100644 --- a/app/assets/javascripts/groups_select.js +++ b/app/assets/javascripts/groups_select.js @@ -1,21 +1,11 @@ import $ from 'jquery'; import { escape } from 'lodash'; +import { groupsPath } from '~/vue_shared/components/group_select/utils'; import { __ } from '~/locale'; import Api from './api'; import { loadCSSFile } from './lib/utils/css_utils'; import { select2AxiosTransport } from './lib/utils/select2_utils'; -const groupsPath = (groupsFilter, parentGroupID) => { - switch (groupsFilter) { - case 'descendant_groups': - return Api.descendantGroupsPath.replace(':id', parentGroupID); - case 'subgroups': - return Api.subgroupsPath.replace(':id', parentGroupID); - default: - return Api.groupsPath; - } -}; - const groupsSelect = () => { loadCSSFile(gon.select2_css_path) .then(() => { diff --git a/app/assets/javascripts/issues/show/components/edited.vue b/app/assets/javascripts/issues/show/components/edited.vue index 732752bcc40..5138a4530e9 100644 --- a/app/assets/javascripts/issues/show/components/edited.vue +++ b/app/assets/javascripts/issues/show/components/edited.vue @@ -41,7 +41,7 @@ export default { @@ -51,7 +51,7 @@ export default { diff --git a/app/assets/javascripts/issues/show/components/header_actions.vue b/app/assets/javascripts/issues/show/components/header_actions.vue index adf449aca7b..74d166f82bb 100644 --- a/app/assets/javascripts/issues/show/components/header_actions.vue +++ b/app/assets/javascripts/issues/show/components/header_actions.vue @@ -229,7 +229,7 @@ export default { diff --git a/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql index 79222d11226..d404cfb10ed 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql @@ -6,6 +6,11 @@ fragment WorkItemWidgets on WorkItemWidget { type description descriptionHtml + lastEditedAt + lastEditedBy { + name + webPath + } } ... on WorkItemWidgetAssignees { type diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 96daf398243..2804a58da9e 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -157,9 +157,9 @@ module IssuablesHelper if issuable.respond_to?(:work_item_type) && WorkItems::Type::WI_TYPES_WITH_CREATED_HEADER.include?(issuable.work_item_type.base_type) output << content_tag(:span, sprite_icon("#{issuable.work_item_type.icon_name}", css_class: 'gl-icon gl-vertical-align-middle gl-text-gray-500'), class: 'gl-mr-2', aria: { hidden: 'true' }) - output << s_('IssuableStatus|%{wi_type} created %{created_at} by ').html_safe % { wi_type: issuable.issue_type.capitalize, created_at: time_ago_with_tooltip(issuable.created_at) } + output << content_tag(:span, s_('IssuableStatus|%{wi_type} created %{created_at} by ').html_safe % { wi_type: issuable.issue_type.capitalize, created_at: time_ago_with_tooltip(issuable.created_at) }, class: 'gl-mr-2' ) else - output << s_('IssuableStatus|Created %{created_at} by').html_safe % { created_at: time_ago_with_tooltip(issuable.created_at) } + output << content_tag(:span, s_('IssuableStatus|Created %{created_at} by').html_safe % { created_at: time_ago_with_tooltip(issuable.created_at) }, class: 'gl-mr-2' ) end if issuable.is_a?(Issue) && issuable.service_desk_reply_to diff --git a/app/models/integrations/datadog.rb b/app/models/integrations/datadog.rb index c9407aa738e..ab0fdbd777f 100644 --- a/app/models/integrations/datadog.rb +++ b/app/models/integrations/datadog.rb @@ -15,7 +15,77 @@ module Integrations TAG_KEY_VALUE_RE = %r{\A [\w-]+ : .*\S.* \z}x.freeze - prop_accessor :datadog_site, :api_url, :api_key, :datadog_service, :datadog_env, :datadog_tags + field :datadog_site, + placeholder: DEFAULT_DOMAIN, + help: -> do + ERB::Util.html_escape( + s_('DatadogIntegration|The Datadog site to send data to. To send data to the EU site, use %{codeOpen}datadoghq.eu%{codeClose}.') + ) % { + codeOpen: ''.html_safe, + codeClose: ''.html_safe + } + end + + field :api_url, + exposes_secrets: true, + title: -> { s_('DatadogIntegration|API URL') }, + help: -> { s_('DatadogIntegration|(Advanced) The full URL for your Datadog site.') } + + field :api_key, + type: 'password', + title: -> { _('API key') }, + non_empty_password_title: -> { s_('ProjectService|Enter new API key') }, + non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current API key') }, + help: -> do + ERB::Util.html_escape( + s_('DatadogIntegration|%{linkOpen}API key%{linkClose} used for authentication with Datadog.') + ) % { + linkOpen: %Q{}.html_safe, + linkClose: ''.html_safe + } + end, + required: true + + field :archive_trace_events, + storage: :attribute, + type: 'checkbox', + title: -> { s_('Logs') }, + checkbox_label: -> { s_('Enable logs collection') }, + help: -> { s_('When enabled, job logs are collected by Datadog and displayed along with pipeline execution traces.') } + + field :datadog_service, + title: -> { s_('DatadogIntegration|Service') }, + placeholder: 'gitlab-ci', + help: -> { s_('DatadogIntegration|Tag all data from this GitLab instance in Datadog. Useful when managing several self-managed deployments.') } + + field :datadog_env, + title: -> { s_('DatadogIntegration|Environment') }, + placeholder: 'ci', + help: -> do + ERB::Util.html_escape( + s_('DatadogIntegration|For self-managed deployments, set the %{codeOpen}env%{codeClose} tag for all the data sent to Datadog. %{linkOpen}How do I use tags?%{linkClose}') + ) % { + codeOpen: ''.html_safe, + codeClose: ''.html_safe, + linkOpen: ''.html_safe, + linkClose: ''.html_safe + } + end + + field :datadog_tags, + type: 'textarea', + title: -> { s_('DatadogIntegration|Tags') }, + placeholder: "tag:value\nanother_tag:value", + help: -> do + ERB::Util.html_escape( + s_('DatadogIntegration|Custom tags in Datadog. Enter one tag per line in the %{codeOpen}key:value%{codeClose} format. %{linkOpen}How do I use tags?%{linkClose}') + ) % { + codeOpen: ''.html_safe, + codeClose: ''.html_safe, + linkOpen: ''.html_safe, + linkClose: ''.html_safe + } + end before_validation :strip_properties @@ -68,87 +138,6 @@ module Integrations 'datadog' end - def fields - [ - { - type: 'text', - name: 'datadog_site', - placeholder: DEFAULT_DOMAIN, - help: ERB::Util.html_escape( - s_('DatadogIntegration|The Datadog site to send data to. To send data to the EU site, use %{codeOpen}datadoghq.eu%{codeClose}.') - ) % { - codeOpen: ''.html_safe, - codeClose: ''.html_safe - }, - required: false - }, - { - type: 'text', - name: 'api_url', - title: s_('DatadogIntegration|API URL'), - help: s_('DatadogIntegration|(Advanced) The full URL for your Datadog site.'), - required: false - }, - { - type: 'password', - name: 'api_key', - title: _('API key'), - non_empty_password_title: s_('ProjectService|Enter new API key'), - non_empty_password_help: s_('ProjectService|Leave blank to use your current API key'), - help: ERB::Util.html_escape( - s_('DatadogIntegration|%{linkOpen}API key%{linkClose} used for authentication with Datadog.') - ) % { - linkOpen: %Q{}.html_safe, - linkClose: ''.html_safe - }, - required: true - }, - { - type: 'checkbox', - name: 'archive_trace_events', - title: s_('Logs'), - checkbox_label: s_('Enable logs collection'), - help: s_('When enabled, job logs are collected by Datadog and displayed along with pipeline execution traces.'), - required: false - }, - { - type: 'text', - name: 'datadog_service', - title: s_('DatadogIntegration|Service'), - placeholder: 'gitlab-ci', - help: s_('DatadogIntegration|Tag all data from this GitLab instance in Datadog. Useful when managing several self-managed deployments.') - }, - { - type: 'text', - name: 'datadog_env', - title: s_('DatadogIntegration|Environment'), - placeholder: 'ci', - help: ERB::Util.html_escape( - s_('DatadogIntegration|For self-managed deployments, set the %{codeOpen}env%{codeClose} tag for all the data sent to Datadog. %{linkOpen}How do I use tags?%{linkClose}') - ) % { - codeOpen: ''.html_safe, - codeClose: ''.html_safe, - linkOpen: ''.html_safe, - linkClose: ''.html_safe - } - }, - { - type: 'textarea', - name: 'datadog_tags', - title: s_('DatadogIntegration|Tags'), - placeholder: "tag:value\nanother_tag:value", - help: ERB::Util.html_escape( - s_('DatadogIntegration|Custom tags in Datadog. Enter one tag per line in the %{codeOpen}key:value%{codeClose} format. %{linkOpen}How do I use tags?%{linkClose}') - ) % { - codeOpen: ''.html_safe, - codeClose: ''.html_safe, - linkOpen: ''.html_safe, - linkClose: ''.html_safe - } - } - ] - end - override :hook_url def hook_url url = api_url.presence || sprintf(URL_TEMPLATE, datadog_domain: datadog_domain) diff --git a/app/views/shared/issue_type/_details_header.html.haml b/app/views/shared/issue_type/_details_header.html.haml index 08fba712d5e..ccb501dae11 100644 --- a/app/views/shared/issue_type/_details_header.html.haml +++ b/app/views/shared/issue_type/_details_header.html.haml @@ -2,7 +2,7 @@ - badge_classes = 'issuable-status-badge gl-mr-3' .detail-page-header - .detail-page-header-body + .detail-page-header-body.gl-flex-wrap-wrap = gl_badge_tag({ variant: :info, icon: 'issue-closed', icon_classes: 'gl-mr-0!' }, { class: "#{issue_status_visibility(issuable, status_box: :closed)} #{badge_classes} issuable-status-badge-closed" }) do .gl-display-none.gl-sm-display-block.gl-ml-2 = issue_closed_text(issuable, current_user) @@ -13,9 +13,8 @@ %span.gl-display-none.gl-sm-display-block.gl-ml-2 = _('Open') - .issuable-meta - #js-issuable-header-warnings{ data: { hidden: issue_hidden?(issuable).to_s } } - = issuable_meta(issuable, @project) + #js-issuable-header-warnings{ data: { hidden: issue_hidden?(issuable).to_s } } + = issuable_meta(issuable, @project) %a.btn.gl-button.btn-default.btn-icon.float-right.gl-display-block.d-sm-none.gutter-toggle.issuable-gutter-toggle.js-sidebar-toggle{ href: "#" } = sprite_icon('chevron-double-lg-left') diff --git a/bin/diagnostic-reports-uploader b/bin/diagnostic-reports-uploader new file mode 100755 index 00000000000..a19fe15dcb9 --- /dev/null +++ b/bin/diagnostic-reports-uploader @@ -0,0 +1,29 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'fog/google' + +require_relative '../lib/gitlab/memory/reports_uploader' +require_relative '../lib/gitlab/memory/upload_and_cleanup_reports' +require_relative '../lib/gitlab/memory/diagnostic_reports_logger' + +# Fail fast if the necessary ENV vars are not set. +reports_path = ENV["GITLAB_DIAGNOSTIC_REPORTS_PATH"].to_s +raise 'GITLAB_DIAGNOSTIC_REPORTS_PATH dir is missing' unless Dir.exist?(reports_path) + +gcs_key = ENV["GITLAB_GCP_KEY_PATH"].to_s +raise "GCS keyfile not found: #{gcs_key}" unless File.exist?(gcs_key) + +gcs_project = ENV["GITLAB_DIAGNOSTIC_REPORTS_PROJECT"].to_s +raise 'GITLAB_DIAGNOSTIC_REPORTS_PROJECT is missing' unless gcs_project && !gcs_project.empty? + +gcs_bucket = ENV["GITLAB_DIAGNOSTIC_REPORTS_BUCKET"].to_s +raise 'GITLAB_DIAGNOSTIC_REPORTS_BUCKET is missing' unless gcs_bucket && !gcs_bucket.empty? + +rails_root = File.expand_path("..", __dir__) +log_file = File.expand_path('log/diagnostic_reports_json.log', rails_root) +logger = Gitlab::Memory::DiagnosticReportsLogger.new(log_file) + +uploader = Gitlab::Memory::ReportsUploader.new(gcs_key: gcs_key, gcs_project: gcs_project, gcs_bucket: gcs_bucket, + logger: logger) +Gitlab::Memory::UploadAndCleanupReports.new(uploader: uploader, reports_path: reports_path, logger: logger).call diff --git a/config/feature_flags/ops/gitlab_diagnostic_reports_uploader.yml b/config/feature_flags/ops/gitlab_diagnostic_reports_uploader.yml deleted file mode 100644 index d3ec5026d2f..00000000000 --- a/config/feature_flags/ops/gitlab_diagnostic_reports_uploader.yml +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: gitlab_diagnostic_reports_uploader -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/97155 -rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/372771 -milestone: '15.4' -type: ops -group: group::application performance -default_enabled: false diff --git a/config/initializers/diagnostic_reports.rb b/config/initializers/diagnostic_reports.rb index 57de8c5d545..47266f99f2d 100644 --- a/config/initializers/diagnostic_reports.rb +++ b/config/initializers/diagnostic_reports.rb @@ -6,13 +6,4 @@ return unless Gitlab::Runtime.puma? Gitlab::Cluster::LifecycleEvents.on_worker_start do Gitlab::Memory::ReportsDaemon.instance.start - - # Avoid concurrent uploads, so thread out from a single worker. - # We want only one uploader thread running for the Puma cluster. - # We do not spawn a thread from the `master`, to keep its state pristine. - # This should have a minimal impact on the given worker. - if ::Prometheus::PidProvider.worker_id == 'puma_0' - reports_watcher = Gitlab::Memory::UploadAndCleanupReports.new - Gitlab::BackgroundTask.new(reports_watcher).start - end end diff --git a/doc/user/admin_area/license_file.md b/doc/user/admin_area/license_file.md index 76e0adf8cf2..c203c2532f8 100644 --- a/doc/user/admin_area/license_file.md +++ b/doc/user/admin_area/license_file.md @@ -140,3 +140,7 @@ rules apply: For example, if you purchase a license for 100 users, you can have 110 users when you add your license. However, if you have 111 users, you must purchase more users before you can add the license. + +### `Start GitLab Ultimate trial` still displays after adding license + +To fix this issue, restart [Puma or your entire GitLab instance](../../administration/restart_gitlab.md). diff --git a/doc/user/packages/dependency_proxy/index.md b/doc/user/packages/dependency_proxy/index.md index f5953dd2914..3fb22437eb0 100644 --- a/doc/user/packages/dependency_proxy/index.md +++ b/doc/user/packages/dependency_proxy/index.md @@ -84,7 +84,7 @@ You can authenticate using: - Your GitLab username and password. - A [personal access token](../../../user/profile/personal_access_tokens.md) with the scope set to `read_registry` and `write_registry`. -- A [group deploy token](../../../user/project/deploy_tokens/index.md#group-deploy-token) with the scope set to `read_registry` and `write_registry`. +- A [group deploy token](../../../user/project/deploy_tokens/index.md) with the scope set to `read_registry` and `write_registry`. Users accessing the Dependency Proxy with a personal access token or username and password must have at least the Guest role for the group they pull images from. diff --git a/doc/user/packages/package_registry/index.md b/doc/user/packages/package_registry/index.md index dcc12137c9c..2d8cb46f933 100644 --- a/doc/user/packages/package_registry/index.md +++ b/doc/user/packages/package_registry/index.md @@ -53,7 +53,7 @@ For most package types, the following credential types are valid: - [Project deploy token](../../project/deploy_tokens/index.md): allows access to all packages in a project. Good for granting and revoking project access to many users. -- [Group deploy token](../../project/deploy_tokens/index.md#group-deploy-token): +- [Group deploy token](../../project/deploy_tokens/index.md): allows access to all packages in a group and its subgroups. Good for granting and revoking access to a large number of packages to sets of users. - [Job token](../../../ci/jobs/ci_job_token.md): diff --git a/doc/user/project/deploy_tokens/img/deploy_tokens_ui.png b/doc/user/project/deploy_tokens/img/deploy_tokens_ui.png deleted file mode 100644 index 4ab6a45aee1679a04a1ff53f2a6e77ec94587fd2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 35336 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

ffX|$%` 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 mp;Fo{^_BFy%k;yW26EA0000 Repository**. 1. Expand **Deploy tokens**. -1. Choose a name, and optionally, an expiration date and username for the token. -1. Choose the [desired scopes](#scope). +1. Complete the fields, and select the desired [scopes](#scope). 1. Select **Create deploy token**. -Save the deploy token somewhere safe. After you leave or refresh -the page, **you can't access it again**. +Record the deploy token's values. After you leave or refresh the page, **you cannot access it +again**. -![Personal access tokens page](img/deploy_tokens_ui.png) +## Revoke a deploy token -## Revoking a deploy token +Revoke a token when it's no longer required. + +Prerequisites: + +- You must have at least the Maintainer role for the project or group. To revoke a deploy token: 1. On the top bar, select **Main menu**, and: - - For a project, select ***Projects** and find your project. - - For a group, select **Groups** and find your group. + - For a project deploy token, select **Projects** and find your project. + - For a group deploy token, select **Groups** and find your group. 1. On the left sidebar, select **Settings > Repository**. 1. Expand **Deploy tokens**. 1. In the **Active Deploy Tokens** section, by the token you want to revoke, select **Revoke**. -## Usage +## Clone a repository -### Git clone a repository +You can use a deploy token to clone a repository. -To download a repository using a deploy token: +Prerequisites: -1. Create a deploy token with `read_repository` as a scope. -1. Take note of your `username` and `token`. -1. `git clone` the project using the deploy token: +- A deploy token with the `read_repository` scope. - ```shell - git clone https://:@gitlab.example.com/tanuki/awesome_project.git - ``` +Example of using a deploy token to clone a repository: -Replace `` and `` with the proper values. +```shell +git clone https://:@gitlab.example.com/tanuki/awesome_project.git +``` -### Read Container Registry images +## Pull images from a container registry -To read the container registry images, you must: +You can use a deploy token to pull images from a container registry. -1. Create a deploy token with `read_registry` as a scope. -1. Take note of your `username` and `token`. -1. Sign in to the GitLab Container Registry using the deploy token: +Prerequisites: + +- A deploy token with the `read_registry` scope. + +Example of using a deploy token to pull images from a container registry: ```shell docker login -u -p registry.example.com +docker pull $CONTAINER_TEST_IMAGE ``` -Replace `` and `` with the proper values. You can now -pull images from your Container Registry. +## Push images to a container registry -### Push Container Registry images +You can use a deploy token to push images to a container registry. -> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/22743) in GitLab 12.10. +Prerequisites: -To push the container registry images, you must: +- A deploy token with the `write_registry` scope. -1. Create a deploy token with `write_registry` as a scope. -1. Take note of your `username` and `token`. -1. Sign in to the GitLab Container Registry using the deploy token: +Example of using a deploy token to push an image to a container registry: - ```shell - docker login -u -p registry.example.com - ``` +```shell +docker login -u -p registry.example.com +docker push $CONTAINER_TEST_IMAGE +``` -Replace `` and `` with the proper values. You can now -push images to your Container Registry. - -### Read or pull packages +## Pull packages from a package registry > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/213566) in GitLab 13.0. -To pull packages in the GitLab package registry, you must: +You can use a deploy token to pull packages from a package registry. -1. Create a deploy token with `read_package_registry` as a scope. -1. Take note of your `username` and `token`. -1. For the [package type of your choice](../../packages/index.md), follow the - authentication instructions for deploy tokens. +Prerequisites: -Example request publishing a NuGet package using a deploy token: +- A deploy token with the `read_package_registry` scope. + +For the [package type of your choice](../../packages/index.md), follow the authentication +instructions for deploy tokens. + +Example of installing a NuGet package from a GitLab registry: ```shell -nuget source Add -Name GitLab -Source "https://gitlab.example.com/api/v4/projects/10/packages/nuget/index.json" -UserName deploy-token-username -Password 12345678asdf +nuget source Add -Name GitLab -Source "https://gitlab.example.com/api/v4/projects/10/packages/nuget/index.json" -UserName -Password +nuget install mypkg.nupkg +``` +## Push packages to a package repository + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/213566) in GitLab 13.0. + +You can use a deploy token to push packages to a GitLab package registry. + +Prerequisites: + +- A deploy token with the `write_package_registry` scope. + +For the [package type of your choice](../../packages/index.md), follow the authentication +instructions for deploy tokens. + +Example of publishing a NuGet package to a package registry: + +```shell +nuget source Add -Name GitLab -Source "https://gitlab.example.com/api/v4/projects/10/packages/nuget/index.json" -UserName -Password nuget push mypkg.nupkg -Source GitLab ``` -### Push or upload packages - -> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/213566) in GitLab 13.0. - -To upload packages in the GitLab package registry, you must: - -1. Create a deploy token with `write_package_registry` as a scope. -1. Take note of your `username` and `token`. -1. For the [package type of your choice](../../packages/index.md), follow the - authentication instructions for deploy tokens. - -### Group deploy token - -> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/21765) in GitLab 12.9. - -A deploy token created at the group level can be used across all projects that -belong either to the specific group or to one of its subgroups. - - -For an overview, see [Group Deploy Tokens](https://youtu.be/8kxTJvaD9ks). - -The Group deploy tokens UI is now accessible under **Settings > Repository**, -not **Settings > CI/CD** as indicated in the video. - -To use a group deploy token: - -1. [Create](#creating-a-deploy-token) a deploy token for a group. -1. Use it the same way you use a project deploy token when - [cloning a repository](#git-clone-a-repository). - -The scopes applied to a group deploy token (such as `read_repository`) -apply consistently when cloning the repository of related projects. - -### Pull images from the Dependency Proxy +## Pull images from the dependency proxy > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/280586) in GitLab 14.2. -To pull images from the Dependency Proxy, you must: +You can use a deploy token to pull images from the dependency proxy. -1. Create a group deploy token with both `read_registry` and `write_registry` scopes. -1. Take note of your `username` and `token`. -1. Follow the Dependency Proxy [authentication instructions](../../packages/dependency_proxy/index.md). +Prerequisites: + +- A deploy token with `read_registry` and `write_registry` scopes. + +Follow the dependency proxy [authentication instructions](../../packages/dependency_proxy/index.md). ## Troubleshooting -### Group deploy tokens and LFS +### Error: `api error: Repository or object not found:` -A bug -[prevents Group Deploy Tokens from cloning LFS objects](https://gitlab.com/gitlab-org/gitlab/-/issues/235398). -If you receive `404 Not Found` errors and this error, -use a Project Deploy Token to work around the bug: +When using a group deploy token to clone from LFS objects, you might get `404 Not Found` responses +and this error message. This occurs because of a bug, documented in +[issue 235398](https://gitlab.com/gitlab-org/gitlab/-/issues/235398). ```plaintext api error: Repository or object not found: https://.git/info/lfs/objects/batch Check that it exists and that you have proper access to it ``` + +The workaround is to use a project deploy token. diff --git a/doc/user/tasks.md b/doc/user/tasks.md index c8124229653..f2d9f777849 100644 --- a/doc/user/tasks.md +++ b/doc/user/tasks.md @@ -118,6 +118,22 @@ To change the assignee on a task: 1. From the dropdown list, select the users to add as an assignee. 1. Select any area outside the dropdown list. +## Assign labels to a task + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/339756) in GitLab 15.5 [with a flag](../administration/feature_flags.md) named `work_items_mvc_2`. Disabled by default. + +FLAG: +On self-managed GitLab, by default this feature is not available. To make it available, ask an administrator to [enable the feature flag](../administration/feature_flags.md) named `work_items_mvc_2`. +On GitLab.com, this feature is not available. +This feature is not ready for production use. + +To add [labels](project/labels.md) to a task: + +1. In the issue description, in the **Tasks** section, select the title of the task you want to edit. The task window opens. +1. Next to **Labels**, select **Add labels**. +1. From the dropdown list, select the labels to add. +1. Select any area outside the dropdown list. + ## Set a start and due date > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/365399) in GitLab 15.4 [with a flag](../administration/feature_flags.md) named `work_items_mvc_2`. Disabled by default. diff --git a/lib/gitlab/memory/diagnostic_reports_logger.rb b/lib/gitlab/memory/diagnostic_reports_logger.rb new file mode 100644 index 00000000000..cc5b719fa19 --- /dev/null +++ b/lib/gitlab/memory/diagnostic_reports_logger.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'logger' + +module Gitlab + module Memory + class DiagnosticReportsLogger < ::Logger + def format_message(severity, timestamp, progname, message) + data = {} + data[:severity] = severity + data[:time] = timestamp.utc.iso8601(3) + + data.merge!(message) + + "#{JSON.generate(data)}\n" # rubocop:disable Gitlab/Json + end + end + end +end diff --git a/lib/gitlab/memory/reports_uploader.rb b/lib/gitlab/memory/reports_uploader.rb index 846a9e5fcae..76c3e0862e2 100644 --- a/lib/gitlab/memory/reports_uploader.rb +++ b/lib/gitlab/memory/reports_uploader.rb @@ -1,35 +1,52 @@ # frozen_string_literal: true +require_relative '../metrics/system' + module Gitlab module Memory class ReportsUploader - # This is no-op currently, it will only write logs. - # The uploader implementation will be done in the next MR(s). For more details, check: - # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/97155#note_1099244930 + def initialize(gcs_key:, gcs_project:, gcs_bucket:, logger:) + @gcs_bucket = gcs_bucket + @fog = Fog::Storage::Google.new(google_project: gcs_project, google_json_key_location: gcs_key) + @logger = logger + end + def upload(path) log_upload_requested(path) + start_monotonic_time = Gitlab::Metrics::System.monotonic_time - false # nothing is uploaded in the current implementation + File.open(path.to_s) { |file| fog.put_object(gcs_bucket, File.basename(path), file) } + + duration_s = Gitlab::Metrics::System.monotonic_time - start_monotonic_time + log_upload_success(path, duration_s) + rescue StandardError, Errno::ENOENT => error + log_exception(error) end private + attr_reader :gcs_bucket, :fog, :logger + def log_upload_requested(path) - Gitlab::AppLogger.info(log_labels.merge(perf_report_status: 'upload requested', perf_report_path: path)) + logger.info(log_labels.merge(perf_report_status: 'upload requested', perf_report_path: path)) + end + + def log_upload_success(path, duration_s) + logger.info(log_labels.merge(perf_report_status: 'upload success', perf_report_path: path, + duration_s: duration_s)) + end + + def log_exception(error) + logger.error(log_labels.merge(perf_report_status: "error", error: error.message)) end def log_labels { message: "Diagnostic reports", class: self.class.name, - pid: $$, - worker_id: worker_id + pid: $$ } end - - def worker_id - ::Prometheus::PidProvider.worker_id - end end end end diff --git a/lib/gitlab/memory/upload_and_cleanup_reports.rb b/lib/gitlab/memory/upload_and_cleanup_reports.rb index b14f24fe13c..27d94df478c 100644 --- a/lib/gitlab/memory/upload_and_cleanup_reports.rb +++ b/lib/gitlab/memory/upload_and_cleanup_reports.rb @@ -6,32 +6,26 @@ module Gitlab DEFAULT_SLEEP_TIME_SECONDS = 900 # 15 minutes def initialize( - sleep_time_seconds: ENV['GITLAB_DIAGNOSTIC_REPORTS_UPLOADER_SLEEP_S']&.to_i || DEFAULT_SLEEP_TIME_SECONDS, - reports_path: ENV["GITLAB_DIAGNOSTIC_REPORTS_PATH"]) + uploader:, + reports_path:, + logger:, + sleep_time_seconds: ENV['GITLAB_DIAGNOSTIC_REPORTS_UPLOADER_SLEEP_S']&.to_i || DEFAULT_SLEEP_TIME_SECONDS) - @sleep_time_seconds = sleep_time_seconds + @uploader = uploader @reports_path = reports_path - - unless @reports_path.present? - log_error_reports_path_missing - return - end - - @uploader = ReportsUploader.new - + @sleep_time_seconds = sleep_time_seconds @alive = true + @logger = logger end - attr_reader :sleep_time_seconds, :reports_path, :uploader, :alive + attr_reader :uploader, :reports_path, :sleep_time_seconds, :logger def call log_started - while alive + loop do sleep(sleep_time_seconds) - next unless Feature.enabled?(:gitlab_diagnostic_reports_uploader, type: :ops) - files_to_process.each { |path| upload_and_cleanup!(path) } end end @@ -39,9 +33,11 @@ module Gitlab private def upload_and_cleanup!(path) - cleanup!(path) if uploader.upload(path) - rescue StandardError => error + uploader.upload(path) + rescue StandardError, Errno::ENOENT => error log_exception(error) + ensure + cleanup!(path) end def cleanup!(path) @@ -56,30 +52,21 @@ module Gitlab .select { |path| File.file?(path) } end - def log_error_reports_path_missing - Gitlab::AppLogger.error(log_labels.merge(perf_report_status: "path is not configured")) - end - def log_started - Gitlab::AppLogger.info(log_labels.merge(perf_report_status: "started")) + logger.info(log_labels.merge(perf_report_status: "started")) end def log_exception(error) - Gitlab::ErrorTracking.log_exception(error, log_labels) + logger.error(log_labels.merge(perf_report_status: "error", error: error.message)) end def log_labels { message: "Diagnostic reports", class: self.class.name, - pid: $$, - worker_id: worker_id + pid: $$ } end - - def worker_id - ::Prometheus::PidProvider.worker_id - end end end end diff --git a/lib/gitlab/request_endpoints.rb b/lib/gitlab/request_endpoints.rb index 157c0f91e65..4efafaa0ac2 100644 --- a/lib/gitlab/request_endpoints.rb +++ b/lib/gitlab/request_endpoints.rb @@ -8,6 +8,7 @@ module Gitlab # but if they weren't, the routes will be drawn and available for the rest of # application. API::API.compile! + API::API.reset_routes! API::API.routes.select { |route| route.app.options[:for] < API::Base } end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 516a979f862..5140658c217 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -15302,6 +15302,9 @@ msgstr "" msgid "Epics|Add an existing epic" msgstr "" +msgid "Epics|Are you sure you want to remove %{bStart}%{targetEpicTitle}%{bEnd} from %{bStart}%{parentEpicTitle}%{bEnd}?" +msgstr "" + msgid "Epics|Are you sure you want to remove %{bStart}%{targetIssueTitle}%{bEnd} from %{bStart}%{parentEpicTitle}%{bEnd}?" msgstr "" @@ -34954,6 +34957,9 @@ msgstr "" msgid "Runners|Runs untagged jobs" msgstr "" +msgid "Runners|Select all" +msgstr "" + msgid "Runners|Select projects to assign to this runner" msgstr "" @@ -35040,6 +35046,9 @@ msgstr "" msgid "Runners|Token expiry" msgstr "" +msgid "Runners|Unselect all" +msgstr "" + msgid "Runners|Up to date" msgstr "" diff --git a/spec/bin/diagnostic_reports_uploader_spec.rb b/spec/bin/diagnostic_reports_uploader_spec.rb new file mode 100644 index 00000000000..9a929de6d0e --- /dev/null +++ b/spec/bin/diagnostic_reports_uploader_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' +require 'tempfile' + +RSpec.describe 'bin/diagnostic-reports-uploader' do + let(:reports_dir) { Dir.mktmpdir } + let(:gcs_key) { Tempfile.new } + let(:gcs_project) { 'test_gcs_project' } + let(:gcs_bucket) { 'test_gcs_bucket' } + + after do + FileUtils.remove_entry(reports_dir) + FileUtils.remove_entry(gcs_key) + end + + subject(:load_bin) { load File.expand_path('../../bin/diagnostic-reports-uploader', __dir__) } + + context 'when necessary ENV vars are set' do + before do + stub_env('GITLAB_DIAGNOSTIC_REPORTS_PATH', reports_dir) + stub_env('GITLAB_GCP_KEY_PATH', gcs_key.path) + stub_env('GITLAB_DIAGNOSTIC_REPORTS_PROJECT', gcs_project) + stub_env('GITLAB_DIAGNOSTIC_REPORTS_BUCKET', gcs_bucket) + end + + let(:reports_uploader) { instance_double(Gitlab::Memory::ReportsUploader) } + let(:upload_and_cleanup_reports) { instance_double(Gitlab::Memory::UploadAndCleanupReports) } + let(:logger) { instance_double(Gitlab::Memory::DiagnosticReportsLogger) } + + it 'runs successfully' do + expect(Gitlab::Memory::DiagnosticReportsLogger).to receive(:new).and_return(logger) + + expect(Gitlab::Memory::ReportsUploader) + .to receive(:new).with(gcs_key: gcs_key.path, gcs_project: gcs_project, gcs_bucket: gcs_bucket, logger: logger) + .and_return(reports_uploader) + + expect(Gitlab::Memory::UploadAndCleanupReports) + .to receive(:new).with(uploader: reports_uploader, reports_path: reports_dir, logger: logger) + .and_return(upload_and_cleanup_reports) + + expect(upload_and_cleanup_reports).to receive(:call) + + load_bin + end + end + + context 'when GITLAB_DIAGNOSTIC_REPORTS_PATH is missing' do + it 'raises RuntimeError' do + expect { load_bin }.to raise_error(RuntimeError, 'GITLAB_DIAGNOSTIC_REPORTS_PATH dir is missing') + end + end + + context 'when GITLAB_GCP_KEY_PATH is missing' do + before do + stub_env('GITLAB_DIAGNOSTIC_REPORTS_PATH', reports_dir) + end + + it 'raises RuntimeError' do + expect { load_bin }.to raise_error(RuntimeError, /GCS keyfile not found/) + end + end + + context 'when GITLAB_DIAGNOSTIC_REPORTS_PROJECT is missing' do + before do + stub_env('GITLAB_DIAGNOSTIC_REPORTS_PATH', reports_dir) + stub_env('GITLAB_GCP_KEY_PATH', gcs_key.path) + end + + it 'raises RuntimeError' do + expect { load_bin }.to raise_error(RuntimeError, 'GITLAB_DIAGNOSTIC_REPORTS_PROJECT is missing') + end + end + + context 'when GITLAB_DIAGNOSTIC_REPORTS_BUCKET is missing' do + before do + stub_env('GITLAB_DIAGNOSTIC_REPORTS_PATH', reports_dir) + stub_env('GITLAB_GCP_KEY_PATH', gcs_key.path) + stub_env('GITLAB_DIAGNOSTIC_REPORTS_PROJECT', gcs_project) + end + + it 'raises RuntimeError' do + expect { load_bin }.to raise_error(RuntimeError, 'GITLAB_DIAGNOSTIC_REPORTS_BUCKET is missing') + end + end +end diff --git a/spec/commands/diagnostic_reports/uploader_smoke_spec.rb b/spec/commands/diagnostic_reports/uploader_smoke_spec.rb new file mode 100644 index 00000000000..9fbceb68844 --- /dev/null +++ b/spec/commands/diagnostic_reports/uploader_smoke_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' +require 'tempfile' + +# We need to capture pid from Process.spawn and then clean up by killing the process, which requires instance variables. +# rubocop: disable RSpec/InstanceVariable +RSpec.describe 'bin/diagnostic-reports-uploader' do + # This is a smoke test for 'bin/diagnostic-reports-uploader'. + # We intend to run this binary with `ruby bin/diagnostic-reports-uploader`, without preloading the entire Rails app. + # Also, we use inline gemfile, to avoid pulling full Gemfile from the main app into memory. + # The goal of that test is to confirm that the binary starts that way. + # The implementation logic is covered in 'spec/bin/diagnostic_reports_uploader_spec.rb' + include FastRailsRoot + + let(:gcs_bucket) { 'test_bucket' } + let(:gcs_project) { 'test_project' } + let(:gcs_key) { Tempfile.new } + let(:reports_dir) { Dir.mktmpdir } + let(:report) { Tempfile.new('report.json', reports_dir) } + + let(:env) do + { + 'GITLAB_DIAGNOSTIC_REPORTS_BUCKET' => gcs_bucket, + 'GITLAB_DIAGNOSTIC_REPORTS_PROJECT' => gcs_project, + 'GITLAB_GCP_KEY_PATH' => gcs_key.path, + 'GITLAB_DIAGNOSTIC_REPORTS_PATH' => reports_dir, + 'GITLAB_DIAGNOSTIC_REPORTS_UPLOADER_SLEEP_S' => '1' + } + end + + before do + gcs_key.write( + { + type: "service_account", + client_email: 'test@gitlab.com', + private_key_id: "test_id", + private_key: File.read(rails_root_join('spec/fixtures/ssl_key.pem')) + }.to_json + ) + gcs_key.rewind + + FileUtils.touch(report.path) + end + + after do + if @pid + Timeout.timeout(10) do + Process.kill('TERM', @pid) + Process.waitpid(@pid) + end + end + rescue Errno::ESRCH, Errno::ECHILD => _ + # 'No such process' or 'No child processes' means the process died before + ensure + gcs_key.unlink + FileUtils.rm_rf(reports_dir, secure: true) + end + + it 'starts successfully' do + expect(File.exist?(report.path)).to be true + + bin_path = rails_root_join("bin/diagnostic-reports-uploader") + + cmd = ['bundle', 'exec', 'ruby', bin_path] + @pid = Process.spawn(env, *cmd) + + expect(Gitlab::ProcessManagement.process_alive?(@pid)).to be true + + expect do + Timeout.timeout(10) do + # Uploader will remove the file, no matter the upload result. We are waiting for exactly that. + # The report being removed means the uploader loop works. We are not attempting real upload. + attempted_upload_and_cleanup = false + until attempted_upload_and_cleanup + sleep 1 + attempted_upload_and_cleanup = !File.exist?(report.path) + end + end + end.not_to raise_error + end +end +# rubocop: enable RSpec/InstanceVariable diff --git a/spec/controllers/concerns/product_analytics_tracking_spec.rb b/spec/controllers/concerns/product_analytics_tracking_spec.rb index f85b6806f30..28b79a10624 100644 --- a/spec/controllers/concerns/product_analytics_tracking_spec.rb +++ b/spec/controllers/concerns/product_analytics_tracking_spec.rb @@ -51,11 +51,9 @@ RSpec.describe ProductAnalyticsTracking, :snowplow do end end - def expect_tracking(user: self.user) + def expect_redis_hll_tracking expect(Gitlab::UsageDataCounters::HLLRedisCounter).to have_received(:track_event) .with('g_analytics_valuestream', values: instance_of(String)) - - expect_snowplow_tracking(user) end def expect_snowplow_tracking(user) @@ -85,7 +83,8 @@ RSpec.describe ProductAnalyticsTracking, :snowplow do it 'tracks the event' do get :index - expect_tracking + expect_redis_hll_tracking + expect_snowplow_tracking(user) end context 'when FF is disabled' do @@ -105,7 +104,8 @@ RSpec.describe ProductAnalyticsTracking, :snowplow do get :index - expect_tracking + expect_redis_hll_tracking + expect_snowplow_tracking(user) end it 'does not track the event if DNT is enabled' do @@ -145,7 +145,8 @@ RSpec.describe ProductAnalyticsTracking, :snowplow do get :show, params: { id: 1 } - expect_tracking(user: nil) + expect_redis_hll_tracking + expect_snowplow_tracking(nil) end end @@ -159,16 +160,24 @@ RSpec.describe ProductAnalyticsTracking, :snowplow do it 'tracks the event when there is custom id' do get :show, params: { id: 1 } - expect_tracking(user: nil) + expect_redis_hll_tracking + expect_snowplow_tracking(nil) end - it 'does not track the HLL event when there is no custom id' do - allow(controller).to receive(:get_custom_id).and_return(nil) + context 'when there is no custom_id set' do + before do + allow(controller).to receive(:get_custom_id).and_return(nil) - get :show, params: { id: 2 } + get :show, params: { id: 2 } + end - expect(Gitlab::UsageDataCounters::HLLRedisCounter).not_to receive(:track_event) - expect_snowplow_tracking(nil) + it 'does not track the HLL event' do + expect(Gitlab::UsageDataCounters::HLLRedisCounter).not_to receive(:track_event) + end + + it 'tracks Snowplow event' do + expect_snowplow_tracking(nil) + end end end end diff --git a/spec/features/admin/admin_runners_spec.rb b/spec/features/admin/admin_runners_spec.rb index ece5012f0ca..35e57213bdb 100644 --- a/spec/features/admin/admin_runners_spec.rb +++ b/spec/features/admin/admin_runners_spec.rb @@ -66,10 +66,26 @@ RSpec.describe "Admin Runners" do it 'has all necessary texts' do expect(page).to have_text "Register an instance runner" + expect(page).to have_text "#{s_('Runners|All')} 3" expect(page).to have_text "#{s_('Runners|Online')} 1" expect(page).to have_text "#{s_('Runners|Offline')} 2" expect(page).to have_text "#{s_('Runners|Stale')} 1" end + + describe 'delete all runners in bulk' do + before do + check s_('Runners|Select all') + click_button s_('Runners|Delete selected') + + within_modal do + click_on 'Permanently delete 3 runners' + end + + wait_for_requests + end + + it_behaves_like 'shows no runners registered' + end end it 'shows a job count' do diff --git a/spec/frontend/vue_shared/components/group_select/utils_spec.js b/spec/frontend/vue_shared/components/group_select/utils_spec.js new file mode 100644 index 00000000000..5188e1aabf1 --- /dev/null +++ b/spec/frontend/vue_shared/components/group_select/utils_spec.js @@ -0,0 +1,24 @@ +import { groupsPath } from '~/vue_shared/components/group_select/utils'; + +describe('group_select utils', () => { + describe('groupsPath', () => { + it.each` + groupsFilter | parentGroupID | expectedPath + ${undefined} | ${undefined} | ${'/api/:version/groups.json'} + ${undefined} | ${1} | ${'/api/:version/groups.json'} + ${'descendant_groups'} | ${1} | ${'/api/:version/groups/1/descendant_groups'} + ${'subgroups'} | ${1} | ${'/api/:version/groups/1/subgroups'} + `( + 'returns $expectedPath with groupsFilter = $groupsFilter and parentGroupID = $parentGroupID', + ({ groupsFilter, parentGroupID, expectedPath }) => { + expect(groupsPath(groupsFilter, parentGroupID)).toBe(expectedPath); + }, + ); + }); + + it('throws if groupsFilter is passed but parentGroupID is undefined', () => { + expect(() => { + groupsPath('descendant_groups'); + }).toThrow('Cannot use groupsFilter without a parentGroupID'); + }); +}); diff --git a/spec/frontend/work_items/components/work_item_description_spec.js b/spec/frontend/work_items/components/work_item_description_spec.js index d3165d8dc26..0691fe25e0d 100644 --- a/spec/frontend/work_items/components/work_item_description_spec.js +++ b/spec/frontend/work_items/components/work_item_description_spec.js @@ -4,6 +4,7 @@ import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import { mockTracking } from 'helpers/tracking_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import EditedAt from '~/issues/show/components/edited.vue'; import { updateDraft } from '~/lib/utils/autosave'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; @@ -35,6 +36,7 @@ describe('WorkItemDescription', () => { const findEditButton = () => wrapper.find('[data-testid="edit-description"]'); const findMarkdownField = () => wrapper.findComponent(MarkdownField); + const findEditedAt = () => wrapper.findComponent(EditedAt); const editDescription = (newText) => wrapper.find('textarea').setValue(newText); @@ -44,9 +46,9 @@ describe('WorkItemDescription', () => { const createComponent = async ({ mutationHandler = mutationSuccessHandler, canUpdate = true, + workItemResponse = workItemResponseFactory({ canUpdate }), isEditing = false, } = {}) => { - const workItemResponse = workItemResponseFactory({ canUpdate }); const workItemResponseHandler = jest.fn().mockResolvedValue(workItemResponse); const { id } = workItemQueryResponse.data.workItem; @@ -100,6 +102,33 @@ describe('WorkItemDescription', () => { }); describe('editing description', () => { + it('shows edited by text', async () => { + const lastEditedAt = '2022-09-21T06:18:42Z'; + const lastEditedBy = { + name: 'Administrator', + webPath: '/root', + }; + + await createComponent({ + workItemResponse: workItemResponseFactory({ + lastEditedAt, + lastEditedBy, + }), + }); + + expect(findEditedAt().props()).toEqual({ + updatedAt: lastEditedAt, + updatedByName: lastEditedBy.name, + updatedByPath: lastEditedBy.webPath, + }); + }); + + it('does not show edited by text', async () => { + await createComponent(); + + expect(findEditedAt().exists()).toBe(false); + }); + it('cancels when clicking cancel', async () => { await createComponent({ isEditing: true, diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index 47906a6e160..01dd2f7f6c2 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -69,6 +69,8 @@ export const workItemQueryResponse = { description: 'some **great** text', descriptionHtml: '

some great text

', + lastEditedAt: null, + lastEditedBy: null, }, { __typename: 'WorkItemWidgetAssignees', @@ -187,6 +189,8 @@ export const workItemResponseFactory = ({ confidential = false, canInviteMembers = false, allowsScopedLabels = false, + lastEditedAt = null, + lastEditedBy = null, parent = mockParent.parent, } = {}) => ({ data: { @@ -221,6 +225,8 @@ export const workItemResponseFactory = ({ description: 'some **great** text', descriptionHtml: '

some great text

', + lastEditedAt, + lastEditedBy, }, assigneesWidgetPresent ? { @@ -362,6 +368,11 @@ export const createWorkItemFromTaskMutationResponse = { type: 'DESCRIPTION', description: 'New description', descriptionHtml: '

New description

', + lastEditedAt: '2022-09-21T06:18:42Z', + lastEditedBy: { + name: 'Administrator', + webPath: '/root', + }, }, ], }, diff --git a/spec/initializers/diagnostic_reports_spec.rb b/spec/initializers/diagnostic_reports_spec.rb index 9eb240e1c0a..01b1ed9b7b5 100644 --- a/spec/initializers/diagnostic_reports_spec.rb +++ b/spec/initializers/diagnostic_reports_spec.rb @@ -43,43 +43,6 @@ RSpec.describe 'diagnostic reports' do load_initializer end - - context 'with `Gitlab::Memory::UploadAndCleanupReports` added into initializer' do - before do - allow(Gitlab::Memory::ReportsDaemon).to receive(:instance).and_return(report_daemon) - allow(report_daemon).to receive(:start) - end - - context 'when run from `puma_0` worker process' do - let(:uploader) { instance_double(Gitlab::Memory::UploadAndCleanupReports) } - let(:background_task) { instance_double(Gitlab::BackgroundTask) } - - before do - allow(Prometheus::PidProvider).to receive(:worker_id).and_return('puma_0') - end - - it 'sets up `Gitlab::Memory::UploadAndCleanupReports` as `BackgroundTask`' do - expect(Gitlab::Memory::UploadAndCleanupReports).to receive(:new).and_return(uploader) - expect(Gitlab::BackgroundTask).to receive(:new).with(uploader).and_return(background_task) - expect(background_task).to receive(:start) - - load_initializer - end - end - - context 'when run from worker process other than `puma_0`' do - before do - allow(Prometheus::PidProvider).to receive(:worker_id).and_return('puma_1') - end - - it 'does not set up `Gitlab::Memory::UploadAndCleanupReports`' do - expect(Gitlab::Memory::UploadAndCleanupReports).not_to receive(:new) - expect(Gitlab::BackgroundTask).not_to receive(:new) - - load_initializer - end - end - end end context 'when run in non-Puma context, such as rails console, tests, Sidekiq' do diff --git a/spec/lib/gitlab/memory/diagnostic_reports_logger_spec.rb b/spec/lib/gitlab/memory/diagnostic_reports_logger_spec.rb new file mode 100644 index 00000000000..6be528e34b6 --- /dev/null +++ b/spec/lib/gitlab/memory/diagnostic_reports_logger_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' + +RSpec.describe Gitlab::Memory::DiagnosticReportsLogger do + subject { described_class.new('/dev/null') } + + let(:now) { Time.current } + + describe '#format_message' do + it 'formats incoming hash properly' do + output = subject.format_message('INFO', now, 'test', { hello: 1 }) + # Disabling the cop because it is not relevant, we encode with `JSON.generate`. Allows `fast_spec_helper`. + data = JSON.parse(output) # rubocop: disable Gitlab/Json + + expect(data['severity']).to eq('INFO') + expect(data['time']).to eq(now.utc.iso8601(3)) + expect(data['hello']).to eq(1) + expect(data['message']).to be_nil + end + end +end diff --git a/spec/lib/gitlab/memory/reports_uploader_spec.rb b/spec/lib/gitlab/memory/reports_uploader_spec.rb index f56e2064a59..9ff830716f2 100644 --- a/spec/lib/gitlab/memory/reports_uploader_spec.rb +++ b/spec/lib/gitlab/memory/reports_uploader_spec.rb @@ -3,19 +3,78 @@ require 'spec_helper' RSpec.describe Gitlab::Memory::ReportsUploader, :aggregate_failures do - let(:uploader) { described_class.new } + let(:gcs_key) { 'test_gcs_key' } + let(:gcs_project) { 'test_gcs_project' } + let(:gcs_bucket) { 'test_gcs_bucket' } + let(:logger) { instance_double(Gitlab::Memory::DiagnosticReportsLogger) } - let(:path) { '/report/to/upload' } + let(:uploader) do + described_class.new(gcs_key: gcs_key, gcs_project: gcs_project, gcs_bucket: gcs_bucket, logger: logger) + end + + # rubocop: disable RSpec/VerifiedDoubles + # `Fog::Storage::Google` does not implement `put_object` itself, so it is tricky to pinpoint particular method + # with instance_double without revealing `Fog::Storage::Google` internals. For simplicity, we use a simple double. + let(:fog) { double("Fog::Storage::Google") } + # rubocop: enable RSpec/VerifiedDoubles + + let(:report) { Tempfile.new("report.1.worker_1.#{Time.current.to_i}.json") } + + after do + FileUtils.remove_entry(report) + end describe '#upload' do - # currently no-op - it 'logs and returns false' do - expect(Gitlab::AppLogger) - .to receive(:info) - .with(hash_including(:pid, :worker_id, message: "Diagnostic reports", perf_report_status: "upload requested", - class: 'Gitlab::Memory::ReportsUploader', perf_report_path: path)) + before do + allow(Fog::Storage::Google) + .to receive(:new) + .with(google_project: gcs_project, google_json_key_location: gcs_key) + .and_return(fog) + end - expect(uploader.upload(path)).to be false + it 'calls fog, logs upload requested and success with duration' do + expect(logger) + .to receive(:info) + .with(hash_including(:pid, message: "Diagnostic reports", perf_report_status: "upload requested", + class: 'Gitlab::Memory::ReportsUploader', perf_report_path: report.path)) + .ordered + + expect(fog).to receive(:put_object).with(gcs_bucket, File.basename(report), instance_of(File)) + + expect(logger) + .to receive(:info) + .with(hash_including(:pid, :duration_s, + message: "Diagnostic reports", perf_report_status: "upload success", + class: 'Gitlab::Memory::ReportsUploader', perf_report_path: report.path)) + .ordered + + uploader.upload(report.path) + end + + context 'when Google API responds with an error' do + let(:invalid_bucket) { 'WRONG BUCKET' } + + let(:uploader) do + described_class.new(gcs_key: gcs_key, gcs_project: gcs_project, gcs_bucket: invalid_bucket, logger: logger) + end + + it 'logs error raised by Fog and do not re-raise' do + expect(logger) + .to receive(:info) + .with(hash_including(:pid, message: "Diagnostic reports", perf_report_status: "upload requested", + class: 'Gitlab::Memory::ReportsUploader', perf_report_path: report.path)) + + expect(fog).to receive(:put_object).with(invalid_bucket, File.basename(report), instance_of(File)) + .and_raise(Google::Apis::ClientError.new("invalid: Invalid bucket name: #{invalid_bucket}")) + + expect(logger) + .to receive(:error) + .with(hash_including(:pid, + message: "Diagnostic reports", class: 'Gitlab::Memory::ReportsUploader', + perf_report_status: 'error', error: "invalid: Invalid bucket name: #{invalid_bucket}")) + + expect { uploader.upload(report.path) }.not_to raise_error + end end end end diff --git a/spec/lib/gitlab/memory/upload_and_cleanup_reports_spec.rb b/spec/lib/gitlab/memory/upload_and_cleanup_reports_spec.rb index 77279f02878..f3351b276cc 100644 --- a/spec/lib/gitlab/memory/upload_and_cleanup_reports_spec.rb +++ b/spec/lib/gitlab/memory/upload_and_cleanup_reports_spec.rb @@ -3,158 +3,106 @@ require 'spec_helper' RSpec.describe Gitlab::Memory::UploadAndCleanupReports, :aggregate_failures do + let(:uploader) { instance_double(Gitlab::Memory::ReportsUploader) } + let(:logger) { instance_double(Gitlab::Memory::DiagnosticReportsLogger) } + describe '#initalize' do - context 'when settings are passed through the environment' do + let(:reports_path) { '/path/to/reports' } + + context 'when sleep_time_seconds is passed through the environment' do before do stub_env('GITLAB_DIAGNOSTIC_REPORTS_UPLOADER_SLEEP_S', '600') - stub_env('GITLAB_DIAGNOSTIC_REPORTS_PATH', '/path/to/reports') end it 'initializes with these settings' do - upload_and_cleanup = described_class.new + upload_and_cleanup = described_class.new(uploader: uploader, reports_path: reports_path, logger: logger) expect(upload_and_cleanup.sleep_time_seconds).to eq(600) - expect(upload_and_cleanup.reports_path).to eq('/path/to/reports') - expect(upload_and_cleanup.alive).to be true end end - context 'when settings are passed through the initializer' do + context 'when sleep_time_seconds is passed through the initializer' do it 'initializes with these settings' do - upload_and_cleanup = described_class.new(sleep_time_seconds: 600, reports_path: '/path/to/reports') + upload_and_cleanup = described_class.new(uploader: uploader, reports_path: reports_path, sleep_time_seconds: 60, + logger: logger) - expect(upload_and_cleanup.sleep_time_seconds).to eq(600) - expect(upload_and_cleanup.reports_path).to eq('/path/to/reports') - expect(upload_and_cleanup.alive).to be true + expect(upload_and_cleanup.sleep_time_seconds).to eq(60) end end context 'when `sleep_time_seconds` is not passed' do it 'initialized with the default' do - upload_and_cleanup = described_class.new(reports_path: '/path/to/reports') + upload_and_cleanup = described_class.new(uploader: uploader, reports_path: reports_path, logger: logger) expect(upload_and_cleanup.sleep_time_seconds).to eq(described_class::DEFAULT_SLEEP_TIME_SECONDS) - expect(upload_and_cleanup.alive).to be true end end - - shared_examples 'checks reports_path presence' do - it 'logs error and does not set `alive`' do - expect(Gitlab::AppLogger).to receive(:error) - .with(hash_including( - :pid, :worker_id, - message: "Diagnostic reports", - class: 'Gitlab::Memory::UploadAndCleanupReports', - perf_report_status: 'path is not configured')) - - upload_and_cleanup = described_class.new(sleep_time_seconds: 600, reports_path: path) - - expect(upload_and_cleanup.alive).to be_falsey - end - end - - context 'when `reports_path` is nil' do - let(:path) { nil } - - it_behaves_like 'checks reports_path presence' - end - - context 'when `reports_path` is blank' do - let(:path) { '' } - - it_behaves_like 'checks reports_path presence' - end end describe '#call' do let(:upload_and_cleanup) do - described_class.new(sleep_time_seconds: 600, reports_path: dir).tap do |instance| - allow(instance).to receive(:sleep).and_return(nil) - allow(instance).to receive(:alive).and_return(true, false) + described_class.new(sleep_time_seconds: 600, reports_path: dir, uploader: uploader, + logger: logger).tap do |instance| + allow(instance).to receive(:loop).and_yield + allow(instance).to receive(:sleep) end end - let_it_be(:dir) { Dir.mktmpdir } + let(:dir) { Dir.mktmpdir } - after(:all) do + let(:reports_count) { 3 } + + let(:reports) do + (1..reports_count).map do |i| + Tempfile.new("report.1.worker_#{i}.#{Time.current.to_i}.json", dir) + end + end + + after do FileUtils.remove_entry(dir) end - context 'when `gitlab_diagnostic_reports_uploader` ops FF is enabled' do - let_it_be(:reports_count) { 3 } + it 'invokes the uploader and cleans the files' do + expect(logger) + .to receive(:info) + .with(hash_including(:pid, + message: "Diagnostic reports", + class: 'Gitlab::Memory::UploadAndCleanupReports', + perf_report_status: 'started')) - let_it_be(:reports) do - (1..reports_count).map do |i| - Tempfile.new("report.1.worker_#{i}.#{Time.current.to_i}.json", dir) - end + reports.each do |report| + expect(upload_and_cleanup.uploader).to receive(:upload).with(report.path) end - let_it_be(:unfinished_report) do - unfinished_reports_dir = File.join(dir, 'tmp') - FileUtils.mkdir_p(unfinished_reports_dir) - Tempfile.new("report.10.worker_0.#{Time.current.to_i}.json", unfinished_reports_dir) - end + expect { upload_and_cleanup.call } + .to change { Dir.entries(dir).count { |e| e.match(/report.*/) } } + .from(reports_count).to(0) + end - let_it_be(:failed_to_upload_report) do - Tempfile.new("report.100.worker_0.#{Time.current.to_i}.json", dir) - end + context 'when there is an exception' do + let(:report) { Tempfile.new("report.1.worker_1.#{Time.current.to_i}.json", dir) } - it 'invokes the uploader and cleans only successfully uploaded files' do - expect(Gitlab::AppLogger) + it 'logs it and does not crash the loop' do + expect(logger) .to receive(:info) - .with(hash_including(:pid, :worker_id, + .with(hash_including(:pid, message: "Diagnostic reports", class: 'Gitlab::Memory::UploadAndCleanupReports', perf_report_status: 'started')) + .ordered - reports.each do |report| - expect(upload_and_cleanup.uploader).to receive(:upload).with(report.path).and_return(true) - end + expect(upload_and_cleanup.uploader) + .to receive(:upload) + .with(report.path) + .and_raise(StandardError, 'Error Message') - expect(upload_and_cleanup.uploader).not_to receive(:upload).with(unfinished_report.path) + expect(logger) + .to receive(:error) + .with(hash_including(:pid, message: "Diagnostic reports", class: 'Gitlab::Memory::UploadAndCleanupReports', + perf_report_status: 'error', error: 'Error Message')) + .ordered - expect(upload_and_cleanup.uploader).to receive(:upload).with(failed_to_upload_report.path).and_return(false) - - expect { upload_and_cleanup.call } - .to change { Dir.entries(dir).count { |e| e.match(/report.*/) } } - .from(reports_count + 1).to(1) - end - - context 'when there is an exception' do - it 'logs it and does not crash the loop' do - expect(upload_and_cleanup.uploader) - .to receive(:upload) - .at_least(:once) - .and_raise(StandardError, 'Error Message') - - expect(Gitlab::ErrorTracking) - .to receive(:log_exception) - .with(an_instance_of(StandardError), - hash_including(:pid, :worker_id, message: "Diagnostic reports", - class: 'Gitlab::Memory::UploadAndCleanupReports')) - .at_least(:once) - - expect { upload_and_cleanup.call }.not_to raise_error - end - end - end - - context 'when `gitlab_diagnostic_reports_uploader` ops FF is disabled' do - let(:dir) { Dir.mktmpdir } - - before do - stub_feature_flags(gitlab_diagnostic_reports_uploader: false) - Tempfile.new("report.1.worker_1.#{Time.current.to_i}.json", dir) - end - - after do - FileUtils.remove_entry(dir) - end - - it 'does not upload and remove any files' do - expect(upload_and_cleanup.uploader).not_to receive(:upload) - - expect { upload_and_cleanup.call }.not_to change { Dir.entries(dir).count } + expect { upload_and_cleanup.call }.not_to raise_error end end end diff --git a/spec/models/integrations/datadog_spec.rb b/spec/models/integrations/datadog_spec.rb index b7da6a79e44..71a5bbc4db1 100644 --- a/spec/models/integrations/datadog_spec.rb +++ b/spec/models/integrations/datadog_spec.rb @@ -47,6 +47,10 @@ RSpec.describe Integrations::Datadog do Gitlab::DataBuilder::ArchiveTrace.build(build) end + it_behaves_like Integrations::ResetSecretFields do + let(:integration) { instance } + end + it_behaves_like Integrations::HasWebHook do let(:integration) { instance } let(:hook_url) { "#{described_class::URL_TEMPLATE % { datadog_domain: dd_site }}?dd-api-key={api_key}&env=#{dd_env}&service=#{dd_service}" }