diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-04-13 15:09:20 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-04-13 15:09:20 +0000 |
commit | b77fb04678a4e76d025048e9846adc2ac709414a (patch) | |
tree | c65f719e326e1d33d313b5e9d8b3f72366ad7bd2 | |
parent | 75ee59f7a108cf0c57e1e66e3ef5e439bae24fcd (diff) | |
download | gitlab-ce-b77fb04678a4e76d025048e9846adc2ac709414a.tar.gz |
Add latest changes from gitlab-org/gitlab@master
34 files changed, 600 insertions, 378 deletions
diff --git a/app/assets/javascripts/logs/components/environment_logs.vue b/app/assets/javascripts/logs/components/environment_logs.vue index 0d84798d690..838652f7210 100644 --- a/app/assets/javascripts/logs/components/environment_logs.vue +++ b/app/assets/javascripts/logs/components/environment_logs.vue @@ -89,10 +89,9 @@ export default { methods: { ...mapActions('environmentLogs', [ 'setInitData', - 'setSearch', - 'showPodLogs', 'showEnvironment', 'fetchEnvironments', + 'fetchLogs', 'fetchMoreLogsPrepend', 'dismissRequestEnvironmentsError', 'dismissInvalidTimeRangeWarning', @@ -191,13 +190,13 @@ export default { <log-advanced-filters v-if="showAdvancedFilters" ref="log-advanced-filters" - class="d-md-flex flex-grow-1" + class="d-md-flex flex-grow-1 min-width-0" :disabled="environments.isLoading" /> <log-simple-filters v-else ref="log-simple-filters" - class="d-md-flex flex-grow-1" + class="d-md-flex flex-grow-1 min-width-0" :disabled="environments.isLoading" /> @@ -205,7 +204,7 @@ export default { ref="scrollButtons" class="flex-grow-0 pr-2 mb-2 controllers" :scroll-down-button-disabled="scrollDownButtonDisabled" - @refresh="showPodLogs(pods.current)" + @refresh="fetchLogs()" @scrollDown="scrollDown" /> </div> diff --git a/app/assets/javascripts/logs/components/log_advanced_filters.vue b/app/assets/javascripts/logs/components/log_advanced_filters.vue index dfbd858bf18..49bb80b3bfd 100644 --- a/app/assets/javascripts/logs/components/log_advanced_filters.vue +++ b/app/assets/javascripts/logs/components/log_advanced_filters.vue @@ -1,25 +1,15 @@ <script> -import { s__ } from '~/locale'; -import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue'; import { mapActions, mapState } from 'vuex'; -import { - GlIcon, - GlDropdown, - GlDropdownHeader, - GlDropdownDivider, - GlDropdownItem, - GlSearchBoxByClick, -} from '@gitlab/ui'; +import { GlFilteredSearch } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue'; import { timeRanges } from '~/vue_shared/constants'; +import { TOKEN_TYPE_POD_NAME } from '../constants'; +import TokenWithLoadingState from './tokens/token_with_loading_state.vue'; export default { components: { - GlIcon, - GlDropdown, - GlDropdownHeader, - GlDropdownDivider, - GlDropdownItem, - GlSearchBoxByClick, + GlFilteredSearch, DateTimePicker, }, props: { @@ -32,11 +22,10 @@ export default { data() { return { timeRanges, - searchQuery: '', }; }, computed: { - ...mapState('environmentLogs', ['timeRange', 'pods']), + ...mapState('environmentLogs', ['timeRange', 'pods', 'logs']), timeRangeModel: { get() { @@ -46,75 +35,56 @@ export default { this.setTimeRange(val); }, }, + /** + * Token options. + * + * Returns null when no pods are present, so suggestions are displayed in the token + */ + podOptions() { + if (this.pods.options.length) { + return this.pods.options.map(podName => ({ value: podName, title: podName })); + } + return null; + }, - podDropdownText() { - return this.pods.current || s__('Environments|All pods'); + tokens() { + return [ + { + icon: 'pod', + type: TOKEN_TYPE_POD_NAME, + title: s__('Environments|Pod name'), + token: TokenWithLoadingState, + operators: [{ value: '=', description: __('is'), default: 'true' }], + unique: true, + options: this.podOptions, + loading: this.logs.isLoading, + noOptionsText: s__('Environments|No pods to display'), + }, + ]; }, }, methods: { - ...mapActions('environmentLogs', ['setSearch', 'showPodLogs', 'setTimeRange']), - isCurrentPod(podName) { - return podName === this.pods.current; + ...mapActions('environmentLogs', ['showFilteredLogs', 'setTimeRange']), + + filteredSearchSubmit(filters) { + this.showFilteredLogs(filters); }, }, }; </script> <template> <div> - <gl-dropdown - ref="podsDropdown" - :text="podDropdownText" - :disabled="disabled" - class="mb-2 gl-h-32 pr-2 d-flex d-md-block flex-grow-0 qa-pods-dropdown" - > - <gl-dropdown-header class="text-center"> - {{ s__('Environments|Filter by pod') }} - </gl-dropdown-header> - - <gl-dropdown-item v-if="!pods.options.length" disabled> - <span ref="noPodsMsg" class="text-muted"> - {{ s__('Environments|No pods to display') }} - </span> - </gl-dropdown-item> - - <template v-else> - <gl-dropdown-item ref="allPodsOption" key="all-pods" @click="showPodLogs(null)"> - <div class="d-flex"> - <gl-icon - :class="{ invisible: pods.current !== null }" - name="status_success_borderless" - /> - <div class="flex-grow-1">{{ s__('Environments|All pods') }}</div> - </div> - </gl-dropdown-item> - <gl-dropdown-divider /> - <gl-dropdown-item - v-for="podName in pods.options" - :key="podName" - class="text-nowrap" - @click="showPodLogs(podName)" - > - <div class="d-flex"> - <gl-icon - :class="{ invisible: !isCurrentPod(podName) }" - name="status_success_borderless" - /> - <div class="flex-grow-1">{{ podName }}</div> - </div> - </gl-dropdown-item> - </template> - </gl-dropdown> - - <gl-search-box-by-click - ref="searchBox" - v-model.trim="searchQuery" - :disabled="disabled" - :placeholder="s__('Environments|Search')" - class="mb-2 pr-2 flex-grow-1" - type="search" - autofocus - @submit="setSearch(searchQuery)" - /> + <div class="mb-2 pr-2 flex-grow-1 min-width-0"> + <gl-filtered-search + :placeholder="__('Search')" + :clear-button-title="__('Clear')" + :close-button-title="__('Close')" + class="gl-h-32" + :disabled="disabled || logs.isLoading" + :available-tokens="tokens" + @submit="filteredSearchSubmit" + /> + </div> <date-time-picker ref="dateTimePicker" diff --git a/app/assets/javascripts/logs/components/tokens/token_with_loading_state.vue b/app/assets/javascripts/logs/components/tokens/token_with_loading_state.vue new file mode 100644 index 00000000000..f8ce704942b --- /dev/null +++ b/app/assets/javascripts/logs/components/tokens/token_with_loading_state.vue @@ -0,0 +1,30 @@ +<script> +import { GlFilteredSearchToken, GlLoadingIcon } from '@gitlab/ui'; + +export default { + components: { + GlFilteredSearchToken, + GlLoadingIcon, + }, + inheritAttrs: false, + props: { + config: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <gl-filtered-search-token :config="config" v-bind="{ ...$attrs }" v-on="$listeners"> + <template #suggestions> + <div class="m-1"> + <gl-loading-icon v-if="config.loading" /> + <div v-else class="py-1 px-2 text-muted"> + {{ config.noOptionsText }} + </div> + </div> + </template> + </gl-filtered-search-token> +</template> diff --git a/app/assets/javascripts/logs/constants.js b/app/assets/javascripts/logs/constants.js new file mode 100644 index 00000000000..450b83f4827 --- /dev/null +++ b/app/assets/javascripts/logs/constants.js @@ -0,0 +1,3 @@ +export const dateFormatMask = 'UTC:mmm dd HH:MM:ss.l"Z"'; + +export const TOKEN_TYPE_POD_NAME = 'TOKEN_TYPE_POD_NAME'; diff --git a/app/assets/javascripts/logs/stores/actions.js b/app/assets/javascripts/logs/stores/actions.js index be847108a49..a86d3c775a9 100644 --- a/app/assets/javascripts/logs/stores/actions.js +++ b/app/assets/javascripts/logs/stores/actions.js @@ -2,6 +2,7 @@ import { backOff } from '~/lib/utils/common_utils'; import httpStatusCodes from '~/lib/utils/http_status'; import axios from '~/lib/utils/axios_utils'; import { convertToFixedRange } from '~/lib/utils/datetime_range'; +import { TOKEN_TYPE_POD_NAME } from '../constants'; import * as types from './mutation_types'; @@ -49,19 +50,42 @@ const requestLogsUntilData = ({ commit, state }) => { return requestUntilData(logs_api_path, params); }; +/** + * Converts filters emitted by the component, e.g. a filterered-search + * to parameters to be applied to the filters of the store + * @param {Array} filters - List of strings or objects to filter by. + * @returns {Object} - An object with `search` and `podName` keys. + */ +const filtersToParams = (filters = []) => { + // Strings become part of the `search` + const search = filters + .filter(f => typeof f === 'string') + .join(' ') + .trim(); + + // null podName to show all pods + const podName = filters.find(f => f?.type === TOKEN_TYPE_POD_NAME)?.value?.data ?? null; + + return { search, podName }; +}; + export const setInitData = ({ commit }, { timeRange, environmentName, podName }) => { commit(types.SET_TIME_RANGE, timeRange); commit(types.SET_PROJECT_ENVIRONMENT, environmentName); commit(types.SET_CURRENT_POD_NAME, podName); }; -export const showPodLogs = ({ dispatch, commit }, podName) => { +export const showFilteredLogs = ({ dispatch, commit }, filters = []) => { + const { podName, search } = filtersToParams(filters); + commit(types.SET_CURRENT_POD_NAME, podName); + commit(types.SET_SEARCH, search); + dispatch('fetchLogs'); }; -export const setSearch = ({ dispatch, commit }, searchQuery) => { - commit(types.SET_SEARCH, searchQuery); +export const showPodLogs = ({ dispatch, commit }, podName) => { + commit(types.SET_CURRENT_POD_NAME, podName); dispatch('fetchLogs'); }; diff --git a/app/assets/javascripts/logs/utils.js b/app/assets/javascripts/logs/utils.js index 30213dbc130..8479eeb3b59 100644 --- a/app/assets/javascripts/logs/utils.js +++ b/app/assets/javascripts/logs/utils.js @@ -1,7 +1,6 @@ import { secondsToMilliseconds } from '~/lib/utils/datetime_utility'; import dateFormat from 'dateformat'; - -const dateFormatMask = 'UTC:mmm dd HH:MM:ss.l"Z"'; +import { dateFormatMask } from './constants'; /** * Returns a time range (`start`, `end`) where `start` is the diff --git a/app/assets/javascripts/static_site_editor/components/saved_changes_message.vue b/app/assets/javascripts/static_site_editor/components/saved_changes_message.vue index adcacf8a1b0..d76c6d9d681 100644 --- a/app/assets/javascripts/static_site_editor/components/saved_changes_message.vue +++ b/app/assets/javascripts/static_site_editor/components/saved_changes_message.vue @@ -36,7 +36,7 @@ export default { <template> <div> - <div> + <div class="border-bottom pb-4"> <h3>{{ s__('StaticSiteEditor|Success!') }}</h3> <p> {{ @@ -45,35 +45,37 @@ export default { ) }} </p> - <div> + <div class="d-flex justify-content-end"> <gl-new-button ref="returnToSiteButton" :href="returnUrl">{{ s__('StaticSiteEditor|Return to site') }}</gl-new-button> - <gl-new-button ref="mergeRequestButton" :href="mergeRequest.url" variant="info">{{ - s__('StaticSiteEditor|View merge request') - }}</gl-new-button> + <gl-new-button + ref="mergeRequestButton" + class="ml-2" + :href="mergeRequest.url" + variant="success" + >{{ s__('StaticSiteEditor|View merge request') }}</gl-new-button + > </div> </div> - <hr /> - - <div> + <div class="pt-2"> <h4>{{ s__('StaticSiteEditor|Summary of changes') }}</h4> <ul> <li> - {{ s__('StaticSiteEditor|A new branch was created:') }} + {{ s__('StaticSiteEditor|You created a new branch:') }} <gl-link ref="branchLink" :href="branch.url">{{ branch.label }}</gl-link> </li> <li> - {{ s__('StaticSiteEditor|Your changes were committed to it:') }} - <gl-link ref="commitLink" :href="commit.url">{{ commit.label }}</gl-link> - </li> - <li> - {{ s__('StaticSiteEditor|A merge request was created:') }} + {{ s__('StaticSiteEditor|You created a merge request:') }} <gl-link ref="mergeRequestLink" :href="mergeRequest.url">{{ mergeRequest.label }}</gl-link> </li> + <li> + {{ s__('StaticSiteEditor|You added a commit:') }} + <gl-link ref="commitLink" :href="commit.url">{{ commit.label }}</gl-link> + </li> </ul> </div> </div> diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index 9a473876fa0..5f6a26d0a14 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -96,8 +96,8 @@ } .name { - background-color: $filter-name-resting-color; - color: $filter-name-text-color; + background-color: $white-normal; + color: $gl-text-color-secondary; border-radius: 2px 0 0 2px; margin-right: 1px; text-transform: capitalize; @@ -105,7 +105,7 @@ .operator { background-color: $white-normal; - color: $filter-value-text-color; + color: $gl-text-color; margin-right: 1px; } @@ -113,7 +113,7 @@ display: flex; align-items: center; background-color: $white-normal; - color: $filter-value-text-color; + color: $gl-text-color; border-radius: 0 2px 2px 0; margin-right: 5px; padding-right: 8px; @@ -152,7 +152,7 @@ .filtered-search-token .selected, .filtered-search-term .selected { .name { - background-color: $filter-name-selected-color; + background-color: $gray-200; } .operator { diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index 69aed2fc20a..816dbc6931c 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -86,13 +86,13 @@ line-height: 10px; color: $gl-gray-700; vertical-align: middle; - background-color: $kdb-bg; + background-color: $gray-50; border-width: 1px; border-style: solid; - border-color: $gl-gray-200 $gl-gray-200 $kdb-border-bottom; + border-color: $gray-200 $gray-200 $gray-400; border-image: none; border-radius: 3px; - box-shadow: 0 -1px 0 $kdb-shadow inset; + box-shadow: 0 -1px 0 $gray-400 inset; } h1 { diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 65efbabaa4f..fed2b5c905d 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -485,7 +485,7 @@ $line-removed-dark: #fac5cd; $line-number-old: #f9d7dc; $line-number-new: #ddfbe6; $line-number-select: #fbf2da; -$line-target-blue: #f6faff; +$line-target-blue: $blue-50; $line-select-yellow: #fcf8e7; $line-select-yellow-dark: #f0e2bd; $dark-diff-match-bg: rgba(255, 255, 255, 0.3); @@ -698,7 +698,7 @@ $logs-p-color: #333; */ $input-height: 34px; $input-danger-bg: #f2dede; -$input-group-addon-bg: #f7f8fa; +$input-group-addon-bg: $gray-50; $gl-field-focus-shadow: rgba(0, 0, 0, 0.075); $gl-field-focus-shadow-error: rgba($red-500, 0.6); $input-short-width: 200px; @@ -774,9 +774,6 @@ $select2-drop-shadow2: rgba(31, 37, 50, 0.317647); /* * Typography */ -$kdb-bg: #fcfcfc; -$kdb-border-bottom: #bbb; -$kdb-shadow: #bbb; $body-text-shadow: rgba(255, 255, 255, 0.01); /* @@ -801,20 +798,6 @@ CI variable lists $ci-variable-remove-button-width: calc(1em + #{2 * $gl-padding}); /* -Filtered Search -*/ -$filter-name-resting-color: #f8f8f8; -$filter-name-text-color: rgba(0, 0, 0, 0.55); -$filter-value-text-color: rgba(0, 0, 0, 0.85); -$filter-name-selected-color: #ebebeb; -$filter-value-selected-color: #d7d7d7; - -/* -Animation Functions -*/ -$dropdown-animation-timing: cubic-bezier(0.23, 1, 0.32, 1); - -/* GitLab Plans */ $gl-gold-plan: #d4af37; diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index 91bed4fc9f2..2a811e08fd3 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -54,6 +54,11 @@ .mh-50vh { max-height: 50vh; } +.min-width-0 { + // By default flex items don't shrink below their minimum content size. To change this, set the item's min-width + min-width: 0; +} + .font-size-inherit { font-size: inherit; } .gl-w-8 { width: px-to-rem($grid-size); } .gl-w-16 { width: px-to-rem($grid-size * 2); } diff --git a/app/models/group.rb b/app/models/group.rb index 203ed1694b7..f4eaa581d54 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -475,6 +475,16 @@ class Group < Namespace false end + def wiki_access_level + # TODO: Remove this method once we implement group-level features. + # https://gitlab.com/gitlab-org/gitlab/-/issues/208412 + if Feature.enabled?(:group_wiki, self) + ProjectFeature::ENABLED + else + ProjectFeature::DISABLED + end + end + private def update_two_factor_requirement diff --git a/app/policies/project_policy/class_methods.rb b/app/policies/concerns/crud_policy_helpers.rb index 42d993406a9..d8521ca22cc 100644 --- a/app/policies/project_policy/class_methods.rb +++ b/app/policies/concerns/crud_policy_helpers.rb @@ -1,7 +1,9 @@ # frozen_string_literal: true -class ProjectPolicy - module ClassMethods +module CrudPolicyHelpers + extend ActiveSupport::Concern + + class_methods do def create_read_update_admin_destroy(name) [ :"read_#{name}", diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index 5e252c8e564..a34217d90dd 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class GroupPolicy < BasePolicy + include CrudPolicyHelpers include FindGroupProjects desc "Group is public" @@ -42,15 +43,23 @@ class GroupPolicy < BasePolicy @subject.subgroup_creation_level == ::Gitlab::Access::MAINTAINER_SUBGROUP_ACCESS end + desc "Group has wiki disabled" + condition(:wiki_disabled, score: 32) { !feature_available?(:wiki) } + rule { public_group }.policy do enable :read_group enable :read_package + enable :read_wiki end - rule { logged_in_viewable }.enable :read_group + rule { logged_in_viewable }.policy do + enable :read_group + enable :read_wiki + end rule { guest }.policy do enable :read_group + enable :read_wiki enable :upload_file end @@ -78,10 +87,12 @@ class GroupPolicy < BasePolicy enable :create_metrics_dashboard_annotation enable :delete_metrics_dashboard_annotation enable :update_metrics_dashboard_annotation + enable :create_wiki end rule { reporter }.policy do enable :read_container_image + enable :download_wiki_code enable :admin_label enable :admin_list enable :admin_issue @@ -100,6 +111,7 @@ class GroupPolicy < BasePolicy enable :destroy_deploy_token enable :read_deploy_token enable :create_deploy_token + enable :admin_wiki end rule { owner }.policy do @@ -145,6 +157,11 @@ class GroupPolicy < BasePolicy rule { maintainer & can?(:create_projects) }.enable :transfer_projects + rule { wiki_disabled }.policy do + prevent(*create_read_update_admin_destroy(:wiki)) + prevent(:download_wiki_code) + end + def access_level return GroupMember::NO_ACCESS if @user.nil? @@ -154,6 +171,21 @@ class GroupPolicy < BasePolicy def lookup_access_level! @subject.max_member_access_for_user(@user) end + + # TODO: Extract this into a helper shared with ProjectPolicy, once we implement group-level features. + # https://gitlab.com/gitlab-org/gitlab/-/issues/208412 + def feature_available?(feature) + return false unless feature == :wiki + + case @subject.wiki_access_level + when ProjectFeature::DISABLED + false + when ProjectFeature::PRIVATE + admin? || access_level >= ProjectFeature.required_minimum_access_level(feature) + else + true + end + end end GroupPolicy.prepend_if_ee('EE::GroupPolicy') diff --git a/app/policies/issue_policy.rb b/app/policies/issue_policy.rb index f86892227df..20df823c737 100644 --- a/app/policies/issue_policy.rb +++ b/app/policies/issue_policy.rb @@ -5,7 +5,7 @@ class IssuePolicy < IssuablePolicy # Make sure to sync this class checks with issue.rb to avoid security problems. # Check commit 002ad215818450d2cbbc5fa065850a953dc7ada8 for more information. - extend ProjectPolicy::ClassMethods + include CrudPolicyHelpers desc "User can read confidential issues" condition(:can_read_confidential) do diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 0f5e4ac378e..7454343a357 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class ProjectPolicy < BasePolicy - extend ClassMethods + include CrudPolicyHelpers READONLY_FEATURES_WHEN_ARCHIVED = %i[ issue diff --git a/app/services/prometheus/create_default_alerts_service.rb b/app/services/prometheus/create_default_alerts_service.rb index 3eb5ad7711a..c87cbbbe3cf 100644 --- a/app/services/prometheus/create_default_alerts_service.rb +++ b/app/services/prometheus/create_default_alerts_service.rb @@ -16,6 +16,11 @@ module Prometheus identifier: 'response_metrics_nginx_ingress_http_error_rate', operator: 'gt', threshold: 0.1 + }, + { + identifier: 'response_metrics_nginx_http_error_percentage', + operator: 'gt', + threshold: 0.1 } ].freeze diff --git a/changelogs/unreleased/207912-integrate-filtered-search-component.yml b/changelogs/unreleased/207912-integrate-filtered-search-component.yml new file mode 100644 index 00000000000..476acfd4964 --- /dev/null +++ b/changelogs/unreleased/207912-integrate-filtered-search-component.yml @@ -0,0 +1,5 @@ +--- +title: Add filtered search for elastic search in logs +merge_request: 27654 +author: +type: added diff --git a/doc/administration/gitaly/praefect.md b/doc/administration/gitaly/praefect.md index 737deaa7f4e..61c3d7a4042 100644 --- a/doc/administration/gitaly/praefect.md +++ b/doc/administration/gitaly/praefect.md @@ -194,8 +194,6 @@ application server, or a Gitaly node. - `PRAEFECT_HOST` with the IP address or hostname of the Praefect node ```ruby - # Make Praefect accept connections on all network interfaces. - # Use firewalls to restrict access to this address/port. praefect['listen_addr'] = 'PRAEFECT_HOST:2305' # Enable Prometheus metrics access to Praefect. You must use firewalls @@ -532,7 +530,7 @@ Particular attention should be shown to: `/etc/gitlab/gitlab.rb` ```ruby - gitaly['listen_addr'] = 'tcp://GITLAB_HOST:8075' + gitaly['listen_addr'] = 'GITLAB_HOST:8075' ``` 1. Configure the `gitlab_shell['secret_token']` so that callbacks from Gitaly diff --git a/doc/administration/scaling/index.md b/doc/administration/scaling/index.md index c91fe395b3d..ec7492883cc 100644 --- a/doc/administration/scaling/index.md +++ b/doc/administration/scaling/index.md @@ -40,9 +40,13 @@ needs. | Object storage service | Recommended store for shared data objects | [Cloud Object Storage configuration](../high_availability/object_storage.md) | | NFS | Shared disk storage service. Can be used as an alternative for Gitaly or Object Storage. Required for GitLab Pages | [NFS configuration](../high_availability/nfs.md) | -## Examples +## Reference architectures + +- 1 - 1000 Users: A single-node [Omnibus](https://docs.gitlab.com/omnibus/) setup with frequent backups. Refer to the [Single-node Omnibus installation](#single-node-installation) section below. +- 1000 to 50000+ Users: A [Scaled-out Omnibus installation with multiple servers](#multi-node-installation-scaled-out-for-availability), it can be with or without high-availability components applied. + - To decide the level of Availability please refer to our [Availability](../availability/index.md) page. -### Single-node Omnibus installation +### Single-node installation This solution is appropriate for many teams that have a single server at their disposal. With automatic backup of the GitLab repositories, configuration, and the database, this can be an optimal solution if you don't have strict availability requirements. @@ -55,7 +59,7 @@ References: - [Installation Docs](../../install/README.md) - [Backup/Restore Docs](https://docs.gitlab.com/omnibus/settings/backups.html#backup-and-restore-omnibus-gitlab-configuration) -### Omnibus installation with multiple application servers +### Multi-node installation (scaled out for availability) This solution is appropriate for teams that are starting to scale out when scaling up is no longer meeting their needs. In this configuration, additional application nodes will handle frontend traffic, with a load balancer in front to distribute traffic across those nodes. Meanwhile, each application node connects to a shared file server and PostgreSQL and Redis services on the back end. @@ -72,14 +76,6 @@ References: - [Configure packaged PostgreSQL server to listen on TCP/IP](https://docs.gitlab.com/omnibus/settings/database.html#configure-packaged-postgresql-server-to-listen-on-tcpip) - [Setting up a Redis-only server](https://docs.gitlab.com/omnibus/settings/redis.html#setting-up-a-redis-only-server) -## Recommended setups based on number of users - -- 1 - 1000 Users: A single-node [Omnibus](https://docs.gitlab.com/omnibus/) setup with frequent backups. Refer to the [requirements page](../../install/requirements.md) for further details of the specs you will require. -- 1000 - 10000 Users: A scaled environment based on one of our [Reference Architectures](#reference-architectures), without the HA components applied. This can be a reasonable step towards a fully HA environment. -- 2000 - 50000+ Users: A scaled HA environment based on one of our [Reference Architectures](#reference-architectures) below. - -## Reference architectures - In this section we'll detail the Reference Architectures that can support large numbers of users. These were built, tested and verified by our Quality and Support teams. @@ -99,7 +95,7 @@ how much automation you use, mirroring, and repo/change size. Additionally the shown memory values are given directly by [GCP machine types](https://cloud.google.com/compute/docs/machine-types). On different cloud vendors a best effort like for like can be used. -### 2,000 user configuration +#### 2,000 user configuration - **Supported users (approximate):** 2,000 - **Test RPS rates:** API: 40 RPS, Web: 4 RPS, Git: 4 RPS @@ -120,7 +116,7 @@ On different cloud vendors a best effort like for like can be used. | External load balancing node[^6] | 1 | 2 vCPU, 1.8GB Memory | n1-highcpu-2 | c5.large | | Internal load balancing node[^6] | 1 | 2 vCPU, 1.8GB Memory | n1-highcpu-2 | c5.large | -### 5,000 user configuration +#### 5,000 user configuration - **Supported users (approximate):** 5,000 - **Test RPS rates:** API: 100 RPS, Web: 10 RPS, Git: 10 RPS @@ -141,7 +137,7 @@ On different cloud vendors a best effort like for like can be used. | External load balancing node[^6] | 1 | 2 vCPU, 1.8GB Memory | n1-highcpu-2 | c5.large | | Internal load balancing node[^6] | 1 | 2 vCPU, 1.8GB Memory | n1-highcpu-2 | c5.large | -### 10,000 user configuration +#### 10,000 user configuration - **Supported users (approximate):** 10,000 - **Test RPS rates:** API: 200 RPS, Web: 20 RPS, Git: 20 RPS @@ -165,7 +161,7 @@ On different cloud vendors a best effort like for like can be used. | External load balancing node[^6] | 1 | 2 vCPU, 1.8GB Memory | n1-highcpu-2 | c5.large | | Internal load balancing node[^6] | 1 | 2 vCPU, 1.8GB Memory | n1-highcpu-2 | c5.large | -### 25,000 user configuration +#### 25,000 user configuration - **Supported users (approximate):** 25,000 - **Test RPS rates:** API: 500 RPS, Web: 50 RPS, Git: 50 RPS @@ -189,7 +185,7 @@ On different cloud vendors a best effort like for like can be used. | External load balancing node[^6] | 1 | 2 vCPU, 1.8GB Memory | n1-highcpu-2 | c5.large | | Internal load balancing node[^6] | 1 | 4 vCPU, 3.6GB Memory | n1-highcpu-4 | c5.xlarge | -### 50,000 user configuration +#### 50,000 user configuration - **Supported users (approximate):** 50,000 - **Test RPS rates:** API: 1000 RPS, Web: 100 RPS, Git: 100 RPS diff --git a/doc/user/project/clusters/img/kubernetes_pod_logs_v12_10.png b/doc/user/project/clusters/img/kubernetes_pod_logs_v12_10.png Binary files differnew file mode 100644 index 00000000000..abac22e3f1f --- /dev/null +++ b/doc/user/project/clusters/img/kubernetes_pod_logs_v12_10.png diff --git a/doc/user/project/clusters/img/kubernetes_pod_logs_v12_9.png b/doc/user/project/clusters/img/kubernetes_pod_logs_v12_9.png Binary files differdeleted file mode 100644 index 02b7cad1e3f..00000000000 --- a/doc/user/project/clusters/img/kubernetes_pod_logs_v12_9.png +++ /dev/null diff --git a/doc/user/project/clusters/img/sidebar_menu_pod_logs_v12_10.png b/doc/user/project/clusters/img/sidebar_menu_pod_logs_v12_10.png Binary files differnew file mode 100644 index 00000000000..ee37970d867 --- /dev/null +++ b/doc/user/project/clusters/img/sidebar_menu_pod_logs_v12_10.png diff --git a/doc/user/project/clusters/img/sidebar_menu_pod_logs_v12_5.png b/doc/user/project/clusters/img/sidebar_menu_pod_logs_v12_5.png Binary files differdeleted file mode 100644 index f113b0353f2..00000000000 --- a/doc/user/project/clusters/img/sidebar_menu_pod_logs_v12_5.png +++ /dev/null diff --git a/doc/user/project/clusters/kubernetes_pod_logs.md b/doc/user/project/clusters/kubernetes_pod_logs.md index e02c105e628..5543187b6de 100644 --- a/doc/user/project/clusters/kubernetes_pod_logs.md +++ b/doc/user/project/clusters/kubernetes_pod_logs.md @@ -14,7 +14,7 @@ Everything you need to build, test, deploy, and run your app at scale. [Kubernetes](https://kubernetes.io) logs can be viewed directly within GitLab. -![Pod logs](img/kubernetes_pod_logs_v12_9.png) +![Pod logs](img/kubernetes_pod_logs_v12_10.png) ## Requirements @@ -32,7 +32,7 @@ You can access them in two ways. Go to **{cloud-gear}** **Operations > Logs** on the sidebar menu. -![Sidebar menu](img/sidebar_menu_pod_logs_v12_5.png) +![Sidebar menu](img/sidebar_menu_pod_logs_v12_10.png) ### From Deploy Boards diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 9b39c97cb38..5f9011b4441 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -7869,9 +7869,6 @@ msgstr "" msgid "EnvironmentsDashboard|This dashboard displays a maximum of 7 projects and 3 environments per project. %{readMoreLink}" msgstr "" -msgid "Environments|All pods" -msgstr "" - msgid "Environments|An error occurred while canceling the auto stop, please try again" msgstr "" @@ -7938,9 +7935,6 @@ msgstr "" msgid "Environments|Environments are places where code gets deployed, such as staging or production." msgstr "" -msgid "Environments|Filter by pod" -msgstr "" - msgid "Environments|Install Elastic Stack on your cluster to enable advanced querying capabilities such as full text search." msgstr "" @@ -7980,6 +7974,9 @@ msgstr "" msgid "Environments|Open live environment" msgstr "" +msgid "Environments|Pod name" +msgstr "" + msgid "Environments|Re-deploy" msgstr "" @@ -8007,9 +8004,6 @@ msgstr "" msgid "Environments|Rollback environment %{name}?" msgstr "" -msgid "Environments|Search" -msgstr "" - msgid "Environments|Select environment" msgstr "" @@ -19365,12 +19359,6 @@ msgstr "" msgid "Static Application Security Testing (SAST)" msgstr "" -msgid "StaticSiteEditor|A merge request was created:" -msgstr "" - -msgid "StaticSiteEditor|A new branch was created:" -msgstr "" - msgid "StaticSiteEditor|Return to site" msgstr "" @@ -19383,10 +19371,16 @@ msgstr "" msgid "StaticSiteEditor|View merge request" msgstr "" -msgid "StaticSiteEditor|Your changes have been submitted and a merge request has been created. The changes won’t be visible on the site until the merge request has been accepted." +msgid "StaticSiteEditor|You added a commit:" msgstr "" -msgid "StaticSiteEditor|Your changes were committed to it:" +msgid "StaticSiteEditor|You created a merge request:" +msgstr "" + +msgid "StaticSiteEditor|You created a new branch:" +msgstr "" + +msgid "StaticSiteEditor|Your changes have been submitted and a merge request has been created. The changes won’t be visible on the site until the merge request has been accepted." msgstr "" msgid "Statistics" diff --git a/spec/frontend/logs/components/environment_logs_spec.js b/spec/frontend/logs/components/environment_logs_spec.js index d097610cb0a..9046253bdc6 100644 --- a/spec/frontend/logs/components/environment_logs_spec.js +++ b/spec/frontend/logs/components/environment_logs_spec.js @@ -10,7 +10,6 @@ import { mockPods, mockLogsResult, mockTrace, - mockPodName, mockEnvironmentsEndpoint, mockDocumentationPath, } from '../mock_data'; @@ -302,11 +301,11 @@ describe('EnvironmentLogs', () => { }); it('refresh button, trace is refreshed', () => { - expect(dispatch).not.toHaveBeenCalledWith(`${module}/showPodLogs`, expect.anything()); + expect(dispatch).not.toHaveBeenCalledWith(`${module}/fetchLogs`, undefined); findLogControlButtons().vm.$emit('refresh'); - expect(dispatch).toHaveBeenCalledWith(`${module}/showPodLogs`, mockPodName); + expect(dispatch).toHaveBeenCalledWith(`${module}/fetchLogs`, undefined); }); }); }); diff --git a/spec/frontend/logs/components/log_advanced_filters_spec.js b/spec/frontend/logs/components/log_advanced_filters_spec.js index a6fbc40c2c6..adcd6b4fb07 100644 --- a/spec/frontend/logs/components/log_advanced_filters_spec.js +++ b/spec/frontend/logs/components/log_advanced_filters_spec.js @@ -1,8 +1,9 @@ -import { GlIcon, GlDropdownItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { defaultTimeRange } from '~/vue_shared/constants'; +import { GlFilteredSearch } from '@gitlab/ui'; import { convertToFixedRange } from '~/lib/utils/datetime_range'; import { createStore } from '~/logs/stores'; +import { TOKEN_TYPE_POD_NAME } from '~/logs/constants'; import { mockPods, mockSearch } from '../mock_data'; import LogAdvancedFilters from '~/logs/components/log_advanced_filters.vue'; @@ -15,26 +16,19 @@ describe('LogAdvancedFilters', () => { let wrapper; let state; - const findPodsDropdown = () => wrapper.find({ ref: 'podsDropdown' }); - const findPodsNoPodsText = () => wrapper.find({ ref: 'noPodsMsg' }); - const findPodsDropdownItems = () => - findPodsDropdown() - .findAll(GlDropdownItem) - .filter(item => !item.is('[disabled]')); - const findPodsDropdownItemsSelected = () => - findPodsDropdownItems() - .filter(item => { - return !item.find(GlIcon).classes('invisible'); - }) - .at(0); - const findSearchBox = () => wrapper.find({ ref: 'searchBox' }); + const findFilteredSearch = () => wrapper.find(GlFilteredSearch); const findTimeRangePicker = () => wrapper.find({ ref: 'dateTimePicker' }); + const getSearchToken = type => + findFilteredSearch() + .props('availableTokens') + .filter(token => token.type === type)[0]; const mockStateLoading = () => { state.timeRange.selected = defaultTimeRange; state.timeRange.current = convertToFixedRange(defaultTimeRange); state.pods.options = []; state.pods.current = null; + state.logs.isLoading = true; }; const mockStateWithData = () => { @@ -42,6 +36,7 @@ describe('LogAdvancedFilters', () => { state.timeRange.current = convertToFixedRange(defaultTimeRange); state.pods.options = mockPods; state.pods.current = null; + state.logs.isLoading = false; }; const initWrapper = (propsData = {}) => { @@ -76,11 +71,18 @@ describe('LogAdvancedFilters', () => { expect(wrapper.isVueInstance()).toBe(true); expect(wrapper.isEmpty()).toBe(false); - expect(findPodsDropdown().exists()).toBe(true); - expect(findSearchBox().exists()).toBe(true); + expect(findFilteredSearch().exists()).toBe(true); expect(findTimeRangePicker().exists()).toBe(true); }); + it('displays search tokens', () => { + expect(getSearchToken(TOKEN_TYPE_POD_NAME)).toMatchObject({ + title: 'Pod name', + unique: true, + operators: [expect.objectContaining({ value: '=' })], + }); + }); + describe('disabled state', () => { beforeEach(() => { mockStateLoading(); @@ -90,9 +92,7 @@ describe('LogAdvancedFilters', () => { }); it('displays disabled filters', () => { - expect(findPodsDropdown().props('text')).toBe('All pods'); - expect(findPodsDropdown().attributes('disabled')).toBeTruthy(); - expect(findSearchBox().attributes('disabled')).toBeTruthy(); + expect(findFilteredSearch().attributes('disabled')).toBeTruthy(); expect(findTimeRangePicker().attributes('disabled')).toBeTruthy(); }); }); @@ -103,16 +103,17 @@ describe('LogAdvancedFilters', () => { initWrapper(); }); - it('displays a enabled filters', () => { - expect(findPodsDropdown().props('text')).toBe('All pods'); - expect(findPodsDropdown().attributes('disabled')).toBeFalsy(); - expect(findSearchBox().attributes('disabled')).toBeFalsy(); + it('displays a disabled search', () => { + expect(findFilteredSearch().attributes('disabled')).toBeTruthy(); + }); + + it('displays an enable date filter', () => { expect(findTimeRangePicker().attributes('disabled')).toBeFalsy(); }); - it('displays an empty pods dropdown', () => { - expect(findPodsNoPodsText().exists()).toBe(true); - expect(findPodsDropdownItems()).toHaveLength(0); + it('displays no pod options when no pods are available, so suggestions can be displayed', () => { + expect(getSearchToken(TOKEN_TYPE_POD_NAME).options).toBe(null); + expect(getSearchToken(TOKEN_TYPE_POD_NAME).loading).toBe(true); }); }); @@ -122,20 +123,24 @@ describe('LogAdvancedFilters', () => { initWrapper(); }); - it('displays an enabled pods dropdown', () => { - expect(findPodsDropdown().attributes('disabled')).toBeFalsy(); - expect(findPodsDropdown().props('text')).toBe('All pods'); + it('displays a single token for pods', () => { + initWrapper(); + + const tokens = findFilteredSearch().props('availableTokens'); + + expect(tokens).toHaveLength(1); + expect(tokens[0].type).toBe(TOKEN_TYPE_POD_NAME); }); - it('displays options in a pods dropdown', () => { - const items = findPodsDropdownItems(); - expect(items).toHaveLength(mockPods.length + 1); + it('displays a enabled filters', () => { + expect(findFilteredSearch().attributes('disabled')).toBeFalsy(); + expect(findTimeRangePicker().attributes('disabled')).toBeFalsy(); }); - it('displays "all pods" selected in a pods dropdown', () => { - const selected = findPodsDropdownItemsSelected(); + it('displays options in the pods token', () => { + const { options } = getSearchToken(TOKEN_TYPE_POD_NAME); - expect(selected.text()).toBe('All pods'); + expect(options).toHaveLength(mockPods.length); }); it('displays options in date time picker', () => { @@ -146,30 +151,16 @@ describe('LogAdvancedFilters', () => { }); describe('when the user interacts', () => { - it('clicks on a all options, showPodLogs is dispatched with null', () => { - const items = findPodsDropdownItems(); - items.at(0).vm.$emit('click'); - - expect(dispatch).toHaveBeenCalledWith(`${module}/showPodLogs`, null); - }); - - it('clicks on a pod name, showPodLogs is dispatched with pod name', () => { - const items = findPodsDropdownItems(); - const index = 2; // any pod + it('clicks on the search button, showFilteredLogs is dispatched', () => { + findFilteredSearch().vm.$emit('submit', null); - items.at(index + 1).vm.$emit('click'); // skip "All pods" option - - expect(dispatch).toHaveBeenCalledWith(`${module}/showPodLogs`, mockPods[index]); + expect(dispatch).toHaveBeenCalledWith(`${module}/showFilteredLogs`, null); }); - it('clicks on search, a serches is done', () => { - expect(findSearchBox().attributes('disabled')).toBeFalsy(); - - // input a query and click `search` - findSearchBox().vm.$emit('input', mockSearch); - findSearchBox().vm.$emit('submit'); + it('clicks on the search button, showFilteredLogs is dispatched with null', () => { + findFilteredSearch().vm.$emit('submit', [mockSearch]); - expect(dispatch).toHaveBeenCalledWith(`${module}/setSearch`, mockSearch); + expect(dispatch).toHaveBeenCalledWith(`${module}/showFilteredLogs`, [mockSearch]); }); it('selects a new time range', () => { diff --git a/spec/frontend/logs/components/tokens/token_with_loading_state_spec.js b/spec/frontend/logs/components/tokens/token_with_loading_state_spec.js new file mode 100644 index 00000000000..d98d7d05c92 --- /dev/null +++ b/spec/frontend/logs/components/tokens/token_with_loading_state_spec.js @@ -0,0 +1,68 @@ +import { GlFilteredSearchToken, GlLoadingIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; + +import TokenWithLoadingState from '~/logs/components/tokens/token_with_loading_state.vue'; + +describe('TokenWithLoadingState', () => { + let wrapper; + + const findFilteredSearchToken = () => wrapper.find(GlFilteredSearchToken); + const findLoadingIcon = () => wrapper.find(GlLoadingIcon); + + const initWrapper = (props = {}, options) => { + wrapper = shallowMount(TokenWithLoadingState, { + propsData: props, + ...options, + }); + }; + + beforeEach(() => {}); + + it('passes entire config correctly', () => { + const config = { + icon: 'pod', + type: 'pod', + title: 'Pod name', + unique: true, + }; + + initWrapper({ config }); + + expect(findFilteredSearchToken().props('config')).toEqual(config); + }); + + describe('suggestions are replaced', () => { + let mockNoOptsText; + let config; + let stubs; + + beforeEach(() => { + mockNoOptsText = 'No suggestions available'; + config = { + loading: false, + noOptionsText: mockNoOptsText, + }; + stubs = { + GlFilteredSearchToken: { + template: `<div><slot name="suggestions"></slot></div>`, + }, + }; + }); + + it('renders a loading icon', () => { + config.loading = true; + + initWrapper({ config }, { stubs }); + + expect(findLoadingIcon().exists()).toBe(true); + expect(wrapper.text()).toBe(''); + }); + + it('renders an empty results message', () => { + initWrapper({ config }, { stubs }); + + expect(findLoadingIcon().exists()).toBe(false); + expect(wrapper.text()).toBe(mockNoOptsText); + }); + }); +}); diff --git a/spec/frontend/logs/stores/actions_spec.js b/spec/frontend/logs/stores/actions_spec.js index 882673af984..6199c400e16 100644 --- a/spec/frontend/logs/stores/actions_spec.js +++ b/spec/frontend/logs/stores/actions_spec.js @@ -6,7 +6,7 @@ import { convertToFixedRange } from '~/lib/utils/datetime_range'; import logsPageState from '~/logs/stores/state'; import { setInitData, - setSearch, + showFilteredLogs, showPodLogs, fetchEnvironments, fetchLogs, @@ -31,6 +31,7 @@ import { mockCursor, mockNextCursor, } from '../mock_data'; +import { TOKEN_TYPE_POD_NAME } from '~/logs/constants'; jest.mock('~/flash'); jest.mock('~/lib/utils/datetime_range'); @@ -93,13 +94,80 @@ describe('Logs Store actions', () => { )); }); - describe('setSearch', () => { - it('should commit search mutation', () => + describe('showFilteredLogs', () => { + it('empty search should filter with defaults', () => testAction( - setSearch, - mockSearch, + showFilteredLogs, + undefined, state, - [{ type: types.SET_SEARCH, payload: mockSearch }], + [ + { type: types.SET_CURRENT_POD_NAME, payload: null }, + { type: types.SET_SEARCH, payload: '' }, + ], + [{ type: 'fetchLogs' }], + )); + + it('text search should filter with a search term', () => + testAction( + showFilteredLogs, + [mockSearch], + state, + [ + { type: types.SET_CURRENT_POD_NAME, payload: null }, + { type: types.SET_SEARCH, payload: mockSearch }, + ], + [{ type: 'fetchLogs' }], + )); + + it('pod search should filter with a search term', () => + testAction( + showFilteredLogs, + [{ type: TOKEN_TYPE_POD_NAME, value: { data: mockPodName, operator: '=' } }], + state, + [ + { type: types.SET_CURRENT_POD_NAME, payload: mockPodName }, + { type: types.SET_SEARCH, payload: '' }, + ], + [{ type: 'fetchLogs' }], + )); + + it('pod search should filter with a pod selection and a search term', () => + testAction( + showFilteredLogs, + [{ type: TOKEN_TYPE_POD_NAME, value: { data: mockPodName, operator: '=' } }, mockSearch], + state, + [ + { type: types.SET_CURRENT_POD_NAME, payload: mockPodName }, + { type: types.SET_SEARCH, payload: mockSearch }, + ], + [{ type: 'fetchLogs' }], + )); + + it('pod search should filter with a pod selection and two search terms', () => + testAction( + showFilteredLogs, + ['term1', 'term2'], + state, + [ + { type: types.SET_CURRENT_POD_NAME, payload: null }, + { type: types.SET_SEARCH, payload: `term1 term2` }, + ], + [{ type: 'fetchLogs' }], + )); + + it('pod search should filter with a pod selection and a search terms before and after', () => + testAction( + showFilteredLogs, + [ + 'term1', + { type: TOKEN_TYPE_POD_NAME, value: { data: mockPodName, operator: '=' } }, + 'term2', + ], + state, + [ + { type: types.SET_CURRENT_POD_NAME, payload: mockPodName }, + { type: types.SET_SEARCH, payload: `term1 term2` }, + ], [{ type: 'fetchLogs' }], )); }); diff --git a/spec/policies/group_policy_spec.rb b/spec/policies/group_policy_spec.rb index 5a9ca9f7b7e..13f1bcb389a 100644 --- a/spec/policies/group_policy_spec.rb +++ b/spec/policies/group_policy_spec.rb @@ -655,4 +655,26 @@ describe GroupPolicy do end end end + + it_behaves_like 'model with wiki policies' do + let(:container) { create(:group) } + + def set_access_level(access_level) + allow(container).to receive(:wiki_access_level).and_return(access_level) + end + + before do + stub_feature_flags(group_wiki: true) + end + + context 'when the feature flag is disabled' do + before do + stub_feature_flags(group_wiki: false) + end + + it 'does not include the wiki permissions' do + expect_disallowed(*permissions) + end + end + end end diff --git a/spec/policies/project_policy_spec.rb b/spec/policies/project_policy_spec.rb index d098369e124..db643e3a31f 100644 --- a/spec/policies/project_policy_spec.rb +++ b/spec/policies/project_policy_spec.rb @@ -121,147 +121,11 @@ describe ProjectPolicy do expect(Ability).not_to be_allowed(user, :read_issue, project) end - context 'wiki feature' do - let(:permissions) { %i(read_wiki create_wiki update_wiki admin_wiki download_wiki_code) } + it_behaves_like 'model with wiki policies' do + let(:container) { project } - subject { described_class.new(owner, project) } - - context 'when the feature is disabled' do - before do - project.project_feature.update_attribute(:wiki_access_level, ProjectFeature::DISABLED) - end - - it 'does not include the wiki permissions' do - expect_disallowed(*permissions) - end - - context 'when there is an external wiki' do - it 'does not include the wiki permissions' do - allow(project).to receive(:has_external_wiki?).and_return(true) - - expect_disallowed(*permissions) - end - end - end - - describe 'read_wiki' do - subject { described_class.new(user, project) } - - member_roles = %i[guest developer] - stranger_roles = %i[anonymous non_member] - - user_roles = stranger_roles + member_roles - - # When a user is anonymous, their `current_user == nil` - let(:user) { create(:user) unless user_role == :anonymous } - - before do - project.visibility = project_visibility - project.project_feature.update_attribute(:wiki_access_level, wiki_access_level) - project.add_user(user, user_role) if member_roles.include?(user_role) - end - - title = ->(project_visibility, wiki_access_level, user_role) do - [ - "project is #{Gitlab::VisibilityLevel.level_name project_visibility}", - "wiki is #{ProjectFeature.str_from_access_level wiki_access_level}", - "user is #{user_role}" - ].join(', ') - end - - describe 'Situations where :read_wiki is always false' do - where(case_names: title, - project_visibility: Gitlab::VisibilityLevel.options.values, - wiki_access_level: [ProjectFeature::DISABLED], - user_role: user_roles) - - with_them do - it { is_expected.to be_disallowed(:read_wiki) } - end - end - - describe 'Situations where :read_wiki is always true' do - where(case_names: title, - project_visibility: [Gitlab::VisibilityLevel::PUBLIC], - wiki_access_level: [ProjectFeature::ENABLED], - user_role: user_roles) - - with_them do - it { is_expected.to be_allowed(:read_wiki) } - end - end - - describe 'Situations where :read_wiki requires project membership' do - context 'the wiki is private, and the user is a member' do - where(case_names: title, - project_visibility: [Gitlab::VisibilityLevel::PUBLIC, - Gitlab::VisibilityLevel::INTERNAL], - wiki_access_level: [ProjectFeature::PRIVATE], - user_role: member_roles) - - with_them do - it { is_expected.to be_allowed(:read_wiki) } - end - end - - context 'the wiki is private, and the user is not member' do - where(case_names: title, - project_visibility: [Gitlab::VisibilityLevel::PUBLIC, - Gitlab::VisibilityLevel::INTERNAL], - wiki_access_level: [ProjectFeature::PRIVATE], - user_role: stranger_roles) - - with_them do - it { is_expected.to be_disallowed(:read_wiki) } - end - end - - context 'the wiki is enabled, and the user is a member' do - where(case_names: title, - project_visibility: [Gitlab::VisibilityLevel::PRIVATE], - wiki_access_level: [ProjectFeature::ENABLED], - user_role: member_roles) - - with_them do - it { is_expected.to be_allowed(:read_wiki) } - end - end - - context 'the wiki is enabled, and the user is not a member' do - where(case_names: title, - project_visibility: [Gitlab::VisibilityLevel::PRIVATE], - wiki_access_level: [ProjectFeature::ENABLED], - user_role: stranger_roles) - - with_them do - it { is_expected.to be_disallowed(:read_wiki) } - end - end - end - - describe 'Situations where :read_wiki prohibits anonymous access' do - context 'the user is not anonymous' do - where(case_names: title, - project_visibility: [Gitlab::VisibilityLevel::INTERNAL], - wiki_access_level: [ProjectFeature::ENABLED, ProjectFeature::PUBLIC], - user_role: user_roles.reject { |u| u == :anonymous }) - - with_them do - it { is_expected.to be_allowed(:read_wiki) } - end - end - - context 'the user is not anonymous' do - where(case_names: title, - project_visibility: [Gitlab::VisibilityLevel::INTERNAL], - wiki_access_level: [ProjectFeature::ENABLED, ProjectFeature::PUBLIC], - user_role: %i[anonymous]) - - with_them do - it { is_expected.to be_disallowed(:read_wiki) } - end - end - end + def set_access_level(access_level) + project.project_feature.update_attribute(:wiki_access_level, access_level) end end diff --git a/spec/support/shared_contexts/policies/group_policy_shared_context.rb b/spec/support/shared_contexts/policies/group_policy_shared_context.rb index 4f81a71f586..c2797c49c02 100644 --- a/spec/support/shared_contexts/policies/group_policy_shared_context.rb +++ b/spec/support/shared_contexts/policies/group_policy_shared_context.rb @@ -14,16 +14,17 @@ RSpec.shared_context 'GroupPolicy context' do %i[ read_label read_group upload_file read_namespace read_group_activity read_group_issues read_group_boards read_group_labels read_group_milestones - read_group_merge_requests + read_group_merge_requests read_wiki ] end let(:read_group_permissions) { %i[read_label read_list read_milestone read_board] } - let(:reporter_permissions) { %i[admin_label read_container_image read_metrics_dashboard_annotation] } - let(:developer_permissions) { %i[admin_milestone create_metrics_dashboard_annotation delete_metrics_dashboard_annotation update_metrics_dashboard_annotation] } + let(:reporter_permissions) { %i[admin_label read_container_image read_metrics_dashboard_annotation download_wiki_code] } + let(:developer_permissions) { %i[admin_milestone create_metrics_dashboard_annotation delete_metrics_dashboard_annotation update_metrics_dashboard_annotation create_wiki] } let(:maintainer_permissions) do %i[ create_projects read_cluster create_cluster update_cluster admin_cluster add_cluster + admin_wiki ] end let(:owner_permissions) do diff --git a/spec/support/shared_examples/policies/wiki_policies_shared_examples.rb b/spec/support/shared_examples/policies/wiki_policies_shared_examples.rb new file mode 100644 index 00000000000..b91500ffd9c --- /dev/null +++ b/spec/support/shared_examples/policies/wiki_policies_shared_examples.rb @@ -0,0 +1,152 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'model with wiki policies' do + let(:container) { raise NotImplementedError } + let(:permissions) { %i(read_wiki create_wiki update_wiki admin_wiki download_wiki_code) } + + # TODO: Remove this helper once we implement group features + # https://gitlab.com/gitlab-org/gitlab/-/issues/208412 + def set_access_level(access_level) + raise NotImplementedError + end + + subject { described_class.new(owner, container) } + + context 'when the feature is disabled' do + before do + set_access_level(ProjectFeature::DISABLED) + end + + it 'does not include the wiki permissions' do + expect_disallowed(*permissions) + end + + context 'when there is an external wiki' do + it 'does not include the wiki permissions' do + allow(container).to receive(:has_external_wiki?).and_return(true) + + expect_disallowed(*permissions) + end + end + end + + describe 'read_wiki' do + subject { described_class.new(user, container) } + + member_roles = %i[guest developer] + stranger_roles = %i[anonymous non_member] + + user_roles = stranger_roles + member_roles + + # When a user is anonymous, their `current_user == nil` + let(:user) { create(:user) unless user_role == :anonymous } + + before do + container.visibility = container_visibility + set_access_level(wiki_access_level) + container.add_user(user, user_role) if member_roles.include?(user_role) + end + + title = ->(container_visibility, wiki_access_level, user_role) do + [ + "container is #{Gitlab::VisibilityLevel.level_name container_visibility}", + "wiki is #{ProjectFeature.str_from_access_level wiki_access_level}", + "user is #{user_role}" + ].join(', ') + end + + describe 'Situations where :read_wiki is always false' do + where(case_names: title, + container_visibility: Gitlab::VisibilityLevel.options.values, + wiki_access_level: [ProjectFeature::DISABLED], + user_role: user_roles) + + with_them do + it { is_expected.to be_disallowed(:read_wiki) } + end + end + + describe 'Situations where :read_wiki is always true' do + where(case_names: title, + container_visibility: [Gitlab::VisibilityLevel::PUBLIC], + wiki_access_level: [ProjectFeature::ENABLED], + user_role: user_roles) + + with_them do + it { is_expected.to be_allowed(:read_wiki) } + end + end + + describe 'Situations where :read_wiki requires membership' do + context 'the wiki is private, and the user is a member' do + where(case_names: title, + container_visibility: [Gitlab::VisibilityLevel::PUBLIC, + Gitlab::VisibilityLevel::INTERNAL], + wiki_access_level: [ProjectFeature::PRIVATE], + user_role: member_roles) + + with_them do + it { is_expected.to be_allowed(:read_wiki) } + end + end + + context 'the wiki is private, and the user is not member' do + where(case_names: title, + container_visibility: [Gitlab::VisibilityLevel::PUBLIC, + Gitlab::VisibilityLevel::INTERNAL], + wiki_access_level: [ProjectFeature::PRIVATE], + user_role: stranger_roles) + + with_them do + it { is_expected.to be_disallowed(:read_wiki) } + end + end + + context 'the wiki is enabled, and the user is a member' do + where(case_names: title, + container_visibility: [Gitlab::VisibilityLevel::PRIVATE], + wiki_access_level: [ProjectFeature::ENABLED], + user_role: member_roles) + + with_them do + it { is_expected.to be_allowed(:read_wiki) } + end + end + + context 'the wiki is enabled, and the user is not a member' do + where(case_names: title, + container_visibility: [Gitlab::VisibilityLevel::PRIVATE], + wiki_access_level: [ProjectFeature::ENABLED], + user_role: stranger_roles) + + with_them do + it { is_expected.to be_disallowed(:read_wiki) } + end + end + end + + describe 'Situations where :read_wiki prohibits anonymous access' do + context 'the user is not anonymous' do + where(case_names: title, + container_visibility: [Gitlab::VisibilityLevel::INTERNAL], + wiki_access_level: [ProjectFeature::ENABLED, ProjectFeature::PUBLIC], + user_role: user_roles.reject { |u| u == :anonymous }) + + with_them do + it { is_expected.to be_allowed(:read_wiki) } + end + end + + context 'the user is anonymous' do + where(case_names: title, + container_visibility: [Gitlab::VisibilityLevel::INTERNAL], + wiki_access_level: [ProjectFeature::ENABLED, ProjectFeature::PUBLIC], + user_role: %i[anonymous]) + + with_them do + it { is_expected.to be_disallowed(:read_wiki) } + end + end + end + end +end |