diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-02-07 15:09:49 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-02-07 15:09:49 +0000 |
commit | 84f9f0cb8137637708a41152347e7754c1e9fb83 (patch) | |
tree | 6db9d8931bdb3c5b932b36345373936e2a543126 | |
parent | 75f809a2ff829574ab91628407993187d55e14a4 (diff) | |
download | gitlab-ce-84f9f0cb8137637708a41152347e7754c1e9fb83.tar.gz |
Add latest changes from gitlab-org/gitlab@master
47 files changed, 1174 insertions, 113 deletions
diff --git a/.rubocop_todo/layout/argument_alignment.yml b/.rubocop_todo/layout/argument_alignment.yml index 94016d3d3a0..65750e500c1 100644 --- a/.rubocop_todo/layout/argument_alignment.yml +++ b/.rubocop_todo/layout/argument_alignment.yml @@ -2047,7 +2047,6 @@ Layout/ArgumentAlignment: - 'lib/gitlab/ci/ansi2json/line.rb' - 'lib/gitlab/ci/config/entry/environment.rb' - 'lib/gitlab/ci/config/entry/imageable.rb' - - 'lib/gitlab/ci/config/entry/include.rb' - 'lib/gitlab/ci/config/entry/job.rb' - 'lib/gitlab/ci/config/entry/product/parallel.rb' - 'lib/gitlab/ci/config/entry/pull_policy.rb' diff --git a/.rubocop_todo/layout/array_alignment.yml b/.rubocop_todo/layout/array_alignment.yml index 34a2eefcadf..de7139d1380 100644 --- a/.rubocop_todo/layout/array_alignment.yml +++ b/.rubocop_todo/layout/array_alignment.yml @@ -256,7 +256,6 @@ Layout/ArrayAlignment: - 'spec/lib/gitlab/ci/config/external/file/remote_spec.rb' - 'spec/lib/gitlab/ci/config/external/mapper/filter_spec.rb' - 'spec/lib/gitlab/ci/config/external/mapper/location_expander_spec.rb' - - 'spec/lib/gitlab/ci/config/external/mapper/matcher_spec.rb' - 'spec/lib/gitlab/ci/config/external/mapper/normalizer_spec.rb' - 'spec/lib/gitlab/ci/config/external/mapper_spec.rb' - 'spec/lib/gitlab/ci/config_spec.rb' diff --git a/.rubocop_todo/layout/line_length.yml b/.rubocop_todo/layout/line_length.yml index 198fada5fa1..f8f5c6d6dfa 100644 --- a/.rubocop_todo/layout/line_length.yml +++ b/.rubocop_todo/layout/line_length.yml @@ -1871,7 +1871,6 @@ Layout/LineLength: - 'ee/spec/graphql/types/incident_management/escalation_rule_input_type_spec.rb' - 'ee/spec/graphql/types/issue_type_spec.rb' - 'ee/spec/graphql/types/permission_types/vulnerability_spec.rb' - - 'ee/spec/graphql/types/pipeline_security_report_finding_type_spec.rb' - 'ee/spec/graphql/types/project_type_spec.rb' - 'ee/spec/graphql/types/security_scanner_type_enum_spec.rb' - 'ee/spec/graphql/types/vulnerability_details/file_location_type_spec.rb' diff --git a/app/assets/javascripts/analytics/shared/components/metric_popover.vue b/app/assets/javascripts/analytics/shared/components/metric_popover.vue index 8d90e7b2392..373a7fac6f7 100644 --- a/app/assets/javascripts/analytics/shared/components/metric_popover.vue +++ b/app/assets/javascripts/analytics/shared/components/metric_popover.vue @@ -1,5 +1,6 @@ <script> import { GlPopover, GlLink, GlIcon } from '@gitlab/ui'; +import { METRIC_POPOVER_LABEL } from '../constants'; export default { name: 'MetricPopover', @@ -19,34 +20,34 @@ export default { }, }, computed: { - metricLinks() { - return this.metric.links?.filter((link) => !link.docs_link) || []; + metricLink() { + return this.metric.links?.find((link) => !link.docs_link); }, docsLink() { return this.metric.links?.find((link) => link.docs_link); }, }, + metricPopoverLabel: METRIC_POPOVER_LABEL, }; </script> <template> - <gl-popover :target="target" placement="bottom"> + <gl-popover :target="target" placement="top"> <template #title> - <span class="gl-display-block gl-text-left" data-testid="metric-label">{{ - metric.label - }}</span> + <div + class="gl-display-flex gl-justify-content-space-between gl-text-right gl-py-1 gl-align-items-center" + > + <span data-testid="metric-label">{{ metric.label }}</span> + <gl-link + v-if="metricLink" + :href="metricLink.url" + class="gl-font-sm gl-font-weight-normal" + data-testid="metric-link" + >{{ $options.metricPopoverLabel }} + <gl-icon name="chart" /> + </gl-link> + </div> </template> - <div - v-for="(link, idx) in metricLinks" - :key="`link-${idx}`" - class="gl-display-flex gl-justify-content-space-between gl-text-right gl-py-1" - data-testid="metric-link" - > - <span>{{ link.label }}</span> - <gl-link :href="link.url" class="gl-font-sm"> - {{ link.name }} - </gl-link> - </div> <span v-if="metric.description" data-testid="metric-description">{{ metric.description }}</span> <gl-link v-if="docsLink" diff --git a/app/assets/javascripts/analytics/shared/constants.js b/app/assets/javascripts/analytics/shared/constants.js index a82633035b5..7ced658f483 100644 --- a/app/assets/javascripts/analytics/shared/constants.js +++ b/app/assets/javascripts/analytics/shared/constants.js @@ -13,6 +13,8 @@ export const dateFormats = { month: 'mmmm', }; +export const METRIC_POPOVER_LABEL = s__('ValueStreamAnalytics|View details'); + export const KEY_METRICS = { LEAD_TIME: 'lead_time', CYCLE_TIME: 'cycle_time', diff --git a/app/assets/javascripts/ci/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue b/app/assets/javascripts/ci/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue index 84c29e48114..7368d1a3a91 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue @@ -52,7 +52,7 @@ export default { }; </script> <template> - <div class="gl-mb-4"> + <div class="gl-mb-4 gl-display-flex gl-flex-wrap gl-gap-3"> <gl-button v-if="showFileTreeToggle" id="file-tree-toggle" diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json index 324acb177b0..57477a993c5 100644 --- a/app/assets/javascripts/editor/schema/ci.json +++ b/app/assets/javascripts/editor/schema/ci.json @@ -417,6 +417,20 @@ "type": "object", "additionalProperties": false, "properties": { + "component": { + "description": "Local path to component directory or full path to external component directory.", + "type": "string", + "format": "uri-reference" + } + }, + "required": [ + "component" + ] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { "remote": { "description": "URL to a `yaml`/`yml` template file using HTTP/HTTPS.", "type": "string", @@ -1955,4 +1969,4 @@ "additionalProperties": false } } -}
\ No newline at end of file +} diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue b/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue index 9b669024a8b..f3d392a0ec4 100644 --- a/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue +++ b/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue @@ -1,23 +1,22 @@ <script> -import { s__ } from '~/locale'; +import { GlButton, GlModal, GlModalDirective } from '@gitlab/ui'; import { createAlert } from '~/flash'; import branchRulesQuery from 'ee_else_ce/projects/settings/repository/branch_rules/graphql/queries/branch_rules.query.graphql'; +import { expandSection } from '~/settings_panels'; +import { scrollToElement } from '~/lib/utils/common_utils'; import BranchRule from './components/branch_rule.vue'; - -export const i18n = { - queryError: s__( - 'ProtectedBranch|An error occurred while loading branch rules. Please try again.', - ), - emptyState: s__( - 'ProtectedBranch|Protected branches, merge request approvals, and status checks will appear here once configured.', - ), -}; +import { I18N, PROTECTED_BRANCHES_ANCHOR, BRANCH_PROTECTION_MODAL_ID } from './constants'; export default { name: 'BranchRules', - i18n, + i18n: I18N, components: { BranchRule, + GlButton, + GlModal, + }, + directives: { + GlModal: GlModalDirective, }, apollo: { branchRules: { @@ -36,20 +35,27 @@ export default { }, }, inject: { - projectPath: { - default: '', - }, + projectPath: { default: '' }, }, data() { return { branchRules: [], }; }, + methods: { + showProtectedBranches() { + // Protected branches section is on the same page as the branch rules section. + expandSection(this.$options.protectedBranchesAnchor); + scrollToElement(this.$options.protectedBranchesAnchor); + }, + }, + modalId: BRANCH_PROTECTION_MODAL_ID, + protectedBranchesAnchor: PROTECTED_BRANCHES_ANCHOR, }; </script> <template> - <div class="settings-content"> + <div class="settings-content gl-mb-0"> <branch-rule v-for="(rule, index) in branchRules" :key="`${rule.name}-${index}`" @@ -61,6 +67,21 @@ export default { :matching-branches-count="rule.matchingBranchesCount" /> - <span v-if="!branchRules.length" data-testid="empty">{{ $options.i18n.emptyState }}</span> + <div v-if="!branchRules.length" data-testid="empty">{{ $options.i18n.emptyState }}</div> + + <gl-button v-gl-modal="$options.modalId" class="gl-mt-5" category="secondary" variant="info">{{ + $options.i18n.addBranchRule + }}</gl-button> + + <gl-modal + :ref="$options.modalId" + :modal-id="$options.modalId" + :title="$options.i18n.addBranchRule" + :ok-title="$options.i18n.createProtectedBranch" + @ok="showProtectedBranches" + > + <p>{{ $options.i18n.branchRuleModalDescription }}</p> + <p>{{ $options.i18n.branchRuleModalContent }}</p> + </gl-modal> </div> </template> diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/constants.js b/app/assets/javascripts/projects/settings/repository/branch_rules/constants.js new file mode 100644 index 00000000000..4413d8eab4e --- /dev/null +++ b/app/assets/javascripts/projects/settings/repository/branch_rules/constants.js @@ -0,0 +1,22 @@ +import { s__ } from '~/locale'; + +export const I18N = { + queryError: s__( + 'ProtectedBranch|An error occurred while loading branch rules. Please try again.', + ), + emptyState: s__( + 'ProtectedBranch|After you configure a protected branch, merge request approval, or status check, it appears here.', + ), + addBranchRule: s__('BranchRules|Add branch rule'), + branchRuleModalDescription: s__( + 'BranchRules|To create a branch rule, you first need to create a protected branch.', + ), + branchRuleModalContent: s__( + 'BranchRules|After a protected branch is created, it will show up in the list as a branch rule.', + ), + createProtectedBranch: s__('BranchRules|Create protected branch'), +}; + +export const PROTECTED_BRANCHES_ANCHOR = '#js-protected-branches-settings'; + +export const BRANCH_PROTECTION_MODAL_ID = 'addBranchRuleModal'; diff --git a/app/assets/stylesheets/page_bundles/settings.scss b/app/assets/stylesheets/page_bundles/settings.scss index 9037eb7ae62..8978b8d798b 100644 --- a/app/assets/stylesheets/page_bundles/settings.scss +++ b/app/assets/stylesheets/page_bundles/settings.scss @@ -71,6 +71,7 @@ animation: collapseMaxHeight 300ms ease-out; // Keep the section from expanding when we scroll over it pointer-events: none; + margin-bottom: $gl-spacing-scale-5; .settings.expanded & { max-height: none; @@ -101,7 +102,6 @@ display: block; height: 1px; overflow: hidden; - margin-top: 20px; } .sub-section { diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index 0dca5b18a24..b5622959c75 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -141,8 +141,11 @@ module Ci before_save :set_size, if: :file_changed? after_save :store_file_in_transaction!, unless: :store_after_commit? + after_commit :store_file_after_transaction!, on: [:create, :update], if: :store_after_commit? + after_destroy_commit :log_destroy + validates :job, presence: true validates :file_format, presence: true, unless: :trace?, on: :create validate :validate_file_format!, unless: :trace?, on: :create @@ -384,6 +387,10 @@ module Ci # Use job.project to avoid extra DB query for project job.project.pending_delete? end + + def log_destroy + Gitlab::Ci::Artifacts::Logger.log_deleted(self, __method__) + end end end diff --git a/app/models/environment.rb b/app/models/environment.rb index 7d99f10822d..7febafc2cca 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -74,7 +74,11 @@ class Environment < ApplicationRecord # Currently, the tier presence is validaed for newly created environments. # After the `BackfillEnvironmentTiers` background migration has been completed, we should remove `on: :create`. # See https://gitlab.com/gitlab-org/gitlab/-/issues/385253. - validates :tier, presence: true, on: :create + # Todo: Remove along with FF `validate_environment_tier_presence`. + validates :tier, presence: true, on: :create, unless: :validate_environment_tier_present? + + validates :tier, presence: true, if: :validate_environment_tier_present? + validate :safe_external_url validate :merge_request_not_changed @@ -600,6 +604,10 @@ class Environment < ApplicationRecord self.class.tiers[:other] end end + + def validate_environment_tier_present? + Feature.enabled?(:validate_environment_tier_presence, self.project) + end end Environment.prepend_mod_with('Environment') diff --git a/app/services/ci/components/fetch_service.rb b/app/services/ci/components/fetch_service.rb new file mode 100644 index 00000000000..45abb415174 --- /dev/null +++ b/app/services/ci/components/fetch_service.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Ci + module Components + class FetchService + include Gitlab::Utils::StrongMemoize + + TEMPLATE_FILE = 'template.yml' + + COMPONENT_PATHS = [ + ::Gitlab::Ci::Components::InstancePath + ].freeze + + def initialize(address:, current_user:) + @address = address + @current_user = current_user + end + + def execute + unless component_path_class + return ServiceResponse.error( + message: "#{error_prefix} the component path is not supported", + reason: :unsupported_path) + end + + component_path = component_path_class.new(address: address, content_filename: TEMPLATE_FILE) + content = component_path.fetch_content!(current_user: current_user) + + if content.present? + ServiceResponse.success(payload: { content: content, path: component_path }) + else + ServiceResponse.error(message: "#{error_prefix} content not found", reason: :content_not_found) + end + rescue Gitlab::Access::AccessDeniedError + ServiceResponse.error( + message: "#{error_prefix} project does not exist or you don't have sufficient permissions", + reason: :not_allowed) + end + + private + + attr_reader :current_user, :address + + def component_path_class + COMPONENT_PATHS.find { |klass| klass.match?(address) } + end + strong_memoize_attr :component_path_class + + def error_prefix + "component '#{address}' -" + end + end + end +end diff --git a/config/events/1675167870_Gitlab__Ci__Pipeline__Chain__Metrics_create_pipeline_with_name.yml b/config/events/1675167870_Gitlab__Ci__Pipeline__Chain__Metrics_create_pipeline_with_name.yml new file mode 100644 index 00000000000..0427f86fb27 --- /dev/null +++ b/config/events/1675167870_Gitlab__Ci__Pipeline__Chain__Metrics_create_pipeline_with_name.yml @@ -0,0 +1,26 @@ +--- +description: Pipeline with name is created +category: Gitlab::Ci::Pipeline::Chain::Metrics +action: create_pipeline_with_name +label_description: +property_description: +value_description: +extra_properties: +identifiers: +- project +- user +- namespace +product_section: ops +product_stage: verify +product_group: pipeline_execution +product_category: Continuous Integration +milestone: "15.9" +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/109549 +distributions: +- ce +- ee +tiers: +- free +- premium +- ultimate + diff --git a/config/feature_flags/development/ci_include_components.yml b/config/feature_flags/development/ci_include_components.yml new file mode 100644 index 00000000000..b61863e9675 --- /dev/null +++ b/config/feature_flags/development/ci_include_components.yml @@ -0,0 +1,8 @@ +--- +name: ci_include_components +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/109154 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/39064 +milestone: '15.9' +type: development +group: group::pipeline authoring +default_enabled: false diff --git a/config/feature_flags/development/validate_environment_tier_presence.yml b/config/feature_flags/development/validate_environment_tier_presence.yml new file mode 100644 index 00000000000..ca9f7be8e75 --- /dev/null +++ b/config/feature_flags/development/validate_environment_tier_presence.yml @@ -0,0 +1,7 @@ +name: validate_environment_tier_presence +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/111011 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/385253 +milestone: '15.9' +type: development +group: group::release +default_enabled: false diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index 06cb5e97a17..8a44e4077f4 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -265,6 +265,7 @@ Settings['gitlab_ci'] ||= Settingslogic.new({}) Settings.gitlab_ci['shared_runners_enabled'] = true if Settings.gitlab_ci['shared_runners_enabled'].nil? Settings.gitlab_ci['builds_path'] = Settings.absolute(Settings.gitlab_ci['builds_path'] || "builds/") Settings.gitlab_ci['url'] ||= Settings.__send__(:build_gitlab_ci_url) +Settings.gitlab_ci['component_fqdn'] ||= Settings.__send__(:build_ci_component_fqdn) # # CI Secure Files diff --git a/config/settings.rb b/config/settings.rb index 34acb09b9ed..ae95af802c4 100644 --- a/config/settings.rb +++ b/config/settings.rb @@ -11,6 +11,17 @@ class Settings < Settingslogic on_standard_port?(gitlab) end + def build_ci_component_fqdn + custom_port = ":#{gitlab.port}" unless on_standard_port?(gitlab) + + [ + gitlab.host, + custom_port, + gitlab.relative_url_root, + '/' + ].join('') + end + def host_without_www(url) host(url).sub('www.', '') end diff --git a/db/init_structure.sql b/db/init_structure.sql index 0883ef40d82..00f06078426 100644 --- a/db/init_structure.sql +++ b/db/init_structure.sql @@ -10,8 +10,6 @@ CREATE EXTENSION IF NOT EXISTS btree_gist; CREATE EXTENSION IF NOT EXISTS pg_trgm; -CREATE EXTENSION IF NOT EXISTS plpgsql; - CREATE FUNCTION set_has_external_issue_tracker() RETURNS trigger LANGUAGE plpgsql AS $$ diff --git a/db/structure.sql b/db/structure.sql index 40c2ec248d2..4345733c761 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -10,8 +10,6 @@ CREATE EXTENSION IF NOT EXISTS btree_gist; CREATE EXTENSION IF NOT EXISTS pg_trgm; -CREATE EXTENSION IF NOT EXISTS plpgsql; - CREATE FUNCTION delete_associated_project_namespace() RETURNS trigger LANGUAGE plpgsql AS $$ diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 0b5d73afacd..5c7a53c3089 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -17449,6 +17449,9 @@ Represents vulnerability finding of a security report on the pipeline. | <a id="pipelinesecurityreportfindingdescription"></a>`description` | [`String`](#string) | Description of the vulnerability finding. | | <a id="pipelinesecurityreportfindingdescriptionhtml"></a>`descriptionHtml` | [`String`](#string) | GitLab Flavored Markdown rendering of `description`. | | <a id="pipelinesecurityreportfindingdetails"></a>`details` | [`[VulnerabilityDetail!]!`](#vulnerabilitydetail) | Details of the security finding. | +| <a id="pipelinesecurityreportfindingdismissalreason"></a>`dismissalReason` | [`VulnerabilityDismissalReason`](#vulnerabilitydismissalreason) | Reason for the dismissal of the security report finding. | +| <a id="pipelinesecurityreportfindingdismissedat"></a>`dismissedAt` | [`Time`](#time) | Time of the dismissal of the security report finding. | +| <a id="pipelinesecurityreportfindingdismissedby"></a>`dismissedBy` | [`UserCore`](#usercore) | User who dismissed the security report finding. | | <a id="pipelinesecurityreportfindingevidence"></a>`evidence` | [`VulnerabilityEvidence`](#vulnerabilityevidence) | Evidence for the vulnerability. | | <a id="pipelinesecurityreportfindingfalsepositive"></a>`falsePositive` | [`Boolean`](#boolean) | Indicates whether the vulnerability is a false positive. | | <a id="pipelinesecurityreportfindingidentifiers"></a>`identifiers` | [`[VulnerabilityIdentifier!]!`](#vulnerabilityidentifier) | Identifiers of the vulnerability finding. | @@ -17465,6 +17468,7 @@ Represents vulnerability finding of a security report on the pipeline. | <a id="pipelinesecurityreportfindingseverity"></a>`severity` | [`VulnerabilitySeverity`](#vulnerabilityseverity) | Severity of the vulnerability finding. | | <a id="pipelinesecurityreportfindingsolution"></a>`solution` | [`String`](#string) | Solution for resolving the security report finding. | | <a id="pipelinesecurityreportfindingstate"></a>`state` | [`VulnerabilityState`](#vulnerabilitystate) | Finding status. | +| <a id="pipelinesecurityreportfindingstatecomment"></a>`stateComment` | [`String`](#string) | Comment for the state of the security report finding. | | <a id="pipelinesecurityreportfindingtitle"></a>`title` | [`String`](#string) | Title of the vulnerability finding. | | <a id="pipelinesecurityreportfindinguuid"></a>`uuid` | [`String`](#string) | UUIDv5 digest based on the vulnerability's report type, primary identifier, location, fingerprint, project identifier. | | <a id="pipelinesecurityreportfindingvulnerability"></a>`vulnerability` | [`Vulnerability`](#vulnerability) | Vulnerability related to the security report finding. | diff --git a/doc/development/code_review.md b/doc/development/code_review.md index a3906990402..1b39b74a23b 100644 --- a/doc/development/code_review.md +++ b/doc/development/code_review.md @@ -723,6 +723,7 @@ Enterprise Edition instance. This has some implications: 1. Try to avoid that, and add to `ApplicationSetting` instead. 1. Ensure that it is also [added to Omnibus](https://docs.gitlab.com/omnibus/settings/gitlab.yml#adding-a-new-setting-to-gitlabyml). + 1. Ensure that it is also [added to Charts](https://docs.gitlab.com/charts/development/style_guide.html), if needed. 1. **File system access** is not possible in a [cloud-native architecture](architecture.md#adapting-existing-and-introducing-new-components). Ensure that we support object storage for any file storage we need to perform. For more information, see the [uploads documentation](uploads/index.md). diff --git a/doc/raketasks/restore_gitlab.md b/doc/raketasks/restore_gitlab.md index c97a71b1b5d..c5bbb38cc37 100644 --- a/doc/raketasks/restore_gitlab.md +++ b/doc/raketasks/restore_gitlab.md @@ -44,10 +44,6 @@ or `/home/git/gitlab/config/gitlab.yml` (for installations from source) and any TLS keys, certificates (`/etc/gitlab/ssl`, `/etc/gitlab/trusted-certs`), or [SSH host keys](https://superuser.com/questions/532040/copy-ssh-keys-from-one-server-to-another-server/532079#532079). -Starting with GitLab 12.9, if an untarred backup (like the ones made with -`SKIP=tar`) is found, and no backup is chosen with `BACKUP=<timestamp>`, the -untarred backup is used. - Depending on your case, you might want to run the restore command with one or more of the following options: @@ -383,3 +379,20 @@ For example, to restore all repositories for all projects in **Group A** (`group ```shell sudo -u git -H bundle exec rake gitlab:backup:restore BACKUP=timestamp_of_backup REPOSITORIES_PATHS=group-a,group-b/project-c ``` + +### Restore untarred backups + +If an [untarred backup](backup_gitlab.md#skipping-tar-creation) (made with `SKIP=tar`) is found, +and no backup is chosen with `BACKUP=<timestamp>`, the untarred backup is used. + +For example, for Omnibus GitLab installations: + +```shell +sudo gitlab-backup restore +``` + +For example, for installations from source: + +```shell +sudo -u git -H bundle exec rake gitlab:backup:restore +``` diff --git a/doc/user/project/issues/create_issues.md b/doc/user/project/issues/create_issues.md index 18adce313d3..5ebb2fc2e1c 100644 --- a/doc/user/project/issues/create_issues.md +++ b/doc/user/project/issues/create_issues.md @@ -127,7 +127,7 @@ You can send an email to create an issue in a project on the project's Prerequisites: - Your GitLab instance must have [incoming email](../../../administration/incoming_email.md) - configured. + configured with [email sub-addressing or catch-all mailbox](../../../administration/incoming_email.md#requirements). - There must be at least one issue in the issue list. - You must have at least the Guest role for the project. diff --git a/doc/user/shortcuts.md b/doc/user/shortcuts.md index de3ff4ef6e5..d67c595512a 100644 --- a/doc/user/shortcuts.md +++ b/doc/user/shortcuts.md @@ -333,6 +333,13 @@ To disable keyboard shortcuts: press <kbd>?</kbd> to display the list of shortcuts. 1. Select **Toggle shortcuts**. +## Enable keyboard shortcuts + +To enable keyboard shortcuts: + +1. On the top bar, select the Help menu (**{question}**), then **Keyboard shortcuts**. +1. Select **Toggle shortcuts**. + ## Troubleshooting ### Linux shortcuts diff --git a/lib/gitlab/ci/build/auto_retry.rb b/lib/gitlab/ci/build/auto_retry.rb index 4950a7616c8..453c293f6cd 100644 --- a/lib/gitlab/ci/build/auto_retry.rb +++ b/lib/gitlab/ci/build/auto_retry.rb @@ -47,7 +47,9 @@ class Gitlab::Ci::Build::AutoRetry end def options_retry_when - options_retry.fetch(:when, ['always']) + default = ['always'] + + options_retry.fetch(:when, default) || default end def retry_on_reason_or_always? diff --git a/lib/gitlab/ci/components/instance_path.rb b/lib/gitlab/ci/components/instance_path.rb new file mode 100644 index 00000000000..010ce57d2a0 --- /dev/null +++ b/lib/gitlab/ci/components/instance_path.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Components + class InstancePath + include Gitlab::Utils::StrongMemoize + + def self.match?(address) + address.include?('@') && address.start_with?(Settings.gitlab_ci['component_fqdn']) + end + + attr_reader :host + + def initialize(address:, content_filename:) + @full_path, @version = address.to_s.split('@', 2) + @content_filename = content_filename + @host = Settings.gitlab_ci['component_fqdn'] + end + + def fetch_content!(current_user:) + return unless project + return unless sha + + raise Gitlab::Access::AccessDeniedError unless Ability.allowed?(current_user, :download_code, project) + + project.repository.blob_data_at(sha, project_file_path) + end + + def project + find_project_by_component_path(instance_path) + end + strong_memoize_attr :project + + def project_file_path + return unless project + + component_dir = instance_path.delete_prefix(project.full_path) + File.join(component_dir, @content_filename).delete_prefix('/') + end + + # TODO: Add support when version is a released tag and "~latest" moving target + def sha + return unless project + + project.commit(version)&.id + end + strong_memoize_attr :sha + + private + + attr_reader :version, :path + + def instance_path + @full_path.delete_prefix(host) + end + + # Given a path like "my-org/sub-group/the-project/path/to/component" + # find the project "my-org/sub-group/the-project" by looking at all possible paths. + def find_project_by_component_path(path) + possible_paths = [path] + + while index = path.rindex('/') # find index of last `/` in a path + possible_paths << (path = path[0..index - 1]) + end + + # remove shortest path as it is group + possible_paths.pop + + ::Project.where_full_path_in(possible_paths).take # rubocop: disable CodeReuse/ActiveRecord + end + end + end + end +end diff --git a/lib/gitlab/ci/config/entry/include.rb b/lib/gitlab/ci/config/entry/include.rb index 368d8f07f8d..baab7ebdb79 100644 --- a/lib/gitlab/ci/config/entry/include.rb +++ b/lib/gitlab/ci/config/entry/include.rb @@ -12,7 +12,7 @@ module Gitlab include ::Gitlab::Config::Entry::Configurable include ::Gitlab::Config::Entry::Attributable - ALLOWED_KEYS = %i[local file remote template artifact job project ref rules].freeze + ALLOWED_KEYS = %i[local file remote template component artifact job project ref rules].freeze validations do validates :config, hash_or_string: true @@ -36,8 +36,8 @@ module Gitlab end entry :rules, ::Gitlab::Ci::Config::Entry::Include::Rules, - description: 'List of evaluable Rules to determine file inclusion.', - inherit: false + description: 'List of evaluable Rules to determine file inclusion.', + inherit: false attributes :rules diff --git a/lib/gitlab/ci/config/external/file/component.rb b/lib/gitlab/ci/config/external/file/component.rb new file mode 100644 index 00000000000..33e7724bf9b --- /dev/null +++ b/lib/gitlab/ci/config/external/file/component.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + class Config + module External + module File + class Component < Base + extend ::Gitlab::Utils::Override + include Gitlab::Utils::StrongMemoize + + def initialize(params, context) + @location = params[:component] + super + end + + def matching? + super && ::Feature.enabled?(:ci_include_components, context.project) + end + + def content + return unless component_result.success? + + component_result.payload.fetch(:content) + end + strong_memoize_attr :content + + def metadata + super.merge( + type: :component, + location: masked_location, + blob: masked_blob, + raw: nil, + extra: {} + ) + end + + def validate_location! + return unless invalid_location_type? + + errors.push("Included file `#{masked_location}` needs to be a string") + end + + def validate_context! + return if context.project&.repository + + errors.push('Unable to use components outside of a project context') + end + + def validate_content! + return if content.present? + + errors.push(component_result.message) + end + + private + + attr_reader :path, :version + + def component_result + ::Ci::Components::FetchService.new( + address: location, + current_user: context.user + ).execute + end + strong_memoize_attr :component_result + + override :expand_context_attrs + def expand_context_attrs + { + project: component_path.project, + sha: component_path.sha, + user: context.user, + variables: context.variables + } + end + + def masked_blob + return unless component_path + + context.mask_variables_from( + Gitlab::Routing.url_helpers.project_blob_url( + component_path.project, + ::File.join(component_path.sha, component_path.project_file_path)) + ) + end + strong_memoize_attr :masked_blob + + def component_path + return unless component_result.success? + + component_result.payload.fetch(:path) + end + strong_memoize_attr :component_path + end + end + end + end + end +end diff --git a/lib/gitlab/ci/config/external/mapper/matcher.rb b/lib/gitlab/ci/config/external/mapper/matcher.rb index 85e19ff1ced..e59eaa6d324 100644 --- a/lib/gitlab/ci/config/external/mapper/matcher.rb +++ b/lib/gitlab/ci/config/external/mapper/matcher.rb @@ -10,6 +10,7 @@ module Gitlab FILE_CLASSES = [ External::File::Local, External::File::Project, + External::File::Component, External::File::Remote, External::File::Template, External::File::Artifact @@ -29,11 +30,11 @@ module Gitlab matching.first elsif matching.empty? raise Mapper::AmbigiousSpecificationError, - "`#{masked_location(location.to_json)}` does not have a valid subkey for include. " \ - "Valid subkeys are: `#{FILE_SUBKEYS.join('`, `')}`" + "`#{masked_location(location.to_json)}` does not have a valid subkey for include. " \ + "Valid subkeys are: `#{FILE_SUBKEYS.join('`, `')}`" else raise Mapper::AmbigiousSpecificationError, - "Each include must use only one of: `#{FILE_SUBKEYS.join('`, `')}`" + "Each include must use only one of: `#{FILE_SUBKEYS.join('`, `')}`" end end end diff --git a/lib/gitlab/ci/pipeline/chain/metrics.rb b/lib/gitlab/ci/pipeline/chain/metrics.rb index b17ae77d445..b886aa22ba3 100644 --- a/lib/gitlab/ci/pipeline/chain/metrics.rb +++ b/lib/gitlab/ci/pipeline/chain/metrics.rb @@ -6,15 +6,27 @@ module Gitlab module Chain class Metrics < Chain::Base def perform! - counter.increment(source: @pipeline.source) + increment_pipeline_created_counter + create_snowplow_event_for_pipeline_name end def break? false end - def counter - ::Gitlab::Ci::Pipeline::Metrics.pipelines_created_counter + def increment_pipeline_created_counter + ::Gitlab::Ci::Pipeline::Metrics.pipelines_created_counter.increment(source: @pipeline.source) + end + + def create_snowplow_event_for_pipeline_name + return unless @pipeline.pipeline_metadata&.name + + Gitlab::Tracking.event( + self.class.name, + 'create_pipeline_with_name', + project: @pipeline.project, + user: @pipeline.user, + namespace: @pipeline.project.namespace) end end end diff --git a/lib/gitlab/ci/reports/sbom/component.rb b/lib/gitlab/ci/reports/sbom/component.rb index 5188304f4ed..08307580987 100644 --- a/lib/gitlab/ci/reports/sbom/component.rb +++ b/lib/gitlab/ci/reports/sbom/component.rb @@ -31,8 +31,10 @@ module Gitlab end def supported_purl_type? + # the purl type is not required as per the spec: https://cyclonedx.org/docs/1.4/json/#components_items_purl return true unless purl + # however, if the purl type is provided, it _must be valid_ ::Enums::Sbom.purl_types.include?(purl.type.to_sym) end end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 66ffaf0922c..f267026ea44 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -7076,6 +7076,12 @@ msgstr "" msgid "BranchRules|%{total} status %{subject}" msgstr "" +msgid "BranchRules|Add branch rule" +msgstr "" + +msgid "BranchRules|After a protected branch is created, it will show up in the list as a branch rule." +msgstr "" + msgid "BranchRules|All branches" msgstr "" @@ -7121,6 +7127,9 @@ msgstr "" msgid "BranchRules|Check for a status response in merge requests. Failures do not block merges. %{linkStart}Learn more.%{linkEnd}" msgstr "" +msgid "BranchRules|Create protected branch" +msgstr "" + msgid "BranchRules|Create wildcard: %{searchTerm}" msgstr "" @@ -7184,6 +7193,9 @@ msgstr "" msgid "BranchRules|Target branch" msgstr "" +msgid "BranchRules|To create a branch rule, you first need to create a protected branch." +msgstr "" + msgid "BranchRules|Users" msgstr "" @@ -34316,6 +34328,9 @@ msgstr "" msgid "ProtectedBranch|%{wildcards_link_start}Wildcards%{wildcards_link_end} such as %{code_tag_start}*-stable%{code_tag_end} or %{code_tag_start}production/*%{code_tag_end} are supported." msgstr "" +msgid "ProtectedBranch|After you configure a protected branch, merge request approval, or status check, it appears here." +msgstr "" + msgid "ProtectedBranch|Allow all users with push access to %{tag_start}force push%{tag_end}." msgstr "" @@ -34400,9 +34415,6 @@ msgstr "" msgid "ProtectedBranch|Protected branches" msgstr "" -msgid "ProtectedBranch|Protected branches, merge request approvals, and status checks will appear here once configured." -msgstr "" - msgid "ProtectedBranch|Protected tags (%{tags_count})" msgstr "" @@ -46520,6 +46532,9 @@ msgstr "" msgid "ValueStreamAnalytics|Value stream" msgstr "" +msgid "ValueStreamAnalytics|View details" +msgstr "" + msgid "ValueStreamEvent|Items in stage" msgstr "" diff --git a/spec/config/settings_spec.rb b/spec/config/settings_spec.rb index 4917b043812..1aa1d885be3 100644 --- a/spec/config/settings_spec.rb +++ b/spec/config/settings_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' RSpec.describe Settings, feature_category: :authentication_and_authorization do + using RSpec::Parameterized::TableSyntax + describe 'omniauth' do it 'defaults to enabled' do expect(described_class.omniauth.enabled).to be true @@ -15,6 +17,32 @@ RSpec.describe Settings, feature_category: :authentication_and_authorization do end end + describe '.build_ci_component_fqdn' do + subject(:fqdn) { described_class.build_ci_component_fqdn } + + where(:host, :port, :relative_url, :result) do + 'acme.com' | 9090 | '/gitlab' | 'acme.com:9090/gitlab/' + 'acme.com' | 443 | '/gitlab' | 'acme.com/gitlab/' + 'acme.com' | 443 | '' | 'acme.com/' + 'acme.com' | 9090 | '' | 'acme.com:9090/' + 'test' | 9090 | '' | 'test:9090/' + end + + with_them do + before do + allow(Gitlab.config).to receive(:gitlab).and_return( + Settingslogic.new({ + 'host' => host, + 'https' => true, + 'port' => port, + 'relative_url_root' => relative_url + })) + end + + it { is_expected.to eq(result) } + end + end + describe '.attr_encrypted_db_key_base_truncated' do it 'is a string with maximum 32 bytes size' do expect(described_class.attr_encrypted_db_key_base_truncated.bytesize) diff --git a/spec/frontend/analytics/shared/components/metric_popover_spec.js b/spec/frontend/analytics/shared/components/metric_popover_spec.js index 6a58f8c6d29..e0bfff3e664 100644 --- a/spec/frontend/analytics/shared/components/metric_popover_spec.js +++ b/spec/frontend/analytics/shared/components/metric_popover_spec.js @@ -1,6 +1,7 @@ import { GlLink, GlIcon } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import MetricPopover from '~/analytics/shared/components/metric_popover.vue'; +import { METRIC_POPOVER_LABEL } from '~/analytics/shared/constants'; const MOCK_METRIC = { key: 'deployment-frequency', @@ -27,10 +28,11 @@ describe('MetricPopover', () => { }; const findMetricLabel = () => wrapper.findByTestId('metric-label'); - const findAllMetricLinks = () => wrapper.findAll('[data-testid="metric-link"]'); + const findMetricLink = () => wrapper.find('[data-testid="metric-link"]'); const findMetricDescription = () => wrapper.findByTestId('metric-description'); const findMetricDocsLink = () => wrapper.findByTestId('metric-docs-link'); const findMetricDocsLinkIcon = () => findMetricDocsLink().findComponent(GlIcon); + const findMetricDetailsIcon = () => findMetricLink().findComponent(GlIcon); afterEach(() => { wrapper.destroy(); @@ -47,17 +49,14 @@ describe('MetricPopover', () => { }); describe('with links', () => { + const METRIC_NAME = 'Deployment frequency'; + const LINK_URL = '/groups/gitlab-org/-/analytics/ci_cd?tab=deployment-frequency'; const links = [ { - name: 'Deployment frequency', - url: '/groups/gitlab-org/-/analytics/ci_cd?tab=deployment-frequency', + name: METRIC_NAME, + url: LINK_URL, label: 'Dashboard', }, - { - name: 'Another link', - url: '/groups/gitlab-org/-/analytics/another-link', - label: 'Another link', - }, ]; const docsLink = { name: 'Deployment frequency', @@ -68,37 +67,34 @@ describe('MetricPopover', () => { const linksWithDocs = [...links, docsLink]; describe.each` - hasDocsLink | allLinks | displayedMetricLinks - ${true} | ${linksWithDocs} | ${links} - ${false} | ${links} | ${links} - `( - 'when one link has docs_link=$hasDocsLink', - ({ hasDocsLink, allLinks, displayedMetricLinks }) => { - beforeEach(() => { - wrapper = createComponent({ metric: { ...MOCK_METRIC, links: allLinks } }); - }); + hasDocsLink | allLinks + ${true} | ${linksWithDocs} + ${false} | ${links} + `('when one link has docs_link=$hasDocsLink', ({ hasDocsLink, allLinks }) => { + beforeEach(() => { + wrapper = createComponent({ metric: { ...MOCK_METRIC, links: allLinks } }); + }); - displayedMetricLinks.forEach((link, idx) => { - it(`renders a link for "${link.name}"`, () => { - const allLinkContainers = findAllMetricLinks(); + describe('Metric title row', () => { + it(`renders a link for "${METRIC_NAME}"`, () => { + expect(findMetricLink().text()).toContain(METRIC_POPOVER_LABEL); + expect(findMetricLink().findComponent(GlLink).attributes('href')).toBe(LINK_URL); + }); - expect(allLinkContainers.at(idx).text()).toContain(link.name); - expect(allLinkContainers.at(idx).findComponent(GlLink).attributes('href')).toBe( - link.url, - ); - }); + it('renders the chart icon', () => { + expect(findMetricDetailsIcon().attributes('name')).toBe('chart'); }); + }); - it(`${hasDocsLink ? 'renders' : "doesn't render"} a docs link`, () => { - expect(findMetricDocsLink().exists()).toBe(hasDocsLink); + it(`${hasDocsLink ? 'renders' : "doesn't render"} a docs link`, () => { + expect(findMetricDocsLink().exists()).toBe(hasDocsLink); - if (hasDocsLink) { - expect(findMetricDocsLink().attributes('href')).toBe(docsLink.url); - expect(findMetricDocsLink().text()).toBe(docsLink.label); - expect(findMetricDocsLinkIcon().attributes('name')).toBe('external-link'); - } - }); - }, - ); + if (hasDocsLink) { + expect(findMetricDocsLink().attributes('href')).toBe(docsLink.url); + expect(findMetricDocsLink().text()).toBe(docsLink.label); + expect(findMetricDocsLinkIcon().attributes('name')).toBe('external-link'); + } + }); + }); }); }); diff --git a/spec/frontend/projects/settings/repository/branch_rules/app_spec.js b/spec/frontend/projects/settings/repository/branch_rules/app_spec.js index 447d7e86ceb..56b39f04580 100644 --- a/spec/frontend/projects/settings/repository/branch_rules/app_spec.js +++ b/spec/frontend/projects/settings/repository/branch_rules/app_spec.js @@ -1,9 +1,10 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import { GlModal } from '@gitlab/ui'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { mountExtended } from 'helpers/vue_test_utils_helper'; -import BranchRules, { i18n } from '~/projects/settings/repository/branch_rules/app.vue'; +import BranchRules from '~/projects/settings/repository/branch_rules/app.vue'; import BranchRule from '~/projects/settings/repository/branch_rules/components/branch_rule.vue'; import branchRulesQuery from 'ee_else_ce/projects/settings/repository/branch_rules/graphql/queries/branch_rules.query.graphql'; import { createAlert } from '~/flash'; @@ -11,8 +12,19 @@ import { branchRulesMockResponse, appProvideMock, } from 'ee_else_ce_jest/projects/settings/repository/branch_rules/mock_data'; +import { + I18N, + BRANCH_PROTECTION_MODAL_ID, + PROTECTED_BRANCHES_ANCHOR, +} from '~/projects/settings/repository/branch_rules/constants'; +import { stubComponent, RENDER_ALL_SLOTS_TEMPLATE } from 'helpers/stub_component'; +import { expandSection } from '~/settings_panels'; +import { scrollToElement } from '~/lib/utils/common_utils'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; jest.mock('~/flash'); +jest.mock('~/settings_panels'); +jest.mock('~/lib/utils/common_utils'); Vue.use(VueApollo); @@ -28,6 +40,8 @@ describe('Branch rules app', () => { wrapper = mountExtended(BranchRules, { apolloProvider: fakeApollo, provide: appProvideMock, + stubs: { GlModal: stubComponent(GlModal, { template: RENDER_ALL_SLOTS_TEMPLATE }) }, + directives: { GlModal: createMockDirective() }, }); await waitForPromises(); @@ -35,17 +49,19 @@ describe('Branch rules app', () => { const findAllBranchRules = () => wrapper.findAllComponents(BranchRule); const findEmptyState = () => wrapper.findByTestId('empty'); + const findAddBranchRuleButton = () => wrapper.findByRole('button', I18N.addBranchRule); + const findModal = () => wrapper.findComponent(GlModal); beforeEach(() => createComponent()); it('displays an error if branch rules query fails', async () => { await createComponent({ queryHandler: jest.fn().mockRejectedValue() }); - expect(createAlert).toHaveBeenCalledWith({ message: i18n.queryError }); + expect(createAlert).toHaveBeenCalledWith({ message: I18N.queryError }); }); it('displays an empty state if no branch rules are present', async () => { await createComponent({ queryHandler: jest.fn().mockRejectedValue() }); - expect(findEmptyState().text()).toBe(i18n.emptyState); + expect(findEmptyState().text()).toBe(I18N.emptyState); }); it('renders branch rules', () => { @@ -61,4 +77,38 @@ describe('Branch rules app', () => { expect(findAllBranchRules().at(1).props('branchProtection')).toEqual(nodes[1].branchProtection); }); + + describe('Add branch rule', () => { + it('renders an Add branch rule button', () => { + expect(findAddBranchRuleButton().exists()).toBe(true); + }); + + it('renders a modal with correct props/attributes', () => { + expect(findModal().props()).toMatchObject({ + modalId: BRANCH_PROTECTION_MODAL_ID, + title: I18N.addBranchRule, + }); + + expect(findModal().attributes('ok-title')).toBe(I18N.createProtectedBranch); + }); + + it('renders correct modal id for the default action', () => { + const binding = getBinding(findAddBranchRuleButton().element, 'gl-modal'); + + expect(binding.value).toBe(BRANCH_PROTECTION_MODAL_ID); + }); + + it('renders the correct modal content', () => { + expect(findModal().text()).toContain(I18N.branchRuleModalDescription); + expect(findModal().text()).toContain(I18N.branchRuleModalContent); + }); + + it('when the primary modal action is clicked, takes user to the correct location', () => { + findAddBranchRuleButton().trigger('click'); + findModal().vm.$emit('ok'); + + expect(expandSection).toHaveBeenCalledWith(PROTECTED_BRANCHES_ANCHOR); + expect(scrollToElement).toHaveBeenCalledWith(PROTECTED_BRANCHES_ANCHOR); + }); + }); }); diff --git a/spec/lib/gitlab/ci/build/auto_retry_spec.rb b/spec/lib/gitlab/ci/build/auto_retry_spec.rb index d69b6679e30..314714c543b 100644 --- a/spec/lib/gitlab/ci/build/auto_retry_spec.rb +++ b/spec/lib/gitlab/ci/build/auto_retry_spec.rb @@ -112,5 +112,13 @@ RSpec.describe Gitlab::Ci::Build::AutoRetry, feature_category: :pipeline_authori expect(result).to eq ['always'] end end + + context 'with retry[:when] set to nil' do + let(:build) { create(:ci_build, options: { retry: { when: nil } }) } + + it 'returns always array' do + expect(result).to eq ['always'] + end + end end end diff --git a/spec/lib/gitlab/ci/components/instance_path_spec.rb b/spec/lib/gitlab/ci/components/instance_path_spec.rb new file mode 100644 index 00000000000..d9beae0555c --- /dev/null +++ b/spec/lib/gitlab/ci/components/instance_path_spec.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::Components::InstancePath, feature_category: :pipeline_authoring do + let_it_be(:user) { create(:user) } + + let(:path) { described_class.new(address: address, content_filename: 'template.yml') } + let(:settings) { Settingslogic.new({ 'component_fqdn' => current_host }) } + let(:current_host) { 'acme.com/' } + + before do + allow(::Settings).to receive(:gitlab_ci).and_return(settings) + end + + describe 'FQDN path' do + let_it_be(:existing_project) { create(:project, :repository) } + + let(:project_path) { existing_project.full_path } + let(:address) { "acme.com/#{project_path}/component@#{version}" } + let(:version) { 'master' } + + context 'when project exists' do + it 'provides the expected attributes', :aggregate_failures do + expect(path.project).to eq(existing_project) + expect(path.host).to eq(current_host) + expect(path.sha).to eq(existing_project.commit('master').id) + expect(path.project_file_path).to eq('component/template.yml') + end + + context 'when content exists' do + let(:content) { 'image: alpine' } + + before do + allow_next_instance_of(Repository) do |instance| + allow(instance) + .to receive(:blob_data_at) + .with(existing_project.commit('master').id, 'component/template.yml') + .and_return(content) + end + end + + context 'when user has permissions to read code' do + before do + existing_project.add_developer(user) + end + + it 'fetches the content' do + expect(path.fetch_content!(current_user: user)).to eq(content) + end + end + + context 'when user does not have permissions to download code' do + it 'raises an error when fetching the content' do + expect { path.fetch_content!(current_user: user) } + .to raise_error(Gitlab::Access::AccessDeniedError) + end + end + end + end + + context 'when project path is nested under a subgroup' do + let(:existing_group) { create(:group, :nested) } + let(:existing_project) { create(:project, :repository, group: existing_group) } + + it 'provides the expected attributes', :aggregate_failures do + expect(path.project).to eq(existing_project) + expect(path.host).to eq(current_host) + expect(path.sha).to eq(existing_project.commit('master').id) + expect(path.project_file_path).to eq('component/template.yml') + end + end + + context 'when current GitLab instance is installed on a relative URL' do + let(:address) { "acme.com/gitlab/#{project_path}/component@#{version}" } + let(:current_host) { 'acme.com/gitlab/' } + + it 'provides the expected attributes', :aggregate_failures do + expect(path.project).to eq(existing_project) + expect(path.host).to eq(current_host) + expect(path.sha).to eq(existing_project.commit('master').id) + expect(path.project_file_path).to eq('component/template.yml') + end + end + + context 'when version does not exist' do + let(:version) { 'non-existent' } + + it 'provides the expected attributes', :aggregate_failures do + expect(path.project).to eq(existing_project) + expect(path.host).to eq(current_host) + expect(path.sha).to be_nil + expect(path.project_file_path).to eq('component/template.yml') + end + + it 'returns nil when fetching the content' do + expect(path.fetch_content!(current_user: user)).to be_nil + end + end + + context 'when project does not exist' do + let(:project_path) { 'non-existent/project' } + + it 'provides the expected attributes', :aggregate_failures do + expect(path.project).to be_nil + expect(path.host).to eq(current_host) + expect(path.sha).to be_nil + expect(path.project_file_path).to be_nil + end + + it 'returns nil when fetching the content' do + expect(path.fetch_content!(current_user: user)).to be_nil + end + end + end +end diff --git a/spec/lib/gitlab/ci/config/entry/include_spec.rb b/spec/lib/gitlab/ci/config/entry/include_spec.rb index fd7f85c9298..5eecff5b592 100644 --- a/spec/lib/gitlab/ci/config/entry/include_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/include_spec.rb @@ -44,6 +44,12 @@ RSpec.describe ::Gitlab::Ci::Config::Entry::Include do it { is_expected.to be_valid } end + context 'when using "component"' do + let(:config) { { component: 'path/to/component@1.0' } } + + it { is_expected.to be_valid } + end + context 'when using "artifact"' do context 'and specifying "job"' do let(:config) { { artifact: 'test.yml', job: 'generator' } } diff --git a/spec/lib/gitlab/ci/config/external/file/component_spec.rb b/spec/lib/gitlab/ci/config/external/file/component_spec.rb new file mode 100644 index 00000000000..9863941c370 --- /dev/null +++ b/spec/lib/gitlab/ci/config/external/file/component_spec.rb @@ -0,0 +1,179 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::Config::External::File::Component, feature_category: :pipeline_authoring do + let_it_be(:context_project) { create(:project, :repository) } + let_it_be(:project) { create(:project, :repository) } + let_it_be(:user) { create(:user) } + let_it_be(:project_variables) { project.predefined_variables } + + let(:context) { Gitlab::Ci::Config::External::Context.new(**context_params) } + let(:external_resource) { described_class.new(params, context) } + let(:params) { { component: 'gitlab.com/acme/components/my-component@1.0' } } + let(:fetch_service) { instance_double(::Ci::Components::FetchService) } + let(:response) { ServiceResponse.error(message: 'some error message') } + + let(:context_params) do + { + project: context_project, + sha: '12345', + user: user, + variables: project_variables + } + end + + before do + allow(::Ci::Components::FetchService) + .to receive(:new) + .with( + address: params[:component], + current_user: context.user + ).and_return(fetch_service) + + allow(fetch_service).to receive(:execute).and_return(response) + end + + describe '#matching?' do + subject(:matching) { external_resource.matching? } + + context 'when component is specified' do + let(:params) { { component: 'some-value' } } + + it { is_expected.to be_truthy } + + context 'when feature flag ci_include_components is disabled' do + before do + stub_feature_flags(ci_include_components: false) + end + + it { is_expected.to be_falsey } + end + end + + context 'when component is not specified' do + let(:params) { { local: 'some-value' } } + + it { is_expected.to be_falsy } + end + end + + describe '#valid?' do + subject(:valid?) do + external_resource.validate! + external_resource.valid? + end + + context 'when the context project does not have a repository' do + before do + allow(context_project).to receive(:repository).and_return(nil) + end + + it 'is invalid' do + expect(subject).to be_falsy + expect(external_resource.error_message).to eq('Unable to use components outside of a project context') + end + end + + context 'when location is not provided' do + let(:params) { { component: 123 } } + + it 'is invalid' do + expect(subject).to be_falsy + expect(external_resource.error_message).to eq('Included file `123` needs to be a string') + end + end + + context 'when component path is provided' do + context 'when component is not found' do + let(:response) do + ServiceResponse.error(message: 'Content not found') + end + + it 'is invalid' do + expect(subject).to be_falsy + expect(external_resource.error_message).to eq('Content not found') + end + end + + context 'when component is found' do + let(:content) do + <<~COMPONENT + job: + script: echo + COMPONENT + end + + let(:response) do + ServiceResponse.success(payload: { + content: content, + path: instance_double(::Gitlab::Ci::Components::InstancePath, project: project, sha: '12345') + }) + end + + it 'is valid' do + expect(subject).to be_truthy + expect(external_resource.content).to eq(content) + end + + context 'when content is not a valid YAML' do + let(:content) { 'the-content' } + + it 'is invalid' do + expect(subject).to be_falsy + expect(external_resource.error_message).to match(/does not have valid YAML syntax/) + end + end + end + end + end + + describe '#metadata' do + subject(:metadata) { external_resource.metadata } + + let(:component_path) do + instance_double(::Gitlab::Ci::Components::InstancePath, + project: project, + sha: '12345', + project_file_path: 'my-component/template.yml') + end + + let(:response) do + ServiceResponse.success(payload: { path: component_path }) + end + + it 'returns the metadata' do + is_expected.to include( + context_project: context_project.full_path, + context_sha: context.sha, + type: :component, + location: 'gitlab.com/acme/components/my-component@1.0', + blob: a_string_ending_with("#{project.full_path}/-/blob/12345/my-component/template.yml"), + raw: nil, + extra: {} + ) + end + end + + describe '#expand_context' do + let(:component_path) do + instance_double(::Gitlab::Ci::Components::InstancePath, + project: project, + sha: '12345') + end + + let(:response) do + ServiceResponse.success(payload: { path: component_path }) + end + + subject { external_resource.send(:expand_context_attrs) } + + it 'inherits user and variables while changes project and sha' do + is_expected.to include( + project: project, + sha: '12345', + user: context.user, + variables: context.variables) + end + end +end diff --git a/spec/lib/gitlab/ci/config/external/mapper/matcher_spec.rb b/spec/lib/gitlab/ci/config/external/mapper/matcher_spec.rb index 5f321a696c9..11c79e19cff 100644 --- a/spec/lib/gitlab/ci/config/external/mapper/matcher_spec.rb +++ b/spec/lib/gitlab/ci/config/external/mapper/matcher_spec.rb @@ -17,11 +17,14 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper::Matcher, feature_category: describe '#process' do let(:locations) do - [{ local: 'file.yml' }, - { file: 'file.yml', project: 'namespace/project' }, - { remote: 'https://example.com/.gitlab-ci.yml' }, - { template: 'file.yml' }, - { artifact: 'generated.yml', job: 'test' }] + [ + { local: 'file.yml' }, + { file: 'file.yml', project: 'namespace/project' }, + { component: 'gitlab.com/org/component@1.0' }, + { remote: 'https://example.com/.gitlab-ci.yml' }, + { template: 'file.yml' }, + { artifact: 'generated.yml', job: 'test' } + ] end subject(:process) { matcher.process(locations) } @@ -30,6 +33,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper::Matcher, feature_category: is_expected.to contain_exactly( an_instance_of(Gitlab::Ci::Config::External::File::Local), an_instance_of(Gitlab::Ci::Config::External::File::Project), + an_instance_of(Gitlab::Ci::Config::External::File::Component), an_instance_of(Gitlab::Ci::Config::External::File::Remote), an_instance_of(Gitlab::Ci::Config::External::File::Template), an_instance_of(Gitlab::Ci::Config::External::File::Artifact) @@ -42,8 +46,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper::Matcher, feature_category: it 'raises an error' do expect { process }.to raise_error( Gitlab::Ci::Config::External::Mapper::AmbigiousSpecificationError, - '`{"invalid":"file.yml"}` does not have a valid subkey for include. ' \ - 'Valid subkeys are: `local`, `project`, `remote`, `template`, `artifact`' + /`{"invalid":"file.yml"}` does not have a valid subkey for include. Valid subkeys are:/ ) end @@ -53,8 +56,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper::Matcher, feature_category: it 'raises an error with a masked sentence' do expect { process }.to raise_error( Gitlab::Ci::Config::External::Mapper::AmbigiousSpecificationError, - '`{"invalid":"xxxxxxxxxxxxxx.yml"}` does not have a valid subkey for include. ' \ - 'Valid subkeys are: `local`, `project`, `remote`, `template`, `artifact`' + /`{"invalid":"xxxxxxxxxxxxxx.yml"}` does not have a valid subkey for include. Valid subkeys are:/ ) end end @@ -66,7 +68,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper::Matcher, feature_category: it 'raises an error' do expect { process }.to raise_error( Gitlab::Ci::Config::External::Mapper::AmbigiousSpecificationError, - "Each include must use only one of: `local`, `project`, `remote`, `template`, `artifact`" + /Each include must use only one of:/ ) end end diff --git a/spec/lib/gitlab/ci/config/external/mapper_spec.rb b/spec/lib/gitlab/ci/config/external/mapper_spec.rb index 344e9095fab..a053c3047de 100644 --- a/spec/lib/gitlab/ci/config/external/mapper_spec.rb +++ b/spec/lib/gitlab/ci/config/external/mapper_spec.rb @@ -124,7 +124,7 @@ RSpec.shared_context 'gitlab_ci_config_external_mapper' do |ci_batch_request_for end it 'returns ambigious specification error' do - expect { subject }.to raise_error(described_class::AmbigiousSpecificationError, '`{"invalid":"secret-file.yml"}` does not have a valid subkey for include. Valid subkeys are: `local`, `project`, `remote`, `template`, `artifact`') + expect { subject }.to raise_error(described_class::AmbigiousSpecificationError, /`{"invalid":"secret-file.yml"}` does not have a valid subkey for include. Valid subkeys are:/) end end @@ -138,7 +138,7 @@ RSpec.shared_context 'gitlab_ci_config_external_mapper' do |ci_batch_request_for end it 'returns ambigious specification error' do - expect { subject }.to raise_error(described_class::AmbigiousSpecificationError, 'Each include must use only one of: `local`, `project`, `remote`, `template`, `artifact`') + expect { subject }.to raise_error(described_class::AmbigiousSpecificationError, /Each include must use only one of/) end end diff --git a/spec/lib/gitlab/ci/config/external/processor_spec.rb b/spec/lib/gitlab/ci/config/external/processor_spec.rb index 311b433b7d2..bb65c2ef10c 100644 --- a/spec/lib/gitlab/ci/config/external/processor_spec.rb +++ b/spec/lib/gitlab/ci/config/external/processor_spec.rb @@ -400,6 +400,44 @@ RSpec.describe Gitlab::Ci::Config::External::Processor, feature_category: :pipel end end + describe 'include:component' do + let(:values) do + { + include: { component: "#{Gitlab.config.gitlab.host}/#{another_project.full_path}/component-x@master" }, + image: 'image:1.0' + } + end + + let(:other_project_files) do + { + '/component-x/template.yml' => <<~YAML + component_x_job: + script: echo Component X + YAML + } + end + + before do + another_project.add_developer(user) + end + + it 'appends the file to the values' do + output = processor.perform + expect(output.keys).to match_array([:image, :component_x_job]) + end + + context 'when feature flag ci_include_components is disabled' do + before do + stub_feature_flags(ci_include_components: false) + end + + it 'returns an error' do + expect { processor.perform } + .to raise_error(described_class::IncludeError, /does not have a valid subkey for include./) + end + end + end + context 'when a valid project file is defined' do let(:values) do { diff --git a/spec/lib/gitlab/ci/pipeline/chain/metrics_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/metrics_spec.rb new file mode 100644 index 00000000000..b955d0e7cee --- /dev/null +++ b/spec/lib/gitlab/ci/pipeline/chain/metrics_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::Pipeline::Chain::Metrics, feature_category: :continuous_integration do + let_it_be(:project) { create(:project) } + let_it_be(:user) { create(:user) } + + let_it_be(:pipeline) do + create(:ci_pipeline, project: project, ref: 'master', user: user, name: 'Build pipeline') + end + + let(:command) do + Gitlab::Ci::Pipeline::Chain::Command.new( + project: project, + current_user: user, + origin_ref: 'master') + end + + let(:step) { described_class.new(pipeline, command) } + + subject(:run_chain) { step.perform! } + + it 'does not break the chain' do + run_chain + + expect(step.break?).to be false + end + + context 'with pipeline name' do + it 'creates snowplow event' do + run_chain + + expect_snowplow_event( + category: described_class.to_s, + action: 'create_pipeline_with_name', + project: pipeline.project, + user: pipeline.user, + namespace: pipeline.project.namespace + ) + end + end + + context 'without pipeline name' do + let_it_be(:pipeline) do + create(:ci_pipeline, project: project, ref: 'master', user: user) + end + + it 'does not create snowplow event' do + run_chain + + expect_no_snowplow_event + end + end +end diff --git a/spec/models/ci/job_artifact_spec.rb b/spec/models/ci/job_artifact_spec.rb index a1fd51f60ea..917c0d33183 100644 --- a/spec/models/ci/job_artifact_spec.rb +++ b/spec/models/ci/job_artifact_spec.rb @@ -27,6 +27,14 @@ RSpec.describe Ci::JobArtifact do subject { build(:ci_job_artifact, :archive, job: job, size: 107464) } end + describe 'after_destroy callback' do + it 'logs the job artifact destroy' do + expect(Gitlab::Ci::Artifacts::Logger).to receive(:log_deleted).with(artifact, :log_destroy) + + artifact.destroy! + end + end + describe '.not_expired' do it 'returns artifacts that have not expired' do _expired_artifact = create(:ci_job_artifact, :expired) diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb index 0d53ebdefe9..23de9a50e50 100644 --- a/spec/models/environment_spec.rb +++ b/spec/models/environment_spec.rb @@ -62,6 +62,33 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching, feature_categ expect(environment).not_to be_valid end end + + context 'tier' do + let!(:env) { build(:environment, tier: nil) } + + before do + # Disable `before_validation: :ensure_environment_tier` since it always set tier and interfere with tests. + # See: https://github.com/thoughtbot/shoulda/issues/178#issuecomment-1654014 + + allow_any_instance_of(described_class).to receive(:ensure_environment_tier).and_return(env) + end + + context 'presence is checked' do + it 'during create and update' do + expect(env).to validate_presence_of(:tier).on(:create) + expect(env).to validate_presence_of(:tier).on(:update) + end + end + + context 'when FF is disabled' do + before do + stub_feature_flags(validate_environment_tier_presence: false) + end + + it { expect(env).to validate_presence_of(:tier).on(:create) } + it { expect(env).not_to validate_presence_of(:tier).on(:update) } + end + end end describe 'preloading deployment associations' do @@ -145,7 +172,7 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching, feature_categ environment = create(:environment, name: 'gprd') environment.update_column(:tier, nil) - expect { environment.stop! }.to change { environment.reload.tier }.from(nil).to('production') + expect { environment.save! }.to change { environment.reload.tier }.from(nil).to('production') end it 'does not overwrite the existing environment tier' do diff --git a/spec/services/ci/components/fetch_service_spec.rb b/spec/services/ci/components/fetch_service_spec.rb new file mode 100644 index 00000000000..f2eaa8d31b4 --- /dev/null +++ b/spec/services/ci/components/fetch_service_spec.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::Components::FetchService, feature_category: :pipeline_authoring do + let_it_be(:project) { create(:project, :repository, create_tag: 'v1.0') } + let_it_be(:user) { create(:user) } + let_it_be(:current_user) { user } + let_it_be(:current_host) { Gitlab.config.gitlab.host } + + let(:service) do + described_class.new(address: address, current_user: current_user) + end + + before do + project.add_developer(user) + end + + describe '#execute', :aggregate_failures do + subject(:result) { service.execute } + + shared_examples 'an external component' do + shared_examples 'component address' do + context 'when content exists' do + let(:sha) { project.commit(version).id } + + let(:content) do + <<~COMPONENT + job: + script: echo + COMPONENT + end + + before do + stub_project_blob(sha, component_yaml_path, content) + end + + it 'returns the content' do + expect(result).to be_success + expect(result.payload[:content]).to eq(content) + end + end + + context 'when content does not exist' do + it 'returns an error' do + expect(result).to be_error + expect(result.reason).to eq(:content_not_found) + end + end + end + + context 'when user does not have permissions to read the code' do + let(:version) { 'master' } + let(:current_user) { create(:user) } + + it 'returns an error' do + expect(result).to be_error + expect(result.reason).to eq(:not_allowed) + end + end + + context 'when version is a branch name' do + it_behaves_like 'component address' do + let(:version) { project.default_branch } + end + end + + context 'when version is a tag name' do + it_behaves_like 'component address' do + let(:version) { project.repository.tags.first.name } + end + end + + context 'when version is a commit sha' do + it_behaves_like 'component address' do + let(:version) { project.repository.tags.first.id } + end + end + + context 'when version is not provided' do + let(:version) { nil } + + it 'returns an error' do + expect(result).to be_error + expect(result.reason).to eq(:content_not_found) + end + end + + context 'when project does not exist' do + let(:component_path) { 'unknown/component' } + let(:version) { '1.0' } + + it 'returns an error' do + expect(result).to be_error + expect(result.reason).to eq(:content_not_found) + end + end + + context 'when host is different than the current instance host' do + let(:current_host) { 'another-host.com' } + let(:version) { '1.0' } + + it 'returns an error' do + expect(result).to be_error + expect(result.reason).to eq(:unsupported_path) + end + end + end + + context 'when address points to an external component' do + let(:address) { "#{current_host}/#{component_path}@#{version}" } + + context 'when component path is the full path to a project' do + let(:component_path) { project.full_path } + let(:component_yaml_path) { 'template.yml' } + + it_behaves_like 'an external component' + end + + context 'when component path points to a directory in a project' do + let(:component_path) { "#{project.full_path}/my-component" } + let(:component_yaml_path) { 'my-component/template.yml' } + + it_behaves_like 'an external component' + end + + context 'when component path points to a nested directory in a project' do + let(:component_path) { "#{project.full_path}/my-dir/my-component" } + let(:component_yaml_path) { 'my-dir/my-component/template.yml' } + + it_behaves_like 'an external component' + end + end + end + + def stub_project_blob(ref, path, content) + allow_next_instance_of(Repository) do |instance| + allow(instance).to receive(:blob_data_at).with(ref, path).and_return(content) + end + end +end |