diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-09-17 21:11:44 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-09-17 21:11:44 +0300 |
commit | 4e582508047c7f5feedeba627461298e584e99dc (patch) | |
tree | 310eff30567463d986547a2d19ed31a3e657f900 | |
parent | 3b9468e8b913d43f45e472b69b98c9da05f338bb (diff) |
Add latest changes from gitlab-org/gitlab@master
35 files changed, 694 insertions, 58 deletions
diff --git a/app/assets/javascripts/cycle_analytics/store/actions.js b/app/assets/javascripts/cycle_analytics/store/actions.js index 5aa86d24dd2..e39cd224199 100644 --- a/app/assets/javascripts/cycle_analytics/store/actions.js +++ b/app/assets/javascripts/cycle_analytics/store/actions.js @@ -9,7 +9,6 @@ import { import createFlash from '~/flash'; import { __ } from '~/locale'; import { DEFAULT_VALUE_STREAM, I18N_VSA_ERROR_STAGE_MEDIAN } from '../constants'; -import { appendExtension } from '../utils'; import * as types from './mutation_types'; export const setSelectedValueStream = ({ commit, dispatch }, valueStream) => { @@ -184,8 +183,8 @@ export const initializeVsa = ({ commit, dispatch }, initialData = {}) => { endpoints: { fullPath, groupPath, milestonesPath = '', labelsPath = '' }, } = initialData; dispatch('filters/setEndpoints', { - labelsEndpoint: appendExtension(labelsPath), - milestonesEndpoint: appendExtension(milestonesPath), + labelsEndpoint: labelsPath, + milestonesEndpoint: milestonesPath, groupEndpoint: groupPath, projectEndpoint: fullPath, }); diff --git a/app/assets/javascripts/cycle_analytics/utils.js b/app/assets/javascripts/cycle_analytics/utils.js index 888ef68e69a..fa02fdf914a 100644 --- a/app/assets/javascripts/cycle_analytics/utils.js +++ b/app/assets/javascripts/cycle_analytics/utils.js @@ -149,5 +149,3 @@ export const prepareTimeMetricsData = (data = [], popoverContent = {}) => description: popoverContent[key]?.description || '', }; }); - -export const appendExtension = (path) => (path.indexOf('.') > -1 ? path : `${path}.json`); diff --git a/app/assets/javascripts/tracking/constants.js b/app/assets/javascripts/tracking/constants.js index 598111e4086..062a3404355 100644 --- a/app/assets/javascripts/tracking/constants.js +++ b/app/assets/javascripts/tracking/constants.js @@ -24,3 +24,7 @@ export const LOAD_ACTION_ATTR_SELECTOR = '[data-track-action="render"]'; export const DEPRECATED_EVENT_ATTR_SELECTOR = '[data-track-event]'; export const DEPRECATED_LOAD_EVENT_ATTR_SELECTOR = '[data-track-event="render"]'; + +export const URLS_CACHE_STORAGE_KEY = 'gl-snowplow-pseudonymized-urls'; + +export const REFERRER_TTL = 24 * 60 * 60 * 1000; diff --git a/app/assets/javascripts/tracking/index.js b/app/assets/javascripts/tracking/index.js index b7240bc6d6d..7e99ecb4f4e 100644 --- a/app/assets/javascripts/tracking/index.js +++ b/app/assets/javascripts/tracking/index.js @@ -39,6 +39,9 @@ export function initDefaultTrackers() { const opts = { ...DEFAULT_SNOWPLOW_OPTIONS, ...window.snowplowOptions }; + // must be before initializing the trackers + Tracking.setAnonymousUrls(); + window.snowplow('enableActivityTracking', 30, 30); // must be after enableActivityTracking const standardContext = getStandardContext(); diff --git a/app/assets/javascripts/tracking/tracking.js b/app/assets/javascripts/tracking/tracking.js index a1f745bc172..657e0a79911 100644 --- a/app/assets/javascripts/tracking/tracking.js +++ b/app/assets/javascripts/tracking/tracking.js @@ -1,7 +1,14 @@ import { LOAD_ACTION_ATTR_SELECTOR, DEPRECATED_LOAD_EVENT_ATTR_SELECTOR } from './constants'; import { dispatchSnowplowEvent } from './dispatch_snowplow_event'; import getStandardContext from './get_standard_context'; -import { getEventHandlers, createEventPayload, renameKey, addExperimentContext } from './utils'; +import { + getEventHandlers, + createEventPayload, + renameKey, + addExperimentContext, + getReferrersCache, + addReferrersCacheEntry, +} from './utils'; export default class Tracking { static queuedEvents = []; @@ -159,6 +166,37 @@ export default class Tracking { } /** + * Replaces the URL and referrer for the default web context + * if the replacements are available. + * + * @returns {undefined} + */ + static setAnonymousUrls() { + const { snowplowPseudonymizedPageUrl: pageUrl } = window.gl; + + if (!pageUrl) { + return; + } + + const referrers = getReferrersCache(); + const pageLinks = Object.seal({ url: '', referrer: '', originalUrl: window.location.href }); + + pageLinks.url = `${pageUrl}${window.location.hash}`; + window.snowplow('setCustomUrl', pageLinks.url); + + if (document.referrer) { + const node = referrers.find((links) => links.originalUrl === document.referrer); + + if (node) { + pageLinks.referrer = node.url; + window.snowplow('setReferrerUrl', pageLinks.referrer); + } + } + + addReferrersCacheEntry(referrers, pageLinks); + } + + /** * Returns an implementation of this class in the form of * a Vue mixin. * diff --git a/app/assets/javascripts/tracking/utils.js b/app/assets/javascripts/tracking/utils.js index 1189b2168ad..3507872b511 100644 --- a/app/assets/javascripts/tracking/utils.js +++ b/app/assets/javascripts/tracking/utils.js @@ -6,6 +6,8 @@ import { LOAD_ACTION_ATTR_SELECTOR, DEPRECATED_EVENT_ATTR_SELECTOR, DEPRECATED_LOAD_EVENT_ATTR_SELECTOR, + URLS_CACHE_STORAGE_KEY, + REFERRER_TTL, } from './constants'; export const addExperimentContext = (opts) => { @@ -100,3 +102,25 @@ export const renameKey = (o, oldKey, newKey) => { return ret; }; + +export const filterOldReferrersCacheEntries = (cache) => { + const now = Date.now(); + + return cache.filter((entry) => entry.timestamp && entry.timestamp > now - REFERRER_TTL); +}; + +export const getReferrersCache = () => { + try { + const referrers = JSON.parse(window.localStorage.getItem(URLS_CACHE_STORAGE_KEY) || '[]'); + + return filterOldReferrersCacheEntries(referrers); + } catch { + return []; + } +}; + +export const addReferrersCacheEntry = (cache, entry) => { + const referrers = JSON.stringify([{ ...entry, timestamp: Date.now() }, ...cache]); + + window.localStorage.setItem(URLS_CACHE_STORAGE_KEY, referrers); +}; diff --git a/app/graphql/mutations/customer_relations/organizations/create.rb b/app/graphql/mutations/customer_relations/organizations/create.rb index 7e5324b21d1..3fa7b0327ca 100644 --- a/app/graphql/mutations/customer_relations/organizations/create.rb +++ b/app/graphql/mutations/customer_relations/organizations/create.rb @@ -38,15 +38,10 @@ module Mutations def resolve(args) group = authorized_find!(id: args[:group_id]) - raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled' unless Feature.enabled?(:customer_relations, group) + raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled' unless Feature.enabled?(:customer_relations, group, default_enabled: :yaml) result = ::CustomerRelations::Organizations::CreateService.new(group: group, current_user: current_user, params: args).execute - - if result.success? - { organization: result.payload } - else - { errors: result.errors } - end + { organization: result.payload, errors: result.errors } end def find_object(id:) diff --git a/app/graphql/mutations/customer_relations/organizations/update.rb b/app/graphql/mutations/customer_relations/organizations/update.rb new file mode 100644 index 00000000000..c6ae62193f9 --- /dev/null +++ b/app/graphql/mutations/customer_relations/organizations/update.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Mutations + module CustomerRelations + module Organizations + class Update < Mutations::BaseMutation + include ResolvesIds + + graphql_name 'CustomerRelationsOrganizationUpdate' + + authorize :admin_organization + + field :organization, + Types::CustomerRelations::OrganizationType, + null: false, + description: 'Organization after the mutation.' + + argument :id, ::Types::GlobalIDType[::CustomerRelations::Organization], + required: true, + description: 'Global ID of the organization.' + + argument :name, + GraphQL::Types::String, + required: false, + description: 'Name of the organization.' + + argument :default_rate, + GraphQL::Types::Float, + required: false, + description: 'Standard billing rate for the organization.' + + argument :description, + GraphQL::Types::String, + required: false, + description: 'Description or notes for the organization.' + + def resolve(args) + organization = ::Gitlab::Graphql::Lazy.force(GitlabSchema.object_from_id(args.delete(:id), expected_type: ::CustomerRelations::Organization)) + raise_resource_not_available_error! unless organization + + group = organization.group + raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled' unless Feature.enabled?(:customer_relations, group, default_enabled: :yaml) + + authorize!(group) + + result = ::CustomerRelations::Organizations::UpdateService.new(group: group, current_user: current_user, params: args).execute(organization) + { organization: result.payload, errors: result.errors } + end + end + end + end +end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 40d4f86de00..ea50af1c554 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -35,6 +35,7 @@ module Types mount_mutation Mutations::CustomEmoji::Create, feature_flag: :custom_emoji mount_mutation Mutations::CustomEmoji::Destroy, feature_flag: :custom_emoji mount_mutation Mutations::CustomerRelations::Organizations::Create + mount_mutation Mutations::CustomerRelations::Organizations::Update mount_mutation Mutations::Discussions::ToggleResolve mount_mutation Mutations::DependencyProxy::ImageTtlGroupPolicy::Update mount_mutation Mutations::Environments::CanaryIngress::Update diff --git a/app/helpers/analytics/cycle_analytics_helper.rb b/app/helpers/analytics/cycle_analytics_helper.rb index c43ac545bf8..35a5d4f469d 100644 --- a/app/helpers/analytics/cycle_analytics_helper.rb +++ b/app/helpers/analytics/cycle_analytics_helper.rb @@ -7,5 +7,23 @@ module Analytics Analytics::CycleAnalytics::StagePresenter.new(stage_params) end end + + def cycle_analytics_initial_data(project, group = nil) + base_data = { project_id: project.id, group_path: project.group&.path, request_path: project_cycle_analytics_path(project), full_path: project.full_path } + svgs = { empty_state_svg_path: image_path("illustrations/analytics/cycle-analytics-empty-chart.svg"), no_data_svg_path: image_path("illustrations/analytics/cycle-analytics-empty-chart.svg"), no_access_svg_path: image_path("illustrations/analytics/no-access.svg") } + api_paths = group.present? ? cycle_analytics_group_api_paths(group) : cycle_analytics_project_api_paths(project) + + base_data.merge(svgs, api_paths) + end + + private + + def cycle_analytics_group_api_paths(group) + { milestones_path: group_milestones_path(group, format: :json), labels_path: group_labels_path(group, format: :json), group_path: group_path(group), group_id: group&.id } + end + + def cycle_analytics_project_api_paths(project) + { milestones_path: project_milestones_path(project, format: :json), labels_path: project_labels_path(project, format: :json), group_path: project.parent&.path, group_id: project.parent&.id } + end end end diff --git a/app/helpers/routing/pseudonymization_helper.rb b/app/helpers/routing/pseudonymization_helper.rb index b83a2b30d06..1d9320f0106 100644 --- a/app/helpers/routing/pseudonymization_helper.rb +++ b/app/helpers/routing/pseudonymization_helper.rb @@ -6,6 +6,8 @@ module Routing return unless Feature.enabled?(:mask_page_urls, type: :ops) mask_params(Rails.application.routes.recognize_path(request.original_fullpath)) + rescue ActionController::RoutingError, URI::InvalidURIError + nil end private @@ -19,31 +21,37 @@ module Routing end def url_without_namespace_type(request_params) - masked_url = "#{request.protocol}#{request.host_with_port}/" + masked_url = "#{request.protocol}#{request.host_with_port}" masked_url += case request_params[:controller] when 'groups' - "namespace:#{group.id}/" + "/namespace:#{group.id}" when 'projects' - "namespace:#{project.namespace.id}/project:#{project.id}/" + "/namespace:#{project.namespace.id}/project:#{project.id}" when 'root' '' + else + "#{request.path}" end + masked_url += request.query_string.present? ? "?#{request.query_string}" : '' + masked_url end def url_with_namespace_type(request_params, namespace_type) - masked_url = "#{request.protocol}#{request.host_with_port}/" + masked_url = "#{request.protocol}#{request.host_with_port}" if request_params.has_key?(:project_id) - masked_url += "namespace:#{project.namespace.id}/project:#{project.id}/-/#{namespace_type}/" + masked_url += "/namespace:#{project.namespace.id}/project:#{project.id}/-/#{namespace_type}" end if request_params.has_key?(:id) - masked_url += namespace_type == 'blob' ? ':repository_path' : request_params[:id] + masked_url += namespace_type == 'blob' ? '/:repository_path' : "/#{request_params[:id]}" end + masked_url += request.query_string.present? ? "?#{request.query_string}" : '' + masked_url end end diff --git a/app/services/customer_relations/organizations/base_service.rb b/app/services/customer_relations/organizations/base_service.rb new file mode 100644 index 00000000000..63261534b37 --- /dev/null +++ b/app/services/customer_relations/organizations/base_service.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module CustomerRelations + module Organizations + class BaseService < ::BaseGroupService + private + + def allowed? + current_user&.can?(:admin_organization, group) + end + + def error(message) + ServiceResponse.error(message: message) + end + end + end +end diff --git a/app/services/customer_relations/organizations/create_service.rb b/app/services/customer_relations/organizations/create_service.rb index 33dcb4b8976..9c223796eaf 100644 --- a/app/services/customer_relations/organizations/create_service.rb +++ b/app/services/customer_relations/organizations/create_service.rb @@ -2,7 +2,7 @@ module CustomerRelations module Organizations - class CreateService < ::BaseGroupService + class CreateService < BaseService # returns the created organization def execute return error_no_permissions unless allowed? @@ -18,14 +18,6 @@ module CustomerRelations private - def allowed? - current_user&.can?(:admin_organization, group) - end - - def error(message) - ServiceResponse.error(message: message) - end - def error_no_permissions error('You have insufficient permissions to create an organization for this group') end diff --git a/app/services/customer_relations/organizations/update_service.rb b/app/services/customer_relations/organizations/update_service.rb new file mode 100644 index 00000000000..9d8f908db14 --- /dev/null +++ b/app/services/customer_relations/organizations/update_service.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module CustomerRelations + module Organizations + class UpdateService < BaseService + def execute(organization) + return error_no_permissions unless allowed? + return error_updating(organization) unless organization.update(params) + + ServiceResponse.success(payload: organization) + end + + private + + def error_no_permissions + error('You have insufficient permissions to update an organization for this group') + end + + def error_updating(organization) + error(organization&.errors&.full_messages || 'Failed to update organization') + end + end + end +end diff --git a/app/views/layouts/_snowplow.html.haml b/app/views/layouts/_snowplow.html.haml index eb63c98e13f..fc3b12acc46 100644 --- a/app/views/layouts/_snowplow.html.haml +++ b/app/views/layouts/_snowplow.html.haml @@ -11,3 +11,4 @@ gl = window.gl || {}; gl.snowplowStandardContext = #{Gitlab::Tracking::StandardContext.new.to_context.to_json.to_json} + gl.snowplowPseudonymizedPageUrl = #{masked_page_url.to_json}; diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml index 4e7899664fd..f398ac6ede7 100644 --- a/app/views/projects/cycle_analytics/show.html.haml +++ b/app/views/projects/cycle_analytics/show.html.haml @@ -1,7 +1,4 @@ - page_title _("Value Stream Analytics") - add_page_specific_style 'page_bundles/cycle_analytics' -- svgs = { empty_state_svg_path: image_path("illustrations/analytics/cycle-analytics-empty-chart.svg"), no_data_svg_path: image_path("illustrations/analytics/cycle-analytics-empty-chart.svg"), no_access_svg_path: image_path("illustrations/analytics/no-access.svg") } -- api_paths = @group.present? ? { milestones_path: group_milestones_path(@group), labels_path: group_labels_path(@group), group_path: group_path(@group), group_id: @group&.id } : { milestones_path: project_milestones_path(@project), labels_path: project_labels_path(@project), group_path: @project.parent&.path, group_id: @project.parent&.id } -- initial_data = { project_id: @project.id, group_path: @project.group&.path, request_path: project_cycle_analytics_path(@project), full_path: @project.full_path }.merge!(svgs, api_paths) -#js-cycle-analytics{ data: initial_data } +#js-cycle-analytics{ data: cycle_analytics_initial_data(@project, @group) } diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index b5722933bea..6b3140d62a2 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -1430,6 +1430,28 @@ Input type: `CustomerRelationsOrganizationCreateInput` | <a id="mutationcustomerrelationsorganizationcreateerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | | <a id="mutationcustomerrelationsorganizationcreateorganization"></a>`organization` | [`CustomerRelationsOrganization`](#customerrelationsorganization) | Organization after the mutation. | +### `Mutation.customerRelationsOrganizationUpdate` + +Input type: `CustomerRelationsOrganizationUpdateInput` + +#### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="mutationcustomerrelationsorganizationupdateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| <a id="mutationcustomerrelationsorganizationupdatedefaultrate"></a>`defaultRate` | [`Float`](#float) | Standard billing rate for the organization. | +| <a id="mutationcustomerrelationsorganizationupdatedescription"></a>`description` | [`String`](#string) | Description or notes for the organization. | +| <a id="mutationcustomerrelationsorganizationupdateid"></a>`id` | [`CustomerRelationsOrganizationID!`](#customerrelationsorganizationid) | Global ID of the organization. | +| <a id="mutationcustomerrelationsorganizationupdatename"></a>`name` | [`String`](#string) | Name of the organization. | + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="mutationcustomerrelationsorganizationupdateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| <a id="mutationcustomerrelationsorganizationupdateerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | +| <a id="mutationcustomerrelationsorganizationupdateorganization"></a>`organization` | [`CustomerRelationsOrganization!`](#customerrelationsorganization) | Organization after the mutation. | + ### `Mutation.dastOnDemandScanCreate` Input type: `DastOnDemandScanCreateInput` @@ -16654,6 +16676,12 @@ A `CustomEmojiID` is a global ID. It is encoded as a string. An example `CustomEmojiID` is: `"gid://gitlab/CustomEmoji/1"`. +### `CustomerRelationsOrganizationID` + +A `CustomerRelationsOrganizationID` is a global ID. It is encoded as a string. + +An example `CustomerRelationsOrganizationID` is: `"gid://gitlab/CustomerRelations::Organization/1"`. + ### `DastProfileID` A `DastProfileID` is a global ID. It is encoded as a string. diff --git a/doc/user/admin_area/analytics/dev_ops_report.md b/doc/user/admin_area/analytics/dev_ops_report.md index 9ce012366ef..7ddddfc5e53 100644 --- a/doc/user/admin_area/analytics/dev_ops_report.md +++ b/doc/user/admin_area/analytics/dev_ops_report.md @@ -20,7 +20,7 @@ To see DevOps Report: ## DevOps Score NOTE: -To see the DevOps score, you must activate your GitLab instance's [Service Ping](../settings/usage_statistics.md#service-ping). +To see the DevOps score, you must activate your GitLab instance's [Service Ping](../settings/usage_statistics.md#service-ping). This is because DevOps Score is a comparative tool, so your score data must be centrally processed by GitLab Inc. first. You can use the DevOps score to compare your DevOps status to other organizations. diff --git a/doc/user/clusters/agent/ci_cd_tunnel.md b/doc/user/clusters/agent/ci_cd_tunnel.md index 023affe43d4..1ea5168f30c 100644 --- a/doc/user/clusters/agent/ci_cd_tunnel.md +++ b/doc/user/clusters/agent/ci_cd_tunnel.md @@ -8,6 +8,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/327409) in GitLab 14.1. > - The pre-configured `KUBECONFIG` was [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/324275) in GitLab 14.2. +> - The ability to authorize groups was [introduced](https://gitlab.com/groups/gitlab-org/-/epics/5784) in GitLab 14.3. The CI/CD Tunnel enables users to access Kubernetes clusters from GitLab CI/CD jobs even if there is no network connectivity between GitLab Runner and a cluster. GitLab Runner does not have to be running in the same cluster. @@ -29,10 +30,8 @@ jobs provide a `KUBECONFIG` variable compatible with `kubectl`. Also, each Agent has a separate context (`kubecontext`). By default, there isn't any context selected. - Contexts are named in the following format: `<agent-configuration-project-path>:<agent-name>`. - -You can get the list of available contexts by running `kubectl config get-contexts`. +To get the list of available contexts, run `kubectl config get-contexts`. ## Example for a `kubectl` command using the CI/CD Tunnel diff --git a/doc/user/clusters/agent/index.md b/doc/user/clusters/agent/index.md index ad9b538ef51..d2dc57c0849 100644 --- a/doc/user/clusters/agent/index.md +++ b/doc/user/clusters/agent/index.md @@ -106,7 +106,8 @@ To use the KAS: ### Define a configuration repository -> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/259669) in GitLab 13.7, the Agent manifest configuration can be added to multiple directories (or subdirectories) of its repository. +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/259669) in GitLab 13.7, the Agent manifest configuration can be added to multiple directories (or subdirectories) of its repository. +> - Group authorization was [introduced](https://gitlab.com/groups/gitlab-org/-/epics/5784) in GitLab 14.3. To configure an Agent, you need: @@ -125,6 +126,7 @@ In your repository, add the Agent configuration file under: Your `config.yaml` file specifies all configurations of the Agent, such as: - The manifest projects to synchronize. +- The groups that can access this Agent via the [CI/CD Tunnel](ci_cd_tunnel.md). - The address of the `hubble-relay` for the Network Security policy integrations. As an example, a minimal Agent configuration that sets up only the manifest diff --git a/doc/user/clusters/agent/repository.md b/doc/user/clusters/agent/repository.md index a3a3e4c29b0..ea57ded3320 100644 --- a/doc/user/clusters/agent/repository.md +++ b/doc/user/clusters/agent/repository.md @@ -9,6 +9,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/259669) in [GitLab Premium](https://about.gitlab.com/pricing/) 13.7. > - [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/3834) in GitLab 13.11, the Kubernetes Agent became available on GitLab.com. > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/332227) in GitLab 14.0, the `resource_inclusions` and `resource_exclusions` attributes were removed and `reconcile_timeout`, `dry_run_strategy`, `prune`, `prune_timeout`, `prune_propagation_policy`, and `inventory_policy` attributes were added. +> - The `ci_access` attribute was [introduced](https://gitlab.com/groups/gitlab-org/-/epics/5784) in GitLab 14.3. WARNING: This feature might not be available to you. Check the **version history** note above for details. @@ -147,6 +148,40 @@ gitops: - glob: '/**/*.yaml' ``` +## Authorize groups to use an Agent + +> [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/5784) in GitLab 14.3. + +If you use the same cluster across multiple projects, you can set up the CI/CD Tunnel +to grant the Agent access to one or more groups. This way, all the projects that belong +to the authorized groups can access the same Agent. This enables you to save resources and +have a scalable setup. + +When you authorize a group, the agent's Kubernetes context is automatically injected +into every project of the authorized group, and users can select the connection as +described in the [CI/CD Tunnel documentation](ci_cd_tunnel.md). +To authorize a group to access the Agent through the [CI/CD Tunnel](ci_cd_tunnel.md), +use the `ci_access` attribute in your `config.yaml` configuration file. + +An Agent can only authorize groups in the same group hierarchy as the Agent's configuration project. At most +100 groups can be authorized per Agent. + +To authorize a group: + +1. Edit your `.config.yaml` file under the `.gitlab/agents/<agent name>` directory. +1. Add the `ci_access` attribute. +1. Add the `groups` attribute into `ci_access`. +1. Add the group `id` into `groups`, identifying the authorized group through its path. + +For example: + +```yaml +ci_access: + # This agent is accessible from CI jobs in projects in these groups + groups: + - id: group/subgroup +``` + ## Surface network security alerts from cluster to GitLab The GitLab Agent provides an [integration with Cilium](index.md#kubernetes-network-security-alerts). diff --git a/lib/gitlab/saas.rb b/lib/gitlab/saas.rb index 4066c8a7906..e87c2a0b700 100644 --- a/lib/gitlab/saas.rb +++ b/lib/gitlab/saas.rb @@ -24,6 +24,18 @@ module Gitlab def self.registry_prefix 'registry.gitlab.com' end + + def self.customer_support_url + 'https://support.gitlab.com' + end + + def self.customer_license_support_url + 'https://support.gitlab.com/hc/en-us/requests/new?ticket_form_id=360000071293' + end + + def self.gitlab_com_status_url + 'https://status.gitlab.com' + end end end diff --git a/lib/gitlab/subscription_portal.rb b/lib/gitlab/subscription_portal.rb index ab2e1404cd2..78fa5009bc4 100644 --- a/lib/gitlab/subscription_portal.rb +++ b/lib/gitlab/subscription_portal.rb @@ -13,6 +13,38 @@ module Gitlab def self.payment_form_url "#{self.subscriptions_url}/payment_forms/cc_validation" end + + def self.subscriptions_comparison_url + 'https://about.gitlab.com/pricing/gitlab-com/feature-comparison' + end + + def self.subscriptions_graphql_url + "#{self.subscriptions_url}/graphql" + end + + def self.subscriptions_more_minutes_url + "#{self.subscriptions_url}/buy_pipeline_minutes" + end + + def self.subscriptions_more_storage_url + "#{self.subscriptions_url}/buy_storage" + end + + def self.subscriptions_manage_url + "#{self.subscriptions_url}/subscriptions" + end + + def self.subscriptions_plans_url + "#{self.subscriptions_url}/plans" + end + + def self.subscription_portal_admin_email + ENV.fetch('SUBSCRIPTION_PORTAL_ADMIN_EMAIL', 'gl_com_api@gitlab.com') + end + + def self.subscription_portal_admin_token + ENV.fetch('SUBSCRIPTION_PORTAL_ADMIN_TOKEN', 'customer_admin_token') + end end end diff --git a/qa/qa/runtime/browser.rb b/qa/qa/runtime/browser.rb index f208a93d302..0566bc237bb 100644 --- a/qa/qa/runtime/browser.rb +++ b/qa/qa/runtime/browser.rb @@ -237,6 +237,8 @@ module QA else browser.manage.delete_cookie("gitlab_canary") end + + browser.navigate.refresh end ## diff --git a/qa/qa/specs/features/browser_ui/1_manage/login/maintain_log_in_mixed_env_spec.rb b/qa/qa/specs/features/browser_ui/1_manage/login/maintain_log_in_mixed_env_spec.rb new file mode 100644 index 00000000000..2b1c956039f --- /dev/null +++ b/qa/qa/specs/features/browser_ui/1_manage/login/maintain_log_in_mixed_env_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module QA + RSpec.describe 'Manage', :mixed_env, :smoke, only: { subdomain: :staging } do + describe 'basic user' do + it 'remains logged in when redirected from canary to non-canary node', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/quality/test_cases/2251' do + Runtime::Browser.visit(:gitlab, Page::Main::Login) + + Support::Retrier.retry_until(sleep_interval: 0.5) do + Page::Main::Login.perform(&:can_sign_in?) + end + + Runtime::Browser::Session.target_canary(true) + Flow::Login.sign_in + + verify_session_on_canary(true) + + Runtime::Browser::Session.target_canary(false) + + verify_session_on_canary(false) + + Support::Retrier.retry_until(sleep_interval: 0.5) do + Page::Main::Menu.perform(&:sign_out) + + Page::Main::Login.perform(&:can_sign_in?) + end + end + + def verify_session_on_canary(enable_canary) + Page::Main::Menu.perform do |menu| + aggregate_failures 'testing session log in' do + expect(menu.canary?).to be(enable_canary) + expect(menu).to have_personal_area + end + end + end + end + end +end diff --git a/spec/features/groups/issues_spec.rb b/spec/features/groups/issues_spec.rb index d2cdaaa43b9..489beb70ab3 100644 --- a/spec/features/groups/issues_spec.rb +++ b/spec/features/groups/issues_spec.rb @@ -33,7 +33,7 @@ RSpec.describe 'Group issues page' do # However,`:js` option forces Capybara to use Selenium that doesn't support`:has` context "it has an RSS button with current_user's feed token" do it "shows the RSS button with current_user's feed token" do - expect(find('[data-testid="rss-feed-link"]')['href']).to have_content(user.feed_token) + expect(page).to have_link 'Subscribe to RSS feed', href: /feed_token=#{user.feed_token}/ end end end @@ -46,7 +46,7 @@ RSpec.describe 'Group issues page' do # Note: please see the above context "it has an RSS button without a feed token" do it "shows the RSS button without a feed token" do - expect(find('[data-testid="rss-feed-link"]')['href']).not_to have_content('feed_token') + expect(page).not_to have_link 'Subscribe to RSS feed', href: /feed_token/ end end end diff --git a/spec/features/issues/filtered_search/filter_issues_spec.rb b/spec/features/issues/filtered_search/filter_issues_spec.rb index 88a7b890daa..edf3df7c16e 100644 --- a/spec/features/issues/filtered_search/filter_issues_spec.rb +++ b/spec/features/issues/filtered_search/filter_issues_spec.rb @@ -565,21 +565,18 @@ RSpec.describe 'Filter issues', :js do end it 'maintains filter' do - # Closed - find('.issues-state-filters [data-state="closed"]').click + click_link 'Closed' wait_for_requests expect(page).to have_selector('.issues-list .issue', count: 1) expect(page).to have_link(closed_issue.title) - # Opened - find('.issues-state-filters [data-state="opened"]').click + click_link 'Open' wait_for_requests expect(page).to have_selector('.issues-list .issue', count: 4) - # All - find('.issues-state-filters [data-state="all"]').click + click_link 'All' wait_for_requests expect(page).to have_selector('.issues-list .issue', count: 5) diff --git a/spec/features/issues/filtered_search/visual_tokens_spec.rb b/spec/features/issues/filtered_search/visual_tokens_spec.rb index 644d7cc4611..2d8587d886f 100644 --- a/spec/features/issues/filtered_search/visual_tokens_spec.rb +++ b/spec/features/issues/filtered_search/visual_tokens_spec.rb @@ -16,9 +16,6 @@ RSpec.describe 'Visual tokens', :js do let(:filtered_search) { find('.filtered-search') } let(:filter_author_dropdown) { find("#js-dropdown-author .filter-dropdown") } - let(:filter_assignee_dropdown) { find("#js-dropdown-assignee .filter-dropdown") } - let(:filter_milestone_dropdown) { find("#js-dropdown-milestone .filter-dropdown") } - let(:filter_label_dropdown) { find("#js-dropdown-label .filter-dropdown") } def is_input_focused page.evaluate_script("document.activeElement.classList.contains('filtered-search')") diff --git a/spec/frontend/cycle_analytics/store/actions_spec.js b/spec/frontend/cycle_analytics/store/actions_spec.js index a4b638ddd42..97b5bd03e18 100644 --- a/spec/frontend/cycle_analytics/store/actions_spec.js +++ b/spec/frontend/cycle_analytics/store/actions_spec.js @@ -14,8 +14,8 @@ import { } from '../mock_data'; const { id: groupId, path: groupPath } = currentGroup; -const mockMilestonesPath = 'mock-milestones'; -const mockLabelsPath = 'mock-labels'; +const mockMilestonesPath = 'mock-milestones.json'; +const mockLabelsPath = 'mock-labels.json'; const mockRequestPath = 'some/cool/path'; const mockFullPath = '/namespace/-/analytics/value_stream_analytics/value_streams'; const mockEndpoints = { @@ -83,8 +83,8 @@ describe('Project Value Stream Analytics actions', () => { const payload = { endpoints: mockEndpoints }; const mockFilterEndpoints = { groupEndpoint: 'foo', - labelsEndpoint: 'mock-labels.json', - milestonesEndpoint: 'mock-milestones.json', + labelsEndpoint: mockLabelsPath, + milestonesEndpoint: mockMilestonesPath, projectEndpoint: '/namespace/-/analytics/value_stream_analytics/value_streams', }; diff --git a/spec/frontend/tracking_spec.js b/spec/frontend/tracking_spec.js index b59c3cda055..21fed51ff10 100644 --- a/spec/frontend/tracking_spec.js +++ b/spec/frontend/tracking_spec.js @@ -1,7 +1,9 @@ import { setHTMLFixture } from 'helpers/fixtures'; +import { TEST_HOST } from 'helpers/test_constants'; import { TRACKING_CONTEXT_SCHEMA } from '~/experimentation/constants'; import { getExperimentData, getAllExperimentContexts } from '~/experimentation/utils'; import Tracking, { initUserTracking, initDefaultTrackers } from '~/tracking'; +import { REFERRER_TTL, URLS_CACHE_STORAGE_KEY } from '~/tracking/constants'; import getStandardContext from '~/tracking/get_standard_context'; jest.mock('~/experimentation/utils', () => ({ @@ -15,9 +17,11 @@ describe('Tracking', () => { let bindDocumentSpy; let trackLoadEventsSpy; let enableFormTracking; + let setAnonymousUrlsSpy; beforeAll(() => { window.gl = window.gl || {}; + window.gl.snowplowUrls = {}; window.gl.snowplowStandardContext = { schema: 'iglu:com.gitlab/gitlab_standard', data: { @@ -74,6 +78,7 @@ describe('Tracking', () => { enableFormTracking = jest .spyOn(Tracking, 'enableFormTracking') .mockImplementation(() => null); + setAnonymousUrlsSpy = jest.spyOn(Tracking, 'setAnonymousUrls').mockImplementation(() => null); }); it('should activate features based on what has been enabled', () => { @@ -105,6 +110,11 @@ describe('Tracking', () => { expect(trackLoadEventsSpy).toHaveBeenCalled(); }); + it('calls the anonymized URLs method', () => { + initDefaultTrackers(); + expect(setAnonymousUrlsSpy).toHaveBeenCalled(); + }); + describe('when there are experiment contexts', () => { const experimentContexts = [ { @@ -295,6 +305,110 @@ describe('Tracking', () => { }); }); + describe('.setAnonymousUrls', () => { + afterEach(() => { + window.gl.snowplowPseudonymizedPageUrl = ''; + localStorage.removeItem(URLS_CACHE_STORAGE_KEY); + }); + + it('does nothing if URLs are not provided', () => { + Tracking.setAnonymousUrls(); + + expect(snowplowSpy).not.toHaveBeenCalled(); + expect(localStorage.getItem(URLS_CACHE_STORAGE_KEY)).toBe(null); + }); + + it('sets the page URL when provided and populates the cache', () => { + window.gl.snowplowPseudonymizedPageUrl = TEST_HOST; + + Tracking.setAnonymousUrls(); + + expect(snowplowSpy).toHaveBeenCalledWith('setCustomUrl', TEST_HOST); + expect(JSON.parse(localStorage.getItem(URLS_CACHE_STORAGE_KEY))[0]).toStrictEqual({ + url: TEST_HOST, + referrer: '', + originalUrl: window.location.href, + timestamp: Date.now(), + }); + }); + + it('appends the hash/fragment to the pseudonymized URL', () => { + const hash = 'first-heading'; + window.gl.snowplowPseudonymizedPageUrl = TEST_HOST; + window.location.hash = hash; + + Tracking.setAnonymousUrls(); + + expect(snowplowSpy).toHaveBeenCalledWith('setCustomUrl', `${TEST_HOST}#${hash}`); + }); + + it('does not set the referrer URL by default', () => { + window.gl.snowplowPseudonymizedPageUrl = TEST_HOST; + + Tracking.setAnonymousUrls(); + + expect(snowplowSpy).not.toHaveBeenCalledWith('setReferrerUrl', expect.any(String)); + }); + + describe('with referrers cache', () => { + const testUrl = '/namespace:1/project:2/-/merge_requests/5'; + const testOriginalUrl = '/my-namespace/my-project/-/merge_requests/'; + const setUrlsCache = (data) => + localStorage.setItem(URLS_CACHE_STORAGE_KEY, JSON.stringify(data)); + + beforeEach(() => { + window.gl.snowplowPseudonymizedPageUrl = TEST_HOST; + Object.defineProperty(document, 'referrer', { value: '', configurable: true }); + }); + + it('does nothing if a referrer can not be found', () => { + setUrlsCache([ + { + url: testUrl, + originalUrl: TEST_HOST, + timestamp: Date.now(), + }, + ]); + + Tracking.setAnonymousUrls(); + + expect(snowplowSpy).not.toHaveBeenCalledWith('setReferrerUrl', expect.any(String)); + }); + + it('sets referrer URL from the page URL found in cache', () => { + Object.defineProperty(document, 'referrer', { value: testOriginalUrl }); + setUrlsCache([ + { + url: testUrl, + originalUrl: testOriginalUrl, + timestamp: Date.now(), + }, + ]); + + Tracking.setAnonymousUrls(); + + expect(snowplowSpy).toHaveBeenCalledWith('setReferrerUrl', testUrl); + }); + + it('ignores and removes old entries from the cache', () => { + const oldTimestamp = Date.now() - (REFERRER_TTL + 1); + Object.defineProperty(document, 'referrer', { value: testOriginalUrl }); + setUrlsCache([ + { + url: testUrl, + originalUrl: testOriginalUrl, + timestamp: oldTimestamp, + }, + ]); + + Tracking.setAnonymousUrls(); + + expect(snowplowSpy).not.toHaveBeenCalledWith('setReferrerUrl', testUrl); + expect(localStorage.getItem(URLS_CACHE_STORAGE_KEY)).not.toContain(oldTimestamp); + }); + }); + }); + describe.each` term ${'event'} diff --git a/spec/graphql/mutations/customer_relations/organizations/create_spec.rb b/spec/graphql/mutations/customer_relations/organizations/create_spec.rb index 4741a96b397..ab430b9240b 100644 --- a/spec/graphql/mutations/customer_relations/organizations/create_spec.rb +++ b/spec/graphql/mutations/customer_relations/organizations/create_spec.rb @@ -5,8 +5,6 @@ require 'spec_helper' RSpec.describe Mutations::CustomerRelations::Organizations::Create do let_it_be(:user) { create(:user) } - let(:group) { create(:group) } - let(:valid_params) do attributes_for(:organization, group: group, @@ -25,6 +23,8 @@ RSpec.describe Mutations::CustomerRelations::Organizations::Create do end context 'when the user does not have permission' do + let_it_be(:group) { create(:group) } + before do group.add_guest(user) end @@ -35,7 +35,9 @@ RSpec.describe Mutations::CustomerRelations::Organizations::Create do end context 'when the user has permission' do - before do + let_it_be(:group) { create(:group) } + + before_all do group.add_reporter(user) end diff --git a/spec/graphql/mutations/customer_relations/organizations/update_spec.rb b/spec/graphql/mutations/customer_relations/organizations/update_spec.rb new file mode 100644 index 00000000000..f5aa6c00301 --- /dev/null +++ b/spec/graphql/mutations/customer_relations/organizations/update_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::CustomerRelations::Organizations::Update do + let_it_be(:user) { create(:user) } + let_it_be(:name) { 'GitLab' } + let_it_be(:default_rate) { 1000.to_f } + let_it_be(:description) { 'VIP' } + + let(:organization) { create(:organization, group: group) } + let(:attributes) do + { + id: organization.to_global_id, + name: name, + default_rate: default_rate, + description: description + } + end + + describe '#resolve' do + subject(:resolve_mutation) do + described_class.new(object: nil, context: { current_user: user }, field: nil).resolve( + attributes + ) + end + + context 'when the user does not have permission to update an organization' do + let_it_be(:group) { create(:group) } + + before do + group.add_guest(user) + end + + it 'raises an error' do + expect { resolve_mutation }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + end + + context 'when the organization does not exist' do + let_it_be(:group) { create(:group) } + + it 'raises an error' do + attributes[:id] = 'gid://gitlab/CustomerRelations::Organization/999' + + expect { resolve_mutation }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + end + + context 'when the user has permission to update an organization' do + let_it_be(:group) { create(:group) } + + before_all do + group.add_reporter(user) + end + + it 'updates the organization with correct values' do + expect(resolve_mutation[:organization]).to have_attributes(attributes) + end + + context 'when the feature is disabled' do + before do + stub_feature_flags(customer_relations: false) + end + + it 'raises an error' do + expect { resolve_mutation }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + end + end + end + + specify { expect(described_class).to require_graphql_authorizations(:admin_organization) } +end diff --git a/spec/helpers/analytics/cycle_analytics_helper_spec.rb b/spec/helpers/analytics/cycle_analytics_helper_spec.rb new file mode 100644 index 00000000000..d906646e25c --- /dev/null +++ b/spec/helpers/analytics/cycle_analytics_helper_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true +require "spec_helper" + +RSpec.describe Analytics::CycleAnalyticsHelper do + describe '#cycle_analytics_initial_data' do + let(:user) { create(:user, name: 'fake user', username: 'fake_user') } + let(:image_path_keys) { [:empty_state_svg_path, :no_data_svg_path, :no_access_svg_path] } + let(:api_path_keys) { [:milestones_path, :labels_path] } + let(:additional_data_keys) { [:full_path, :group_id, :group_path, :project_id, :request_path] } + let(:group) { create(:group) } + + subject(:cycle_analytics_data) { helper.cycle_analytics_initial_data(project, group) } + + before do + project.add_maintainer(user) + end + + context 'when a group is present' do + let(:project) { create(:project, group: group) } + + it "sets the correct data keys" do + expect(cycle_analytics_data.keys) + .to match_array(api_path_keys + image_path_keys + additional_data_keys) + end + + it "sets group paths" do + expect(cycle_analytics_data) + .to include({ + full_path: project.full_path, + group_path: "/#{project.namespace.name}", + group_id: project.namespace.id, + request_path: "/#{project.full_path}/-/value_stream_analytics", + milestones_path: "/groups/#{group.name}/-/milestones.json", + labels_path: "/groups/#{group.name}/-/labels.json" + }) + end + end + + context 'when a group is not present' do + let(:group) { nil } + let(:project) { create(:project) } + + it "sets the correct data keys" do + expect(cycle_analytics_data.keys) + .to match_array(image_path_keys + api_path_keys + additional_data_keys) + end + + it "sets project name space paths" do + expect(cycle_analytics_data) + .to include({ + full_path: project.full_path, + group_path: project.namespace.path, + group_id: project.namespace.id, + request_path: "/#{project.full_path}/-/value_stream_analytics", + milestones_path: "/#{project.full_path}/-/milestones.json", + labels_path: "/#{project.full_path}/-/labels.json" + }) + end + end + end +end diff --git a/spec/helpers/routing/pseudonymization_helper_spec.rb b/spec/helpers/routing/pseudonymization_helper_spec.rb index f47e8185f07..10563502555 100644 --- a/spec/helpers/routing/pseudonymization_helper_spec.rb +++ b/spec/helpers/routing/pseudonymization_helper_spec.rb @@ -56,7 +56,7 @@ RSpec.describe ::Routing::PseudonymizationHelper do end context 'with controller for groups with subgroups and project' do - let(:masked_url) { "http://test.host/namespace:#{subgroup.id}/project:#{project.id}/"} + let(:masked_url) { "http://test.host/namespace:#{subgroup.id}/project:#{project.id}"} before do allow(helper).to receive(:group).and_return(subgroup) @@ -73,7 +73,7 @@ RSpec.describe ::Routing::PseudonymizationHelper do end context 'with controller for groups and subgroups' do - let(:masked_url) { "http://test.host/namespace:#{subgroup.id}/"} + let(:masked_url) { "http://test.host/namespace:#{subgroup.id}"} before do allow(helper).to receive(:group).and_return(subgroup) @@ -102,10 +102,25 @@ RSpec.describe ::Routing::PseudonymizationHelper do it_behaves_like 'masked url' end + + context 'with non identifiable controller' do + let(:masked_url) { "http://test.host/dashboard/issues?assignee_username=root" } + + before do + controller.request.path = '/dashboard/issues' + controller.request.query_string = 'assignee_username=root' + allow(Rails.application.routes).to receive(:recognize_path).and_return({ + controller: 'dashboard', + action: 'issues' + }) + end + + it_behaves_like 'masked url' + end end describe 'when url has no params to mask' do - let(:root_url) { 'http://test.host/' } + let(:root_url) { 'http://test.host' } context 'returns root url' do it 'masked_page_url' do diff --git a/spec/services/customer_relations/organizations/update_service_spec.rb b/spec/services/customer_relations/organizations/update_service_spec.rb new file mode 100644 index 00000000000..eb253540863 --- /dev/null +++ b/spec/services/customer_relations/organizations/update_service_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe CustomerRelations::Organizations::UpdateService do + let_it_be(:user) { create(:user) } + + let(:organization) { create(:organization, name: 'Test', group: group) } + + subject(:update) { described_class.new(group: group, current_user: user, params: params).execute(organization) } + + describe '#execute' do + context 'when the user has no permission' do + let_it_be(:group) { create(:group) } + + let(:params) { { name: 'GitLab' } } + + it 'returns an error' do + response = update + + expect(response).to be_error + expect(response.message).to eq('You have insufficient permissions to update an organization for this group') + end + end + + context 'when user has permission' do + let_it_be(:group) { create(:group) } + + before_all do + group.add_reporter(user) + end + + context 'when name is changed' do + let(:params) { { name: 'GitLab' } } + + it 'updates the organization' do + response = update + + expect(response).to be_success + expect(response.payload.name).to eq('GitLab') + end + end + + context 'when the organization is invalid' do + let(:params) { { name: nil } } + + it 'returns an error' do + response = update + + expect(response).to be_error + expect(response.message).to eq(["Name can't be blank"]) + end + end + end + end +end |