diff options
111 files changed, 1793 insertions, 196 deletions
diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 9b0025a7850..787ffc30a81 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -0.40.0 +0.42.0 @@ -398,7 +398,7 @@ group :ed25519 do end # Gitaly GRPC client -gem 'gitaly-proto', '~> 0.33.0', require: 'gitaly' +gem 'gitaly-proto', '~> 0.37.0', require: 'gitaly' gem 'toml-rb', '~> 0.3.15', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 3313a687ff0..dbcf3177f6e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -275,7 +275,7 @@ GEM po_to_json (>= 1.0.0) rails (>= 3.2.0) gherkin-ruby (0.3.2) - gitaly-proto (0.33.0) + gitaly-proto (0.37.0) google-protobuf (~> 3.1) grpc (~> 1.0) github-linguist (4.7.6) @@ -1025,7 +1025,7 @@ DEPENDENCIES gettext (~> 3.2.2) gettext_i18n_rails (~> 1.8.0) gettext_i18n_rails_js (~> 1.2.0) - gitaly-proto (~> 0.33.0) + gitaly-proto (~> 0.37.0) github-linguist (~> 4.7.0) gitlab-flowdock-git-hook (~> 1.0.1) gitlab-markup (~> 1.6.2) diff --git a/app/assets/javascripts/blob/notebook/index.js b/app/assets/javascripts/blob/notebook/index.js index 27312d718b0..c858a6bb7b4 100644 --- a/app/assets/javascripts/blob/notebook/index.js +++ b/app/assets/javascripts/blob/notebook/index.js @@ -40,10 +40,10 @@ export default () => { class="text-center" v-if="error"> <span v-if="loadError"> - An error occured whilst loading the file. Please try again later. + An error occurred whilst loading the file. Please try again later. </span> <span v-else> - An error occured whilst parsing the file. + An error occurred whilst parsing the file. </span> </p> </div> diff --git a/app/assets/javascripts/blob/pdf/index.js b/app/assets/javascripts/blob/pdf/index.js index 0ed915c1ac9..7109f356540 100644 --- a/app/assets/javascripts/blob/pdf/index.js +++ b/app/assets/javascripts/blob/pdf/index.js @@ -48,10 +48,10 @@ export default () => { class="text-center" v-if="error"> <span v-if="loadError"> - An error occured whilst loading the file. Please try again later. + An error occurred whilst loading the file. Please try again later. </span> <span v-else> - An error occured whilst decoding the file. + An error occurred whilst decoding the file. </span> </p> </div> diff --git a/app/assets/javascripts/boards/components/board_new_issue.js b/app/assets/javascripts/boards/components/board_new_issue.js index 541b8049855..bc28f7f45f4 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.js +++ b/app/assets/javascripts/boards/components/board_new_issue.js @@ -68,7 +68,7 @@ export default { <div class="flash-container" v-if="error"> <div class="flash-alert"> - An error occured. Please try again. + An error occurred. Please try again. </div> </div> <label class="label-light" diff --git a/app/assets/javascripts/build.js b/app/assets/javascripts/build.js index 286a758b8a9..3d27a3544eb 100644 --- a/app/assets/javascripts/build.js +++ b/app/assets/javascripts/build.js @@ -167,7 +167,7 @@ window.Build = (function () { Build.prototype.getBuildTrace = function () { return $.ajax({ url: `${this.pageUrl}/trace.json`, - data: this.state, + data: { state: this.state }, }) .done((log) => { setCiStatusFavicon(`${this.pageUrl}/status.json`); diff --git a/app/assets/javascripts/environments/components/environment.vue b/app/assets/javascripts/environments/components/environment.vue index 14fde1afb16..ce5f6219a3e 100644 --- a/app/assets/javascripts/environments/components/environment.vue +++ b/app/assets/javascripts/environments/components/environment.vue @@ -163,7 +163,7 @@ export default { this.service.postAction(endpoint) .then(() => this.fetchEnvironments()) - .catch(() => new Flash('An error occured while making the request.')); + .catch(() => new Flash('An error occurred while making the request.')); } }, diff --git a/app/assets/javascripts/environments/folder/environments_folder_view.vue b/app/assets/javascripts/environments/folder/environments_folder_view.vue index 35891240239..01e70c0bbb7 100644 --- a/app/assets/javascripts/environments/folder/environments_folder_view.vue +++ b/app/assets/javascripts/environments/folder/environments_folder_view.vue @@ -158,7 +158,7 @@ export default { this.service.postAction(endpoint) .then(() => this.fetchEnvironments()) - .catch(() => new Flash('An error occured while making the request.')); + .catch(() => new Flash('An error occurred while making the request.')); } }, }, diff --git a/app/assets/javascripts/filtered_search/dropdown_emoji.js b/app/assets/javascripts/filtered_search/dropdown_emoji.js index f9bbbf0cbc1..ada14d2053c 100644 --- a/app/assets/javascripts/filtered_search/dropdown_emoji.js +++ b/app/assets/javascripts/filtered_search/dropdown_emoji.js @@ -14,7 +14,7 @@ class DropdownEmoji extends gl.FilteredSearchDropdown { loadingTemplate: this.loadingTemplate, onError() { /* eslint-disable no-new */ - new Flash('An error occured fetching the dropdown data.'); + new Flash('An error occurred fetching the dropdown data.'); /* eslint-enable no-new */ }, }, diff --git a/app/assets/javascripts/filtered_search/dropdown_non_user.js b/app/assets/javascripts/filtered_search/dropdown_non_user.js index 0bc4b6f22a9..b32d589481d 100644 --- a/app/assets/javascripts/filtered_search/dropdown_non_user.js +++ b/app/assets/javascripts/filtered_search/dropdown_non_user.js @@ -17,7 +17,7 @@ class DropdownNonUser extends gl.FilteredSearchDropdown { preprocessing, onError() { /* eslint-disable no-new */ - new Flash('An error occured fetching the dropdown data.'); + new Flash('An error occurred fetching the dropdown data.'); /* eslint-enable no-new */ }, }, diff --git a/app/assets/javascripts/filtered_search/dropdown_user.js b/app/assets/javascripts/filtered_search/dropdown_user.js index 720fbc87ea0..ce8817b1b2e 100644 --- a/app/assets/javascripts/filtered_search/dropdown_user.js +++ b/app/assets/javascripts/filtered_search/dropdown_user.js @@ -26,7 +26,7 @@ class DropdownUser extends gl.FilteredSearchDropdown { }, onError() { /* eslint-disable no-new */ - new Flash('An error occured fetching the dropdown data.'); + new Flash('An error occurred fetching the dropdown data.'); /* eslint-enable no-new */ }, }, diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index 9178fec085a..a44dc279a6f 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -36,7 +36,7 @@ class FilteredSearchManager { .catch((error) => { if (error.name === 'RecentSearchesServiceError') return undefined; // eslint-disable-next-line no-new - new window.Flash('An error occured while parsing recent searches'); + new window.Flash('An error occurred while parsing recent searches'); // Gracefully fail to empty array return []; }) diff --git a/app/assets/javascripts/pipelines/mixins/pipelines.js b/app/assets/javascripts/pipelines/mixins/pipelines.js index 9adc15e6266..e97f5632dc8 100644 --- a/app/assets/javascripts/pipelines/mixins/pipelines.js +++ b/app/assets/javascripts/pipelines/mixins/pipelines.js @@ -97,7 +97,7 @@ export default { postAction(endpoint) { this.service.postAction(endpoint) .then(() => eventHub.$emit('refreshPipelines')) - .catch(() => new Flash('An error occured while making the request.')); + .catch(() => new Flash('An error occurred while making the request.')); }, }, }; diff --git a/app/assets/javascripts/repo/components/repo_commit_section.vue b/app/assets/javascripts/repo/components/repo_commit_section.vue index 7e4ba11c9f3..119e38c583d 100644 --- a/app/assets/javascripts/repo/components/repo_commit_section.vue +++ b/app/assets/javascripts/repo/components/repo_commit_section.vue @@ -44,7 +44,7 @@ export default { Store.submitCommitsLoading = true; Service.commitFiles(payload) .then(this.resetCommitState) - .catch(() => Flash('An error occured while committing your changes')); + .catch(() => Flash('An error occurred while committing your changes')); }, resetCommitState() { diff --git a/app/assets/javascripts/repo/components/repo_preview.vue b/app/assets/javascripts/repo/components/repo_preview.vue index 0d9d132f766..2fe369a4693 100644 --- a/app/assets/javascripts/repo/components/repo_preview.vue +++ b/app/assets/javascripts/repo/components/repo_preview.vue @@ -49,7 +49,7 @@ export default { v-else class="vertical-center render-error"> <p class="text-center"> - The source could not be displayed because a rendering error occured. You can <a :href="activeFile.raw_path">download</a> it instead. + The source could not be displayed because a rendering error occurred. You can <a :href="activeFile.raw_path">download</a> it instead. </p> </div> </div> diff --git a/app/assets/javascripts/repo/components/repo_sidebar.vue b/app/assets/javascripts/repo/components/repo_sidebar.vue index 3414128526d..dc1bda95a01 100644 --- a/app/assets/javascripts/repo/components/repo_sidebar.vue +++ b/app/assets/javascripts/repo/components/repo_sidebar.vue @@ -37,17 +37,24 @@ export default { let file = clickedFile; if (file.loading) return; file.loading = true; + if (file.type === 'tree' && file.opened) { file = Store.removeChildFilesOfTree(file); file.loading = false; } else { - Service.url = file.url; - Helper.getContent(file) - .then(() => { - file.loading = false; - Helper.scrollTabsRight(); - }) - .catch(Helper.loadingError); + const openFile = Helper.getFileFromPath(file.url); + if (openFile) { + file.loading = false; + Store.setActiveFiles(openFile); + } else { + Service.url = file.url; + Helper.getContent(file) + .then(() => { + file.loading = false; + Helper.scrollTabsRight(); + }) + .catch(Helper.loadingError); + } } }, diff --git a/app/assets/javascripts/repo/helpers/repo_helper.js b/app/assets/javascripts/repo/helpers/repo_helper.js index 655e4e7605b..2c960a147c2 100644 --- a/app/assets/javascripts/repo/helpers/repo_helper.js +++ b/app/assets/javascripts/repo/helpers/repo_helper.js @@ -263,6 +263,10 @@ const RepoHelper = { return Store.openedFiles.find(openedFile => Store.activeFile.url === openedFile.url); }, + getFileFromPath(path) { + return Store.openedFiles.find(file => file.url === path); + }, + loadingError() { Flash('Unable to load this content at this time.'); }, diff --git a/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js b/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js index 1c15a1b877a..3c9de02407e 100644 --- a/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js +++ b/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js @@ -38,7 +38,7 @@ class SidebarMoveIssue { data: (searchTerm, callback) => { this.mediator.fetchAutocompleteProjects(searchTerm) .then(callback) - .catch(() => new Flash('An error occured while fetching projects autocomplete.')); + .catch(() => new Flash('An error occurred while fetching projects autocomplete.')); }, renderRow: project => ` <li> @@ -73,7 +73,7 @@ class SidebarMoveIssue { this.mediator.moveIssue() .catch(() => { - Flash('An error occured while moving the issue.'); + Flash('An error occurred while moving the issue.'); this.$confirmButton .enable() .removeClass('is-loading'); diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js index e38a8db4cc5..2fe6e5b31f0 100644 --- a/app/assets/javascripts/sidebar/sidebar_mediator.js +++ b/app/assets/javascripts/sidebar/sidebar_mediator.js @@ -41,7 +41,7 @@ export default class SidebarMediator { this.store.setAssigneeData(data); this.store.setTimeTrackingData(data); }) - .catch(() => new Flash('Error occured when fetching sidebar data')); + .catch(() => new Flash('Error occurred when fetching sidebar data')); } fetchAutocompleteProjects(searchTerm) { diff --git a/app/finders/concerns/custom_attributes_filter.rb b/app/finders/concerns/custom_attributes_filter.rb new file mode 100644 index 00000000000..5bbf9ca242d --- /dev/null +++ b/app/finders/concerns/custom_attributes_filter.rb @@ -0,0 +1,20 @@ +module CustomAttributesFilter + def by_custom_attributes(items) + return items unless params[:custom_attributes].is_a?(Hash) + return items unless Ability.allowed?(current_user, :read_custom_attribute) + + association = items.reflect_on_association(:custom_attributes) + attributes_table = association.klass.arel_table + attributable_table = items.model.arel_table + + custom_attributes = association.klass.select('true').where( + attributes_table[association.foreign_key] + .eq(attributable_table[association.association_primary_key]) + ) + + # perform a subquery for each attribute to be filtered + params[:custom_attributes].inject(items) do |scope, (key, value)| + scope.where('EXISTS (?)', custom_attributes.where(key: key, value: value)) + end + end +end diff --git a/app/finders/users_finder.rb b/app/finders/users_finder.rb index 33f7ae90598..1a7e97004fb 100644 --- a/app/finders/users_finder.rb +++ b/app/finders/users_finder.rb @@ -15,6 +15,7 @@ # class UsersFinder include CreatedAtFilter + include CustomAttributesFilter attr_accessor :current_user, :params @@ -32,6 +33,7 @@ class UsersFinder users = by_external_identity(users) users = by_external(users) users = by_created_at(users) + users = by_custom_attributes(users) users end diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index df390dd5aab..7713fb0b9f8 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -248,16 +248,25 @@ module IssuablesHelper Gitlab::IssuablesCountForState.new(finder)[state] end - def close_issuable_url(issuable) - issuable_url(issuable, close_reopen_params(issuable, :close)) + def close_issuable_path(issuable) + issuable_path(issuable, close_reopen_params(issuable, :close)) end - def reopen_issuable_url(issuable) - issuable_url(issuable, close_reopen_params(issuable, :reopen)) + def reopen_issuable_path(issuable) + issuable_path(issuable, close_reopen_params(issuable, :reopen)) end - def close_reopen_issuable_url(issuable, should_inverse = false) - issuable.closed? ^ should_inverse ? reopen_issuable_url(issuable) : close_issuable_url(issuable) + def close_reopen_issuable_path(issuable, should_inverse = false) + issuable.closed? ^ should_inverse ? reopen_issuable_path(issuable) : close_issuable_path(issuable) + end + + def issuable_path(issuable, *options) + case issuable + when Issue + issue_path(issuable, *options) + when MergeRequest + merge_request_path(issuable, *options) + end end def issuable_url(issuable, *options) diff --git a/app/models/project.rb b/app/models/project.rb index f7221e4f3b2..9b10401f5b8 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -245,6 +245,9 @@ class Project < ActiveRecord::Base scope :pending_delete, -> { where(pending_delete: true) } scope :without_deleted, -> { where(pending_delete: false) } + scope :with_hashed_storage, -> { where('storage_version >= 1') } + scope :with_legacy_storage, -> { where(storage_version: [nil, 0]) } + scope :sorted_by_activity, -> { reorder(last_activity_at: :desc) } scope :sorted_by_stars, -> { reorder('projects.star_count DESC') } @@ -1550,18 +1553,44 @@ class Project < ActiveRecord::Base end def legacy_storage? - self.storage_version.nil? + [nil, 0].include?(self.storage_version) + end + + def hashed_storage? + self.storage_version && self.storage_version >= 1 end def renamed? persisted? && path_changed? end + def migrate_to_hashed_storage! + return if hashed_storage? + + update!(repository_read_only: true) + + if repo_reference_count > 0 || wiki_reference_count > 0 + ProjectMigrateHashedStorageWorker.perform_in(Gitlab::ReferenceCounter::REFERENCE_EXPIRE_TIME, id) + else + ProjectMigrateHashedStorageWorker.perform_async(id) + end + end + + def storage_version=(value) + super + + @storage = nil if storage_version_changed? + end + + def gl_repository(is_wiki:) + Gitlab::GlRepository.gl_repository(self, is_wiki) + end + private def storage @storage ||= - if self.storage_version && self.storage_version >= 1 + if hashed_storage? Storage::HashedProject.new(self) else Storage::LegacyProject.new(self) @@ -1574,6 +1603,14 @@ class Project < ActiveRecord::Base end end + def repo_reference_count + Gitlab::ReferenceCounter.new(gl_repository(is_wiki: false)).value + end + + def wiki_reference_count + Gitlab::ReferenceCounter.new(gl_repository(is_wiki: true)).value + end + # set last_activity_at to the same as created_at def set_last_activity_at update_column(:last_activity_at, self.created_at) diff --git a/app/models/storage/hashed_project.rb b/app/models/storage/hashed_project.rb index fae1b64961a..f025f40994e 100644 --- a/app/models/storage/hashed_project.rb +++ b/app/models/storage/hashed_project.rb @@ -4,6 +4,7 @@ module Storage delegate :gitlab_shell, :repository_storage_path, to: :project ROOT_PATH_PREFIX = '@hashed'.freeze + STORAGE_VERSION = 1 def initialize(project) @project = project diff --git a/app/models/user.rb b/app/models/user.rb index 103ac78783f..4d523aa983f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -130,6 +130,8 @@ class User < ActiveRecord::Base has_many :assigned_issues, class_name: "Issue", through: :issue_assignees, source: :issue has_many :assigned_merge_requests, dependent: :nullify, foreign_key: :assignee_id, class_name: "MergeRequest" # rubocop:disable Cop/ActiveRecordDependent + has_many :custom_attributes, class_name: 'UserCustomAttribute' + # # Validations # diff --git a/app/models/user_custom_attribute.rb b/app/models/user_custom_attribute.rb new file mode 100644 index 00000000000..eff25b31f9b --- /dev/null +++ b/app/models/user_custom_attribute.rb @@ -0,0 +1,6 @@ +class UserCustomAttribute < ActiveRecord::Base + belongs_to :user + + validates :user_id, :key, :value, presence: true + validates :key, uniqueness: { scope: [:user_id] } +end diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb index 1be7bbe9953..8f7c01bb71f 100644 --- a/app/policies/global_policy.rb +++ b/app/policies/global_policy.rb @@ -47,4 +47,9 @@ class GlobalPolicy < BasePolicy rule { ~(anonymous & restricted_public_level) }.policy do enable :read_users_list end + + rule { admin }.policy do + enable :read_custom_attribute + enable :update_custom_attribute + end end diff --git a/app/services/merge_requests/post_merge_service.rb b/app/services/merge_requests/post_merge_service.rb index 261a8bfa200..b1d6bac4d4a 100644 --- a/app/services/merge_requests/post_merge_service.rb +++ b/app/services/merge_requests/post_merge_service.rb @@ -14,6 +14,7 @@ module MergeRequests notification_service.merge_mr(merge_request, current_user) execute_hooks(merge_request, 'merge') invalidate_cache_counts(merge_request, users: merge_request.assignees) + merge_request.update_project_counter_caches end private diff --git a/app/services/projects/hashed_storage_migration_service.rb b/app/services/projects/hashed_storage_migration_service.rb new file mode 100644 index 00000000000..41259de3a16 --- /dev/null +++ b/app/services/projects/hashed_storage_migration_service.rb @@ -0,0 +1,68 @@ +module Projects + class HashedStorageMigrationService < BaseService + include Gitlab::ShellAdapter + + attr_reader :old_disk_path, :new_disk_path + + def initialize(project, logger = nil) + @project = project + @logger ||= Rails.logger + end + + def execute + return if project.hashed_storage? + + @old_disk_path = project.disk_path + has_wiki = project.wiki.repository_exists? + + project.storage_version = Storage::HashedProject::STORAGE_VERSION + project.ensure_storage_path_exists + + @new_disk_path = project.disk_path + + result = move_repository(@old_disk_path, @new_disk_path) + + if has_wiki + result &&= move_repository("#{@old_disk_path}.wiki", "#{@new_disk_path}.wiki") + end + + unless result + rollback_folder_move + return + end + + project.repository_read_only = false + project.save! + + block_given? ? yield : result + end + + private + + def move_repository(from_name, to_name) + from_exists = gitlab_shell.exists?(project.repository_storage_path, "#{from_name}.git") + to_exists = gitlab_shell.exists?(project.repository_storage_path, "#{to_name}.git") + + # If we don't find the repository on either original or target we should log that as it could be an issue if the + # project was not originally empty. + if !from_exists && !to_exists + logger.warn "Can't find a repository on either source or target paths for #{project.full_path} (ID=#{project.id}) ..." + return false + elsif !from_exists + # Repository have been moved already. + return true + end + + gitlab_shell.mv_repository(project.repository_storage_path, from_name, to_name) + end + + def rollback_folder_move + move_repository(@new_disk_path, @old_disk_path) + move_repository("#{@new_disk_path}.wiki", "#{@old_disk_path}.wiki") + end + + def logger + @logger + end + end +end diff --git a/app/services/tags/create_service.rb b/app/services/tags/create_service.rb index b3f4a72d6fe..cc76d0df3a1 100644 --- a/app/services/tags/create_service.rb +++ b/app/services/tags/create_service.rb @@ -11,7 +11,7 @@ module Tags begin new_tag = repository.add_tag(current_user, tag_name, target, message) - rescue Rugged::TagError + rescue Gitlab::Git::Repository::TagExistsError return error("Tag #{tag_name} already exists") rescue Gitlab::Git::HooksService::PreReceiveError => ex return error(ex.message) diff --git a/app/views/devise/mailer/confirmation_instructions.html.haml b/app/views/devise/mailer/confirmation_instructions.html.haml index a508b7537a2..4d1037807be 100644 --- a/app/views/devise/mailer/confirmation_instructions.html.haml +++ b/app/views/devise/mailer/confirmation_instructions.html.haml @@ -1,9 +1,10 @@ +- confirmation_link = confirmation_url(@resource, confirmation_token: @token) - if @resource.unconfirmed_email.present? #content = email_default_heading(@resource.unconfirmed_email) %p Click the link below to confirm your email address. #cta - = link_to 'Confirm your email address', confirmation_url(@resource, confirmation_token: @token) + = link_to confirmation_link, confirmation_link - else #content - if Gitlab.com? @@ -12,4 +13,4 @@ = email_default_heading("Welcome, #{@resource.name}!") %p To get started, click the link below to confirm your account. #cta - = link_to 'Confirm your account', confirmation_url(@resource, confirmation_token: @token) + = link_to confirmation_link, confirmation_link diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index d8fc371497d..aefed985af4 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -24,19 +24,19 @@ = link_to search_path, title: 'Search', aria: { label: "Search" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = icon('search') - if current_user - %li.user-counter + = nav_link(path: 'dashboard#issues', html_options: { class: "user-counter" }) do = link_to assigned_issues_dashboard_path, title: 'Issues', class: 'dashboard-shortcuts-issues', aria: { label: "Issues" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = custom_icon('issues') - issues_count = assigned_issuables_count(:issues) %span.badge.issues-count{ class: ('hidden' if issues_count.zero?) } = number_with_delimiter(issues_count) - %li.user-counter + = nav_link(path: 'dashboard#merge_requests', html_options: { class: "user-counter" }) do = link_to assigned_mrs_dashboard_path, title: 'Merge requests', class: 'dashboard-shortcuts-merge_requests', aria: { label: "Merge requests" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = custom_icon('mr_bold') - merge_requests_count = assigned_issuables_count(:merge_requests) %span.badge.merge-requests-count{ class: ('hidden' if merge_requests_count.zero?) } = number_with_delimiter(merge_requests_count) - %li.user-counter + = nav_link(controller: 'dashboard/todos', html_options: { class: "user-counter" }) do = link_to dashboard_todos_path, title: 'Todos', aria: { label: "Todos" }, class: 'shortcuts-todos', data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = custom_icon('todo_done') %span.badge.todos-count{ class: ('hidden' if todos_pending_count.zero?) } diff --git a/app/views/shared/issuable/_close_reopen_button.html.haml b/app/views/shared/issuable/_close_reopen_button.html.haml index f16bc8dd430..9ef015047c9 100644 --- a/app/views/shared/issuable/_close_reopen_button.html.haml +++ b/app/views/shared/issuable/_close_reopen_button.html.haml @@ -3,9 +3,9 @@ - button_method = issuable_close_reopen_button_method(issuable) - if can_update && is_current_user - = link_to "Close #{display_issuable_type}", close_issuable_url(issuable), method: button_method, + = link_to "Close #{display_issuable_type}", close_issuable_path(issuable), method: button_method, class: "hidden-xs hidden-sm btn btn-grouped btn-close js-btn-issue-action #{issuable_button_visibility(issuable, true)}", title: "Close #{display_issuable_type}" - = link_to "Reopen #{display_issuable_type}", reopen_issuable_url(issuable), method: button_method, + = link_to "Reopen #{display_issuable_type}", reopen_issuable_path(issuable), method: button_method, class: "hidden-xs hidden-sm btn btn-grouped btn-reopen js-btn-issue-action #{issuable_button_visibility(issuable, false)}", title: "Reopen #{display_issuable_type}" - elsif can_update && !is_current_user = render 'shared/issuable/close_reopen_report_toggle', issuable: issuable diff --git a/app/views/shared/issuable/_close_reopen_report_toggle.html.haml b/app/views/shared/issuable/_close_reopen_report_toggle.html.haml index a38cd319e3c..39a5171c1d6 100644 --- a/app/views/shared/issuable/_close_reopen_report_toggle.html.haml +++ b/app/views/shared/issuable/_close_reopen_report_toggle.html.haml @@ -7,7 +7,7 @@ - button_method = issuable_close_reopen_button_method(issuable) .pull-left.btn-group.prepend-left-10.issuable-close-dropdown.droplab-dropdown.js-issuable-close-dropdown - = link_to "#{display_button_action} #{display_issuable_type}", close_reopen_issuable_url(issuable), + = link_to "#{display_button_action} #{display_issuable_type}", close_reopen_issuable_path(issuable), method: button_method, class: "#{button_class} btn-#{button_action}", title: "#{display_button_action} #{display_issuable_type}" = button_tag type: 'button', class: "#{toggle_class} btn-#{button_action}-color", @@ -16,7 +16,7 @@ %ul#issuable-close-menu.js-issuable-close-menu.dropdown-menu{ class: button_responsive_class, data: { dropdown: true } } %li.close-item{ class: "#{issuable_button_visibility(issuable, true) || 'droplab-item-selected'}", - data: { text: "Close #{display_issuable_type}", url: close_issuable_url(issuable), + data: { text: "Close #{display_issuable_type}", url: close_issuable_path(issuable), button_class: "#{button_class} btn-close", toggle_class: "#{toggle_class} btn-close-color", method: button_method } } %button.btn.btn-transparent = icon('check', class: 'icon') @@ -26,7 +26,7 @@ = display_issuable_type %li.reopen-item{ class: "#{issuable_button_visibility(issuable, false) || 'droplab-item-selected'}", - data: { text: "Reopen #{display_issuable_type}", url: reopen_issuable_url(issuable), + data: { text: "Reopen #{display_issuable_type}", url: reopen_issuable_path(issuable), button_class: "#{button_class} btn-reopen", toggle_class: "#{toggle_class} btn-reopen-color", method: button_method } } %button.btn.btn-transparent = icon('check', class: 'icon') diff --git a/app/workers/project_migrate_hashed_storage_worker.rb b/app/workers/project_migrate_hashed_storage_worker.rb new file mode 100644 index 00000000000..ca276d7801c --- /dev/null +++ b/app/workers/project_migrate_hashed_storage_worker.rb @@ -0,0 +1,11 @@ +class ProjectMigrateHashedStorageWorker + include Sidekiq::Worker + include DedicatedSidekiqQueue + + def perform(project_id) + project = Project.find_by(id: project_id) + return if project.nil? || project.pending_delete? + + ::Projects::HashedStorageMigrationService.new(project, logger).execute + end +end diff --git a/app/workers/storage_migrator_worker.rb b/app/workers/storage_migrator_worker.rb new file mode 100644 index 00000000000..b48ead799b9 --- /dev/null +++ b/app/workers/storage_migrator_worker.rb @@ -0,0 +1,30 @@ +class StorageMigratorWorker + include Sidekiq::Worker + include DedicatedSidekiqQueue + + BATCH_SIZE = 100 + + def perform(start, finish) + projects = build_relation(start, finish) + + projects.with_route.find_each(batch_size: BATCH_SIZE) do |project| + Rails.logger.info "Starting storage migration of #{project.full_path} (ID=#{project.id})..." + + begin + project.migrate_to_hashed_storage! + rescue => err + Rails.logger.error("#{err.message} migrating storage of #{project.full_path} (ID=#{project.id}), trace - #{err.backtrace}") + end + end + end + + def build_relation(start, finish) + relation = Project + table = Project.arel_table + + relation = relation.where(table[:id].gteq(start)) if start + relation = relation.where(table[:id].lteq(finish)) if finish + + relation + end +end diff --git a/changelogs/unreleased/13637-show-account-confirmation-link-in-e-mail-text.yml b/changelogs/unreleased/13637-show-account-confirmation-link-in-e-mail-text.yml new file mode 100644 index 00000000000..5f98d0cc766 --- /dev/null +++ b/changelogs/unreleased/13637-show-account-confirmation-link-in-e-mail-text.yml @@ -0,0 +1,5 @@ +--- +title: Confirmation email shows link as text instead of human readable text +merge_request: 14243 +author: bitsapien +type: changed diff --git a/changelogs/unreleased/37335-counter-active-state.yml b/changelogs/unreleased/37335-counter-active-state.yml new file mode 100644 index 00000000000..a9632201a89 --- /dev/null +++ b/changelogs/unreleased/37335-counter-active-state.yml @@ -0,0 +1,5 @@ +--- +title: Add active states to nav bar counters +merge_request: +author: +type: changed diff --git a/changelogs/unreleased/38528-build-url.yml b/changelogs/unreleased/38528-build-url.yml new file mode 100644 index 00000000000..357b9aacea8 --- /dev/null +++ b/changelogs/unreleased/38528-build-url.yml @@ -0,0 +1,5 @@ +--- +title: Fixes data parameter not being sent in ajax request for jobs log +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/bvl-fix-close-issuable-link.yml b/changelogs/unreleased/bvl-fix-close-issuable-link.yml new file mode 100644 index 00000000000..140a9d35cc1 --- /dev/null +++ b/changelogs/unreleased/bvl-fix-close-issuable-link.yml @@ -0,0 +1,5 @@ +--- +title: Fix CSRF validation issue when closing/opening merge requests from the UI +merge_request: 14555 +author: +type: fixed diff --git a/changelogs/unreleased/dm-api-unauthorized.yml b/changelogs/unreleased/dm-api-unauthorized.yml new file mode 100644 index 00000000000..26b45bd4c40 --- /dev/null +++ b/changelogs/unreleased/dm-api-unauthorized.yml @@ -0,0 +1,5 @@ +--- +title: Make sure API responds with 401 when invalid authentication info is provided +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/feature-custom-attributes.yml b/changelogs/unreleased/feature-custom-attributes.yml new file mode 100644 index 00000000000..98736bc8d72 --- /dev/null +++ b/changelogs/unreleased/feature-custom-attributes.yml @@ -0,0 +1,4 @@ +--- +title: Support custom attributes on users +merge_request: 13038 +author: Markus Koller diff --git a/changelogs/unreleased/fix-mr-sidebar-counter-after-merge.yml b/changelogs/unreleased/fix-mr-sidebar-counter-after-merge.yml new file mode 100644 index 00000000000..22a3efb8b1e --- /dev/null +++ b/changelogs/unreleased/fix-mr-sidebar-counter-after-merge.yml @@ -0,0 +1,5 @@ +--- +title: Fix merge request counter updates after merge +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/hashed-storage-migration-path.yml b/changelogs/unreleased/hashed-storage-migration-path.yml new file mode 100644 index 00000000000..5890eb09c38 --- /dev/null +++ b/changelogs/unreleased/hashed-storage-migration-path.yml @@ -0,0 +1,5 @@ +--- +title: Script to migrate project's repositories to new Hashed Storage +merge_request: 14067 +author: +type: added diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml index d169c38a693..8235e3853dc 100644 --- a/config/sidekiq_queues.yml +++ b/config/sidekiq_queues.yml @@ -62,3 +62,5 @@ - [update_user_activity, 1] - [propagate_service_template, 1] - [background_migration, 1] + - [project_migrate_hashed_storage, 1] + - [storage_migrator, 1] diff --git a/db/migrate/20160713200638_add_repository_read_only_to_projects.rb b/db/migrate/20160713200638_add_repository_read_only_to_projects.rb new file mode 100644 index 00000000000..8ee8b55f210 --- /dev/null +++ b/db/migrate/20160713200638_add_repository_read_only_to_projects.rb @@ -0,0 +1,9 @@ +class AddRepositoryReadOnlyToProjects < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :projects, :repository_read_only, :boolean + end +end diff --git a/db/migrate/20170720122741_create_user_custom_attributes.rb b/db/migrate/20170720122741_create_user_custom_attributes.rb new file mode 100644 index 00000000000..b1c0bebc633 --- /dev/null +++ b/db/migrate/20170720122741_create_user_custom_attributes.rb @@ -0,0 +1,17 @@ +class CreateUserCustomAttributes < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + create_table :user_custom_attributes do |t| + t.timestamps_with_timezone null: false + t.references :user, null: false, foreign_key: { on_delete: :cascade } + t.string :key, null: false + t.string :value, null: false + + t.index [:user_id, :key], unique: true + t.index [:key, :value] + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 330336e8e61..d14d4d8b94d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -1215,6 +1215,7 @@ ActiveRecord::Schema.define(version: 20170921115009) do t.datetime "last_repository_updated_at" t.integer "storage_version", limit: 2 t.boolean "resolve_outdated_diff_discussions" + t.boolean "repository_read_only" end add_index "projects", ["ci_id"], name: "index_projects_on_ci_id", using: :btree @@ -1534,6 +1535,17 @@ ActiveRecord::Schema.define(version: 20170921115009) do add_index "user_agent_details", ["subject_id", "subject_type"], name: "index_user_agent_details_on_subject_id_and_subject_type", using: :btree + create_table "user_custom_attributes", force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.integer "user_id", null: false + t.string "key", null: false + t.string "value", null: false + end + + add_index "user_custom_attributes", ["key", "value"], name: "index_user_custom_attributes_on_key_and_value", using: :btree + add_index "user_custom_attributes", ["user_id", "key"], name: "index_user_custom_attributes_on_user_id_and_key", unique: true, using: :btree + create_table "user_synced_attributes_metadata", force: :cascade do |t| t.boolean "name_synced", default: false t.boolean "email_synced", default: false @@ -1760,6 +1772,7 @@ ActiveRecord::Schema.define(version: 20170921115009) do add_foreign_key "todos", "projects", name: "fk_45054f9c45", on_delete: :cascade add_foreign_key "trending_projects", "projects", on_delete: :cascade add_foreign_key "u2f_registrations", "users" + add_foreign_key "user_custom_attributes", "users", on_delete: :cascade add_foreign_key "user_synced_attributes_metadata", "users", on_delete: :cascade add_foreign_key "users_star_projects", "projects", name: "fk_22cd27ddfc", on_delete: :cascade add_foreign_key "web_hook_logs", "web_hooks", on_delete: :cascade diff --git a/doc/administration/raketasks/storage.md b/doc/administration/raketasks/storage.md new file mode 100644 index 00000000000..bac8fa4bd9d --- /dev/null +++ b/doc/administration/raketasks/storage.md @@ -0,0 +1,107 @@ +# Repository Storage Rake Tasks + +This is a collection of rake tasks you can use to help you list and migrate +existing projects from Legacy storage to the new Hashed storage type. + +You can read more about the storage types [here][storage-types]. + +## List projects on Legacy storage + +To have a simple summary of projects using **Legacy** storage: + +**Omnibus Installation** + +```bash +gitlab-rake gitlab:storage:legacy_projects +``` + +**Source Installation** + +```bash +rake gitlab:storage:legacy_projects + +``` + +------ + +To list projects using **Legacy** storage: + +**Omnibus Installation** + +```bash +gitlab-rake gitlab:storage:list_legacy_projects +``` + +**Source Installation** + +```bash +rake gitlab:storage:list_legacy_projects + +``` + +## List projects on Hashed storage + +To have a simple summary of projects using **Hashed** storage: + +**Omnibus Installation** + +```bash +gitlab-rake gitlab:storage:hashed_projects +``` + +**Source Installation** + +```bash +rake gitlab:storage:hashed_projects + +``` + +------ + +To list projects using **Hashed** storage: + +**Omnibus Installation** + +```bash +gitlab-rake gitlab:storage:list_hashed_projects +``` + +**Source Installation** + +```bash +rake gitlab:storage:list_hashed_projects + +``` + +## Migrate existing projects to Hashed storage + +Before migrating your existing projects, you should +[enable hashed storage][storage-migration] for the new projects as well. + +This task will schedule all your existing projects to be migrated to the +**Hashed** storage type: + +**Omnibus Installation** + +```bash +gitlab-rake gitlab:storage:migrate_to_hashed +``` + +**Source Installation** + +```bash +rake gitlab:storage:migrate_to_hashed + +``` + +You can monitor the progress in the _Admin > Monitoring > Background jobs_ screen. +There is a specific Queue you can watch to see how long it will take to finish: **project_migrate_hashed_storage** + +After it reaches zero, you can confirm every project has been migrated by running the commands above. +If you find it necessary, you can run this migration script again to schedule missing projects. + +Any error or warning will be logged in the sidekiq log file. + + +[storage-types]: ../repository_storage_types.md +[storage-migration]: ../repository_storage_types.md#how-to-migrate-to-hashed-storage diff --git a/doc/administration/repository_storage_types.md b/doc/administration/repository_storage_types.md new file mode 100644 index 00000000000..fa882bbe28a --- /dev/null +++ b/doc/administration/repository_storage_types.md @@ -0,0 +1,69 @@ +# Repository Storage Types + +> [Introduced][ce-28283] in GitLab 10.0. + +## Legacy Storage + +Legacy Storage is the storage behavior prior to version 10.0. For historical reasons, GitLab replicated the same +mapping structure from the projects URLs: + + * Project's repository: `#{namespace}/#{project_name}.git` + * Project's wiki: `#{namespace}/#{project_name}.wiki.git` + +This structure made simple to migrate from existing solutions to GitLab and easy for Administrators to find where the +repository is stored. + +On the other hand this has some drawbacks: + +Storage location will concentrate huge amount of top-level namespaces. The impact can be reduced by the introduction of [multiple storage paths][storage-paths]. + +Because Backups are a snapshot of the same URL mapping, if you try to recover a very old backup, you need to verify +if any project has taken the place of an old removed project sharing the same URL. This means that `mygroup/myproject` +from your backup may not be the same original project that is today in the same URL. + +Any change in the URL will need to be reflected on disk (when groups / users or projects are renamed). This can add a lot +of load in big installations, and can be even worst if they are using any type of network based filesystem. + +Last, for GitLab Geo, this storage type means we have to synchronize the disk state, replicate renames in the correct +order or we may end-up with wrong repository or missing data temporarily. + +## Hashed Storage + +Hashed Storage is the new storage behavior we are rolling out with 10.0. It's not enabled by default yet, but we +encourage everyone to try-it and take the time to fix any script you may have that depends on the old behavior. + +Instead of coupling project URL and the folder structure where the repository will be stored on disk, we are coupling +a hash, based on the project's ID. + +This makes the folder structure immutable, and therefore eliminates any requirement to synchronize state from URLs to +disk structure. This means that renaming a group, user or project will cost only the database transaction, and will take +effect immediately. + +The hash also helps to spread the repositories more evenly on the disk, so the top-level directory will contain less +folders than the total amount of top-level namespaces. + +Hash format is based on hexadecimal representation of SHA256: `SHA256(project.id)`. +Top-level folder uses first 2 characters, followed by another folder with the next 2 characters. They are both stored in +a special folder `@hashed`, to co-exist with existing Legacy projects: + +```ruby +# Project's repository: +"@hashed/#{hash[0..1]}/#{hash[2..3]}/#{hash}.git" + +# Wiki's repository: +"@hashed/#{hash[0..1]}/#{hash[2..3]}/#{hash}.wiki.git" +``` + +This new format also makes possible to restore backups with confidence, as when restoring a repository from the backup, +you will never mistakenly restore a repository in the wrong project (considering the backup is made after the migration). + +### How to migrate to Hashed Storage + +In GitLab, go to **Admin > Settings**, find the **Repository Storage** section and select +"_Create new projects using hashed storage paths_". + +To migrate your existing projects to the new storage type, check the specific [rake tasks]. + +[ce-28283]: https://gitlab.com/gitlab-org/gitlab-ce/issues/28283 +[rake tasks]: raketasks/storage.md#migrate-existing-projects-to-hashed-storage +[storage-paths]: repository_storage_types.md diff --git a/doc/api/README.md b/doc/api/README.md index 6cbea29bda6..3fd4c97e536 100644 --- a/doc/api/README.md +++ b/doc/api/README.md @@ -14,6 +14,7 @@ following locations: - [Project-level Variables](project_level_variables.md) - [Group-level Variables](group_level_variables.md) - [Commits](commits.md) +- [Custom Attributes](custom_attributes.md) - [Deployments](deployments.md) - [Deploy Keys](deploy_keys.md) - [Environments](environments.md) diff --git a/doc/api/custom_attributes.md b/doc/api/custom_attributes.md new file mode 100644 index 00000000000..8b26f7093ab --- /dev/null +++ b/doc/api/custom_attributes.md @@ -0,0 +1,105 @@ +# Custom Attributes API + +Every API call to custom attributes must be authenticated as administrator. + +## List custom attributes + +Get all custom attributes on a user. + +``` +GET /users/:id/custom_attributes +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of a user | + +```bash +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/users/42/custom_attributes +``` + +Example response: + +```json +[ + { + "key": "location", + "value": "Antarctica" + }, + { + "key": "role", + "value": "Developer" + } +] +``` + +## Single custom attribute + +Get a single custom attribute on a user. + +``` +GET /users/:id/custom_attributes/:key +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of a user | +| `key` | string | yes | The key of the custom attribute | + +```bash +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/users/42/custom_attributes/location +``` + +Example response: + +```json +{ + "key": "location", + "value": "Antarctica" +} +``` + +## Set custom attribute + +Set a custom attribute on a user. The attribute will be updated if it already exists, +or newly created otherwise. + +``` +PUT /users/:id/custom_attributes/:key +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of a user | +| `key` | string | yes | The key of the custom attribute | +| `value` | string | yes | The value of the custom attribute | + +```bash +curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" --data "value=Greenland" https://gitlab.example.com/api/v4/users/42/custom_attributes/location +``` + +Example response: + +```json +{ + "key": "location", + "value": "Greenland" +} +``` + +## Delete custom attribute + +Delete a custom attribute on a user. + +``` +DELETE /users/:id/custom_attributes/:key +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of a user | +| `key` | string | yes | The key of the custom attribute | + +```bash +curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/users/42/custom_attributes/location +``` diff --git a/doc/api/users.md b/doc/api/users.md index 6d5db16b36a..1643c584244 100644 --- a/doc/api/users.md +++ b/doc/api/users.md @@ -154,6 +154,12 @@ You can search users by creation date time range with: GET /users?created_before=2001-01-02T00:00:00.060Z&created_after=1999-01-02T00:00:00.060 ``` +You can filter by [custom attributes](custom_attributes.md) with: + +``` +GET /users?custom_attributes[key]=value&custom_attributes[other_key]=other_value +``` + ## Single user Get a single user. diff --git a/doc/ci/docker/using_docker_build.md b/doc/ci/docker/using_docker_build.md index f28c9791bee..4586caa457d 100644 --- a/doc/ci/docker/using_docker_build.md +++ b/doc/ci/docker/using_docker_build.md @@ -250,6 +250,8 @@ By default, when using `docker:dind`, Docker uses the `vfs` storage driver which copies the filesystem on every run. This is a very disk-intensive operation which can be avoided if a different driver is used, for example `overlay2`. +### Requirements + 1. Make sure a recent kernel is used, preferably `>= 4.2`. 1. Check whether the `overlay` module is loaded: @@ -271,14 +273,27 @@ which can be avoided if a different driver is used, for example `overlay2`. overlay ``` -1. Use the driver by defining a variable at the top of your `.gitlab-ci.yml`: +### Use driver per project - ``` - variables: - DOCKER_DRIVER: overlay2 - ``` - -> **Note:** +You can enable the driver for each project individually by editing the project's `.gitlab-ci.yml`: + +``` +variables: + DOCKER_DRIVER: overlay2 +``` + +### Use driver for every project + +To enable the driver for every project, you can set the environment variable for every build by adding `environment` in the `[[runners]]` section of `config.toml`: + +```toml +environment = ["DOCKER_DRIVER=overlay2"] +``` + +If you're running multiple Runners you will have to modify all configuration files. + +> **Notes:** +- More information about the Runner configuration is available in the [Runner documentation](https://docs.gitlab.com/runner/configuration/). - For more information about using OverlayFS with Docker, you can read [Use the OverlayFS storage driver](https://docs.docker.com/engine/userguide/storagedriver/overlayfs-driver/). diff --git a/doc/development/ux_guide/illustrations.md b/doc/development/ux_guide/illustrations.md new file mode 100644 index 00000000000..7e16c300921 --- /dev/null +++ b/doc/development/ux_guide/illustrations.md @@ -0,0 +1,86 @@ +# Illustrations + +The illustrations should always align with topics and goals in specific context. + +## Principles + +#### Be simple. +- For clarity, we use simple and specific elements to create our illustrations. + +#### Be optimistic. +- We are an open-minded, optimistic, and friendly team. We should reflect those values in our illustrations to connect with our brand experience. + +#### Be gentle. +- Our illustrations assist users in understanding context and guide users in the right direction. Illustrations are supportive, so they should be obvious but not aggressive. + + +## Style + +#### Shapes +- All illustrations are geometric rather than organic. +- The illustrations are made by circles, rectangles, squares, and triangles. + +<img src="img/illustrations-geometric.png" width=224px alt="Example for geometric" /> + +#### Stroke +- Standard border thickness: **4px** +- Depending on the situation, border thickness can be changed to **3px**. For example, when the illustration size is small, an illustration with 4px border thickness would look tight. In this case, the border thickness can be changed to 3px. +- We use **rounded caps** and **rounded corner**. + +| Do | Don't | +| -------- | -------- | +| <img src="img/illustrations-caps-do.png" width= 133px alt="Do: caps and corner" /> | <img src="img/illustrations-caps-don't.png" width= 133px alt="Don't: caps and corner"/> | + +#### Radius +- Standard corner radius: **10px** +- Depending on the situation, corner radius can be changed to **5px**. For example, when the illustration size is small, an illustration with 10px corner radius would be over-rounded. In this case, the corner radius can be changed to 5px. + +<img src="img/illustrations-border-radius.png" width= 464px alt="Example for border radius"/> + +#### Size +Depends on the situation, the illustration size can be the 3 types below: + +**Large** +* Use case: Empty states, error pages(e.g. 404, 403) +* For vertical layout, the illustration should not larger than **430*300 px**. +* For horizontal layout, the illustration should not larger than **430*380 px**. + +| Vertical layout | Horizontal layout | +| --------------- | ----------------- | +| <img src="img/illustration-size-large-vertical.png" /> | <img src="img/illustration-size-large-horizontal.png" /> + +**Medium** +* Use case: Banner +* The illustration should not larger than **240*160 px** +* The illustration should keep simple and clear. We recommend not including too many elements in the medium size illustration. + +<img src="img/illustration-size-medium.png" width=983px /> + +**Small** +* Use case: Graphics for explanatory text, graphics for status. +* The illustration should not larger than **160*90 px**. +* The illustration should keep simple and clear. We recommend not including too many elements in the small size illustration. + +<img src="img/illustration-size-small.png" width=983px /> + +**Illustration on mobile** +- Keep the proportions in original ratio. + +#### Colors palette + +For consistency, we recommend choosing colors from our color palette. + +| Orange | Purple | Grey | +| -------- | -------- | -------- | +| <img src="img/illustrations-color-orange.png" width= 160px alt="Orange" /> | <img src="img/illustrations-color-purple.png" width= 160px alt="Purple" /> | <img src="img/illustrations-color-grey.png" width= 160px alt="Grey" /> | +| #FC6D26 | #6B4FBB | #EEEEEE | + +#### Don't +- Don't include the typography in the illustration. +- Don't include tanuki in the illustration. If necessary, we recommend having tanuki monochromatic. + +--- + +| Orange | Purple | +| -------- | -------- | +| <img src="img/illustrations-palette-oragne.png" width= 160px alt="Palette - Orange" /> | <img src="img/illustrations-palette-purple.png" width= 160px alt="Palette - Purple" /> | diff --git a/doc/development/ux_guide/img/illustration-size-large-horizontal.png b/doc/development/ux_guide/img/illustration-size-large-horizontal.png Binary files differnew file mode 100755 index 00000000000..8aa835adccc --- /dev/null +++ b/doc/development/ux_guide/img/illustration-size-large-horizontal.png diff --git a/doc/development/ux_guide/img/illustration-size-large-vertical.png b/doc/development/ux_guide/img/illustration-size-large-vertical.png Binary files differnew file mode 100644 index 00000000000..813b6a065e5 --- /dev/null +++ b/doc/development/ux_guide/img/illustration-size-large-vertical.png diff --git a/doc/development/ux_guide/img/illustration-size-medium.png b/doc/development/ux_guide/img/illustration-size-medium.png Binary files differnew file mode 100755 index 00000000000..55cfe1dcb91 --- /dev/null +++ b/doc/development/ux_guide/img/illustration-size-medium.png diff --git a/doc/development/ux_guide/img/illustration-size-small.png b/doc/development/ux_guide/img/illustration-size-small.png Binary files differnew file mode 100644 index 00000000000..0124f58f48e --- /dev/null +++ b/doc/development/ux_guide/img/illustration-size-small.png diff --git a/doc/development/ux_guide/img/illustrations-border-radius.png b/doc/development/ux_guide/img/illustrations-border-radius.png Binary files differnew file mode 100755 index 00000000000..4e2fef5c7f5 --- /dev/null +++ b/doc/development/ux_guide/img/illustrations-border-radius.png diff --git a/doc/development/ux_guide/img/illustrations-caps-do.png b/doc/development/ux_guide/img/illustrations-caps-do.png Binary files differnew file mode 100755 index 00000000000..7a2c74382f6 --- /dev/null +++ b/doc/development/ux_guide/img/illustrations-caps-do.png diff --git a/doc/development/ux_guide/img/illustrations-caps-don't.png b/doc/development/ux_guide/img/illustrations-caps-don't.png Binary files differnew file mode 100755 index 00000000000..848f72dbe30 --- /dev/null +++ b/doc/development/ux_guide/img/illustrations-caps-don't.png diff --git a/doc/development/ux_guide/img/illustrations-color-grey.png b/doc/development/ux_guide/img/illustrations-color-grey.png Binary files differnew file mode 100755 index 00000000000..63855026c2b --- /dev/null +++ b/doc/development/ux_guide/img/illustrations-color-grey.png diff --git a/doc/development/ux_guide/img/illustrations-color-orange.png b/doc/development/ux_guide/img/illustrations-color-orange.png Binary files differnew file mode 100755 index 00000000000..96765c8c28c --- /dev/null +++ b/doc/development/ux_guide/img/illustrations-color-orange.png diff --git a/doc/development/ux_guide/img/illustrations-color-purple.png b/doc/development/ux_guide/img/illustrations-color-purple.png Binary files differnew file mode 100755 index 00000000000..745d2c853ba --- /dev/null +++ b/doc/development/ux_guide/img/illustrations-color-purple.png diff --git a/doc/development/ux_guide/img/illustrations-geometric.png b/doc/development/ux_guide/img/illustrations-geometric.png Binary files differnew file mode 100755 index 00000000000..33f05547bac --- /dev/null +++ b/doc/development/ux_guide/img/illustrations-geometric.png diff --git a/doc/development/ux_guide/img/illustrations-palette-oragne.png b/doc/development/ux_guide/img/illustrations-palette-oragne.png Binary files differnew file mode 100755 index 00000000000..15f35912646 --- /dev/null +++ b/doc/development/ux_guide/img/illustrations-palette-oragne.png diff --git a/doc/development/ux_guide/img/illustrations-palette-purple.png b/doc/development/ux_guide/img/illustrations-palette-purple.png Binary files differnew file mode 100755 index 00000000000..e0f5839705e --- /dev/null +++ b/doc/development/ux_guide/img/illustrations-palette-purple.png diff --git a/doc/development/ux_guide/index.md b/doc/development/ux_guide/index.md index 8a849f239dc..42bcf234e12 100644 --- a/doc/development/ux_guide/index.md +++ b/doc/development/ux_guide/index.md @@ -21,6 +21,11 @@ Guidance on the timing, curving and motion for GitLab. --- +### [Illustrations](illustrations.md) +Guidelines for principals and styles related to illustrations for GitLab. + +--- + ### [Copy](copy.md) Conventions on text and messaging within labels, buttons, and other components. diff --git a/doc/development/writing_documentation.md b/doc/development/writing_documentation.md index b1eb020a592..68ba3dd2da3 100644 --- a/doc/development/writing_documentation.md +++ b/doc/development/writing_documentation.md @@ -1,6 +1,6 @@ # Writing documentation - - **General Documentation**: written by the developers responsible by creating features. Should be submitted in the same merge request containing code. Feature proposals (by GitLab contributors) should also be accompanied by its respective documentation. They can be later improved by PMs and Technical Writers. + - **General Documentation**: written by the [developers responsible by creating features](#contributing-to-docs). Should be submitted in the same merge request containing code. Feature proposals (by GitLab contributors) should also be accompanied by its respective documentation. They can be later improved by PMs and Technical Writers. - **Technical Articles**: written by any [GitLab Team](https://about.gitlab.com/team/) member, GitLab contributors, or [Community Writers](https://about.gitlab.com/handbook/product/technical-writing/community-writers/). - **Indexes per topic**: initially prepared by the Technical Writing Team, and kept up-to-date by developers and PMs in the same merge request containing code. They gather all resources for that topic in a single page (user and admin documentation, articles, and third-party docs). @@ -69,6 +69,51 @@ Use the [writing method](https://about.gitlab.com/handbook/product/technical-wri All the docs follow the same [styleguide](doc_styleguide.md). +### Contributing to docs + +Whenever a feature is changed, updated, introduced, or deprecated, the merge +request introducing these changes must be accompanied by the documentation +(either updating existing ones or creating new ones). This is also valid when +changes are introduced to the UI. + +The one resposible for writing the first piece of documentation is the developer who +wrote the code. It's the job of the Product Manager to ensure all features are +shipped with its docs, whether is a small or big change. At the pace GitLab evolves, +this is the only way to keep the docs up-to-date. If you have any questions about it, +please ask a Technical Writer. Otherwise, when your content is ready, assign one of +them to review it for you. + +We use the [monthly release blog post](https://about.gitlab.com/handbook/marketing/blog/release-posts/#monthly-releases) as a changelog checklist to ensure everything +is documented. + +### Feature overview and use cases + +Every major feature (regardless if present in GitLab Community or Enterprise editions) +should present, at the beginning of the document, two main sections: **overview** and +**use cases**. Every GitLab EE-only feature should also contain these sections. + +**Overview**: at the name suggests, the goal here is to provide an overview of the feature. +Describe what is it, what it does, why it is important/cool/nice-to-have, +what problem it solves, and what you can do with this feature that you couldn't +do before. + +**Use cases**: provide at least two, ideally three, use cases for every major feature. +You should answer this question: what can you do with this feature/change? Use cases +are examples of how this feauture or change can be used in real life. + +Examples: +- CE and EE: [Issues](../user/project/issues/index.md#use-cases) +- CE and EE: [Merge Requests](../user/project/merge_requests/index.md#overview) +- EE-only: [Geo](https://docs.gitlab.com/ee/gitlab-geo/README.html#overview) +- EE-only: [Jenkins integration](https://docs.gitlab.com/ee/integration/jenkins.md#overview) + +Note that if you don't have anything to add between the doc title (`<h1>`) and +the header `## Overview`, you can omit the header, but keep the content of the +overview there. + +> **Overview** and **use cases** are required to **every** Enterprise Edition feature, +and for every **major** feature present in Community Edition. + ### Markdown Currently GitLab docs use Redcarpet as [markdown](../user/markdown.md) engine, but there's an [open discussion](https://gitlab.com/gitlab-com/gitlab-docs/issues/50) for implementing Kramdown in the near future. diff --git a/doc/user/project/pages/getting_started_part_one.md b/doc/user/project/pages/getting_started_part_one.md index 46fa4378fe7..453e10184f0 100644 --- a/doc/user/project/pages/getting_started_part_one.md +++ b/doc/user/project/pages/getting_started_part_one.md @@ -62,7 +62,7 @@ which is highly recommendable and much faster than hardcoding. If you set up a GitLab Pages project on GitLab.com, it will automatically be accessible under a -[subdomain of `namespace.pages.io`](https://docs.gitlab.com/ce/user/project/pages/). +[subdomain of `namespace.pages.io`](introduction.md#gitlab-pages-on-gitlab-com). The `namespace` is defined by your username on GitLab.com, or the group name you created this project under. @@ -73,6 +73,8 @@ Pages wildcard domain. This guide is valid for any GitLab instance, you just need to replace Pages wildcard domain on GitLab.com (`*.gitlab.io`) with your own. +Learn more about [namespaces](../../group/index.md#namespaces). + ### Practical examples #### Project Websites diff --git a/doc/user/project/pages/getting_started_part_three.md b/doc/user/project/pages/getting_started_part_three.md index 53fd1786cfa..0096f8507d2 100644 --- a/doc/user/project/pages/getting_started_part_three.md +++ b/doc/user/project/pages/getting_started_part_three.md @@ -1,9 +1,14 @@ +--- +last_updated: 2017-09-28 +--- + # GitLab Pages from A to Z: Part 3 -> **Article [Type](../../../development/writing_documentation.html#types-of-technical-articles)**: user guide || +> **[Article Type](../../../development/writing_documentation.md#types-of-technical-articles)**: user guide || > **Level**: beginner || > **Author**: [Marcia Ramos](https://gitlab.com/marcia) || -> **Publication date:** 2017/02/22 +> **Publication date:** 2017-02-22 || +> **Last updated**: 2017-09-28 - [Part 1: Static sites and GitLab Pages domains](getting_started_part_one.md) - [Part 2: Quick start guide - Setting up GitLab Pages](getting_started_part_two.md) @@ -16,6 +21,21 @@ As described in the previous part of this series, setting up GitLab Pages with c These steps assume you've already [set your site up](getting_started_part_two.md) and and it's served under the default Pages domain `namespace.gitlab.io`, or `namespace.gitlab.io/project-name`. +### Adding your custom domain to GitLab Pages + +To use one or more custom domain with your Pages site, there are two things +you should consider first, which we'll cover in this guide: + +1. Either if you're adding a **root domain** or a **subdomain**, for which +you'll need to set up [DNS records](#dns-records) +1. Whether you want to add an [SSL/TLS certificate](#ssl-tls-certificates) or not + +To finish the association, you need to [add your domain to your project's Pages settings](#add-your-custom-domain-to-gitlab-pages-settings). + +Let's start from the beginning with [DNS records](#dns-records). +If you already know how they work and want to skip the introduction to DNS, +you may be interested in skipping it until the [TL;DR](#tl-dr) section below. + ### DNS Records A Domain Name System (DNS) web service routes visitors to websites @@ -99,6 +119,29 @@ domain. E.g., **do not** point your `subdomain.domain.com` to `namespace.gitlab.io.` or `namespace.gitlab.io/`. > - GitLab Pages IP on GitLab.com [has been changed](https://about.gitlab.com/2017/03/06/we-are-changing-the-ip-of-gitlab-pages-on-gitlab-com/) from `104.208.235.32` to `52.167.214.135`. +### Add your custom domain to GitLab Pages settings + +Once you've set the DNS record, you'll need navigate to your project's +**Setting > Pages** and click **+ New domain** to add your custom domain to +GitLab Pages. You can choose whether to add an [SSL/TLS certificate](#ssl-tls-certificates) +to make your website accessible under HTTPS or leave it blank. If don't add a certificate, +your site will be accessible only via HTTP: + +![Add new domain](img/add_certificate_to_pages.png) + +You can add more than one alias (custom domains and subdomains) to the same project. +An alias can be understood as having many doors leading to the same room. + +All the aliases you've set to your site will be listed on **Setting > Pages**. +From that page, you can view, add, and remove them. + +Note that [DNS propagation may take some time (up to 24h)](http://www.inmotionhosting.com/support/domain-names/dns-nameserver-changes/domain-names-dns-changes), +although it's usually a matter of minutes to complete. Until it does, visit attempts +to your domain will respond with a 404. + +Read through the [general documentation on GitLab Pages](introduction.md#add-a-custom-domain-to-your-pages-website) to learn more about adding +custom domains to GitLab Pages sites. + ### SSL/TLS Certificates Every GitLab Pages project on GitLab.com will be available under diff --git a/doc/user/project/pages/introduction.md b/doc/user/project/pages/introduction.md index 9ecf7a3a8e7..4fcdfa7b281 100644 --- a/doc/user/project/pages/introduction.md +++ b/doc/user/project/pages/introduction.md @@ -28,7 +28,8 @@ In general there are two types of pages one might create: - Pages per project (`username.example.io/projectname` or `groupname.example.io/projectname`) In GitLab, usernames and groupnames are unique and we often refer to them -as namespaces. There can be only one namespace in a GitLab instance. Below you +as [namespaces](../../group/index.md#namespaces). There can be only one namespace +in a GitLab instance. Below you can see the connection between the type of GitLab Pages, what the project name that is created on GitLab looks like and the website URL it will be ultimately be served on. @@ -98,6 +99,9 @@ The steps to create a project page for a user or a group are identical: A user's project will be served under `http(s)://username.example.io/projectname` whereas a group's project under `http(s)://groupname.example.io/projectname`. +For practical examples for group and project Pages, read through the guide +[GitLab Pages from A to Z: Part 1 - Static sites and GitLab Pages domains](getting_started_part_one.md#practical-examples). + ## Quick Start Read through [GitLab Pages Quick Start Guide][pages-quick] or watch the video tutorial on @@ -111,6 +115,9 @@ The key thing about GitLab Pages is the `.gitlab-ci.yml` file, something that gives you absolute control over the build process. You can actually watch your website being built live by following the CI job traces. +For a simplified user guide on setting up GitLab CI/CD for Pages, read through +the article [GitLab Pages from A to Z: Part 4 - Creating and Tweaking `.gitlab-ci.yml` for GitLab Pages](getting_started_part_four.md#creating-and-tweaking-gitlab-ci-yml-for-gitlab-pages) + > **Note:** > Before reading this section, make sure you familiarize yourself with GitLab CI > and the specific syntax of[`.gitlab-ci.yml`][yaml] by @@ -311,6 +318,9 @@ Visit the GitLab Pages group for a full list of example projects: ### Add a custom domain to your Pages website +For a complete guide on Pages domains, read through the article +[GitLab Pages from A to Z: Part 3 - Setting Up Custom Domains - DNS Records and SSL/TLS Certificates](getting_started_part_three.md#setting-up-custom-domains-dns-records-and-ssl-tls-certificates) + If this setting is enabled by your GitLab administrator, you should be able to see the **New Domain** button when visiting your project's settings through the gear icon in the top right and then navigating to **Pages**. @@ -349,6 +359,9 @@ private key when adding a new domain. ![Pages upload cert](img/pages_upload_cert.png) +For a complete guide on Pages domains, read through the article +[GitLab Pages from A to Z: Part 3 - Setting Up Custom Domains - DNS Records and SSL/TLS Certificates](getting_started_part_three.md#setting-up-custom-domains-dns-records-and-ssl-tls-certificates) + ### Custom error codes pages You can provide your own 403 and 404 error pages by creating the `403.html` and @@ -387,6 +400,8 @@ If you are using GitLab.com to host your website, then: The rest of the guide still applies. +See also: [GitLab Pages from A to Z: Part 1 - Static sites and GitLab Pages domains](getting_started_part_one.md#gitlab-pages-domain). + ## Limitations When using Pages under the general domain of a GitLab instance (`*.example.io`), diff --git a/lib/api/api_guard.rb b/lib/api/api_guard.rb index c4c0fdda665..e79f988f549 100644 --- a/lib/api/api_guard.rb +++ b/lib/api/api_guard.rb @@ -75,7 +75,7 @@ module API raise RevokedError when AccessTokenValidationService::VALID - @current_user = User.find(access_token.resource_owner_id) + User.find(access_token.resource_owner_id) end end @@ -84,11 +84,13 @@ module API return nil unless token_string.present? - find_user_by_authentication_token(token_string) || find_user_by_personal_access_token(token_string, scopes) - end + user = + find_user_by_authentication_token(token_string) || + find_user_by_personal_access_token(token_string, scopes) + + raise UnauthorizedError unless user - def current_user - @current_user + user end private @@ -107,7 +109,16 @@ module API end def find_access_token - @access_token ||= Doorkeeper.authenticate(doorkeeper_request, Doorkeeper.configuration.access_token_methods) + return @access_token if defined?(@access_token) + + token = Doorkeeper::OAuth::Token.from_request(doorkeeper_request, *Doorkeeper.configuration.access_token_methods) + return @access_token = nil unless token + + @access_token = Doorkeeper::AccessToken.by_token(token) + raise UnauthorizedError unless @access_token + + @access_token.revoke_previous_refresh_token! + @access_token end def doorkeeper_request @@ -169,6 +180,7 @@ module API TokenNotFoundError = Class.new(StandardError) ExpiredError = Class.new(StandardError) RevokedError = Class.new(StandardError) + UnauthorizedError = Class.new(StandardError) class InsufficientScopeError < StandardError attr_reader :scopes diff --git a/lib/api/custom_attributes_endpoints.rb b/lib/api/custom_attributes_endpoints.rb new file mode 100644 index 00000000000..5000aa0d9ac --- /dev/null +++ b/lib/api/custom_attributes_endpoints.rb @@ -0,0 +1,77 @@ +module API + module CustomAttributesEndpoints + extend ActiveSupport::Concern + + included do + attributable_class = name.demodulize.singularize + attributable_key = attributable_class.underscore + attributable_name = attributable_class.humanize(capitalize: false) + attributable_finder = "find_#{attributable_key}" + + helpers do + params :custom_attributes_key do + requires :key, type: String, desc: 'The key of the custom attribute' + end + end + + desc "Get all custom attributes on a #{attributable_name}" do + success Entities::CustomAttribute + end + get ':id/custom_attributes' do + resource = public_send(attributable_finder, params[:id]) # rubocop:disable GitlabSecurity/PublicSend + authorize! :read_custom_attribute + + present resource.custom_attributes, with: Entities::CustomAttribute + end + + desc "Get a custom attribute on a #{attributable_name}" do + success Entities::CustomAttribute + end + params do + use :custom_attributes_key + end + get ':id/custom_attributes/:key' do + resource = public_send(attributable_finder, params[:id]) # rubocop:disable GitlabSecurity/PublicSend + authorize! :read_custom_attribute + + custom_attribute = resource.custom_attributes.find_by!(key: params[:key]) + + present custom_attribute, with: Entities::CustomAttribute + end + + desc "Set a custom attribute on a #{attributable_name}" + params do + use :custom_attributes_key + requires :value, type: String, desc: 'The value of the custom attribute' + end + put ':id/custom_attributes/:key' do + resource = public_send(attributable_finder, params[:id]) # rubocop:disable GitlabSecurity/PublicSend + authorize! :update_custom_attribute + + custom_attribute = resource.custom_attributes + .find_or_initialize_by(key: params[:key]) + + custom_attribute.update(value: params[:value]) + + if custom_attribute.valid? + present custom_attribute, with: Entities::CustomAttribute + else + render_validation_error!(custom_attribute) + end + end + + desc "Delete a custom attribute on a #{attributable_name}" + params do + use :custom_attributes_key + end + delete ':id/custom_attributes/:key' do + resource = public_send(attributable_finder, params[:id]) # rubocop:disable GitlabSecurity/PublicSend + authorize! :update_custom_attribute + + resource.custom_attributes.find_by!(key: params[:key]).destroy + + status 204 + end + end + end +end diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 7f4736a08cb..5d45b14f592 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -1036,5 +1036,10 @@ module API expose :failing_on_hosts expose :total_failures end + + class CustomAttribute < Grape::Entity + expose :key + expose :value + end end end diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb index 00dbc2aee7a..1e8475ba3ec 100644 --- a/lib/api/helpers.rb +++ b/lib/api/helpers.rb @@ -3,6 +3,8 @@ module API include Gitlab::Utils include Helpers::Pagination + UnauthorizedError = Class.new(StandardError) + SUDO_HEADER = "HTTP_SUDO".freeze SUDO_PARAM = :sudo @@ -139,7 +141,7 @@ module API end def authenticate! - unauthorized! unless current_user && can?(initial_current_user, :access_api) + unauthorized! unless current_user end def authenticate_non_get! @@ -397,19 +399,27 @@ module API def initial_current_user return @initial_current_user if defined?(@initial_current_user) - Gitlab::Auth::UniqueIpsLimiter.limit_user! do - @initial_current_user ||= find_user_by_private_token(scopes: scopes_registered_for_endpoint) - @initial_current_user ||= doorkeeper_guard(scopes: scopes_registered_for_endpoint) - @initial_current_user ||= find_user_from_warden - - unless @initial_current_user && Gitlab::UserAccess.new(@initial_current_user).allowed? - @initial_current_user = nil - end - @initial_current_user + begin + @initial_current_user = Gitlab::Auth::UniqueIpsLimiter.limit_user! { find_current_user } + rescue APIGuard::UnauthorizedError, UnauthorizedError + unauthorized! end end + def find_current_user + user = + find_user_by_private_token(scopes: scopes_registered_for_endpoint) || + doorkeeper_guard(scopes: scopes_registered_for_endpoint) || + find_user_from_warden + + return nil unless user + + raise UnauthorizedError unless Gitlab::UserAccess.new(user).allowed? && user.can?(:access_api) + + user + end + def sudo! return unless sudo_identifier return unless initial_current_user diff --git a/lib/api/users.rb b/lib/api/users.rb index 99024d1f0ad..d07dc302717 100644 --- a/lib/api/users.rb +++ b/lib/api/users.rb @@ -6,6 +6,8 @@ module API allow_access_with_scope :read_user, if: -> (request) { request.get? } resource :users, requirements: { uid: /[0-9]*/, id: /[0-9]*/ } do + include CustomAttributesEndpoints + before do authenticate_non_get! end diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb index 6baff362dad..6d710ffe2bb 100644 --- a/lib/gitlab/git/repository.rb +++ b/lib/gitlab/git/repository.rb @@ -20,6 +20,7 @@ module Gitlab GitError = Class.new(StandardError) DeleteBranchError = Class.new(StandardError) CreateTreeError = Class.new(StandardError) + TagExistsError = Class.new(StandardError) class << self # Unlike `new`, `create` takes the storage path, not the storage name @@ -646,24 +647,13 @@ module Gitlab end def add_tag(tag_name, user:, target:, message: nil) - target_object = Ref.dereference_object(lookup(target)) - raise InvalidRef.new("target not found: #{target}") unless target_object - - user = Gitlab::Git::User.from_gitlab(user) unless user.respond_to?(:gl_id) - - options = nil # Use nil, not the empty hash. Rugged cares about this. - if message - options = { - message: message, - tagger: Gitlab::Git.committer_hash(email: user.email, name: user.name) - } + gitaly_migrate(:operation_user_add_tag) do |is_enabled| + if is_enabled + gitaly_add_tag(tag_name, user: user, target: target, message: message) + else + rugged_add_tag(tag_name, user: user, target: target, message: message) + end end - - OperationService.new(user, self).add_tag(tag_name, target_object.oid, options) - - find_tag(tag_name) - rescue Rugged::ReferenceError => ex - raise InvalidRef, ex end def rm_branch(branch_name, user:) @@ -671,7 +661,13 @@ module Gitlab end def rm_tag(tag_name, user:) - OperationService.new(user, self).rm_tag(find_tag(tag_name)) + gitaly_migrate(:operation_user_delete_tag) do |is_enabled| + if is_enabled + gitaly_operations_client.rm_tag(tag_name, user) + else + Gitlab::Git::OperationService.new(user, self).rm_tag(find_tag(tag_name)) + end + end end def find_tag(name) @@ -1048,6 +1044,10 @@ module Gitlab Gitlab::GitalyClient::Util.repository(@storage, @relative_path) end + def gitaly_operations_client + @gitaly_operations_client ||= Gitlab::GitalyClient::OperationService.new(self) + end + def gitaly_ref_client @gitaly_ref_client ||= Gitlab::GitalyClient::RefService.new(self) end @@ -1382,6 +1382,33 @@ module Gitlab false end + def gitaly_add_tag(tag_name, user:, target:, message: nil) + gitaly_operations_client.add_tag(tag_name, user, target, message) + end + + def rugged_add_tag(tag_name, user:, target:, message: nil) + target_object = Ref.dereference_object(lookup(target)) + raise InvalidRef.new("target not found: #{target}") unless target_object + + user = Gitlab::Git::User.from_gitlab(user) unless user.respond_to?(:gl_id) + + options = nil # Use nil, not the empty hash. Rugged cares about this. + if message + options = { + message: message, + tagger: Gitlab::Git.committer_hash(email: user.email, name: user.name) + } + end + + Gitlab::Git::OperationService.new(user, self).add_tag(tag_name, target_object.oid, options) + + find_tag(tag_name) + rescue Rugged::ReferenceError => ex + raise InvalidRef, ex + rescue Rugged::TagError + raise TagExistsError + end + def rugged_create_branch(ref, start_point) rugged_ref = rugged.branches.create(ref, start_point) target_commit = Gitlab::Git::Commit.find(self, rugged_ref.target) diff --git a/lib/gitlab/git_access.rb b/lib/gitlab/git_access.rb index 62d1ecae676..db67ede9d9e 100644 --- a/lib/gitlab/git_access.rb +++ b/lib/gitlab/git_access.rb @@ -16,7 +16,8 @@ module Gitlab account_blocked: 'Your account has been blocked.', command_not_allowed: "The command you're trying to execute is not allowed.", upload_pack_disabled_over_http: 'Pulling over HTTP is not allowed.', - receive_pack_disabled_over_http: 'Pushing over HTTP is not allowed.' + receive_pack_disabled_over_http: 'Pushing over HTTP is not allowed.', + readonly: 'The repository is temporarily read-only. Please try again later.' }.freeze DOWNLOAD_COMMANDS = %w{ git-upload-pack git-upload-archive }.freeze @@ -159,6 +160,10 @@ module Gitlab end def check_push_access!(changes) + if project.repository_read_only? + raise UnauthorizedError, ERROR_MESSAGES[:readonly] + end + if deploy_key check_deploy_key_push_access! elsif user diff --git a/lib/gitlab/gitaly_client/operation_service.rb b/lib/gitlab/gitaly_client/operation_service.rb new file mode 100644 index 00000000000..2d5440e7ea8 --- /dev/null +++ b/lib/gitlab/gitaly_client/operation_service.rb @@ -0,0 +1,45 @@ +module Gitlab + module GitalyClient + class OperationService + def initialize(repository) + @gitaly_repo = repository.gitaly_repository + @repository = repository + end + + def rm_tag(tag_name, user) + request = Gitaly::UserDeleteTagRequest.new( + repository: @gitaly_repo, + tag_name: GitalyClient.encode(tag_name), + user: Util.gitaly_user(user) + ) + + response = GitalyClient.call(@repository.storage, :operation_service, :user_delete_tag, request) + + if pre_receive_error = response.pre_receive_error.presence + raise Gitlab::Git::HooksService::PreReceiveError, pre_receive_error + end + end + + def add_tag(tag_name, user, target, message) + request = Gitaly::UserCreateTagRequest.new( + repository: @gitaly_repo, + user: Util.gitaly_user(user), + tag_name: GitalyClient.encode(tag_name), + target_revision: GitalyClient.encode(target), + message: GitalyClient.encode(message.to_s) + ) + + response = GitalyClient.call(@repository.storage, :operation_service, :user_create_tag, request) + if pre_receive_error = response.pre_receive_error.presence + raise Gitlab::Git::HooksService::PreReceiveError, pre_receive_error + elsif response.exists + raise Gitlab::Git::Repository::TagExistsError + end + + Util.gitlab_tag_from_gitaly_tag(@repository, response.tag) + rescue GRPC::FailedPrecondition => e + raise Gitlab::Git::Repository::InvalidRef, e + end + end + end +end diff --git a/lib/gitlab/gitaly_client/ref_service.rb b/lib/gitlab/gitaly_client/ref_service.rb index 8ef873d5848..b0c73395cb1 100644 --- a/lib/gitlab/gitaly_client/ref_service.rb +++ b/lib/gitlab/gitaly_client/ref_service.rb @@ -155,19 +155,7 @@ module Gitlab def consume_tags_response(response) response.flat_map do |message| - message.tags.map do |gitaly_tag| - if gitaly_tag.target_commit.present? - gitaly_commit = Gitlab::Git::Commit.decorate(@repository, gitaly_tag.target_commit) - end - - Gitlab::Git::Tag.new( - @repository, - encode!(gitaly_tag.name.dup), - gitaly_tag.id, - gitaly_commit, - encode!(gitaly_tag.message.chomp) - ) - end + message.tags.map { |gitaly_tag| Util.gitlab_tag_from_gitaly_tag(@repository, gitaly_tag) } end end diff --git a/lib/gitlab/gitaly_client/util.rb b/lib/gitlab/gitaly_client/util.rb index 8fc937496af..2fb5875a7a2 100644 --- a/lib/gitlab/gitaly_client/util.rb +++ b/lib/gitlab/gitaly_client/util.rb @@ -10,6 +10,30 @@ module Gitlab git_alternate_object_directories: Array.wrap(Gitlab::Git::Env['GIT_ALTERNATE_OBJECT_DIRECTORIES']) ) end + + def gitaly_user(gitlab_user) + return unless gitlab_user + + Gitaly::User.new( + gl_id: Gitlab::GlId.gl_id(gitlab_user), + name: GitalyClient.encode(gitlab_user.name), + email: GitalyClient.encode(gitlab_user.email) + ) + end + + def gitlab_tag_from_gitaly_tag(repository, gitaly_tag) + if gitaly_tag.target_commit.present? + commit = Gitlab::Git::Commit.decorate(repository, gitaly_tag.target_commit) + end + + Gitlab::Git::Tag.new( + repository, + Gitlab::EncodingHelper.encode!(gitaly_tag.name.dup), + gitaly_tag.id, + commit, + Gitlab::EncodingHelper.encode!(gitaly_tag.message.chomp) + ) + end end end end diff --git a/lib/tasks/gitlab/storage.rake b/lib/tasks/gitlab/storage.rake new file mode 100644 index 00000000000..e05be4a3405 --- /dev/null +++ b/lib/tasks/gitlab/storage.rake @@ -0,0 +1,85 @@ +namespace :gitlab do + namespace :storage do + desc 'GitLab | Storage | Migrate existing projects to Hashed Storage' + task migrate_to_hashed: :environment do + legacy_projects_count = Project.with_legacy_storage.count + + if legacy_projects_count == 0 + puts 'There are no projects using legacy storage. Nothing to do!' + + next + end + + print "Enqueuing migration of #{legacy_projects_count} projects in batches of #{batch_size}" + + project_id_batches do |start, finish| + StorageMigratorWorker.perform_async(start, finish) + + print '.' + end + + puts ' Done!' + end + + desc 'Gitlab | Storage | Summary of existing projects using Legacy Storage' + task legacy_projects: :environment do + projects_summary(Project.with_legacy_storage) + end + + desc 'Gitlab | Storage | List existing projects using Legacy Storage' + task list_legacy_projects: :environment do + projects_list(Project.with_legacy_storage) + end + + desc 'Gitlab | Storage | Summary of existing projects using Hashed Storage' + task hashed_projects: :environment do + projects_summary(Project.with_hashed_storage) + end + + desc 'Gitlab | Storage | List existing projects using Hashed Storage' + task list_hashed_projects: :environment do + projects_list(Project.with_hashed_storage) + end + + def batch_size + ENV.fetch('BATCH', 200).to_i + end + + def project_id_batches(&block) + Project.with_legacy_storage.in_batches(of: batch_size, start: ENV['ID_FROM'], finish: ENV['ID_TO']) do |relation| # rubocop: disable Cop/InBatches + ids = relation.pluck(:id) + + yield ids.min, ids.max + end + end + + def projects_summary(relation) + projects_count = relation.count + puts "* Found #{projects_count} projects".color(:green) + + projects_count + end + + def projects_list(relation) + projects_count = projects_summary(relation) + + projects = relation.with_route + limit = ENV.fetch('LIMIT', 500).to_i + + return unless projects_count > 0 + + puts " ! Displaying first #{limit} projects..." if projects_count > limit + + counter = 0 + projects.find_in_batches(batch_size: batch_size) do |batch| + batch.each do |project| + counter += 1 + + puts " - #{project.full_path} (id: #{project.id})".color(:red) + + return if counter >= limit # rubocop:disable Lint/NonLocalExitFromIterator + end + end + end + end +end diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb index 7493b0a8b35..958d62181a2 100644 --- a/spec/factories/projects.rb +++ b/spec/factories/projects.rb @@ -143,6 +143,16 @@ FactoryGirl.define do end end + trait :wiki_repo do + after(:create) do |project| + raise 'Failed to create wiki repository!' unless project.create_wiki + end + end + + trait :readonly do + repository_read_only true + end + trait :broken_repo do after(:create) do |project| raise "Failed to create repository!" unless project.create_repository diff --git a/spec/factories/user_custom_attributes.rb b/spec/factories/user_custom_attributes.rb new file mode 100644 index 00000000000..278cf290d4f --- /dev/null +++ b/spec/factories/user_custom_attributes.rb @@ -0,0 +1,7 @@ +FactoryGirl.define do + factory :user_custom_attribute do + user + sequence(:key) { |n| "key#{n}" } + sequence(:value) { |n| "value#{n}" } + end +end diff --git a/spec/features/issues/filtered_search/recent_searches_spec.rb b/spec/features/issues/filtered_search/recent_searches_spec.rb index 5eeecaeda47..447281ed19d 100644 --- a/spec/features/issues/filtered_search/recent_searches_spec.rb +++ b/spec/features/issues/filtered_search/recent_searches_spec.rb @@ -104,6 +104,6 @@ describe 'Recent searches', js: true do set_recent_searches(project_1_local_storage_key, 'fail') visit project_issues_path(project_1) - expect(find('.flash-alert')).to have_text('An error occured while parsing recent searches') + expect(find('.flash-alert')).to have_text('An error occurred while parsing recent searches') end end diff --git a/spec/features/tags/master_deletes_tag_spec.rb b/spec/features/tags/master_deletes_tag_spec.rb index d6a6b8fc7d5..80750c904b5 100644 --- a/spec/features/tags/master_deletes_tag_spec.rb +++ b/spec/features/tags/master_deletes_tag_spec.rb @@ -35,15 +35,30 @@ feature 'Master deletes tag' do end context 'when pre-receive hook fails', js: true do - before do - allow_any_instance_of(Gitlab::Git::HooksService).to receive(:execute) - .and_raise(Gitlab::Git::HooksService::PreReceiveError, 'Do not delete tags') + context 'when Gitaly operation_user_delete_tag feature is enabled' do + before do + allow_any_instance_of(Gitlab::GitalyClient::OperationService).to receive(:rm_tag) + .and_raise(Gitlab::Git::HooksService::PreReceiveError, 'Do not delete tags') + end + + scenario 'shows the error message' do + delete_first_tag + + expect(page).to have_content('Do not delete tags') + end end - scenario 'shows the error message' do - delete_first_tag + context 'when Gitaly operation_user_delete_tag feature is disabled', skip_gitaly_mock: true do + before do + allow_any_instance_of(Gitlab::Git::HooksService).to receive(:execute) + .and_raise(Gitlab::Git::HooksService::PreReceiveError, 'Do not delete tags') + end + + scenario 'shows the error message' do + delete_first_tag - expect(page).to have_content('Do not delete tags') + expect(page).to have_content('Do not delete tags') + end end end diff --git a/spec/finders/users_finder_spec.rb b/spec/finders/users_finder_spec.rb index 1bab6d64388..4249c52c481 100644 --- a/spec/finders/users_finder_spec.rb +++ b/spec/finders/users_finder_spec.rb @@ -56,6 +56,15 @@ describe UsersFinder do expect(users.map(&:username)).not_to include([filtered_user_before.username, filtered_user_after.username]) end + + it 'does not filter by custom attributes' do + users = described_class.new( + user, + custom_attributes: { foo: 'bar' } + ).execute + + expect(users).to contain_exactly(user, user1, user2, omniauth_user) + end end context 'with an admin user' do @@ -72,6 +81,19 @@ describe UsersFinder do expect(users).to contain_exactly(admin, user1, user2, external_user, omniauth_user) end + + it 'filters by custom attributes' do + create :user_custom_attribute, user: user1, key: 'foo', value: 'foo' + create :user_custom_attribute, user: user1, key: 'bar', value: 'bar' + create :user_custom_attribute, user: user2, key: 'foo', value: 'foo' + + users = described_class.new( + admin, + custom_attributes: { foo: 'foo', bar: 'bar' } + ).execute + + expect(users).to contain_exactly(user1) + end end end end diff --git a/spec/javascripts/blob/notebook/index_spec.js b/spec/javascripts/blob/notebook/index_spec.js index 11f2a950678..c3e67550f05 100644 --- a/spec/javascripts/blob/notebook/index_spec.js +++ b/spec/javascripts/blob/notebook/index_spec.js @@ -117,7 +117,7 @@ describe('iPython notebook renderer', () => { it('shows error message', () => { expect( document.querySelector('.md').textContent.trim(), - ).toBe('An error occured whilst parsing the file.'); + ).toBe('An error occurred whilst parsing the file.'); }); }); @@ -153,7 +153,7 @@ describe('iPython notebook renderer', () => { it('shows error message', () => { expect( document.querySelector('.md').textContent.trim(), - ).toBe('An error occured whilst loading the file. Please try again later.'); + ).toBe('An error occurred whilst loading the file. Please try again later.'); }); }); }); diff --git a/spec/javascripts/blob/pdf/index_spec.js b/spec/javascripts/blob/pdf/index_spec.js index bbeaf95e68d..51bf3086627 100644 --- a/spec/javascripts/blob/pdf/index_spec.js +++ b/spec/javascripts/blob/pdf/index_spec.js @@ -76,7 +76,7 @@ describe('PDF renderer', () => { it('shows error message', () => { expect( document.querySelector('.md').textContent.trim(), - ).toBe('An error occured whilst loading the file. Please try again later.'); + ).toBe('An error occurred whilst loading the file. Please try again later.'); }); }); }); diff --git a/spec/javascripts/build_spec.js b/spec/javascripts/build_spec.js index 35149611095..d5b0f23e7b7 100644 --- a/spec/javascripts/build_spec.js +++ b/spec/javascripts/build_spec.js @@ -289,4 +289,18 @@ describe('Build', () => { }); }); }); + + describe('getBuildTrace', () => { + it('should request build trace with state parameter', (done) => { + spyOn(jQuery, 'ajax').and.callThrough(); + new Build(); + + setTimeout(() => { + expect(jQuery.ajax).toHaveBeenCalledWith( + { url: `${BUILD_URL}/trace.json`, data: { state: '' } }, + ); + done(); + }, 0); + }); + }); }); diff --git a/spec/javascripts/repo/components/repo_sidebar_spec.js b/spec/javascripts/repo/components/repo_sidebar_spec.js index abcff8e537e..db9911c7a2c 100644 --- a/spec/javascripts/repo/components/repo_sidebar_spec.js +++ b/spec/javascripts/repo/components/repo_sidebar_spec.js @@ -79,6 +79,20 @@ describe('RepoSidebar', () => { expect(Helper.getContent).toHaveBeenCalledWith(file1); }); + it('should not fetch data for already opened files', () => { + const file = { + id: 42, + url: 'foo', + }; + + spyOn(Helper, 'getFileFromPath').and.returnValue(file); + spyOn(RepoStore, 'setActiveFiles'); + const vm = createComponent(); + vm.fileClicked(file); + + expect(RepoStore.setActiveFiles).toHaveBeenCalledWith(file); + }); + it('should hide files in directory if already open', () => { spyOn(RepoStore, 'removeChildFilesOfTree').and.callThrough(); const file1 = { diff --git a/spec/lib/gitlab/shell_spec.rb b/spec/lib/gitlab/shell_spec.rb index c7930378240..8edf83864da 100644 --- a/spec/lib/gitlab/shell_spec.rb +++ b/spec/lib/gitlab/shell_spec.rb @@ -48,14 +48,35 @@ describe Gitlab::Shell do end end - describe '#add_key' do - it 'removes trailing garbage' do - allow(gitlab_shell).to receive(:gitlab_shell_keys_path).and_return(:gitlab_shell_keys_path) - expect(gitlab_shell).to receive(:gitlab_shell_fast_execute).with( - [:gitlab_shell_keys_path, 'add-key', 'key-123', 'ssh-rsa foobar'] - ) - - gitlab_shell.add_key('key-123', 'ssh-rsa foobar trailing garbage') + describe 'projects commands' do + let(:gitlab_shell_path) { File.expand_path('tmp/tests/gitlab-shell') } + let(:projects_path) { File.join(gitlab_shell_path, 'bin/gitlab-projects') } + let(:gitlab_shell_hooks_path) { File.join(gitlab_shell_path, 'hooks') } + + before do + allow(Gitlab.config.gitlab_shell).to receive(:path).and_return(gitlab_shell_path) + allow(Gitlab.config.gitlab_shell).to receive(:hooks_path).and_return(gitlab_shell_hooks_path) + allow(Gitlab.config.gitlab_shell).to receive(:git_timeout).and_return(800) + end + + describe '#mv_repository' do + it 'executes the command' do + expect(gitlab_shell).to receive(:gitlab_shell_fast_execute).with( + [projects_path, 'mv-project', 'storage/path', 'project/path.git', 'new/path.git'] + ) + gitlab_shell.mv_repository('storage/path', 'project/path', 'new/path') + end + end + + describe '#add_key' do + it 'removes trailing garbage' do + allow(gitlab_shell).to receive(:gitlab_shell_keys_path).and_return(:gitlab_shell_keys_path) + expect(gitlab_shell).to receive(:gitlab_shell_fast_execute).with( + [:gitlab_shell_keys_path, 'add-key', 'key-123', 'ssh-rsa foobar'] + ) + + gitlab_shell.add_key('key-123', 'ssh-rsa foobar trailing garbage') + end end end @@ -136,7 +157,7 @@ describe Gitlab::Shell do it 'returns true when the command succeeds' do expect(Gitlab::Popen).to receive(:popen) .with([projects_path, 'rm-project', 'current/storage', 'project/path.git'], - nil, popen_vars).and_return([nil, 0]) + nil, popen_vars).and_return([nil, 0]) expect(gitlab_shell.remove_repository('current/storage', 'project/path')).to be true end @@ -144,7 +165,7 @@ describe Gitlab::Shell do it 'returns false when the command fails' do expect(Gitlab::Popen).to receive(:popen) .with([projects_path, 'rm-project', 'current/storage', 'project/path.git'], - nil, popen_vars).and_return(["error", 1]) + nil, popen_vars).and_return(["error", 1]) expect(gitlab_shell.remove_repository('current/storage', 'project/path')).to be false end diff --git a/spec/lib/gitlab/workhorse_spec.rb b/spec/lib/gitlab/workhorse_spec.rb index 699184ad9fe..a333ae33972 100644 --- a/spec/lib/gitlab/workhorse_spec.rb +++ b/spec/lib/gitlab/workhorse_spec.rb @@ -218,7 +218,8 @@ describe Gitlab::Workhorse do storage_name: 'default', relative_path: project.full_path + '.git', git_object_directory: '', - git_alternate_object_directories: [] + git_alternate_object_directories: [], + gl_repository: '' } } expect(subject).to include(repo_param) diff --git a/spec/models/ci/pipeline_variable_spec.rb b/spec/models/ci/pipeline_variable_spec.rb index 2ce78e34b0c..889c243c8d8 100644 --- a/spec/models/ci/pipeline_variable_spec.rb +++ b/spec/models/ci/pipeline_variable_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe Ci::PipelineVariable, models: true do +describe Ci::PipelineVariable do subject { build(:ci_pipeline_variable) } it { is_expected.to include_module(HasVariable) } diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 78226c6c3fa..3ca88071ced 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -2363,10 +2363,22 @@ describe Project do describe '#legacy_storage?' do it 'returns true when storage_version is nil' do - project = build(:project) + project = build(:project, storage_version: nil) expect(project.legacy_storage?).to be_truthy end + + it 'returns true when the storage_version is 0' do + project = build(:project, storage_version: 0) + + expect(project.legacy_storage?).to be_truthy + end + end + + describe '#hashed_storage?' do + it 'returns false' do + expect(project.hashed_storage?).to be_falsey + end end describe '#rename_repo' do @@ -2425,6 +2437,38 @@ describe Project do expect(project.pages_path).to eq(File.join(Settings.pages.path, project.namespace.full_path, project.path)) end end + + describe '#migrate_to_hashed_storage!' do + it 'returns true' do + expect(project.migrate_to_hashed_storage!).to be_truthy + end + + it 'flags as readonly' do + expect { project.migrate_to_hashed_storage! }.to change { project.repository_read_only }.to(true) + end + + it 'schedules ProjectMigrateHashedStorageWorker with delayed start when the project repo is in use' do + Gitlab::ReferenceCounter.new(project.gl_repository(is_wiki: false)).increase + + expect(ProjectMigrateHashedStorageWorker).to receive(:perform_in) + + project.migrate_to_hashed_storage! + end + + it 'schedules ProjectMigrateHashedStorageWorker with delayed start when the wiki repo is in use' do + Gitlab::ReferenceCounter.new(project.gl_repository(is_wiki: true)).increase + + expect(ProjectMigrateHashedStorageWorker).to receive(:perform_in) + + project.migrate_to_hashed_storage! + end + + it 'schedules ProjectMigrateHashedStorageWorker' do + expect(ProjectMigrateHashedStorageWorker).to receive(:perform_async).with(project.id) + + project.migrate_to_hashed_storage! + end + end end context 'hashed storage' do @@ -2438,6 +2482,18 @@ describe Project do allow(project).to receive(:gitlab_shell).and_return(gitlab_shell) end + describe '#legacy_storage?' do + it 'returns false' do + expect(project.legacy_storage?).to be_falsey + end + end + + describe '#hashed_storage?' do + it 'returns true' do + expect(project.hashed_storage?).to be_truthy + end + end + describe '#base_dir' do it 'returns base_dir based on hash of project id' do expect(project.base_dir).to eq('@hashed/6b/86') @@ -2508,6 +2564,26 @@ describe Project do expect(project.pages_path).to eq(File.join(Settings.pages.path, project.namespace.full_path, project.path)) end end + + describe '#migrate_to_hashed_storage!' do + it 'returns nil' do + expect(project.migrate_to_hashed_storage!).to be_nil + end + + it 'does not flag as readonly' do + expect { project.migrate_to_hashed_storage! }.not_to change { project.repository_read_only } + end + end + end + + describe '#gl_repository' do + let(:project) { create(:project) } + + it 'delegates to Gitlab::GlRepository.gl_repository' do + expect(Gitlab::GlRepository).to receive(:gl_repository).with(project, true) + + project.gl_repository(is_wiki: true) + end end describe '#has_ci?' do diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index 76bb658b10d..525c4027e5f 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe Repository, models: true do +describe Repository do include RepoHelpers TestBlob = Struct.new(:path) @@ -1617,27 +1617,41 @@ describe Repository, models: true do end describe '#add_tag' do - context 'with a valid target' do - let(:user) { build_stubbed(:user) } + let(:user) { build_stubbed(:user) } - it 'creates the tag using rugged' do - expect(repository.rugged.tags).to receive(:create) - .with('8.5', repository.commit('master').id, - hash_including(message: 'foo', - tagger: hash_including(name: user.name, email: user.email))) - .and_call_original + shared_examples 'adding tag' do + context 'with a valid target' do + it 'creates the tag' do + repository.add_tag(user, '8.5', 'master', 'foo') - repository.add_tag(user, '8.5', 'master', 'foo') - end + tag = repository.find_tag('8.5') + expect(tag).to be_present + expect(tag.message).to eq('foo') + expect(tag.dereferenced_target.id).to eq(repository.commit('master').id) + end - it 'returns a Gitlab::Git::Tag object' do - tag = repository.add_tag(user, '8.5', 'master', 'foo') + it 'returns a Gitlab::Git::Tag object' do + tag = repository.add_tag(user, '8.5', 'master', 'foo') + + expect(tag).to be_a(Gitlab::Git::Tag) + end + end - expect(tag).to be_a(Gitlab::Git::Tag) + context 'with an invalid target' do + it 'returns false' do + expect(repository.add_tag(user, '8.5', 'bar', 'foo')).to be false + end end + end + + context 'when Gitaly operation_user_add_tag feature is enabled' do + it_behaves_like 'adding tag' + end - it 'passes commit SHA to pre-receive and update hooks,\ - and tag SHA to post-receive hook' do + context 'when Gitaly operation_user_add_tag feature is disabled', skip_gitaly_mock: true do + it_behaves_like 'adding tag' + + it 'passes commit SHA to pre-receive and update hooks and tag SHA to post-receive hook' do pre_receive_hook = Gitlab::Git::Hook.new('pre-receive', project) update_hook = Gitlab::Git::Hook.new('update', project) post_receive_hook = Gitlab::Git::Hook.new('post-receive', project) @@ -1662,12 +1676,6 @@ describe Repository, models: true do .with(anything, anything, tag_sha, anything) end end - - context 'with an invalid target' do - it 'returns false' do - expect(repository.add_tag(user, '8.5', 'bar', 'foo')).to be false - end - end end describe '#rm_branch' do @@ -1682,12 +1690,22 @@ describe Repository, models: true do end describe '#rm_tag' do - it 'removes a tag' do - expect(repository).to receive(:before_remove_tag) + shared_examples 'removing tag' do + it 'removes a tag' do + expect(repository).to receive(:before_remove_tag) - repository.rm_tag(create(:user), 'v1.1.0') + repository.rm_tag(build_stubbed(:user), 'v1.1.0') + + expect(repository.find_tag('v1.1.0')).to be_nil + end + end + + context 'when Gitaly operation_user_delete_tag feature is enabled' do + it_behaves_like 'removing tag' + end - expect(repository.find_tag('v1.1.0')).to be_nil + context 'when Gitaly operation_user_delete_tag feature is disabled', skip_gitaly_mock: true do + it_behaves_like 'removing tag' end end diff --git a/spec/models/user_custom_attribute_spec.rb b/spec/models/user_custom_attribute_spec.rb new file mode 100644 index 00000000000..37fc3cb64f0 --- /dev/null +++ b/spec/models/user_custom_attribute_spec.rb @@ -0,0 +1,16 @@ +require 'spec_helper' + +describe UserCustomAttribute do + describe 'assocations' do + it { is_expected.to belong_to(:user) } + end + + describe 'validations' do + subject { build :user_custom_attribute } + + it { is_expected.to validate_presence_of(:user_id) } + it { is_expected.to validate_presence_of(:key) } + it { is_expected.to validate_presence_of(:value) } + it { is_expected.to validate_uniqueness_of(:key).scoped_to(:user_id) } + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index c1affa812aa..62890dd5002 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -39,6 +39,7 @@ describe User do it { is_expected.to have_many(:chat_names).dependent(:destroy) } it { is_expected.to have_many(:uploads).dependent(:destroy) } it { is_expected.to have_many(:reported_abuse_reports).dependent(:destroy).class_name('AbuseReport') } + it { is_expected.to have_many(:custom_attributes).class_name('UserCustomAttribute') } describe "#abuse_report" do let(:current_user) { create(:user) } diff --git a/spec/policies/global_policy_spec.rb b/spec/policies/global_policy_spec.rb index a6bf70c1e09..983f0e52d31 100644 --- a/spec/policies/global_policy_spec.rb +++ b/spec/policies/global_policy_spec.rb @@ -51,4 +51,18 @@ describe GlobalPolicy do end end end + + describe 'custom attributes' do + context 'regular user' do + it { is_expected.not_to be_allowed(:read_custom_attribute) } + it { is_expected.not_to be_allowed(:update_custom_attribute) } + end + + context 'admin' do + let(:current_user) { create(:user, :admin) } + + it { is_expected.to be_allowed(:read_custom_attribute) } + it { is_expected.to be_allowed(:update_custom_attribute) } + end + end end diff --git a/spec/requests/api/helpers_spec.rb b/spec/requests/api/helpers_spec.rb index d4006fe71a2..98c49d3364c 100644 --- a/spec/requests/api/helpers_spec.rb +++ b/spec/requests/api/helpers_spec.rb @@ -159,18 +159,25 @@ describe API::Helpers do end describe "when authenticating using a user's private token" do - it "returns nil for an invalid token" do + it "returns a 401 response for an invalid token" do env[API::APIGuard::PRIVATE_TOKEN_HEADER] = 'invalid token' allow_any_instance_of(self.class).to receive(:doorkeeper_guard) { false } - expect(current_user).to be_nil + expect { current_user }.to raise_error /401/ end - it "returns nil for a user without access" do + it "returns a 401 response for a user without access" do env[API::APIGuard::PRIVATE_TOKEN_HEADER] = user.private_token allow_any_instance_of(Gitlab::UserAccess).to receive(:allowed?).and_return(false) - expect(current_user).to be_nil + expect { current_user }.to raise_error /401/ + end + + it 'returns a 401 response for a user who is blocked' do + user.block! + env[API::APIGuard::PRIVATE_TOKEN_HEADER] = user.private_token + + expect { current_user }.to raise_error /401/ end it "leaves user as is when sudo not specified" do @@ -193,24 +200,31 @@ describe API::Helpers do allow_any_instance_of(self.class).to receive(:doorkeeper_guard) { false } end - it "returns nil for an invalid token" do + it "returns a 401 response for an invalid token" do env[API::APIGuard::PRIVATE_TOKEN_HEADER] = 'invalid token' - expect(current_user).to be_nil + expect { current_user }.to raise_error /401/ end - it "returns nil for a user without access" do + it "returns a 401 response for a user without access" do env[API::APIGuard::PRIVATE_TOKEN_HEADER] = personal_access_token.token allow_any_instance_of(Gitlab::UserAccess).to receive(:allowed?).and_return(false) - expect(current_user).to be_nil + expect { current_user }.to raise_error /401/ + end + + it 'returns a 401 response for a user who is blocked' do + user.block! + env[API::APIGuard::PRIVATE_TOKEN_HEADER] = personal_access_token.token + + expect { current_user }.to raise_error /401/ end - it "returns nil for a token without the appropriate scope" do + it "returns a 401 response for a token without the appropriate scope" do personal_access_token = create(:personal_access_token, user: user, scopes: ['read_user']) env[API::APIGuard::PRIVATE_TOKEN_HEADER] = personal_access_token.token - expect(current_user).to be_nil + expect { current_user }.to raise_error /401/ end it "leaves user as is when sudo not specified" do @@ -226,14 +240,14 @@ describe API::Helpers do personal_access_token.revoke! env[API::APIGuard::PRIVATE_TOKEN_HEADER] = personal_access_token.token - expect(current_user).to be_nil + expect { current_user }.to raise_error /401/ end it 'does not allow expired tokens' do personal_access_token.update_attributes!(expires_at: 1.day.ago) env[API::APIGuard::PRIVATE_TOKEN_HEADER] = personal_access_token.token - expect(current_user).to be_nil + expect { current_user }.to raise_error /401/ end end @@ -351,6 +365,18 @@ describe API::Helpers do end end end + + context 'when user is blocked' do + before do + user.block! + end + + it 'changes current_user to sudo' do + set_env(admin, user.id) + + expect(current_user).to eq(user) + end + end end context 'with regular user' do @@ -490,11 +516,10 @@ describe API::Helpers do context 'current_user is nil' do before do expect_any_instance_of(self.class).to receive(:current_user).and_return(nil) - allow_any_instance_of(self.class).to receive(:initial_current_user).and_return(nil) end it 'returns a 401 response' do - expect { authenticate! }.to raise_error '401 - {"message"=>"401 Unauthorized"}' + expect { authenticate! }.to raise_error /401/ end end @@ -502,35 +527,12 @@ describe API::Helpers do let(:user) { build(:user) } before do - expect_any_instance_of(self.class).to receive(:current_user).at_least(:once).and_return(user) - expect_any_instance_of(self.class).to receive(:initial_current_user).and_return(user) + expect_any_instance_of(self.class).to receive(:current_user).and_return(user) end it 'does not raise an error' do expect { authenticate! }.not_to raise_error end end - - context 'current_user is blocked' do - let(:user) { build(:user, :blocked) } - - before do - expect_any_instance_of(self.class).to receive(:current_user).at_least(:once).and_return(user) - end - - it 'raises an error' do - expect_any_instance_of(self.class).to receive(:initial_current_user).and_return(user) - - expect { authenticate! }.to raise_error '401 - {"message"=>"401 Unauthorized"}' - end - - it "doesn't raise an error if an admin user is impersonating a blocked user (via sudo)" do - admin_user = build(:user, :admin) - - expect_any_instance_of(self.class).to receive(:initial_current_user).and_return(admin_user) - - expect { authenticate! }.not_to raise_error - end - end end end diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb index c30188b1b41..69c8aa4482a 100644 --- a/spec/requests/api/users_spec.rb +++ b/spec/requests/api/users_spec.rb @@ -1905,4 +1905,8 @@ describe API::Users do expect(impersonation_token.reload.revoked).to be_truthy end end + + include_examples 'custom attributes endpoints', 'users' do + let(:attributable) { user } + end end diff --git a/spec/services/merge_requests/post_merge_service_spec.rb b/spec/services/merge_requests/post_merge_service_spec.rb index a37cdab8928..d2bd05d921f 100644 --- a/spec/services/merge_requests/post_merge_service_spec.rb +++ b/spec/services/merge_requests/post_merge_service_spec.rb @@ -11,5 +11,16 @@ describe MergeRequests::PostMergeService do describe '#execute' do it_behaves_like 'cache counters invalidator' + + it 'refreshes the number of open merge requests for a valid MR', :use_clean_rails_memory_store_caching do + # Cache the counter before the MR changed state. + project.open_merge_requests_count + merge_request.update!(state: 'merged') + + service = described_class.new(project, user, {}) + + expect { service.execute(merge_request) } + .to change { project.open_merge_requests_count }.from(1).to(0) + end end end diff --git a/spec/services/projects/hashed_storage_migration_service_spec.rb b/spec/services/projects/hashed_storage_migration_service_spec.rb new file mode 100644 index 00000000000..1b61207b550 --- /dev/null +++ b/spec/services/projects/hashed_storage_migration_service_spec.rb @@ -0,0 +1,74 @@ +require 'spec_helper' + +describe Projects::HashedStorageMigrationService do + let(:gitlab_shell) { Gitlab::Shell.new } + let(:project) { create(:project, :empty_repo, :wiki_repo) } + let(:service) { described_class.new(project) } + let(:legacy_storage) { Storage::LegacyProject.new(project) } + let(:hashed_storage) { Storage::HashedProject.new(project) } + + describe '#execute' do + before do + allow(service).to receive(:gitlab_shell) { gitlab_shell } + end + + context 'when succeeds' do + it 'renames project and wiki repositories' do + service.execute + + expect(gitlab_shell.exists?(project.repository_storage_path, "#{hashed_storage.disk_path}.git")).to be_truthy + expect(gitlab_shell.exists?(project.repository_storage_path, "#{hashed_storage.disk_path}.wiki.git")).to be_truthy + end + + it 'updates project to be hashed and not readonly' do + service.execute + + expect(project.hashed_storage?).to be_truthy + expect(project.repository_read_only).to be_falsey + end + + it 'move operation is called for both repositories' do + expect_move_repository(project.disk_path, hashed_storage.disk_path) + expect_move_repository("#{project.disk_path}.wiki", "#{hashed_storage.disk_path}.wiki") + + service.execute + end + end + + context 'when one move fails' do + it 'rollsback repositories to original name' do + from_name = project.disk_path + to_name = hashed_storage.disk_path + allow(service).to receive(:move_repository).and_call_original + allow(service).to receive(:move_repository).with(from_name, to_name).once { false } # will disable first move only + + expect(service).to receive(:rollback_folder_move).and_call_original + + service.execute + + expect(gitlab_shell.exists?(project.repository_storage_path, "#{hashed_storage.disk_path}.git")).to be_falsey + expect(gitlab_shell.exists?(project.repository_storage_path, "#{hashed_storage.disk_path}.wiki.git")).to be_falsey + end + + context 'when rollback fails' do + before do + from_name = legacy_storage.disk_path + to_name = hashed_storage.disk_path + + hashed_storage.ensure_storage_path_exists + gitlab_shell.mv_repository(project.repository_storage_path, from_name, to_name) + end + + it 'does not try to move nil repository over hashed' do + expect_move_repository("#{project.disk_path}.wiki", "#{hashed_storage.disk_path}.wiki") + + service.execute + end + end + end + + def expect_move_repository(from_name, to_name) + expect(gitlab_shell).to receive(:mv_repository).with(project.repository_storage_path, from_name, to_name).and_call_original + end + end +end diff --git a/spec/services/tags/create_service_spec.rb b/spec/services/tags/create_service_spec.rb index 57013b54560..e7e9080b6b0 100644 --- a/spec/services/tags/create_service_spec.rb +++ b/spec/services/tags/create_service_spec.rb @@ -28,7 +28,7 @@ describe Tags::CreateService do it 'returns an error' do expect(repository).to receive(:add_tag) .with(user, 'v1.1.0', 'master', 'Foo') - .and_raise(Rugged::TagError) + .and_raise(Gitlab::Git::Repository::TagExistsError) response = service.execute('v1.1.0', 'master', 'Foo') diff --git a/spec/support/shared_examples/requests/api/custom_attributes_shared_examples.rb b/spec/support/shared_examples/requests/api/custom_attributes_shared_examples.rb new file mode 100644 index 00000000000..c9302f7b750 --- /dev/null +++ b/spec/support/shared_examples/requests/api/custom_attributes_shared_examples.rb @@ -0,0 +1,103 @@ +shared_examples 'custom attributes endpoints' do |attributable_name| + let!(:custom_attribute1) { attributable.custom_attributes.create key: 'foo', value: 'foo' } + let!(:custom_attribute2) { attributable.custom_attributes.create key: 'bar', value: 'bar' } + + describe "GET /#{attributable_name} with custom attributes filter" do + let!(:other_attributable) { create attributable.class.name.underscore } + + context 'with an unauthorized user' do + it 'does not filter by custom attributes' do + get api("/#{attributable_name}", user), custom_attributes: { foo: 'foo', bar: 'bar' } + + expect(response).to have_http_status(200) + expect(json_response.size).to be 2 + end + end + + it 'filters by custom attributes' do + get api("/#{attributable_name}", admin), custom_attributes: { foo: 'foo', bar: 'bar' } + + expect(response).to have_http_status(200) + expect(json_response.size).to be 1 + expect(json_response.first['id']).to eq attributable.id + end + end + + describe "GET /#{attributable_name}/:id/custom_attributes" do + context 'with an unauthorized user' do + subject { get api("/#{attributable_name}/#{attributable.id}/custom_attributes", user) } + + it_behaves_like 'an unauthorized API user' + end + + it 'returns all custom attributes' do + get api("/#{attributable_name}/#{attributable.id}/custom_attributes", admin) + + expect(response).to have_http_status(200) + expect(json_response).to contain_exactly( + { 'key' => 'foo', 'value' => 'foo' }, + { 'key' => 'bar', 'value' => 'bar' } + ) + end + end + + describe "GET /#{attributable_name}/:id/custom_attributes/:key" do + context 'with an unauthorized user' do + subject { get api("/#{attributable_name}/#{attributable.id}/custom_attributes/foo", user) } + + it_behaves_like 'an unauthorized API user' + end + + it 'returns a single custom attribute' do + get api("/#{attributable_name}/#{attributable.id}/custom_attributes/foo", admin) + + expect(response).to have_http_status(200) + expect(json_response).to eq({ 'key' => 'foo', 'value' => 'foo' }) + end + end + + describe "PUT /#{attributable_name}/:id/custom_attributes/:key" do + context 'with an unauthorized user' do + subject { put api("/#{attributable_name}/#{attributable.id}/custom_attributes/foo", user), value: 'new' } + + it_behaves_like 'an unauthorized API user' + end + + it 'creates a new custom attribute' do + expect do + put api("/#{attributable_name}/#{attributable.id}/custom_attributes/new", admin), value: 'new' + end.to change { attributable.custom_attributes.count }.by(1) + + expect(response).to have_http_status(200) + expect(json_response).to eq({ 'key' => 'new', 'value' => 'new' }) + expect(attributable.custom_attributes.find_by(key: 'new').value).to eq 'new' + end + + it 'updates an existing custom attribute' do + expect do + put api("/#{attributable_name}/#{attributable.id}/custom_attributes/foo", admin), value: 'new' + end.not_to change { attributable.custom_attributes.count } + + expect(response).to have_http_status(200) + expect(json_response).to eq({ 'key' => 'foo', 'value' => 'new' }) + expect(custom_attribute1.reload.value).to eq 'new' + end + end + + describe "DELETE /#{attributable_name}/:id/custom_attributes/:key" do + context 'with an unauthorized user' do + subject { delete api("/#{attributable_name}/#{attributable.id}/custom_attributes/foo", user) } + + it_behaves_like 'an unauthorized API user' + end + + it 'deletes an existing custom attribute' do + expect do + delete api("/#{attributable_name}/#{attributable.id}/custom_attributes/foo", admin) + end.to change { attributable.custom_attributes.count }.by(-1) + + expect(response).to have_http_status(204) + expect(attributable.custom_attributes.find_by(key: 'foo')).to be_nil + end + end +end diff --git a/spec/tasks/gitlab/storage_rake_spec.rb b/spec/tasks/gitlab/storage_rake_spec.rb new file mode 100644 index 00000000000..f59792c3d36 --- /dev/null +++ b/spec/tasks/gitlab/storage_rake_spec.rb @@ -0,0 +1,52 @@ +require 'rake_helper' + +describe 'gitlab:storage rake tasks' do + before do + Rake.application.rake_require 'tasks/gitlab/storage' + + stub_warn_user_is_not_gitlab + end + + describe 'migrate_to_hashed rake task' do + context '0 legacy projects' do + it 'does nothing' do + expect(StorageMigratorWorker).not_to receive(:perform_async) + + run_rake_task('gitlab:storage:migrate_to_hashed') + end + end + + context '5 legacy projects' do + let(:projects) { create_list(:project, 5, storage_version: 0) } + + context 'in batches of 1' do + before do + stub_env('BATCH' => 1) + end + + it 'enqueues one StorageMigratorWorker per project' do + projects.each do |project| + expect(StorageMigratorWorker).to receive(:perform_async).with(project.id, project.id) + end + + run_rake_task('gitlab:storage:migrate_to_hashed') + end + end + + context 'in batches of 2' do + before do + stub_env('BATCH' => 2) + end + + it 'enqueues one StorageMigratorWorker per 2 projects' do + projects.map(&:id).sort.each_slice(2) do |first, last| + last ||= first + expect(StorageMigratorWorker).to receive(:perform_async).with(first, last) + end + + run_rake_task('gitlab:storage:migrate_to_hashed') + end + end + end + end +end diff --git a/spec/workers/project_migrate_hashed_storage_worker_spec.rb b/spec/workers/project_migrate_hashed_storage_worker_spec.rb new file mode 100644 index 00000000000..f5226dee0ad --- /dev/null +++ b/spec/workers/project_migrate_hashed_storage_worker_spec.rb @@ -0,0 +1,29 @@ +require 'spec_helper' + +describe ProjectMigrateHashedStorageWorker do + describe '#perform' do + let(:project) { create(:project, :empty_repo) } + let(:pending_delete_project) { create(:project, :empty_repo, pending_delete: true) } + + it 'skips when project no longer exists' do + nonexistent_id = 999999999999 + + expect(::Projects::HashedStorageMigrationService).not_to receive(:new) + subject.perform(nonexistent_id) + end + + it 'skips when project is pending delete' do + expect(::Projects::HashedStorageMigrationService).not_to receive(:new) + + subject.perform(pending_delete_project.id) + end + + it 'delegates removal to service class' do + service = double('service') + expect(::Projects::HashedStorageMigrationService).to receive(:new).with(project, subject.logger).and_return(service) + expect(service).to receive(:execute) + + subject.perform(project.id) + end + end +end diff --git a/spec/workers/storage_migrator_worker_spec.rb b/spec/workers/storage_migrator_worker_spec.rb new file mode 100644 index 00000000000..8619ff2f7da --- /dev/null +++ b/spec/workers/storage_migrator_worker_spec.rb @@ -0,0 +1,30 @@ +require 'spec_helper' + +describe StorageMigratorWorker do + subject(:worker) { described_class.new } + let(:projects) { create_list(:project, 2) } + + describe '#perform' do + let(:ids) { projects.map(&:id) } + + it 'enqueue jobs to ProjectMigrateHashedStorageWorker' do + expect(ProjectMigrateHashedStorageWorker).to receive(:perform_async).twice + + worker.perform(ids.min, ids.max) + end + + it 'sets projects as read only' do + allow(ProjectMigrateHashedStorageWorker).to receive(:perform_async).twice + worker.perform(ids.min, ids.max) + + projects.each do |project| + expect(project.reload.repository_read_only?).to be_truthy + end + end + + it 'rescues and log exceptions' do + allow_any_instance_of(Project).to receive(:migrate_to_hashed_storage!).and_raise(StandardError) + expect { worker.perform(ids.min, ids.max) }.not_to raise_error + end + end +end |