diff options
84 files changed, 1493 insertions, 333 deletions
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0f97e779129..4f5d19ce2ce 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,6 +9,10 @@ terms. [DCO + License](https://gitlab.com/gitlab-org/dco/blob/master/README.md) +All Documentation content that resides under the [doc/ directory](/doc) of this +repository is licensed under Creative Commons: +[CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/). + _This notice should stay as the first item in the CONTRIBUTING.md file._ --- @@ -33,7 +33,7 @@ gem 'grape-route-helpers', '~> 2.1.0' gem 'faraday', '~> 0.12' # Authentication libraries -gem 'devise', '~> 4.2' +gem 'devise', '~> 4.4' gem 'doorkeeper', '~> 4.3' gem 'doorkeeper-openid_connect', '~> 1.3' gem 'omniauth', '~> 1.8' @@ -41,7 +41,7 @@ gem 'omniauth-auth0', '~> 2.0.0' gem 'omniauth-azure-oauth2', '~> 0.0.9' gem 'omniauth-cas3', '~> 1.1.4' gem 'omniauth-facebook', '~> 4.0.0' -gem 'omniauth-github', '~> 1.1.1' +gem 'omniauth-github', '~> 1.3' gem 'omniauth-gitlab', '~> 1.0.2' gem 'omniauth-google-oauth2', '~> 0.5.3' gem 'omniauth-kerberos', '~> 0.3.0', group: :kerberos @@ -90,7 +90,7 @@ gem 'github-linguist', '~> 5.3.3', require: 'linguist' # API gem 'grape', '~> 1.0' -gem 'grape-entity', '~> 0.6.0' +gem 'grape-entity', '~> 0.7.1' gem 'rack-cors', '~> 1.0.0', require: 'rack/cors' # Disable strong_params so that Mash does not respond to :permitted? diff --git a/Gemfile.lock b/Gemfile.lock index f11df6a283e..f7e2428a07f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -143,7 +143,7 @@ GEM connection_pool (2.2.1) crack (0.4.3) safe_yaml (~> 1.0.0) - crass (1.0.3) + crass (1.0.4) creole (0.5.0) css_parser (1.5.0) addressable @@ -162,10 +162,10 @@ GEM descendants_tracker (0.0.4) thread_safe (~> 0.3, >= 0.3.1) device_detector (1.0.0) - devise (4.2.0) + devise (4.4.3) bcrypt (~> 3.0) orm_adapter (~> 0.1) - railties (>= 4.1.0, < 5.1) + railties (>= 4.1.0, < 6.0) responders warden (~> 1.2.3) devise-two-factor (3.0.0) @@ -366,8 +366,8 @@ GEM rack (>= 1.3.0) rack-accept virtus (>= 1.0.0) - grape-entity (0.6.0) - activesupport + grape-entity (0.7.1) + activesupport (>= 4.0) multi_json (>= 1.3.2) grape-route-helpers (2.1.0) activesupport @@ -546,9 +546,9 @@ GEM omniauth (~> 1.2) omniauth-facebook (4.0.0) omniauth-oauth2 (~> 1.2) - omniauth-github (1.1.2) - omniauth (~> 1.0) - omniauth-oauth2 (~> 1.1) + omniauth-github (1.3.0) + omniauth (~> 1.5) + omniauth-oauth2 (>= 1.4.0, < 2.0) omniauth-gitlab (1.0.2) omniauth (~> 1.0) omniauth-oauth2 (~> 1.0) @@ -646,7 +646,7 @@ GEM pry (>= 0.9.10) public_suffix (3.0.2) pyu-ruby-sasl (0.0.3.3) - rack (1.6.9) + rack (1.6.10) rack-accept (0.4.5) rack (>= 0.4) rack-attack (4.4.1) @@ -694,7 +694,7 @@ GEM rainbow (2.2.2) rake raindrops (0.18.0) - rake (12.3.0) + rake (12.3.1) rb-fsevent (0.10.2) rb-inotify (0.9.10) ffi (>= 0.5.0, < 2) @@ -735,8 +735,9 @@ GEM declarative-option (< 0.2.0) uber (< 0.2.0) request_store (1.3.1) - responders (2.3.0) - railties (>= 4.2.0, < 5.1) + responders (2.4.0) + actionpack (>= 4.2.0, < 5.3) + railties (>= 4.2.0, < 5.3) rest-client (2.0.2) http-cookie (>= 1.0.2, < 2.0) mime-types (>= 1.16, < 4.0) @@ -966,7 +967,7 @@ GEM descendants_tracker (~> 0.0, >= 0.0.3) equalizer (~> 0.0, >= 0.0.9) vmstat (2.3.0) - warden (1.2.6) + warden (1.2.7) rack (>= 1.0) webmock (2.3.2) addressable (>= 2.3.6) @@ -1028,7 +1029,7 @@ DEPENDENCIES deckar01-task_list (= 2.0.0) default_value_for (~> 3.0.0) device_detector - devise (~> 4.2) + devise (~> 4.4) devise-two-factor (~> 3.0.0) diffy (~> 3.1.0) doorkeeper (~> 4.3) @@ -1072,7 +1073,7 @@ DEPENDENCIES google-protobuf (= 3.5.1) gpgme grape (~> 1.0) - grape-entity (~> 0.6.0) + grape-entity (~> 0.7.1) grape-route-helpers (~> 2.1.0) grape_logging (~> 1.7) grpc (~> 1.11.0) @@ -1113,7 +1114,7 @@ DEPENDENCIES omniauth-azure-oauth2 (~> 0.0.9) omniauth-cas3 (~> 1.1.4) omniauth-facebook (~> 4.0.0) - omniauth-github (~> 1.1.1) + omniauth-github (~> 1.3) omniauth-gitlab (~> 1.0.2) omniauth-google-oauth2 (~> 0.5.3) omniauth-kerberos (~> 0.3.0) @@ -4,4 +4,9 @@ Permission is hereby granted, free of charge, to any person obtaining a copy of The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
\ No newline at end of file +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--- + +All Documentation content that resides under the doc/ directory of this +repository is licensed under Creative Commons: CC BY-SA 4.0. diff --git a/app/assets/javascripts/environments/components/container.vue b/app/assets/javascripts/environments/components/container.vue index dbee81fa320..6bd7c6b49cb 100644 --- a/app/assets/javascripts/environments/components/container.vue +++ b/app/assets/javascripts/environments/components/container.vue @@ -43,6 +43,7 @@ <div class="environments-container"> <loading-icon + class="prepend-top-default" label="Loading environments" v-if="isLoading" size="3" diff --git a/app/assets/javascripts/ide/components/changed_file_icon.vue b/app/assets/javascripts/ide/components/changed_file_icon.vue index fdbc14a4b8f..1cec84706fc 100644 --- a/app/assets/javascripts/ide/components/changed_file_icon.vue +++ b/app/assets/javascripts/ide/components/changed_file_icon.vue @@ -43,7 +43,7 @@ export default { return `${this.changedIcon}-solid`; }, changedIconClass() { - return `multi-${this.changedIcon} prepend-left-5 pull-left`; + return `multi-${this.changedIcon} pull-left`; }, tooltipTitle() { if (!this.showTooltip) return undefined; @@ -79,13 +79,7 @@ export default { class="ide-file-changed-icon" > <icon - v-if="file.staged && showStagedIcon" - :name="stagedIcon" - :size="12" - :css-classes="changedIconClass" - /> - <icon - v-if="file.changed || file.tempFile || (file.staged && !showStagedIcon)" + v-if="file.changed || file.tempFile || file.staged" :name="changedIcon" :size="12" :css-classes="changedIconClass" diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue index ad4713c40d5..872302840e2 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue @@ -36,7 +36,7 @@ export default { return this.file.tempFile ? `file-addition${prefix}` : `file-modified${prefix}`; }, iconClass() { - return `multi-file-${this.file.tempFile ? 'additions' : 'modified'} append-right-8`; + return `multi-file-${this.file.tempFile ? 'addition' : 'modified'} append-right-8`; }, }, methods: { diff --git a/app/assets/javascripts/ide/components/repo_file.vue b/app/assets/javascripts/ide/components/repo_file.vue index 4f102cfc026..14946f8c9fa 100644 --- a/app/assets/javascripts/ide/components/repo_file.vue +++ b/app/assets/javascripts/ide/components/repo_file.vue @@ -1,22 +1,29 @@ <script> -import { mapActions } from 'vuex'; -import skeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue'; -import fileIcon from '~/vue_shared/components/file_icon.vue'; +import { mapActions, mapGetters } from 'vuex'; +import { n__, __, sprintf } from '~/locale'; +import tooltip from '~/vue_shared/directives/tooltip'; +import SkeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue'; +import Icon from '~/vue_shared/components/icon.vue'; +import FileIcon from '~/vue_shared/components/file_icon.vue'; import router from '../ide_router'; -import newDropdown from './new_dropdown/index.vue'; -import fileStatusIcon from './repo_file_status_icon.vue'; -import changedFileIcon from './changed_file_icon.vue'; -import mrFileIcon from './mr_file_icon.vue'; +import NewDropdown from './new_dropdown/index.vue'; +import FileStatusIcon from './repo_file_status_icon.vue'; +import ChangedFileIcon from './changed_file_icon.vue'; +import MrFileIcon from './mr_file_icon.vue'; export default { name: 'RepoFile', + directives: { + tooltip, + }, components: { - skeletonLoadingContainer, - newDropdown, - fileStatusIcon, - fileIcon, - changedFileIcon, - mrFileIcon, + SkeletonLoadingContainer, + NewDropdown, + FileStatusIcon, + FileIcon, + ChangedFileIcon, + MrFileIcon, + Icon, }, props: { file: { @@ -34,6 +41,34 @@ export default { }, }, computed: { + ...mapGetters([ + 'getChangesInFolder', + 'getUnstagedFilesCountForPath', + 'getStagedFilesCountForPath', + ]), + folderUnstagedCount() { + return this.getUnstagedFilesCountForPath(this.file.path); + }, + folderStagedCount() { + return this.getStagedFilesCountForPath(this.file.path); + }, + changesCount() { + return this.getChangesInFolder(this.file.path); + }, + folderChangesTooltip() { + if (this.changesCount === 0) return undefined; + + if (this.folderUnstagedCount > 0 && this.folderStagedCount === 0) { + return n__('%d unstaged change', '%d unstaged changes', this.folderUnstagedCount); + } else if (this.folderUnstagedCount === 0 && this.folderStagedCount > 0) { + return n__('%d staged change', '%d staged changes', this.folderStagedCount); + } + + return sprintf(__('%{unstaged} unstaged and %{staged} staged changes'), { + unstaged: this.folderUnstagedCount, + staged: this.folderStagedCount, + }); + }, isTree() { return this.file.type === 'tree'; }, @@ -53,10 +88,19 @@ export default { 'is-open': this.file.opened, }; }, + showTreeChangesCount() { + return this.isTree && this.changesCount > 0 && !this.file.opened; + }, + showChangedFileIcon() { + return this.file.changed || this.file.tempFile || this.file.staged; + }, }, updated() { if (this.file.type === 'blob' && this.file.active) { - this.$el.scrollIntoView(); + this.$el.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + }); } }, methods: { @@ -104,8 +148,23 @@ export default { <mr-file-icon v-if="file.mrChange" /> + <span + v-if="showTreeChangesCount" + class="ide-tree-changes" + > + {{ changesCount }} + <icon + v-tooltip + :title="folderChangesTooltip" + data-container="body" + data-placement="right" + name="file-modified" + :size="12" + css-classes="prepend-left-5 multi-file-modified" + /> + </span> <changed-file-icon - v-if="file.changed || file.tempFile || file.staged" + v-else-if="showChangedFileIcon" :file="file" :show-tooltip="true" :show-staged-icon="true" diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js index bbe3083e24b..80de60799f2 100644 --- a/app/assets/javascripts/ide/stores/getters.js +++ b/app/assets/javascripts/ide/stores/getters.js @@ -1,4 +1,9 @@ +<<<<<<< HEAD import { activityBarViews } from '../constants'; +======= +import { __ } from '~/locale'; +import { getChangesCountForFiles, filePathMatches } from './utils'; +>>>>>>> master export const activeFile = state => state.openFiles.find(file => file.active) || null; @@ -52,11 +57,27 @@ export const allBlobs = state => }, []) .sort((a, b) => b.lastOpenedAt - a.lastOpenedAt); +export const getChangedFile = state => path => state.changedFiles.find(f => f.path === path); export const getStagedFile = state => path => state.stagedFiles.find(f => f.path === path); export const isEditModeActive = state => state.currentActivityView === activityBarViews.edit; export const isCommitModeActive = state => state.currentActivityView === activityBarViews.commit; export const isReviewModeActive = state => state.currentActivityView === activityBarViews.review; +export const getChangesInFolder = state => path => { + const changedFilesCount = state.changedFiles.filter(f => filePathMatches(f, path)).length; + const stagedFilesCount = state.stagedFiles.filter( + f => filePathMatches(f, path) && !getChangedFile(state)(f.path), + ).length; + + return changedFilesCount + stagedFilesCount; +}; + +export const getUnstagedFilesCountForPath = state => path => + getChangesCountForFiles(state.changedFiles, path); + +export const getStagedFilesCountForPath = state => path => + getChangesCountForFiles(state.stagedFiles, path); + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js index 59185f8f0ad..bc79ff4a542 100644 --- a/app/assets/javascripts/ide/stores/utils.js +++ b/app/assets/javascripts/ide/stores/utils.js @@ -33,7 +33,6 @@ export const dataStructure = () => ({ raw: '', content: '', parentTreeUrl: '', - parentPath: '', renderError: false, base64: false, editorRow: 1, @@ -43,6 +42,7 @@ export const dataStructure = () => ({ viewMode: 'edit', previewMode: null, size: 0, + parentPath: null, lastOpenedAt: 0, }); @@ -83,7 +83,6 @@ export const decorateData = entity => { opened, active, parentTreeUrl, - parentPath, changed, renderError, content, @@ -91,6 +90,7 @@ export const decorateData = entity => { previewMode, file_lock, html, + parentPath, }; }; @@ -137,3 +137,9 @@ export const sortTree = sortedTree => }), ) .sort(sortTreesByTypeAndName); + +export const filePathMatches = (f, path) => + f.path.replace(new RegExp(`${f.name}$`), '').indexOf(`${path}/`) === 0; + +export const getChangesCountForFiles = (files, path) => + files.filter(f => filePathMatches(f, path)).length; diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue index 6d95153af28..8f9e6761d20 100644 --- a/app/assets/javascripts/sidebar/components/participants/participants.vue +++ b/app/assets/javascripts/sidebar/components/participants/participants.vue @@ -70,6 +70,9 @@ toggleMoreParticipants() { this.isShowingMoreParticipants = !this.isShowingMoreParticipants; }, + onClickCollapsedIcon() { + this.$emit('toggleSidebar'); + }, }, }; </script> @@ -82,6 +85,7 @@ data-container="body" data-placement="left" :title="participantLabel" + @click="onClickCollapsedIcon" > <i class="fa fa-users" diff --git a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue index 3e8cc7a6630..385717e7c1e 100644 --- a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue +++ b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue @@ -1,6 +1,5 @@ <script> import Store from '../../stores/sidebar_store'; -import eventHub from '../../event_hub'; import Flash from '../../../flash'; import { __ } from '../../../locale'; import subscriptions from './subscriptions.vue'; @@ -20,12 +19,6 @@ export default { store: new Store(), }; }, - created() { - eventHub.$on('toggleSubscription', this.onToggleSubscription); - }, - beforeDestroy() { - eventHub.$off('toggleSubscription', this.onToggleSubscription); - }, methods: { onToggleSubscription() { this.mediator.toggleSubscription() @@ -42,6 +35,7 @@ export default { <subscriptions :loading="store.isFetching.subscriptions" :subscribed="store.subscribed" + @toggleSubscription="onToggleSubscription" /> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue index d69d100a26c..f0df759ef7a 100644 --- a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue +++ b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue @@ -47,8 +47,25 @@ }, }, methods: { + /** + * We need to emit this event on both component & eventHub + * for 2 dependencies; + * + * 1. eventHub: This component is used in Issue Boards sidebar + * where component template is part of HAML + * and event listeners are tied to app's eventHub. + * 2. Component: This compone is also used in Epics in EE + * where listeners are tied to component event. + */ toggleSubscription() { + // App's eventHub event emission. eventHub.$emit('toggleSubscription', this.id); + + // Component event emission. + this.$emit('toggleSubscription', this.id); + }, + onClickCollapsedIcon() { + this.$emit('toggleSidebar'); }, }, }; @@ -56,7 +73,10 @@ <template> <div> - <div class="sidebar-collapsed-icon"> + <div + class="sidebar-collapsed-icon" + @click="onClickCollapsedIcon" + > <span v-tooltip :title="notificationTooltip" diff --git a/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.js deleted file mode 100644 index bf987562647..00000000000 --- a/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.js +++ /dev/null @@ -1,15 +0,0 @@ -export default { - name: 'time-tracking-spent-only-pane', - props: { - timeSpentHumanReadable: { - type: String, - required: true, - }, - }, - template: ` - <div class="time-tracking-spend-only-pane"> - <span class="bold">Spent:</span> - {{ timeSpentHumanReadable }} - </div> - `, -}; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue b/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue new file mode 100644 index 00000000000..59cd99f8f14 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue @@ -0,0 +1,18 @@ +<script> +export default { + name: 'TimeTrackingSpentOnlyPane', + props: { + timeSpentHumanReadable: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <div class="time-tracking-spend-only-pane"> + <span class="bold">Spent:</span> + {{ timeSpentHumanReadable }} + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue index 9c003aa9f8a..8f5d0bee107 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue @@ -1,7 +1,7 @@ <script> import TimeTrackingHelpState from './help_state.vue'; import TimeTrackingCollapsedState from './collapsed_state.vue'; -import timeTrackingSpentOnlyPane from './spent_only_pane'; +import TimeTrackingSpentOnlyPane from './spent_only_pane.vue'; import TimeTrackingNoTrackingPane from './no_tracking_pane.vue'; import TimeTrackingEstimateOnlyPane from './estimate_only_pane.vue'; import TimeTrackingComparisonPane from './comparison_pane.vue'; @@ -13,7 +13,7 @@ export default { components: { TimeTrackingCollapsedState, TimeTrackingEstimateOnlyPane, - 'time-tracking-spent-only-pane': timeTrackingSpentOnlyPane, + TimeTrackingSpentOnlyPane, TimeTrackingNoTrackingPane, TimeTrackingComparisonPane, TimeTrackingHelpState, diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index 318d3ddaece..681242f8d85 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -317,6 +317,7 @@ a { color: $gl-text-color; word-wrap: break-word; + word-break: break-word; margin-right: 2px; } } @@ -462,6 +463,7 @@ .issuable-header-text { padding-right: 35px; + word-break: break-word; > strong { font-weight: $gl-font-weight-bold; diff --git a/app/assets/stylesheets/pages/repo.scss b/app/assets/stylesheets/pages/repo.scss index c47a14a7922..9c374cda3f3 100644 --- a/app/assets/stylesheets/pages/repo.scss +++ b/app/assets/stylesheets/pages/repo.scss @@ -539,14 +539,14 @@ } } -.multi-file-additions, -.multi-file-additions-solid { - fill: $green-500; +.multi-file-addition, +.multi-file-addition-solid { + color: $green-500; } .multi-file-modified, .multi-file-modified-solid { - fill: $orange-500; + color: $orange-500; } .multi-file-commit-list-collapsed { @@ -978,6 +978,12 @@ color: $gl-text-color-secondary; } +.ide-tree-changes { + display: flex; + align-items: center; + font-size: 12px; +} + .ide-new-modal-label { line-height: 34px; } diff --git a/app/controllers/projects/runners_controller.rb b/app/controllers/projects/runners_controller.rb index c950d0f7001..b9bbe7115c4 100644 --- a/app/controllers/projects/runners_controller.rb +++ b/app/controllers/projects/runners_controller.rb @@ -52,6 +52,12 @@ class Projects::RunnersController < Projects::ApplicationController redirect_to project_settings_ci_cd_path(@project) end + def toggle_group_runners + project.toggle_ci_cd_settings!(:group_runners_enabled) + + redirect_to project_settings_ci_cd_path(@project) + end + protected def set_runner diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb index d80ef8113aa..177c8a54099 100644 --- a/app/controllers/projects/settings/ci_cd_controller.rb +++ b/app/controllers/projects/settings/ci_cd_controller.rb @@ -67,10 +67,18 @@ module Projects def define_runners_variables @project_runners = @project.runners.ordered - @assignable_runners = current_user.ci_authorized_runners - .assignable_for(project).ordered.page(params[:page]).per(20) + + @assignable_runners = current_user + .ci_authorized_runners + .assignable_for(project) + .ordered + .page(params[:page]).per(20) + @shared_runners = ::Ci::Runner.shared.active + @shared_runners_count = @shared_runners.count(:all) + + @group_runners = ::Ci::Runner.belonging_to_parent_group_of_project(@project.id) end def define_secret_variables diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 5a4c56ec0dc..23078f1c3ed 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -14,31 +14,49 @@ module Ci has_many :builds has_many :runner_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :projects, through: :runner_projects + has_many :runner_namespaces + has_many :groups, through: :runner_namespaces has_one :last_build, ->() { order('id DESC') }, class_name: 'Ci::Build' before_validation :set_default_values - scope :specific, ->() { where(is_shared: false) } - scope :shared, ->() { where(is_shared: true) } - scope :active, ->() { where(active: true) } - scope :paused, ->() { where(active: false) } - scope :online, ->() { where('contacted_at > ?', contact_time_deadline) } - scope :ordered, ->() { order(id: :desc) } + scope :specific, -> { where(is_shared: false) } + scope :shared, -> { where(is_shared: true) } + scope :active, -> { where(active: true) } + scope :paused, -> { where(active: false) } + scope :online, -> { where('contacted_at > ?', contact_time_deadline) } + scope :ordered, -> { order(id: :desc) } - scope :owned_or_shared, ->(project_id) do - joins('LEFT JOIN ci_runner_projects ON ci_runner_projects.runner_id = ci_runners.id') - .where("ci_runner_projects.project_id = :project_id OR ci_runners.is_shared = true", project_id: project_id) + scope :belonging_to_project, -> (project_id) { + joins(:runner_projects).where(ci_runner_projects: { project_id: project_id }) + } + + scope :belonging_to_parent_group_of_project, -> (project_id) { + project_groups = ::Group.joins(:projects).where(projects: { id: project_id }) + hierarchy_groups = Gitlab::GroupHierarchy.new(project_groups).base_and_ancestors + + joins(:groups).where(namespaces: { id: hierarchy_groups }) + } + + scope :owned_or_shared, -> (project_id) do + union = Gitlab::SQL::Union.new( + [belonging_to_project(project_id), belonging_to_parent_group_of_project(project_id), shared], + remove_duplicates: false + ) + from("(#{union.to_sql}) ci_runners") end scope :assignable_for, ->(project) do # FIXME: That `to_sql` is needed to workaround a weird Rails bug. # Without that, placeholders would miss one and couldn't match. where(locked: false) - .where.not("id IN (#{project.runners.select(:id).to_sql})").specific + .where.not("ci_runners.id IN (#{project.runners.select(:id).to_sql})") + .specific end validate :tag_constraints + validate :either_projects_or_group validates :access_level, presence: true acts_as_taggable @@ -50,6 +68,12 @@ module Ci ref_protected: 1 } + enum runner_type: { + instance_type: 1, + group_type: 2, + project_type: 3 + } + cached_attr_reader :version, :revision, :platform, :architecture, :contacted_at, :ip_address chronic_duration_attr :maximum_timeout_human_readable, :maximum_timeout @@ -120,6 +144,14 @@ module Ci !shared? end + def assigned_to_group? + runner_namespaces.any? + end + + def assigned_to_project? + runner_projects.any? + end + def can_pick?(build) return false if self.ref_protected? && !build.protected? @@ -174,6 +206,12 @@ module Ci end end + def pick_build!(build) + if can_pick?(build) + tick_runner_queue + end + end + private def cleanup_runner_queue @@ -205,7 +243,17 @@ module Ci end def assignable_for?(project_id) - is_shared? || projects.exists?(id: project_id) + self.class.owned_or_shared(project_id).where(id: self.id).any? + end + + def either_projects_or_group + if groups.many? + errors.add(:runner, 'can only be assigned to one group') + end + + if assigned_to_group? && assigned_to_project? + errors.add(:runner, 'can only be assigned either to projects or to a group') + end end def accepting_tags?(build) diff --git a/app/models/ci/runner_namespace.rb b/app/models/ci/runner_namespace.rb new file mode 100644 index 00000000000..3269f86e8ca --- /dev/null +++ b/app/models/ci/runner_namespace.rb @@ -0,0 +1,9 @@ +module Ci + class RunnerNamespace < ActiveRecord::Base + extend Gitlab::Ci::Model + + belongs_to :runner + belongs_to :namespace, class_name: '::Namespace' + belongs_to :group, class_name: '::Group', foreign_key: :namespace_id + end +end diff --git a/app/models/group.rb b/app/models/group.rb index 9b42bbf99be..f493836a92e 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -9,6 +9,7 @@ class Group < Namespace include SelectForProjectAuthorization include LoadedInGroupList include GroupDescendant + include TokenAuthenticatable has_many :group_members, -> { where(requested_at: nil) }, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent alias_method :members, :group_members @@ -43,6 +44,8 @@ class Group < Namespace validates :two_factor_grace_period, presence: true, numericality: { greater_than_or_equal_to: 0 } + add_authentication_token_field :runners_token + after_create :post_create_hook after_destroy :post_destroy_hook after_save :update_two_factor_requirement @@ -294,6 +297,13 @@ class Group < Namespace refresh_members_authorized_projects(blocking: false) end + # each existing group needs to have a `runners_token`. + # we do this on read since migrating all existing groups is not a feasible + # solution. + def runners_token + ensure_runners_token! + end + private def update_two_factor_requirement diff --git a/app/models/namespace.rb b/app/models/namespace.rb index c29a53e5ce7..5621eeba7c4 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -21,6 +21,9 @@ class Namespace < ActiveRecord::Base has_many :projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :project_statistics + has_many :runner_namespaces, class_name: 'Ci::RunnerNamespace' + has_many :runners, through: :runner_namespaces, source: :runner, class_name: 'Ci::Runner' + # This should _not_ be `inverse_of: :namespace`, because that would also set # `user.namespace` when this user creates a group with themselves as `owner`. belongs_to :owner, class_name: "User" diff --git a/app/models/project.rb b/app/models/project.rb index d4e9e51c7be..50c404c300a 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -230,13 +230,11 @@ class Project < ActiveRecord::Base has_many :project_deploy_tokens has_many :deploy_tokens, through: :project_deploy_tokens - has_many :active_runners, -> { active }, through: :runner_projects, source: :runner, class_name: 'Ci::Runner' - has_one :auto_devops, class_name: 'ProjectAutoDevops' has_many :custom_attributes, class_name: 'ProjectCustomAttribute' has_many :project_badges, class_name: 'ProjectBadge' - has_one :ci_cd_settings, class_name: 'ProjectCiCdSetting' + has_one :ci_cd_settings, class_name: 'ProjectCiCdSetting', inverse_of: :project, autosave: true accepts_nested_attributes_for :variables, allow_destroy: true accepts_nested_attributes_for :project_feature, update_only: true @@ -247,6 +245,7 @@ class Project < ActiveRecord::Base delegate :members, to: :team, prefix: true delegate :add_user, :add_users, to: :team delegate :add_guest, :add_reporter, :add_developer, :add_master, :add_role, to: :team + delegate :group_runners_enabled, :group_runners_enabled=, :group_runners_enabled?, to: :ci_cd_settings # Validations validates :creator, presence: true, on: :create @@ -332,6 +331,11 @@ class Project < ActiveRecord::Base scope :with_issues_available_for_user, ->(current_user) { with_feature_available_for_user(:issues, current_user) } scope :with_merge_requests_enabled, -> { with_feature_enabled(:merge_requests) } + scope :with_group_runners_enabled, -> do + joins(:ci_cd_settings) + .where(project_ci_cd_settings: { group_runners_enabled: true }) + end + enum auto_cancel_pending_pipelines: { disabled: 0, enabled: 1 } chronic_duration_attr :build_timeout_human_readable, :build_timeout, default: 3600 @@ -1301,12 +1305,17 @@ class Project < ActiveRecord::Base @shared_runners ||= shared_runners_available? ? Ci::Runner.shared : Ci::Runner.none end - def active_shared_runners - @active_shared_runners ||= shared_runners.active + def group_runners + @group_runners ||= group_runners_enabled? ? Ci::Runner.belonging_to_parent_group_of_project(self.id) : Ci::Runner.none + end + + def all_runners + union = Gitlab::SQL::Union.new([runners, group_runners, shared_runners]) + Ci::Runner.from("(#{union.to_sql}) ci_runners") end def any_runners?(&block) - active_runners.any?(&block) || active_shared_runners.any?(&block) + all_runners.active.any?(&block) end def valid_runners_token?(token) @@ -1874,6 +1883,10 @@ class Project < ActiveRecord::Base [] end + def toggle_ci_cd_settings!(settings_attribute) + ci_cd_settings.toggle!(settings_attribute) + end + def gitlab_deploy_token @gitlab_deploy_token ||= deploy_tokens.gitlab_deploy_token end diff --git a/app/models/project_ci_cd_setting.rb b/app/models/project_ci_cd_setting.rb index 9f10a93148c..588cced5781 100644 --- a/app/models/project_ci_cd_setting.rb +++ b/app/models/project_ci_cd_setting.rb @@ -1,5 +1,5 @@ class ProjectCiCdSetting < ActiveRecord::Base - belongs_to :project + belongs_to :project, inverse_of: :ci_cd_settings # The version of the schema that first introduced this model/table. MINIMUM_SCHEMA_VERSION = 20180403035759 diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb index 0b087ad73da..4291631913a 100644 --- a/app/services/ci/register_job_service.rb +++ b/app/services/ci/register_job_service.rb @@ -17,8 +17,10 @@ module Ci builds = if runner.shared? builds_for_shared_runner + elsif runner.group_type? + builds_for_group_runner else - builds_for_specific_runner + builds_for_project_runner end valid = true @@ -75,15 +77,24 @@ module Ci .joins('LEFT JOIN project_features ON ci_builds.project_id = project_features.project_id') .where('project_features.builds_access_level IS NULL or project_features.builds_access_level > 0'). - # Implement fair scheduling - # this returns builds that are ordered by number of running builds - # we prefer projects that don't use shared runners at all - joins("LEFT JOIN (#{running_builds_for_shared_runners.to_sql}) AS project_builds ON ci_builds.project_id=project_builds.project_id") + # Implement fair scheduling + # this returns builds that are ordered by number of running builds + # we prefer projects that don't use shared runners at all + joins("LEFT JOIN (#{running_builds_for_shared_runners.to_sql}) AS project_builds ON ci_builds.project_id=project_builds.project_id") .order('COALESCE(project_builds.running_builds, 0) ASC', 'ci_builds.id ASC') end - def builds_for_specific_runner - new_builds.where(project: runner.projects.without_deleted.with_builds_enabled).order('created_at ASC') + def builds_for_project_runner + new_builds.where(project: runner.projects.without_deleted.with_builds_enabled).order('id ASC') + end + + def builds_for_group_runner + hierarchy_groups = Gitlab::GroupHierarchy.new(runner.groups).base_and_descendants + projects = Project.where(namespace_id: hierarchy_groups) + .with_group_runners_enabled + .with_builds_enabled + .without_deleted + new_builds.where(project: projects).order('id ASC') end def running_builds_for_shared_runners @@ -97,10 +108,6 @@ module Ci builds end - def shared_runner_build_limits_feature_enabled? - ENV['DISABLE_SHARED_RUNNER_BUILD_MINUTES_LIMIT'].to_s != 'true' - end - def register_failure failed_attempt_counter.increment attempt_counter.increment diff --git a/app/services/ci/update_build_queue_service.rb b/app/services/ci/update_build_queue_service.rb index 152c8ae5006..41b1c144c3e 100644 --- a/app/services/ci/update_build_queue_service.rb +++ b/app/services/ci/update_build_queue_service.rb @@ -1,18 +1,14 @@ module Ci class UpdateBuildQueueService def execute(build) - build.project.runners.each do |runner| - if runner.can_pick?(build) - runner.tick_runner_queue - end - end + tick_for(build, build.project.all_runners) + end - return unless build.project.shared_runners_enabled? + private - Ci::Runner.shared.each do |runner| - if runner.can_pick?(build) - runner.tick_runner_queue - end + def tick_for(build, runners) + runners.each do |runner| + runner.pick_build!(build) end end end diff --git a/app/views/admin/runners/_runner.html.haml b/app/views/admin/runners/_runner.html.haml index f90b8b8c0a4..99fbbaec487 100644 --- a/app/views/admin/runners/_runner.html.haml +++ b/app/views/admin/runners/_runner.html.haml @@ -2,6 +2,8 @@ %td - if runner.shared? %span.label.label-success shared + - elsif runner.group_type? + %span.label.label-success group - else %span.label.label-info specific - if runner.locked? @@ -19,7 +21,7 @@ %td = runner.ip_address %td - - if runner.shared? + - if runner.shared? || runner.group_type? n/a - else = runner.projects.count(:all) diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml index 9f13dbbbd82..1a3b5e58ed5 100644 --- a/app/views/admin/runners/index.html.haml +++ b/app/views/admin/runners/index.html.haml @@ -17,6 +17,9 @@ %span.label.label-success shared \- Runner runs jobs from all unassigned projects %li + %span.label.label-success group + \- Runner runs jobs from all unassigned projects in its group + %li %span.label.label-info specific \- Runner runs jobs from assigned projects %li diff --git a/app/views/admin/runners/show.html.haml b/app/views/admin/runners/show.html.haml index d04cf48b05c..d022016f70d 100644 --- a/app/views/admin/runners/show.html.haml +++ b/app/views/admin/runners/show.html.haml @@ -19,6 +19,9 @@ %p If you want Runners to build only specific projects, enable them in the table below. Keep in mind that this is a one way transition. +- elsif @runner.group_type? + .bs-callout.bs-callout-success + %h4 This runner will process jobs from all projects in its group and subgroups - else .bs-callout.bs-callout-info %h4 This Runner will process jobs only from ASSIGNED projects diff --git a/app/views/admin/users/index.html.haml b/app/views/admin/users/index.html.haml index 0ef4b71f4fe..10b8bf5d565 100644 --- a/app/views/admin/users/index.html.haml +++ b/app/views/admin/users/index.html.haml @@ -42,31 +42,31 @@ = nav_link(html_options: { class: active_when(params[:filter].nil?) }) do = link_to admin_users_path do Active - %small.badge= number_with_delimiter(User.active.count) + %small.badge= limited_counter_with_delimiter(User.active) = nav_link(html_options: { class: active_when(params[:filter] == 'admins') }) do = link_to admin_users_path(filter: "admins") do Admins - %small.badge= number_with_delimiter(User.admins.count) + %small.badge= limited_counter_with_delimiter(User.admins) = nav_link(html_options: { class: "#{active_when(params[:filter] == 'two_factor_enabled')} filter-two-factor-enabled" }) do = link_to admin_users_path(filter: 'two_factor_enabled') do 2FA Enabled - %small.badge= number_with_delimiter(User.with_two_factor.count) + %small.badge= limited_counter_with_delimiter(User.with_two_factor) = nav_link(html_options: { class: "#{active_when(params[:filter] == 'two_factor_disabled')} filter-two-factor-disabled" }) do = link_to admin_users_path(filter: 'two_factor_disabled') do 2FA Disabled - %small.badge= number_with_delimiter(User.without_two_factor.count) + %small.badge= limited_counter_with_delimiter(User.without_two_factor) = nav_link(html_options: { class: active_when(params[:filter] == 'external') }) do = link_to admin_users_path(filter: 'external') do External - %small.badge= number_with_delimiter(User.external.count) + %small.badge= limited_counter_with_delimiter(User.external) = nav_link(html_options: { class: active_when(params[:filter] == 'blocked') }) do = link_to admin_users_path(filter: "blocked") do Blocked - %small.badge= number_with_delimiter(User.blocked.count) + %small.badge= limited_counter_with_delimiter(User.blocked) = nav_link(html_options: { class: active_when(params[:filter] == 'wop') }) do = link_to admin_users_path(filter: "wop") do Without projects - %small.badge= number_with_delimiter(User.without_projects.count) + %small.badge= limited_counter_with_delimiter(User.without_projects) %ul.flex-list.content-list - if @users.empty? diff --git a/app/views/projects/_import_project_pane.html.haml b/app/views/projects/_import_project_pane.html.haml new file mode 100644 index 00000000000..4bee6cb97eb --- /dev/null +++ b/app/views/projects/_import_project_pane.html.haml @@ -0,0 +1,51 @@ +- active_tab = local_assigns.fetch(:active_tab, 'blank') +- f = local_assigns.fetch(:f) + +.project-import.row + .col-lg-12 + .form-group.import-btn-container.clearfix + = f.label :visibility_level, class: 'label-light' do #the label here seems wrong + Import project from + .import-buttons + - if gitlab_project_import_enabled? + .import_gitlab_project.has-tooltip{ data: { container: 'body' } } + = link_to new_import_gitlab_project_path, class: 'btn btn_import_gitlab_project project-submit' do + = icon('gitlab', text: 'GitLab export') + %div + - if github_import_enabled? + = link_to new_import_github_path, class: 'btn js-import-github' do + = icon('github', text: 'GitHub') + %div + - if bitbucket_import_enabled? + = link_to status_import_bitbucket_path, class: "btn import_bitbucket #{'how_to_import_link' unless bitbucket_import_configured?}" do + = icon('bitbucket', text: 'Bitbucket') + - unless bitbucket_import_configured? + = render 'bitbucket_import_modal' + %div + - if gitlab_import_enabled? + = link_to status_import_gitlab_path, class: "btn import_gitlab #{'how_to_import_link' unless gitlab_import_configured?}" do + = icon('gitlab', text: 'GitLab.com') + - unless gitlab_import_configured? + = render 'gitlab_import_modal' + %div + - if google_code_import_enabled? + = link_to new_import_google_code_path, class: 'btn import_google_code' do + = icon('google', text: 'Google Code') + %div + - if fogbugz_import_enabled? + = link_to new_import_fogbugz_path, class: 'btn import_fogbugz' do + = icon('bug', text: 'Fogbugz') + %div + - if gitea_import_enabled? + = link_to new_import_gitea_path, class: 'btn import_gitea' do + = custom_icon('go_logo') + Gitea + %div + - if git_import_enabled? + %button.btn.js-toggle-button.js-import-git-toggle-button{ type: "button", data: { toggle_open_class: 'active' } } + = icon('git', text: 'Repo by URL') + .col-lg-12 + .js-toggle-content.toggle-import-form{ class: ('hide' if active_tab != 'import') } + %hr + = render "shared/import_form", f: f + = render 'new_project_fields', f: f, project_name_id: "import-url-name" diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml index b66e0559603..5beaa3c6d23 100644 --- a/app/views/projects/new.html.haml +++ b/app/views/projects/new.html.haml @@ -57,54 +57,11 @@ .tab-pane.import-project-pane.js-toggle-container{ id: 'import-project-pane', class: active_when(active_tab == 'import'), role: 'tabpanel' } = form_for @project, html: { class: 'new_project' } do |f| - if import_sources_enabled? - .project-import.row - .col-lg-12 - .form-group.import-btn-container.clearfix - = f.label :visibility_level, class: 'label-light' do #the label here seems wrong - Import project from - .import-buttons - - if gitlab_project_import_enabled? - .import_gitlab_project.has-tooltip{ data: { container: 'body' } } - = link_to new_import_gitlab_project_path, class: 'btn btn_import_gitlab_project project-submit' do - = icon('gitlab', text: 'GitLab export') - %div - - if github_import_enabled? - = link_to new_import_github_path, class: 'btn js-import-github' do - = icon('github', text: 'GitHub') - %div - - if bitbucket_import_enabled? - = link_to status_import_bitbucket_path, class: "btn import_bitbucket #{'how_to_import_link' unless bitbucket_import_configured?}" do - = icon('bitbucket', text: 'Bitbucket') - - unless bitbucket_import_configured? - = render 'bitbucket_import_modal' - %div - - if gitlab_import_enabled? - = link_to status_import_gitlab_path, class: "btn import_gitlab #{'how_to_import_link' unless gitlab_import_configured?}" do - = icon('gitlab', text: 'GitLab.com') - - unless gitlab_import_configured? - = render 'gitlab_import_modal' - %div - - if google_code_import_enabled? - = link_to new_import_google_code_path, class: 'btn import_google_code' do - = icon('google', text: 'Google Code') - %div - - if fogbugz_import_enabled? - = link_to new_import_fogbugz_path, class: 'btn import_fogbugz' do - = icon('bug', text: 'Fogbugz') - %div - - if gitea_import_enabled? - = link_to new_import_gitea_path, class: 'btn import_gitea' do - = custom_icon('go_logo') - Gitea - %div - - if git_import_enabled? - %button.btn.js-toggle-button.js-import-git-toggle-button{ type: "button", data: { toggle_open_class: 'active' } } - = icon('git', text: 'Repo by URL') - .col-lg-12 - .js-toggle-content.toggle-import-form{ class: ('hide' if active_tab != 'import') } - %hr - = render "shared/import_form", f: f - = render 'new_project_fields', f: f, project_name_id: "import-url-name" + = render 'import_project_pane', f: f, active_tab: active_tab + - else + .nothing-here-block + %h4 No import options available + %p Contact an administrator to enable options for importing your project. .save-project-loader.hide .center diff --git a/app/views/projects/runners/_group_runners.html.haml b/app/views/projects/runners/_group_runners.html.haml new file mode 100644 index 00000000000..a9dfd9cc786 --- /dev/null +++ b/app/views/projects/runners/_group_runners.html.haml @@ -0,0 +1,32 @@ +%h3 Group Runners + +.bs-callout.bs-callout-warning + GitLab Group Runners can execute code for all the projects in this group. + They can be managed using the #{link_to 'Runners API', help_page_path('api/runners.md')}. + + - if @project.group + %hr + - if @project.group_runners_enabled? + = link_to toggle_group_runners_project_runners_path(@project), class: 'btn btn-warning', method: :post do + Disable group Runners + - else + = link_to toggle_group_runners_project_runners_path(@project), class: 'btn btn-success', method: :post do + Enable group Runners + for this project + +- if !@project.group + This project does not belong to a group and can therefore not make use of group Runners. + +- elsif @group_runners.empty? + This group does not provide any group Runners yet. + + - if can?(current_user, :admin_pipeline, @project.group) + = render partial: 'ci/runner/how_to_setup_runner', + locals: { registration_token: @project.group.runners_token, type: 'group' } + - else + Ask your group master to setup a group Runner. + +- else + %h4.underlined-title Available group Runners : #{@group_runners.count} + %ul.bordered-list + = render partial: 'projects/runners/runner', collection: @group_runners, as: :runner diff --git a/app/views/projects/runners/_index.html.haml b/app/views/projects/runners/_index.html.haml index f9808f7c990..3f5119d408b 100644 --- a/app/views/projects/runners/_index.html.haml +++ b/app/views/projects/runners/_index.html.haml @@ -23,3 +23,7 @@ = render 'projects/runners/specific_runners' .col-sm-6 = render 'projects/runners/shared_runners' +.row + .col-sm-6 + .col-sm-6 + = render 'projects/runners/group_runners' diff --git a/app/views/projects/runners/_runner.html.haml b/app/views/projects/runners/_runner.html.haml index 6376496ee1a..0d2c0536eb5 100644 --- a/app/views/projects/runners/_runner.html.haml +++ b/app/views/projects/runners/_runner.html.haml @@ -26,7 +26,7 @@ - else - runner_project = @project.runner_projects.find_by(runner_id: runner) = link_to 'Disable for this project', project_runner_project_path(@project, runner_project), data: { confirm: "Are you sure?" }, method: :delete, class: 'btn btn-danger btn-sm' - - elsif runner.specific? + - elsif !(runner.is_shared? || runner.group_type?) # We can simplify this to `runner.project_type?` when migrating #runner_type is complete = form_for [@project.namespace.becomes(Namespace), @project, @project.runner_projects.new] do |f| = f.hidden_field :runner_id, value: runner.id = f.submit 'Enable for this project', class: 'btn btn-sm' diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index 4bf01ecb48c..d35ddf3eb39 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -35,7 +35,7 @@ = link_to avatar_icon_for_user(@user, 400), target: '_blank', rel: 'noopener noreferrer' do = image_tag avatar_icon_for_user(@user, 90), class: "avatar s90", alt: '' - .user-info + .user-info.prepend-left-default.append-right-default .cover-title = @user.name diff --git a/changelogs/unreleased/36762-reconcile-project-templates-with-auto-devops.yml b/changelogs/unreleased/36762-reconcile-project-templates-with-auto-devops.yml new file mode 100644 index 00000000000..8169b18f875 --- /dev/null +++ b/changelogs/unreleased/36762-reconcile-project-templates-with-auto-devops.yml @@ -0,0 +1,5 @@ +--- +title: Reconcile project templates with Auto DevOps +merge_request: 18737 +author: +type: changed diff --git a/changelogs/unreleased/add-loading-icon-padding-for-pipeline-environments.yml b/changelogs/unreleased/add-loading-icon-padding-for-pipeline-environments.yml new file mode 100644 index 00000000000..a6304418517 --- /dev/null +++ b/changelogs/unreleased/add-loading-icon-padding-for-pipeline-environments.yml @@ -0,0 +1,5 @@ +--- +title: Add loading icon padding for pipeline environments +merge_request: 18631 +author: George Tsiolis +type: fixed diff --git a/changelogs/unreleased/add-padding-to-profile-description.yml b/changelogs/unreleased/add-padding-to-profile-description.yml new file mode 100644 index 00000000000..4628a10eb3f --- /dev/null +++ b/changelogs/unreleased/add-padding-to-profile-description.yml @@ -0,0 +1,5 @@ +--- +title: Add padding to profile description +merge_request: 18663 +author: George Tsiolis +type: changed diff --git a/changelogs/unreleased/break-issue-title-for-board-card-title-and-issueable-header-text.yml b/changelogs/unreleased/break-issue-title-for-board-card-title-and-issueable-header-text.yml new file mode 100644 index 00000000000..7acde223962 --- /dev/null +++ b/changelogs/unreleased/break-issue-title-for-board-card-title-and-issueable-header-text.yml @@ -0,0 +1,5 @@ +--- +title: Break issue title for board card title and issuable header text +merge_request: 18674 +author: George Tsiolis +type: changed diff --git a/changelogs/unreleased/bw-add-console-message.yml b/changelogs/unreleased/bw-add-console-message.yml new file mode 100644 index 00000000000..7994f7caced --- /dev/null +++ b/changelogs/unreleased/bw-add-console-message.yml @@ -0,0 +1,5 @@ +--- +title: Output some useful information when running the rails console +merge_request: 18697 +author: +type: added diff --git a/changelogs/unreleased/feature-runner-per-group.yml b/changelogs/unreleased/feature-runner-per-group.yml new file mode 100644 index 00000000000..162a5fae0a4 --- /dev/null +++ b/changelogs/unreleased/feature-runner-per-group.yml @@ -0,0 +1,5 @@ +--- +title: Allow group masters to configure runners for groups +merge_request: 9646 +author: Alexis Reigel +type: added diff --git a/changelogs/unreleased/inform-the-user-when-there-are-no-project-import-options-available.yml b/changelogs/unreleased/inform-the-user-when-there-are-no-project-import-options-available.yml new file mode 100644 index 00000000000..c14f21fc644 --- /dev/null +++ b/changelogs/unreleased/inform-the-user-when-there-are-no-project-import-options-available.yml @@ -0,0 +1,5 @@ +--- +title: Inform the user when there are no project import options available +merge_request: 18716 +author: George Tsiolis +type: changed diff --git a/changelogs/unreleased/move-time-tracking-spent-only-pane-vue-component.yml b/changelogs/unreleased/move-time-tracking-spent-only-pane-vue-component.yml new file mode 100644 index 00000000000..d2db0df5a04 --- /dev/null +++ b/changelogs/unreleased/move-time-tracking-spent-only-pane-vue-component.yml @@ -0,0 +1,5 @@ +--- +title: Move TimeTrackingSpentOnlyPane vue component +merge_request: 18710 +author: George Tsiolis +type: performance diff --git a/config/initializers/console_message.rb b/config/initializers/console_message.rb new file mode 100644 index 00000000000..536ab337d85 --- /dev/null +++ b/config/initializers/console_message.rb @@ -0,0 +1,10 @@ +# rubocop:disable Rails/Output +if defined?(Rails::Console) + # note that this will not print out when using `spring` + justify = 15 + puts "-------------------------------------------------------------------------------------" + puts " Gitlab:".ljust(justify) + "#{Gitlab::VERSION} (#{Gitlab::REVISION})" + puts " Gitlab Shell:".ljust(justify) + Gitlab::Shell.new.version + puts " #{Gitlab::Database.adapter_name}:".ljust(justify) + Gitlab::Database.version + puts "-------------------------------------------------------------------------------------" +end diff --git a/config/initializers/trusted_proxies.rb b/config/initializers/trusted_proxies.rb index 0c32528311e..ca2eed664ed 100644 --- a/config/initializers/trusted_proxies.rb +++ b/config/initializers/trusted_proxies.rb @@ -22,3 +22,16 @@ end.compact Rails.application.config.action_dispatch.trusted_proxies = ( ['127.0.0.1', '::1'] + gitlab_trusted_proxies) + +# A monkey patch to make trusted proxies work with Rails 5.0. +# Inspired by https://github.com/rails/rails/issues/5223#issuecomment-263778719 +# Remove this monkey patch when upstream is fixed. +if Gitlab.rails5? + module TrustedProxyMonkeyPatch + def ip + @ip ||= (get_header("action_dispatch.remote_ip") || super).to_s + end + end + + ActionDispatch::Request.send(:include, TrustedProxyMonkeyPatch) +end diff --git a/config/routes/project.rb b/config/routes/project.rb index 0d24c5a5d4f..7fffd16f3cf 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -409,6 +409,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do collection do post :toggle_shared_runners + post :toggle_group_runners end end diff --git a/db/migrate/20170301101006_add_ci_runner_namespaces.rb b/db/migrate/20170301101006_add_ci_runner_namespaces.rb new file mode 100644 index 00000000000..deaf03e928b --- /dev/null +++ b/db/migrate/20170301101006_add_ci_runner_namespaces.rb @@ -0,0 +1,17 @@ +class AddCiRunnerNamespaces < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + create_table :ci_runner_namespaces do |t| + t.integer :runner_id + t.integer :namespace_id + + t.index [:runner_id, :namespace_id], unique: true + t.index :namespace_id + t.foreign_key :ci_runners, column: :runner_id, on_delete: :cascade + t.foreign_key :namespaces, column: :namespace_id, on_delete: :cascade + end + end +end diff --git a/db/migrate/20170906133745_add_runners_token_to_groups.rb b/db/migrate/20170906133745_add_runners_token_to_groups.rb new file mode 100644 index 00000000000..852f4cba670 --- /dev/null +++ b/db/migrate/20170906133745_add_runners_token_to_groups.rb @@ -0,0 +1,9 @@ +class AddRunnersTokenToGroups < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :namespaces, :runners_token, :string + end +end diff --git a/db/migrate/20180430101916_add_runner_type_to_ci_runners.rb b/db/migrate/20180430101916_add_runner_type_to_ci_runners.rb new file mode 100644 index 00000000000..42409349b75 --- /dev/null +++ b/db/migrate/20180430101916_add_runner_type_to_ci_runners.rb @@ -0,0 +1,9 @@ +class AddRunnerTypeToCiRunners < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :ci_runners, :runner_type, :smallint + end +end diff --git a/db/migrate/20180503150427_add_index_to_namespaces_runners_token.rb b/db/migrate/20180503150427_add_index_to_namespaces_runners_token.rb new file mode 100644 index 00000000000..4c4e576d49f --- /dev/null +++ b/db/migrate/20180503150427_add_index_to_namespaces_runners_token.rb @@ -0,0 +1,20 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddIndexToNamespacesRunnersToken < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_index :namespaces, :runners_token, unique: true + end + + def down + if index_exists?(:namespaces, :runners_token, unique: true) + remove_index :namespaces, :runners_token + end + end +end diff --git a/db/post_migrate/20180430143705_backfill_runner_type_for_ci_runners_post_migrate.rb b/db/post_migrate/20180430143705_backfill_runner_type_for_ci_runners_post_migrate.rb new file mode 100644 index 00000000000..38af5aae924 --- /dev/null +++ b/db/post_migrate/20180430143705_backfill_runner_type_for_ci_runners_post_migrate.rb @@ -0,0 +1,23 @@ +class BackfillRunnerTypeForCiRunnersPostMigrate < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + INSTANCE_RUNNER_TYPE = 1 + PROJECT_RUNNER_TYPE = 3 + + disable_ddl_transaction! + + def up + update_column_in_batches(:ci_runners, :runner_type, INSTANCE_RUNNER_TYPE) do |table, query| + query.where(table[:is_shared].eq(true)).where(table[:runner_type].eq(nil)) + end + + update_column_in_batches(:ci_runners, :runner_type, PROJECT_RUNNER_TYPE) do |table, query| + query.where(table[:is_shared].eq(false)).where(table[:runner_type].eq(nil)) + end + end + + def down + end +end diff --git a/db/schema.rb b/db/schema.rb index 10cd1bff125..a37e6edc8d1 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20180425131009) do +ActiveRecord::Schema.define(version: 20180503150427) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -444,6 +444,14 @@ ActiveRecord::Schema.define(version: 20180425131009) do add_index "ci_pipelines", ["status"], name: "index_ci_pipelines_on_status", using: :btree add_index "ci_pipelines", ["user_id"], name: "index_ci_pipelines_on_user_id", using: :btree + create_table "ci_runner_namespaces", force: :cascade do |t| + t.integer "runner_id" + t.integer "namespace_id" + end + + add_index "ci_runner_namespaces", ["namespace_id"], name: "index_ci_runner_namespaces_on_namespace_id", using: :btree + add_index "ci_runner_namespaces", ["runner_id", "namespace_id"], name: "index_ci_runner_namespaces_on_runner_id_and_namespace_id", unique: true, using: :btree + create_table "ci_runner_projects", force: :cascade do |t| t.integer "runner_id", null: false t.datetime "created_at" @@ -472,6 +480,7 @@ ActiveRecord::Schema.define(version: 20180425131009) do t.integer "access_level", default: 0, null: false t.string "ip_address" t.integer "maximum_timeout" + t.integer "runner_type", limit: 2 end add_index "ci_runners", ["contacted_at"], name: "index_ci_runners_on_contacted_at", using: :btree @@ -1261,6 +1270,7 @@ ActiveRecord::Schema.define(version: 20180425131009) do t.boolean "require_two_factor_authentication", default: false, null: false t.integer "two_factor_grace_period", default: 48, null: false t.integer "cached_markdown_version" + t.string "runners_token" end add_index "namespaces", ["created_at"], name: "index_namespaces_on_created_at", using: :btree @@ -1271,6 +1281,7 @@ ActiveRecord::Schema.define(version: 20180425131009) do add_index "namespaces", ["path"], name: "index_namespaces_on_path", using: :btree add_index "namespaces", ["path"], name: "index_namespaces_on_path_trigram", using: :gin, opclasses: {"path"=>"gin_trgm_ops"} add_index "namespaces", ["require_two_factor_authentication"], name: "index_namespaces_on_require_two_factor_authentication", using: :btree + add_index "namespaces", ["runners_token"], name: "index_namespaces_on_runners_token", unique: true, using: :btree add_index "namespaces", ["type"], name: "index_namespaces_on_type", using: :btree create_table "notes", force: :cascade do |t| @@ -2087,6 +2098,8 @@ ActiveRecord::Schema.define(version: 20180425131009) do add_foreign_key "ci_pipelines", "ci_pipeline_schedules", column: "pipeline_schedule_id", name: "fk_3d34ab2e06", on_delete: :nullify add_foreign_key "ci_pipelines", "ci_pipelines", column: "auto_canceled_by_id", name: "fk_262d4c2d19", on_delete: :nullify add_foreign_key "ci_pipelines", "projects", name: "fk_86635dbd80", on_delete: :cascade + add_foreign_key "ci_runner_namespaces", "ci_runners", column: "runner_id", on_delete: :cascade + add_foreign_key "ci_runner_namespaces", "namespaces", on_delete: :cascade add_foreign_key "ci_runner_projects", "projects", name: "fk_4478a6f1e4", on_delete: :cascade add_foreign_key "ci_stages", "ci_pipelines", column: "pipeline_id", name: "fk_fb57e6cc56", on_delete: :cascade add_foreign_key "ci_stages", "projects", name: "fk_2360681d1d", on_delete: :cascade diff --git a/doc/development/fe_guide/style_guide_js.md b/doc/development/fe_guide/style_guide_js.md index 677168937c7..04dfe418dbe 100644 --- a/doc/development/fe_guide/style_guide_js.md +++ b/doc/development/fe_guide/style_guide_js.md @@ -310,7 +310,7 @@ Please check this [rules][eslint-plugin-vue-rules] for more documentation. })); ``` -1. Don not use a singleton for the service or the store +1. Do not use a singleton for the service or the store ```javascript // bad class Store { @@ -328,9 +328,11 @@ Please check this [rules][eslint-plugin-vue-rules] for more documentation. } } ``` +1. Use `.vue` for Vue templates. Do not use `%template` in HAML. #### Naming -1. **Extensions**: Use `.vue` extension for Vue components. + +1. **Extensions**: Use `.vue` extension for Vue components. Do not use `.js` as file extension ([#34371]). 1. **Reference Naming**: Use PascalCase for their instances: ```javascript // bad @@ -364,6 +366,8 @@ Please check this [rules][eslint-plugin-vue-rules] for more documentation. <component my-prop="prop" /> ``` +[#34371]: https://gitlab.com/gitlab-org/gitlab-ce/issues/34371 + #### Alignment 1. Follow these alignment styles for the template method: 1. With more than one attribute, all attributes should be on a new line: diff --git a/features/steps/shared/group.rb b/features/steps/shared/group.rb index 0a0588346b1..0126ce39c5a 100644 --- a/features/steps/shared/group.rb +++ b/features/steps/shared/group.rb @@ -5,10 +5,6 @@ module SharedGroup is_member_of(current_user.name, "Owned", Gitlab::Access::DEVELOPER) end - step '"John Doe" is owner of group "Owned"' do - is_member_of("John Doe", "Owned", Gitlab::Access::OWNER) - end - step '"John Doe" is guest of group "Guest"' do is_member_of("John Doe", "Guest", Gitlab::Access::GUEST) end diff --git a/features/steps/shared/paths.rb b/features/steps/shared/paths.rb index 014e6ad625b..b6c648a707d 100644 --- a/features/steps/shared/paths.rb +++ b/features/steps/shared/paths.rb @@ -48,10 +48,6 @@ module SharedPaths visit group_group_members_path(Group.find_by(name: "Owned")) end - step 'I visit group "Owned" settings page' do - visit edit_group_path(Group.find_by(name: "Owned")) - end - step 'I visit group "Owned" projects page' do visit projects_group_path(Group.find_by(name: "Owned")) end diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 75d56b82424..086071161b7 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -149,11 +149,11 @@ module API expose_url(api_v4_projects_path(id: project.id)) end - expose :issues, if: -> (*args) { issues_available?(*args) } do |project| + expose :issues, if: -> (project, options) { issues_available?(project, options) } do |project| expose_url(api_v4_projects_issues_path(id: project.id)) end - expose :merge_requests, if: -> (*args) { mrs_available?(*args) } do |project| + expose :merge_requests, if: -> (project, options) { mrs_available?(project, options) } do |project| expose_url(api_v4_projects_merge_requests_path(id: project.id)) end @@ -242,13 +242,18 @@ module API expose :requested_at end - class Group < Grape::Entity - expose :id, :name, :path, :description, :visibility + class BasicGroupDetails < Grape::Entity + expose :id + expose :web_url + expose :name + end + + class Group < BasicGroupDetails + expose :path, :description, :visibility expose :lfs_enabled?, as: :lfs_enabled expose :avatar_url do |group, options| group.avatar_url(only_path: false) end - expose :web_url expose :request_access_enabled expose :full_name, :full_path @@ -984,6 +989,13 @@ module API options[:current_user].authorized_projects.where(id: runner.projects) end end + expose :groups, with: Entities::BasicGroupDetails do |runner, options| + if options[:current_user].admin? + runner.groups + else + options[:current_user].authorized_groups.where(id: runner.groups) + end + end end class RunnerRegistrationDetails < Grape::Entity diff --git a/lib/api/runner.rb b/lib/api/runner.rb index 4d4fbe50f9f..67896ae1fc5 100644 --- a/lib/api/runner.rb +++ b/lib/api/runner.rb @@ -23,10 +23,13 @@ module API runner = if runner_registration_token_valid? # Create shared runner. Requires admin access - Ci::Runner.create(attributes.merge(is_shared: true)) + Ci::Runner.create(attributes.merge(is_shared: true, runner_type: :instance_type)) elsif project = Project.find_by(runners_token: params[:token]) - # Create a specific runner for project. - project.runners.create(attributes) + # Create a specific runner for the project + project.runners.create(attributes.merge(runner_type: :project_type)) + elsif group = Group.find_by(runners_token: params[:token]) + # Create a specific runner for the group + group.runners.create(attributes.merge(runner_type: :group_type)) end break forbidden! unless runner diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb index 84d37f77fbb..60ce8cfc195 100644 --- a/lib/gitlab/git/repository.rb +++ b/lib/gitlab/git/repository.rb @@ -20,6 +20,9 @@ module Gitlab GIT_ALTERNATE_OBJECT_DIRECTORIES_RELATIVE ].freeze SEARCH_CONTEXT_LINES = 3 + # In https://gitlab.com/gitlab-org/gitaly/merge_requests/698 + # We copied these two prefixes into gitaly-go, so don't change these + # or things will break! (REBASE_WORKTREE_PREFIX and SQUASH_WORKTREE_PREFIX) REBASE_WORKTREE_PREFIX = 'rebase'.freeze SQUASH_WORKTREE_PREFIX = 'squash'.freeze GITALY_INTERNAL_URL = 'ssh://gitaly/internal.git'.freeze @@ -1671,10 +1674,14 @@ module Gitlab end end + # This function is duplicated in Gitaly-Go, don't change it! + # https://gitlab.com/gitlab-org/gitaly/merge_requests/698 def fresh_worktree?(path) File.exist?(path) && !clean_stuck_worktree(path) end + # This function is duplicated in Gitaly-Go, don't change it! + # https://gitlab.com/gitlab-org/gitaly/merge_requests/698 def clean_stuck_worktree(path) return false unless File.mtime(path) < 15.minutes.ago diff --git a/lib/gitlab/project_template.rb b/lib/gitlab/project_template.rb index ae136202f0c..08f6a54776f 100644 --- a/lib/gitlab/project_template.rb +++ b/lib/gitlab/project_template.rb @@ -25,9 +25,9 @@ module Gitlab end TEMPLATES_TABLE = [ - ProjectTemplate.new('rails', 'Ruby on Rails', 'Includes an MVC structure, gemfile, rakefile, and .gitlab-ci.yml file, along with many others, to help you get started.', 'https://gitlab.com/gitlab-org/project-templates/rails'), - ProjectTemplate.new('spring', 'Spring', 'Includes an MVC structure, mvnw, pom.xml, and .gitlab-ci.yml file to help you get started.', 'https://gitlab.com/gitlab-org/project-templates/spring'), - ProjectTemplate.new('express', 'NodeJS Express', 'Includes an MVC structure and .gitlab-ci.yml file to help you get started.', 'https://gitlab.com/gitlab-org/project-templates/express') + ProjectTemplate.new('rails', 'Ruby on Rails', 'Includes an MVC structure, Gemfile, Rakefile, along with many others, to help you get started.', 'https://gitlab.com/gitlab-org/project-templates/rails'), + ProjectTemplate.new('spring', 'Spring', 'Includes an MVC structure, mvnw and pom.xml to help you get started.', 'https://gitlab.com/gitlab-org/project-templates/spring'), + ProjectTemplate.new('express', 'NodeJS Express', 'Includes an MVC structure to help you get started.', 'https://gitlab.com/gitlab-org/project-templates/express') ].freeze class << self diff --git a/spec/controllers/projects/settings/ci_cd_controller_spec.rb b/spec/controllers/projects/settings/ci_cd_controller_spec.rb index 7dae9b85d78..a91c868cbaf 100644 --- a/spec/controllers/projects/settings/ci_cd_controller_spec.rb +++ b/spec/controllers/projects/settings/ci_cd_controller_spec.rb @@ -17,6 +17,23 @@ describe Projects::Settings::CiCdController do expect(response).to have_gitlab_http_status(200) expect(response).to render_template(:show) end + + context 'with group runners' do + let(:group_runner) { create(:ci_runner) } + let(:parent_group) { create(:group) } + let(:group) { create(:group, runners: [group_runner], parent: parent_group) } + let(:other_project) { create(:project, group: group) } + let!(:project_runner) { create(:ci_runner, projects: [other_project]) } + let!(:shared_runner) { create(:ci_runner, :shared) } + + it 'sets assignable project runners only' do + group.add_master(user) + + get :show, namespace_id: project.namespace, project_id: project + + expect(assigns(:assignable_runners)).to eq [project_runner] + end + end end describe '#reset_cache' do diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb index 1904615778c..aed5eab8044 100644 --- a/spec/factories/projects.rb +++ b/spec/factories/projects.rb @@ -15,14 +15,18 @@ FactoryBot.define do namespace creator { group ? create(:user) : namespace&.owner } - # Nest Project Feature attributes transient do + # Nest Project Feature attributes wiki_access_level ProjectFeature::ENABLED builds_access_level ProjectFeature::ENABLED snippets_access_level ProjectFeature::ENABLED issues_access_level ProjectFeature::ENABLED merge_requests_access_level ProjectFeature::ENABLED repository_access_level ProjectFeature::ENABLED + + # we can't assign the delegated `#ci_cd_settings` attributes directly, as the + # `#ci_cd_settings` relation needs to be created first + group_runners_enabled nil end after(:create) do |project, evaluator| @@ -47,6 +51,9 @@ FactoryBot.define do end project.group&.refresh_members_authorized_projects + + # assign the delegated `#ci_cd_settings` attributes after create + project.reload.group_runners_enabled = evaluator.group_runners_enabled unless evaluator.group_runners_enabled.nil? end trait :public do diff --git a/spec/features/admin/admin_runners_spec.rb b/spec/features/admin/admin_runners_spec.rb index 8de2e3d199b..3465ccfc423 100644 --- a/spec/features/admin/admin_runners_spec.rb +++ b/spec/features/admin/admin_runners_spec.rb @@ -59,6 +59,47 @@ describe "Admin Runners" do expect(page).to have_text 'No runners found' end end + + context 'group runner' do + let(:group) { create(:group) } + let!(:runner) { create(:ci_runner, groups: [group], runner_type: :group_type) } + + it 'shows the label and does not show the project count' do + visit admin_runners_path + + within "#runner_#{runner.id}" do + expect(page).to have_selector '.label', text: 'group' + expect(page).to have_text 'n/a' + end + end + end + + context 'shared runner' do + it 'shows the label and does not show the project count' do + runner = create :ci_runner, :shared + + visit admin_runners_path + + within "#runner_#{runner.id}" do + expect(page).to have_selector '.label', text: 'shared' + expect(page).to have_text 'n/a' + end + end + end + + context 'specific runner' do + it 'shows the label and the project count' do + project = create :project + runner = create :ci_runner, projects: [project] + + visit admin_runners_path + + within "#runner_#{runner.id}" do + expect(page).to have_selector '.label', text: 'specific' + expect(page).to have_text '1' + end + end + end end describe "Runner show page" do diff --git a/spec/features/admin/admin_users_spec.rb b/spec/features/admin/admin_users_spec.rb index 8f0a3611052..8fc57f4b4c3 100644 --- a/spec/features/admin/admin_users_spec.rb +++ b/spec/features/admin/admin_users_spec.rb @@ -285,7 +285,7 @@ describe "Admin::Users" do it "lists group projects" do within(:css, '.append-bottom-default + .panel') do expect(page).to have_content 'Group projects' - expect(page).to have_link group.name, admin_group_path(group) + expect(page).to have_link group.name, href: admin_group_path(group) end end diff --git a/spec/features/issues/user_uses_slash_commands_spec.rb b/spec/features/issues/user_uses_slash_commands_spec.rb index ddd64fa1412..fd0aa6cf3a3 100644 --- a/spec/features/issues/user_uses_slash_commands_spec.rb +++ b/spec/features/issues/user_uses_slash_commands_spec.rb @@ -161,6 +161,7 @@ feature 'Issues > User uses quick actions', :js do before do target_project.add_master(user) + gitlab_sign_out sign_in(user) visit project_issue_path(project, issue) end @@ -220,6 +221,7 @@ feature 'Issues > User uses quick actions', :js do before do target_project.add_master(user) + gitlab_sign_out sign_in(user) visit project_issue_path(project, issue) end diff --git a/spec/features/runners_spec.rb b/spec/features/runners_spec.rb index df65c2d2f83..b396e103345 100644 --- a/spec/features/runners_spec.rb +++ b/spec/features/runners_spec.rb @@ -181,4 +181,84 @@ feature 'Runners' do expect(page.find('.shared-runners-description')).to have_content('Disable shared Runners') end end + + context 'group runners' do + background do + project.add_master(user) + end + + given(:group) { create :group } + + context 'as project and group master' do + background do + group.add_master(user) + end + + context 'project with a group but no group runner' do + given(:project) { create :project, group: group } + + scenario 'group runners are not available' do + visit runners_path(project) + + expect(page).to have_content 'This group does not provide any group Runners yet.' + + expect(page).to have_content 'Setup a group Runner manually' + expect(page).not_to have_content 'Ask your group master to setup a group Runner.' + end + end + end + + context 'as project master' do + context 'project without a group' do + given(:project) { create :project } + + scenario 'group runners are not available' do + visit runners_path(project) + + expect(page).to have_content 'This project does not belong to a group and can therefore not make use of group Runners.' + end + end + + context 'project with a group but no group runner' do + given(:group) { create :group } + given(:project) { create :project, group: group } + + scenario 'group runners are not available' do + visit runners_path(project) + + expect(page).to have_content 'This group does not provide any group Runners yet.' + + expect(page).not_to have_content 'Setup a group Runner manually' + expect(page).to have_content 'Ask your group master to setup a group Runner.' + end + end + + context 'project with a group and a group runner' do + given(:group) { create :group } + given(:project) { create :project, group: group } + given!(:ci_runner) { create :ci_runner, groups: [group], description: 'group-runner' } + + scenario 'group runners are available' do + visit runners_path(project) + + expect(page).to have_content 'Available group Runners : 1' + expect(page).to have_content 'group-runner' + end + + scenario 'group runners may be disabled for a project' do + visit runners_path(project) + + click_on 'Disable group Runners' + + expect(page).to have_content 'Enable group Runners' + expect(project.reload.group_runners_enabled).to be false + + click_on 'Enable group Runners' + + expect(page).to have_content 'Disable group Runners' + expect(project.reload.group_runners_enabled).to be true + end + end + end + end end diff --git a/spec/javascripts/ide/components/repo_file_spec.js b/spec/javascripts/ide/components/repo_file_spec.js index 4b144eef744..fbef10c90e8 100644 --- a/spec/javascripts/ide/components/repo_file_spec.js +++ b/spec/javascripts/ide/components/repo_file_spec.js @@ -48,6 +48,33 @@ describe('RepoFile', () => { }); }); + describe('folder', () => { + it('renders changes count inside folder', () => { + const f = { + ...file('folder'), + path: 'testing', + type: 'tree', + branchId: 'master', + projectId: 'project', + }; + + store.state.changedFiles.push({ + ...file('fileName'), + path: 'testing/fileName', + }); + + createComponent({ + file: f, + level: 0, + }); + + const treeChangesEl = vm.$el.querySelector('.ide-tree-changes'); + + expect(treeChangesEl).not.toBeNull(); + expect(treeChangesEl.textContent).toContain('1'); + }); + }); + describe('locked file', () => { let f; diff --git a/spec/javascripts/ide/stores/getters_spec.js b/spec/javascripts/ide/stores/getters_spec.js index 68e0cceef76..67a848e8edd 100644 --- a/spec/javascripts/ide/stores/getters_spec.js +++ b/spec/javascripts/ide/stores/getters_spec.js @@ -78,4 +78,67 @@ describe('IDE store getters', () => { expect(getters.allBlobs(localState)[0].name).toBe('blob'); }); }); + + describe('getChangesInFolder', () => { + it('returns length of changed files for a path', () => { + localState.changedFiles.push( + { + path: 'test/index', + name: 'index', + }, + { + path: 'app/123', + name: '123', + }, + ); + + expect(getters.getChangesInFolder(localState)('test')).toBe(1); + }); + + it('returns length of changed & staged files for a path', () => { + localState.changedFiles.push( + { + path: 'test/index', + name: 'index', + }, + { + path: 'testing/123', + name: '123', + }, + ); + + localState.stagedFiles.push( + { + path: 'test/123', + name: '123', + }, + { + path: 'test/index', + name: 'index', + }, + { + path: 'testing/12345', + name: '12345', + }, + ); + + expect(getters.getChangesInFolder(localState)('test')).toBe(2); + }); + + it('returns length of changed & tempFiles files for a path', () => { + localState.changedFiles.push( + { + path: 'test/index', + name: 'index', + }, + { + path: 'test/newfile', + name: 'newfile', + tempFile: true, + }, + ); + + expect(getters.getChangesInFolder(localState)('test')).toBe(2); + }); + }); }); diff --git a/spec/javascripts/sidebar/participants_spec.js b/spec/javascripts/sidebar/participants_spec.js index 2a3b60c399c..e796ddee62f 100644 --- a/spec/javascripts/sidebar/participants_spec.js +++ b/spec/javascripts/sidebar/participants_spec.js @@ -170,5 +170,19 @@ describe('Participants', function () { expect(vm.isShowingMoreParticipants).toBe(true); }); + + it('clicking on participants icon emits `toggleSidebar` event', () => { + vm = mountComponent(Participants, { + loading: false, + participants: PARTICIPANT_LIST, + numberOfLessParticipants: 2, + }); + spyOn(vm, '$emit'); + + const participantsIconEl = vm.$el.querySelector('.sidebar-collapsed-icon'); + + participantsIconEl.click(); + expect(vm.$emit).toHaveBeenCalledWith('toggleSidebar'); + }); }); }); diff --git a/spec/javascripts/sidebar/sidebar_subscriptions_spec.js b/spec/javascripts/sidebar/sidebar_subscriptions_spec.js index 56a2543660b..9e437084224 100644 --- a/spec/javascripts/sidebar/sidebar_subscriptions_spec.js +++ b/spec/javascripts/sidebar/sidebar_subscriptions_spec.js @@ -3,7 +3,6 @@ import sidebarSubscriptions from '~/sidebar/components/subscriptions/sidebar_sub import SidebarMediator from '~/sidebar/sidebar_mediator'; import SidebarService from '~/sidebar/services/sidebar_service'; import SidebarStore from '~/sidebar/stores/sidebar_store'; -import eventHub from '~/sidebar/event_hub'; import mountComponent from 'spec/helpers/vue_mount_component_helper'; import Mock from './mock_data'; @@ -32,7 +31,7 @@ describe('Sidebar Subscriptions', function () { mediator, }); - eventHub.$emit('toggleSubscription'); + vm.onToggleSubscription(); expect(mediator.toggleSubscription).toHaveBeenCalled(); }); diff --git a/spec/javascripts/sidebar/subscriptions_spec.js b/spec/javascripts/sidebar/subscriptions_spec.js index aee8f0acbb9..f0a53e573c3 100644 --- a/spec/javascripts/sidebar/subscriptions_spec.js +++ b/spec/javascripts/sidebar/subscriptions_spec.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import subscriptions from '~/sidebar/components/subscriptions/subscriptions.vue'; +import eventHub from '~/sidebar/event_hub'; import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('Subscriptions', function () { @@ -39,4 +40,22 @@ describe('Subscriptions', function () { expect(vm.$refs.toggleButton.$el.querySelector('.project-feature-toggle')).toHaveClass('is-checked'); }); + + it('toggleSubscription method emits `toggleSubscription` event on eventHub and Component', () => { + vm = mountComponent(Subscriptions, { subscribed: true }); + spyOn(eventHub, '$emit'); + spyOn(vm, '$emit'); + + vm.toggleSubscription(); + expect(eventHub.$emit).toHaveBeenCalledWith('toggleSubscription', jasmine.any(Object)); + expect(vm.$emit).toHaveBeenCalledWith('toggleSubscription', jasmine.any(Object)); + }); + + it('onClickCollapsedIcon method emits `toggleSidebar` event on component', () => { + vm = mountComponent(Subscriptions, { subscribed: true }); + spyOn(vm, '$emit'); + + vm.onClickCollapsedIcon(); + expect(vm.$emit).toHaveBeenCalledWith('toggleSidebar'); + }); }); diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index e7f20f81fe0..dd5640dd9de 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -258,7 +258,6 @@ project: - builds - runner_projects - runners -- active_runners - variables - triggers - pipeline_schedules @@ -286,6 +285,7 @@ project: - internal_ids - project_deploy_tokens - deploy_tokens +- settings - ci_cd_settings award_emoji: - awardable diff --git a/spec/models/ci/runner_spec.rb b/spec/models/ci/runner_spec.rb index ab170e6351c..cc4d4e5e4ae 100644 --- a/spec/models/ci/runner_spec.rb +++ b/spec/models/ci/runner_spec.rb @@ -19,6 +19,63 @@ describe Ci::Runner do end end end + + context 'either_projects_or_group' do + let(:group) { create(:group) } + + it 'disallows assigning to a group if already assigned to a group' do + runner = create(:ci_runner, groups: [group]) + + runner.groups << build(:group) + + expect(runner).not_to be_valid + expect(runner.errors.full_messages).to eq ['Runner can only be assigned to one group'] + end + + it 'disallows assigning to a group if already assigned to a project' do + project = create(:project) + runner = create(:ci_runner, projects: [project]) + + runner.groups << build(:group) + + expect(runner).not_to be_valid + expect(runner.errors.full_messages).to eq ['Runner can only be assigned either to projects or to a group'] + end + + it 'disallows assigning to a project if already assigned to a group' do + runner = create(:ci_runner, groups: [group]) + + runner.projects << build(:project) + + expect(runner).not_to be_valid + expect(runner.errors.full_messages).to eq ['Runner can only be assigned either to projects or to a group'] + end + + it 'allows assigning to a group if not assigned to a group nor a project' do + runner = create(:ci_runner) + + runner.groups << build(:group) + + expect(runner).to be_valid + end + + it 'allows assigning to a project if not assigned to a group nor a project' do + runner = create(:ci_runner) + + runner.projects << build(:project) + + expect(runner).to be_valid + end + + it 'allows assigning to a project if already assigned to a project' do + project = create(:project) + runner = create(:ci_runner, projects: [project]) + + runner.projects << build(:project) + + expect(runner).to be_valid + end + end end describe '#access_level' do @@ -49,6 +106,80 @@ describe Ci::Runner do end end + describe '.shared' do + let(:group) { create(:group) } + let(:project) { create(:project) } + + it 'returns the shared group runner' do + runner = create(:ci_runner, :shared, groups: [group]) + + expect(described_class.shared).to eq [runner] + end + + it 'returns the shared project runner' do + runner = create(:ci_runner, :shared, projects: [project]) + + expect(described_class.shared).to eq [runner] + end + end + + describe '.belonging_to_project' do + it 'returns the specific project runner' do + # own + specific_project = create(:project) + specific_runner = create(:ci_runner, :specific, projects: [specific_project]) + + # other + other_project = create(:project) + create(:ci_runner, :specific, projects: [other_project]) + + expect(described_class.belonging_to_project(specific_project.id)).to eq [specific_runner] + end + end + + describe '.belonging_to_parent_group_of_project' do + let(:project) { create(:project, group: group) } + let(:group) { create(:group) } + let(:runner) { create(:ci_runner, :specific, groups: [group]) } + let!(:unrelated_group) { create(:group) } + let!(:unrelated_project) { create(:project, group: unrelated_group) } + let!(:unrelated_runner) { create(:ci_runner, :specific, groups: [unrelated_group]) } + + it 'returns the specific group runner' do + expect(described_class.belonging_to_parent_group_of_project(project.id)).to contain_exactly(runner) + end + + context 'with a parent group with a runner', :nested_groups do + let(:runner) { create(:ci_runner, :specific, groups: [parent_group]) } + let(:project) { create(:project, group: group) } + let(:group) { create(:group, parent: parent_group) } + let(:parent_group) { create(:group) } + + it 'returns the group runner from the parent group' do + expect(described_class.belonging_to_parent_group_of_project(project.id)).to contain_exactly(runner) + end + end + end + + describe '.owned_or_shared' do + it 'returns a globally shared, a project specific and a group specific runner' do + # group specific + group = create(:group) + project = create(:project, group: group) + group_runner = create(:ci_runner, :specific, groups: [group]) + + # project specific + project_runner = create(:ci_runner, :specific, projects: [project]) + + # globally shared + shared_runner = create(:ci_runner, :shared) + + expect(described_class.owned_or_shared(project.id)).to contain_exactly( + group_runner, project_runner, shared_runner + ) + end + end + describe '#display_name' do it 'returns the description if it has a value' do runner = FactoryBot.build(:ci_runner, description: 'Linux/Ruby-1.9.3-p448') @@ -163,7 +294,9 @@ describe Ci::Runner do describe '#can_pick?' do let(:pipeline) { create(:ci_pipeline) } let(:build) { create(:ci_build, pipeline: pipeline) } - let(:runner) { create(:ci_runner) } + let(:runner) { create(:ci_runner, tag_list: tag_list, run_untagged: run_untagged) } + let(:tag_list) { [] } + let(:run_untagged) { true } subject { runner.can_pick?(build) } @@ -171,6 +304,13 @@ describe Ci::Runner do build.project.runners << runner end + context 'a different runner' do + it 'cannot handle builds' do + other_runner = create(:ci_runner) + expect(other_runner.can_pick?(build)).to be_falsey + end + end + context 'when runner does not have tags' do it 'can handle builds without tags' do expect(runner.can_pick?(build)).to be_truthy @@ -184,9 +324,7 @@ describe Ci::Runner do end context 'when runner has tags' do - before do - runner.tag_list = %w(bb cc) - end + let(:tag_list) { %w(bb cc) } shared_examples 'tagged build picker' do it 'can handle build with matching tags' do @@ -211,9 +349,7 @@ describe Ci::Runner do end context 'when runner cannot pick untagged jobs' do - before do - runner.run_untagged = false - end + let(:run_untagged) { false } it 'cannot handle builds without tags' do expect(runner.can_pick?(build)).to be_falsey @@ -224,8 +360,9 @@ describe Ci::Runner do end context 'when runner is shared' do + let(:runner) { create(:ci_runner, :shared) } + before do - runner.is_shared = true build.project.runners = [] end @@ -234,9 +371,7 @@ describe Ci::Runner do end context 'when runner is locked' do - before do - runner.locked = true - end + let(:runner) { create(:ci_runner, :shared, locked: true) } it 'can handle builds' do expect(runner.can_pick?(build)).to be_truthy @@ -260,6 +395,17 @@ describe Ci::Runner do expect(runner.can_pick?(build)).to be_falsey end end + + context 'when runner is assigned to a group' do + before do + build.project.runners = [] + runner.groups << create(:group, projects: [build.project]) + end + + it 'can handle builds' do + expect(runner.can_pick?(build)).to be_truthy + end + end end context 'when access_level of runner is not_protected' do @@ -583,4 +729,76 @@ describe Ci::Runner do expect(described_class.search(runner.description.upcase)).to eq([runner]) end end + + describe '#assigned_to_group?' do + subject { runner.assigned_to_group? } + + context 'when project runner' do + let(:runner) { create(:ci_runner, description: 'Project runner', projects: [project]) } + let(:project) { create(:project) } + + it { is_expected.to be_falsey } + end + + context 'when shared runner' do + let(:runner) { create(:ci_runner, :shared, description: 'Shared runner') } + + it { is_expected.to be_falsey } + end + + context 'when group runner' do + let(:group) { create(:group) } + let(:runner) { create(:ci_runner, description: 'Group runner', groups: [group]) } + + it { is_expected.to be_truthy } + end + end + + describe '#assigned_to_project?' do + subject { runner.assigned_to_project? } + + context 'when group runner' do + let(:runner) { create(:ci_runner, description: 'Group runner', groups: [group]) } + let(:group) { create(:group) } + it { is_expected.to be_falsey } + end + + context 'when shared runner' do + let(:runner) { create(:ci_runner, :shared, description: 'Shared runner') } + it { is_expected.to be_falsey } + end + + context 'when project runner' do + let(:runner) { create(:ci_runner, description: 'Group runner', projects: [project]) } + let(:project) { create(:project) } + + it { is_expected.to be_truthy } + end + end + + describe '#pick_build!' do + context 'runner can pick the build' do + it 'calls #tick_runner_queue' do + ci_build = build(:ci_build) + runner = build(:ci_runner) + allow(runner).to receive(:can_pick?).with(ci_build).and_return(true) + + expect(runner).to receive(:tick_runner_queue) + + runner.pick_build!(ci_build) + end + end + + context 'runner cannot pick the build' do + it 'does not call #tick_runner_queue' do + ci_build = build(:ci_build) + runner = build(:ci_runner) + allow(runner).to receive(:can_pick?).with(ci_build).and_return(false) + + expect(runner).not_to receive(:tick_runner_queue) + + runner.pick_build!(ci_build) + end + end + end end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index a9587b1005e..08e42b61910 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -63,7 +63,6 @@ describe Project do it { is_expected.to have_many(:build_trace_section_names)} it { is_expected.to have_many(:runner_projects) } it { is_expected.to have_many(:runners) } - it { is_expected.to have_many(:active_runners) } it { is_expected.to have_many(:variables) } it { is_expected.to have_many(:triggers) } it { is_expected.to have_many(:pages_domains) } @@ -1139,45 +1138,106 @@ describe Project do end end - describe '#any_runners' do - let(:project) { create(:project, shared_runners_enabled: shared_runners_enabled) } - let(:specific_runner) { create(:ci_runner) } - let(:shared_runner) { create(:ci_runner, :shared) } + describe '#any_runners?' do + context 'shared runners' do + let(:project) { create :project, shared_runners_enabled: shared_runners_enabled } + let(:specific_runner) { create :ci_runner } + let(:shared_runner) { create :ci_runner, :shared } - context 'for shared runners disabled' do - let(:shared_runners_enabled) { false } + context 'for shared runners disabled' do + let(:shared_runners_enabled) { false } - it 'has no runners available' do - expect(project.any_runners?).to be_falsey - end + it 'has no runners available' do + expect(project.any_runners?).to be_falsey + end - it 'has a specific runner' do - project.runners << specific_runner - expect(project.any_runners?).to be_truthy - end + it 'has a specific runner' do + project.runners << specific_runner + + expect(project.any_runners?).to be_truthy + end + + it 'has a shared runner, but they are prohibited to use' do + shared_runner + + expect(project.any_runners?).to be_falsey + end + + it 'checks the presence of specific runner' do + project.runners << specific_runner + + expect(project.any_runners? { |runner| runner == specific_runner }).to be_truthy + end - it 'has a shared runner, but they are prohibited to use' do - shared_runner - expect(project.any_runners?).to be_falsey + it 'returns false if match cannot be found' do + project.runners << specific_runner + + expect(project.any_runners? { false }).to be_falsey + end end - it 'checks the presence of specific runner' do - project.runners << specific_runner - expect(project.any_runners? { |runner| runner == specific_runner }).to be_truthy + context 'for shared runners enabled' do + let(:shared_runners_enabled) { true } + + it 'has a shared runner' do + shared_runner + + expect(project.any_runners?).to be_truthy + end + + it 'checks the presence of shared runner' do + shared_runner + + expect(project.any_runners? { |runner| runner == shared_runner }).to be_truthy + end + + it 'returns false if match cannot be found' do + shared_runner + + expect(project.any_runners? { false }).to be_falsey + end end end - context 'for shared runners enabled' do - let(:shared_runners_enabled) { true } + context 'group runners' do + let(:project) { create :project, group_runners_enabled: group_runners_enabled } + let(:group) { create :group, projects: [project] } + let(:group_runner) { create :ci_runner, groups: [group] } + + context 'for group runners disabled' do + let(:group_runners_enabled) { false } + + it 'has no runners available' do + expect(project.any_runners?).to be_falsey + end + + it 'has a group runner, but they are prohibited to use' do + group_runner - it 'has a shared runner' do - shared_runner - expect(project.any_runners?).to be_truthy + expect(project.any_runners?).to be_falsey + end end - it 'checks the presence of shared runner' do - shared_runner - expect(project.any_runners? { |runner| runner == shared_runner }).to be_truthy + context 'for group runners enabled' do + let(:group_runners_enabled) { true } + + it 'has a group runner' do + group_runner + + expect(project.any_runners?).to be_truthy + end + + it 'checks the presence of group runner' do + group_runner + + expect(project.any_runners? { |runner| runner == group_runner }).to be_truthy + end + + it 'returns false if match cannot be found' do + group_runner + + expect(project.any_runners? { false }).to be_falsey + end end end end @@ -3541,6 +3601,18 @@ describe Project do end end + describe '#toggle_ci_cd_settings!' do + it 'toggles the value on #settings' do + project = create(:project, group_runners_enabled: false) + + expect(project.group_runners_enabled).to be false + + project.toggle_ci_cd_settings!(:group_runners_enabled) + + expect(project.group_runners_enabled).to be true + end + end + describe '#gitlab_deploy_token' do let(:project) { create(:project) } diff --git a/spec/requests/api/runner_spec.rb b/spec/requests/api/runner_spec.rb index 6c1c13c9d80..9ae39f2ef44 100644 --- a/spec/requests/api/runner_spec.rb +++ b/spec/requests/api/runner_spec.rb @@ -40,18 +40,36 @@ describe API::Runner do expect(json_response['token']).to eq(runner.token) expect(runner.run_untagged).to be true expect(runner.token).not_to eq(registration_token) + expect(runner).to be_instance_type end context 'when project token is used' do let(:project) { create(:project) } - it 'creates runner' do + it 'creates project runner' do post api('/runners'), token: project.runners_token expect(response).to have_gitlab_http_status 201 expect(project.runners.size).to eq(1) - expect(Ci::Runner.first.token).not_to eq(registration_token) - expect(Ci::Runner.first.token).not_to eq(project.runners_token) + runner = Ci::Runner.first + expect(runner.token).not_to eq(registration_token) + expect(runner.token).not_to eq(project.runners_token) + expect(runner).to be_project_type + end + end + + context 'when group token is used' do + let(:group) { create(:group) } + + it 'creates a group runner' do + post api('/runners'), token: group.runners_token + + expect(response).to have_http_status 201 + expect(group.runners.size).to eq(1) + runner = Ci::Runner.first + expect(runner.token).not_to eq(registration_token) + expect(runner.token).not_to eq(group.runners_token) + expect(runner).to be_group_type end end end diff --git a/spec/requests/api/runners_spec.rb b/spec/requests/api/runners_spec.rb index d30f0cf36e2..f22fec31514 100644 --- a/spec/requests/api/runners_spec.rb +++ b/spec/requests/api/runners_spec.rb @@ -8,22 +8,27 @@ describe API::Runners do let(:project) { create(:project, creator_id: user.id) } let(:project2) { create(:project, creator_id: user.id) } - let!(:shared_runner) { create(:ci_runner, :shared) } - let!(:unused_specific_runner) { create(:ci_runner) } + let(:group) { create(:group).tap { |group| group.add_owner(user) } } + let(:group2) { create(:group).tap { |group| group.add_owner(user) } } - let!(:specific_runner) do - create(:ci_runner).tap do |runner| + let!(:shared_runner) { create(:ci_runner, :shared, description: 'Shared runner') } + let!(:unused_project_runner) { create(:ci_runner) } + + let!(:project_runner) do + create(:ci_runner, description: 'Project runner').tap do |runner| create(:ci_runner_project, runner: runner, project: project) end end let!(:two_projects_runner) do - create(:ci_runner).tap do |runner| + create(:ci_runner, description: 'Two projects runner').tap do |runner| create(:ci_runner_project, runner: runner, project: project) create(:ci_runner_project, runner: runner, project: project2) end end + let!(:group_runner) { create(:ci_runner, description: 'Group runner', groups: [group]) } + before do # Set project access for users create(:project_member, :master, user: user, project: project) @@ -37,9 +42,13 @@ describe API::Runners do get api('/runners', user) shared = json_response.any? { |r| r['is_shared'] } + descriptions = json_response.map { |runner| runner['description'] } expect(response).to have_gitlab_http_status(200) expect(response).to include_pagination_headers expect(json_response).to be_an Array + expect(descriptions).to contain_exactly( + 'Project runner', 'Two projects runner' + ) expect(shared).to be_falsey end @@ -129,10 +138,16 @@ describe API::Runners do context 'when runner is not shared' do it "returns runner's details" do - get api("/runners/#{specific_runner.id}", admin) + get api("/runners/#{project_runner.id}", admin) expect(response).to have_gitlab_http_status(200) - expect(json_response['description']).to eq(specific_runner.description) + expect(json_response['description']).to eq(project_runner.description) + end + + it "returns the project's details for a project runner" do + get api("/runners/#{project_runner.id}", admin) + + expect(json_response['projects'].first['id']).to eq(project.id) end end @@ -146,10 +161,10 @@ describe API::Runners do context "runner project's administrative user" do context 'when runner is not shared' do it "returns runner's details" do - get api("/runners/#{specific_runner.id}", user) + get api("/runners/#{project_runner.id}", user) expect(response).to have_gitlab_http_status(200) - expect(json_response['description']).to eq(specific_runner.description) + expect(json_response['description']).to eq(project_runner.description) end end @@ -164,18 +179,18 @@ describe API::Runners do end context 'other authorized user' do - it "does not return runner's details" do - get api("/runners/#{specific_runner.id}", user2) + it "does not return project runner's details" do + get api("/runners/#{project_runner.id}", user2) - expect(response).to have_gitlab_http_status(403) + expect(response).to have_http_status(403) end end context 'unauthorized user' do - it "does not return runner's details" do - get api("/runners/#{specific_runner.id}") + it "does not return project runner's details" do + get api("/runners/#{project_runner.id}") - expect(response).to have_gitlab_http_status(401) + expect(response).to have_http_status(401) end end end @@ -212,16 +227,16 @@ describe API::Runners do context 'when runner is not shared' do it 'updates runner' do - description = specific_runner.description - runner_queue_value = specific_runner.ensure_runner_queue_value + description = project_runner.description + runner_queue_value = project_runner.ensure_runner_queue_value - update_runner(specific_runner.id, admin, description: 'test') - specific_runner.reload + update_runner(project_runner.id, admin, description: 'test') + project_runner.reload expect(response).to have_gitlab_http_status(200) - expect(specific_runner.description).to eq('test') - expect(specific_runner.description).not_to eq(description) - expect(specific_runner.ensure_runner_queue_value) + expect(project_runner.description).to eq('test') + expect(project_runner.description).not_to eq(description) + expect(project_runner.ensure_runner_queue_value) .not_to eq(runner_queue_value) end end @@ -247,29 +262,29 @@ describe API::Runners do end context 'when runner is not shared' do - it 'does not update runner without access to it' do - put api("/runners/#{specific_runner.id}", user2), description: 'test' + it 'does not update project runner without access to it' do + put api("/runners/#{project_runner.id}", user2), description: 'test' - expect(response).to have_gitlab_http_status(403) + expect(response).to have_http_status(403) end - it 'updates runner with access to it' do - description = specific_runner.description - put api("/runners/#{specific_runner.id}", admin), description: 'test' - specific_runner.reload + it 'updates project runner with access to it' do + description = project_runner.description + put api("/runners/#{project_runner.id}", admin), description: 'test' + project_runner.reload expect(response).to have_gitlab_http_status(200) - expect(specific_runner.description).to eq('test') - expect(specific_runner.description).not_to eq(description) + expect(project_runner.description).to eq('test') + expect(project_runner.description).not_to eq(description) end end end context 'unauthorized user' do - it 'does not delete runner' do - put api("/runners/#{specific_runner.id}") + it 'does not delete project runner' do + put api("/runners/#{project_runner.id}") - expect(response).to have_gitlab_http_status(401) + expect(response).to have_http_status(401) end end end @@ -293,17 +308,17 @@ describe API::Runners do context 'when runner is not shared' do it 'deletes unused runner' do expect do - delete api("/runners/#{unused_specific_runner.id}", admin) + delete api("/runners/#{unused_project_runner.id}", admin) expect(response).to have_gitlab_http_status(204) end.to change { Ci::Runner.specific.count }.by(-1) end - it 'deletes used runner' do + it 'deletes used project runner' do expect do - delete api("/runners/#{specific_runner.id}", admin) + delete api("/runners/#{project_runner.id}", admin) - expect(response).to have_gitlab_http_status(204) + expect(response).to have_http_status(204) end.to change { Ci::Runner.specific.count }.by(-1) end end @@ -325,34 +340,34 @@ describe API::Runners do context 'when runner is not shared' do it 'does not delete runner without access to it' do - delete api("/runners/#{specific_runner.id}", user2) + delete api("/runners/#{project_runner.id}", user2) expect(response).to have_gitlab_http_status(403) end - it 'does not delete runner with more than one associated project' do + it 'does not delete project runner with more than one associated project' do delete api("/runners/#{two_projects_runner.id}", user) expect(response).to have_gitlab_http_status(403) end - it 'deletes runner for one owned project' do + it 'deletes project runner for one owned project' do expect do - delete api("/runners/#{specific_runner.id}", user) + delete api("/runners/#{project_runner.id}", user) - expect(response).to have_gitlab_http_status(204) + expect(response).to have_http_status(204) end.to change { Ci::Runner.specific.count }.by(-1) end it_behaves_like '412 response' do - let(:request) { api("/runners/#{specific_runner.id}", user) } + let(:request) { api("/runners/#{project_runner.id}", user) } end end end context 'unauthorized user' do - it 'does not delete runner' do - delete api("/runners/#{specific_runner.id}") + it 'does not delete project runner' do + delete api("/runners/#{project_runner.id}") - expect(response).to have_gitlab_http_status(401) + expect(response).to have_http_status(401) end end end @@ -361,8 +376,8 @@ describe API::Runners do set(:job_1) { create(:ci_build) } let!(:job_2) { create(:ci_build, :running, runner: shared_runner, project: project) } let!(:job_3) { create(:ci_build, :failed, runner: shared_runner, project: project) } - let!(:job_4) { create(:ci_build, :running, runner: specific_runner, project: project) } - let!(:job_5) { create(:ci_build, :failed, runner: specific_runner, project: project) } + let!(:job_4) { create(:ci_build, :running, runner: project_runner, project: project) } + let!(:job_5) { create(:ci_build, :failed, runner: project_runner, project: project) } context 'admin user' do context 'when runner exists' do @@ -380,7 +395,7 @@ describe API::Runners do context 'when runner is specific' do it 'return jobs' do - get api("/runners/#{specific_runner.id}/jobs", admin) + get api("/runners/#{project_runner.id}/jobs", admin) expect(response).to have_gitlab_http_status(200) expect(response).to include_pagination_headers @@ -392,7 +407,7 @@ describe API::Runners do context 'when valid status is provided' do it 'return filtered jobs' do - get api("/runners/#{specific_runner.id}/jobs?status=failed", admin) + get api("/runners/#{project_runner.id}/jobs?status=failed", admin) expect(response).to have_gitlab_http_status(200) expect(response).to include_pagination_headers @@ -405,7 +420,7 @@ describe API::Runners do context 'when invalid status is provided' do it 'return 400' do - get api("/runners/#{specific_runner.id}/jobs?status=non-existing", admin) + get api("/runners/#{project_runner.id}/jobs?status=non-existing", admin) expect(response).to have_gitlab_http_status(400) end @@ -433,7 +448,7 @@ describe API::Runners do context 'when runner is specific' do it 'return jobs' do - get api("/runners/#{specific_runner.id}/jobs", user) + get api("/runners/#{project_runner.id}/jobs", user) expect(response).to have_gitlab_http_status(200) expect(response).to include_pagination_headers @@ -445,7 +460,7 @@ describe API::Runners do context 'when valid status is provided' do it 'return filtered jobs' do - get api("/runners/#{specific_runner.id}/jobs?status=failed", user) + get api("/runners/#{project_runner.id}/jobs?status=failed", user) expect(response).to have_gitlab_http_status(200) expect(response).to include_pagination_headers @@ -458,7 +473,7 @@ describe API::Runners do context 'when invalid status is provided' do it 'return 400' do - get api("/runners/#{specific_runner.id}/jobs?status=non-existing", user) + get api("/runners/#{project_runner.id}/jobs?status=non-existing", user) expect(response).to have_gitlab_http_status(400) end @@ -476,7 +491,7 @@ describe API::Runners do context 'other authorized user' do it 'does not return jobs' do - get api("/runners/#{specific_runner.id}/jobs", user2) + get api("/runners/#{project_runner.id}/jobs", user2) expect(response).to have_gitlab_http_status(403) end @@ -484,7 +499,7 @@ describe API::Runners do context 'unauthorized user' do it 'does not return jobs' do - get api("/runners/#{specific_runner.id}/jobs") + get api("/runners/#{project_runner.id}/jobs") expect(response).to have_gitlab_http_status(401) end @@ -523,7 +538,7 @@ describe API::Runners do describe 'POST /projects/:id/runners' do context 'authorized user' do - let(:specific_runner2) do + let(:project_runner2) do create(:ci_runner).tap do |runner| create(:ci_runner_project, runner: runner, project: project2) end @@ -531,23 +546,23 @@ describe API::Runners do it 'enables specific runner' do expect do - post api("/projects/#{project.id}/runners", user), runner_id: specific_runner2.id + post api("/projects/#{project.id}/runners", user), runner_id: project_runner2.id end.to change { project.runners.count }.by(+1) expect(response).to have_gitlab_http_status(201) end it 'avoids changes when enabling already enabled runner' do expect do - post api("/projects/#{project.id}/runners", user), runner_id: specific_runner.id + post api("/projects/#{project.id}/runners", user), runner_id: project_runner.id end.to change { project.runners.count }.by(0) expect(response).to have_gitlab_http_status(409) end it 'does not enable locked runner' do - specific_runner2.update(locked: true) + project_runner2.update(locked: true) expect do - post api("/projects/#{project.id}/runners", user), runner_id: specific_runner2.id + post api("/projects/#{project.id}/runners", user), runner_id: project_runner2.id end.to change { project.runners.count }.by(0) expect(response).to have_gitlab_http_status(403) @@ -559,10 +574,16 @@ describe API::Runners do expect(response).to have_gitlab_http_status(403) end + it 'does not enable group runner' do + post api("/projects/#{project.id}/runners", user), runner_id: group_runner.id + + expect(response).to have_http_status(403) + end + context 'user is admin' do it 'enables any specific runner' do expect do - post api("/projects/#{project.id}/runners", admin), runner_id: unused_specific_runner.id + post api("/projects/#{project.id}/runners", admin), runner_id: unused_project_runner.id end.to change { project.runners.count }.by(+1) expect(response).to have_gitlab_http_status(201) end @@ -570,7 +591,7 @@ describe API::Runners do context 'user is not admin' do it 'does not enable runner without access to' do - post api("/projects/#{project.id}/runners", user), runner_id: unused_specific_runner.id + post api("/projects/#{project.id}/runners", user), runner_id: unused_project_runner.id expect(response).to have_gitlab_http_status(403) end @@ -619,7 +640,7 @@ describe API::Runners do context 'when runner have one associated projects' do it "does not disable project's runner" do expect do - delete api("/projects/#{project.id}/runners/#{specific_runner.id}", user) + delete api("/projects/#{project.id}/runners/#{project_runner.id}", user) end.to change { project.runners.count }.by(0) expect(response).to have_gitlab_http_status(403) end @@ -634,7 +655,7 @@ describe API::Runners do context 'authorized user without permissions' do it "does not disable project's runner" do - delete api("/projects/#{project.id}/runners/#{specific_runner.id}", user2) + delete api("/projects/#{project.id}/runners/#{project_runner.id}", user2) expect(response).to have_gitlab_http_status(403) end @@ -642,7 +663,7 @@ describe API::Runners do context 'unauthorized user' do it "does not disable project's runner" do - delete api("/projects/#{project.id}/runners/#{specific_runner.id}") + delete api("/projects/#{project.id}/runners/#{project_runner.id}") expect(response).to have_gitlab_http_status(401) end diff --git a/spec/serializers/pipeline_serializer_spec.rb b/spec/serializers/pipeline_serializer_spec.rb index f51c11b141f..e88e86c2998 100644 --- a/spec/serializers/pipeline_serializer_spec.rb +++ b/spec/serializers/pipeline_serializer_spec.rb @@ -118,7 +118,7 @@ describe PipelineSerializer do it 'verifies number of queries', :request_store do recorded = ActiveRecord::QueryRecorder.new { subject } - expect(recorded.count).to be_within(1).of(36) + expect(recorded.count).to be_within(1).of(44) expect(recorded.cached_count).to eq(0) end end diff --git a/spec/services/ci/register_job_service_spec.rb b/spec/services/ci/register_job_service_spec.rb index 8a537e83d5f..8063bc7e1ac 100644 --- a/spec/services/ci/register_job_service_spec.rb +++ b/spec/services/ci/register_job_service_spec.rb @@ -2,11 +2,13 @@ require 'spec_helper' module Ci describe RegisterJobService do - let!(:project) { FactoryBot.create :project, shared_runners_enabled: false } - let!(:pipeline) { FactoryBot.create :ci_pipeline, project: project } - let!(:pending_job) { FactoryBot.create :ci_build, pipeline: pipeline } - let!(:shared_runner) { FactoryBot.create(:ci_runner, is_shared: true) } - let!(:specific_runner) { FactoryBot.create(:ci_runner, is_shared: false) } + set(:group) { create(:group) } + set(:project) { create(:project, group: group, shared_runners_enabled: false, group_runners_enabled: false) } + set(:pipeline) { create(:ci_pipeline, project: project) } + let!(:shared_runner) { create(:ci_runner, is_shared: true) } + let!(:specific_runner) { create(:ci_runner, is_shared: false) } + let!(:group_runner) { create(:ci_runner, groups: [group], runner_type: :group_type) } + let!(:pending_job) { create(:ci_build, pipeline: pipeline) } before do specific_runner.assign_to(project) @@ -150,7 +152,7 @@ module Ci context 'disallow when builds are disabled' do before do - project.update(shared_runners_enabled: true) + project.update(shared_runners_enabled: true, group_runners_enabled: true) project.project_feature.update_attribute(:builds_access_level, ProjectFeature::DISABLED) end @@ -160,13 +162,90 @@ module Ci it { expect(build).to be_nil } end - context 'and uses specific runner' do + context 'and uses group runner' do + let(:build) { execute(group_runner) } + + it { expect(build).to be_nil } + end + + context 'and uses project runner' do let(:build) { execute(specific_runner) } it { expect(build).to be_nil } end end + context 'allow group runners' do + before do + project.update!(group_runners_enabled: true) + end + + context 'for multiple builds' do + let!(:project2) { create :project, group_runners_enabled: true, group: group } + let!(:pipeline2) { create :ci_pipeline, project: project2 } + let!(:project3) { create :project, group_runners_enabled: true, group: group } + let!(:pipeline3) { create :ci_pipeline, project: project3 } + + let!(:build1_project1) { pending_job } + let!(:build2_project1) { create :ci_build, pipeline: pipeline } + let!(:build3_project1) { create :ci_build, pipeline: pipeline } + let!(:build1_project2) { create :ci_build, pipeline: pipeline2 } + let!(:build2_project2) { create :ci_build, pipeline: pipeline2 } + let!(:build1_project3) { create :ci_build, pipeline: pipeline3 } + + # these shouldn't influence the scheduling + let!(:unrelated_group) { create :group } + let!(:unrelated_project) { create :project, group_runners_enabled: true, group: unrelated_group } + let!(:unrelated_pipeline) { create :ci_pipeline, project: unrelated_project } + let!(:build1_unrelated_project) { create :ci_build, pipeline: unrelated_pipeline } + let!(:unrelated_group_runner) { create :ci_runner, groups: [unrelated_group] } + + it 'does not consider builds from other group runners' do + expect(described_class.new(group_runner).send(:builds_for_group_runner).count).to eq 6 + execute(group_runner) + + expect(described_class.new(group_runner).send(:builds_for_group_runner).count).to eq 5 + execute(group_runner) + + expect(described_class.new(group_runner).send(:builds_for_group_runner).count).to eq 4 + execute(group_runner) + + expect(described_class.new(group_runner).send(:builds_for_group_runner).count).to eq 3 + execute(group_runner) + + expect(described_class.new(group_runner).send(:builds_for_group_runner).count).to eq 2 + execute(group_runner) + + expect(described_class.new(group_runner).send(:builds_for_group_runner).count).to eq 1 + execute(group_runner) + + expect(described_class.new(group_runner).send(:builds_for_group_runner).count).to eq 0 + expect(execute(group_runner)).to be_nil + end + end + + context 'group runner' do + let(:build) { execute(group_runner) } + + it { expect(build).to be_kind_of(Build) } + it { expect(build).to be_valid } + it { expect(build).to be_running } + it { expect(build.runner).to eq(group_runner) } + end + end + + context 'disallow group runners' do + before do + project.update!(group_runners_enabled: false) + end + + context 'group runner' do + let(:build) { execute(group_runner) } + + it { expect(build).to be_nil } + end + end + context 'when first build is stalled' do before do pending_job.update(lock_version: 0) @@ -178,7 +257,7 @@ module Ci let!(:other_build) { create :ci_build, pipeline: pipeline } before do - allow_any_instance_of(Ci::RegisterJobService).to receive(:builds_for_specific_runner) + allow_any_instance_of(Ci::RegisterJobService).to receive(:builds_for_project_runner) .and_return(Ci::Build.where(id: [pending_job, other_build])) end @@ -190,7 +269,7 @@ module Ci context 'when single build is in queue' do before do - allow_any_instance_of(Ci::RegisterJobService).to receive(:builds_for_specific_runner) + allow_any_instance_of(Ci::RegisterJobService).to receive(:builds_for_project_runner) .and_return(Ci::Build.where(id: pending_job)) end @@ -201,7 +280,7 @@ module Ci context 'when there is no build in queue' do before do - allow_any_instance_of(Ci::RegisterJobService).to receive(:builds_for_specific_runner) + allow_any_instance_of(Ci::RegisterJobService).to receive(:builds_for_project_runner) .and_return(Ci::Build.none) end diff --git a/spec/services/ci/update_build_queue_service_spec.rb b/spec/services/ci/update_build_queue_service_spec.rb index 0da0e57dbcd..74a23ed2a3f 100644 --- a/spec/services/ci/update_build_queue_service_spec.rb +++ b/spec/services/ci/update_build_queue_service_spec.rb @@ -8,21 +8,19 @@ describe Ci::UpdateBuildQueueService do context 'when updating specific runners' do let(:runner) { create(:ci_runner) } - context 'when there are runner that can pick build' do + context 'when there is a runner that can pick build' do before do build.project.runners << runner end it 'ticks runner queue value' do - expect { subject.execute(build) } - .to change { runner.ensure_runner_queue_value } + expect { subject.execute(build) }.to change { runner.ensure_runner_queue_value } end end - context 'when there are no runners that can pick build' do + context 'when there is no runner that can pick build' do it 'does not tick runner queue value' do - expect { subject.execute(build) } - .not_to change { runner.ensure_runner_queue_value } + expect { subject.execute(build) }.not_to change { runner.ensure_runner_queue_value } end end end @@ -30,21 +28,61 @@ describe Ci::UpdateBuildQueueService do context 'when updating shared runners' do let(:runner) { create(:ci_runner, :shared) } - context 'when there are runner that can pick build' do + context 'when there is no runner that can pick build' do it 'ticks runner queue value' do - expect { subject.execute(build) } - .to change { runner.ensure_runner_queue_value } + expect { subject.execute(build) }.to change { runner.ensure_runner_queue_value } end end - context 'when there are no runners that can pick build' do + context 'when there is no runner that can pick build due to tag mismatch' do before do build.tag_list = [:docker] end it 'does not tick runner queue value' do - expect { subject.execute(build) } - .not_to change { runner.ensure_runner_queue_value } + expect { subject.execute(build) }.not_to change { runner.ensure_runner_queue_value } + end + end + + context 'when there is no runner that can pick build due to being disabled on project' do + before do + build.project.shared_runners_enabled = false + end + + it 'does not tick runner queue value' do + expect { subject.execute(build) }.not_to change { runner.ensure_runner_queue_value } + end + end + end + + context 'when updating group runners' do + let(:group) { create :group } + let(:project) { create :project, group: group } + let(:runner) { create :ci_runner, groups: [group] } + + context 'when there is a runner that can pick build' do + it 'ticks runner queue value' do + expect { subject.execute(build) }.to change { runner.ensure_runner_queue_value } + end + end + + context 'when there is no runner that can pick build due to tag mismatch' do + before do + build.tag_list = [:docker] + end + + it 'does not tick runner queue value' do + expect { subject.execute(build) }.not_to change { runner.ensure_runner_queue_value } + end + end + + context 'when there is no runner that can pick build due to being disabled on project' do + before do + build.project.group_runners_enabled = false + end + + it 'does not tick runner queue value' do + expect { subject.execute(build) }.not_to change { runner.ensure_runner_queue_value } end end end diff --git a/vendor/project_templates/express.tar.gz b/vendor/project_templates/express.tar.gz Binary files differindex 06093deb459..8dd5fa36987 100644 --- a/vendor/project_templates/express.tar.gz +++ b/vendor/project_templates/express.tar.gz diff --git a/vendor/project_templates/rails.tar.gz b/vendor/project_templates/rails.tar.gz Binary files differindex 85cc1b6bb78..89337dc5c31 100644 --- a/vendor/project_templates/rails.tar.gz +++ b/vendor/project_templates/rails.tar.gz diff --git a/vendor/project_templates/spring.tar.gz b/vendor/project_templates/spring.tar.gz Binary files differindex e98d3ce7b8f..31c90d0820f 100644 --- a/vendor/project_templates/spring.tar.gz +++ b/vendor/project_templates/spring.tar.gz |