Commit c98cb155 authored by Payton Burdette's avatar Payton Burdette Committed by Imre Farkas

Add ability to disable shared runners

Add the ability to disable shared runners
on a group level via the UI.
parent 17a184eb
<script>
import { GlToggle, GlLoadingIcon, GlTooltip, GlAlert } from '@gitlab/ui';
import { debounce } from 'lodash';
import { __ } from '~/locale';
import axios from '~/lib/utils/axios_utils';
import {
DEBOUNCE_TOGGLE_DELAY,
ERROR_MESSAGE,
ENABLED,
DISABLED,
ALLOW_OVERRIDE,
} from '../constants';
export default {
components: {
GlToggle,
GlLoadingIcon,
GlTooltip,
GlAlert,
},
props: {
updatePath: {
type: String,
required: true,
},
sharedRunnersAvailability: {
type: String,
required: true,
},
parentSharedRunnersAvailability: {
type: String,
required: false,
default: '',
},
},
data() {
return {
isLoading: false,
enabled: true,
allowOverride: false,
error: null,
};
},
computed: {
toggleDisabled() {
return this.parentSharedRunnersAvailability === DISABLED || this.isLoading;
},
enabledOrDisabledSetting() {
return this.enabled ? ENABLED : DISABLED;
},
disabledWithOverrideSetting() {
return this.allowOverride ? ALLOW_OVERRIDE : DISABLED;
},
},
created() {
if (this.sharedRunnersAvailability !== ENABLED) {
this.enabled = false;
}
if (this.sharedRunnersAvailability === ALLOW_OVERRIDE) {
this.allowOverride = true;
}
},
methods: {
generatePayload(data) {
return { shared_runners_setting: data };
},
enableOrDisable() {
this.updateRunnerSettings(this.generatePayload(this.enabledOrDisabledSetting));
// reset override toggle to false if shared runners are enabled
this.allowOverride = false;
},
override() {
this.updateRunnerSettings(this.generatePayload(this.disabledWithOverrideSetting));
},
updateRunnerSettings: debounce(function debouncedUpdateRunnerSettings(setting) {
this.isLoading = true;
axios
.put(this.updatePath, setting)
.then(() => {
this.isLoading = false;
})
.catch(error => {
const message = [
error.response?.data?.error || __('An error occurred while updating configuration.'),
ERROR_MESSAGE,
].join(' ');
this.error = message;
});
}, DEBOUNCE_TOGGLE_DELAY),
},
};
</script>
<template>
<div ref="sharedRunnersForm">
<gl-alert v-if="error" variant="danger" :dismissible="false">{{ error }}</gl-alert>
<h4 class="gl-display-flex gl-align-items-center">
{{ __('Set up shared runner availability') }}
<gl-loading-icon v-if="isLoading" class="gl-ml-3" inline />
</h4>
<section class="gl-mt-5">
<gl-toggle
v-model="enabled"
:disabled="toggleDisabled"
:label="__('Enable shared runners for this group')"
data-testid="enable-runners-toggle"
@change="enableOrDisable"
/>
<span class="gl-text-gray-600">
{{ __('Enable shared runners for all projects and subgroups in this group.') }}
</span>
</section>
<section v-if="!enabled" class="gl-mt-5">
<gl-toggle
v-model="allowOverride"
:disabled="toggleDisabled"
:label="__('Allow projects and subgroups to override the group setting')"
data-testid="override-runners-toggle"
@change="override"
/>
<span class="gl-text-gray-600">
{{ __('Allows projects or subgroups in this group to override the global setting.') }}
</span>
</section>
<gl-tooltip v-if="toggleDisabled" :target="() => $refs.sharedRunnersForm">
{{ __('Shared runners are disabled for the parent group') }}
</gl-tooltip>
</div>
</template>
import { __ } from '~/locale';
// Debounce delay in milliseconds
export const DEBOUNCE_TOGGLE_DELAY = 1000;
export const ERROR_MESSAGE = __('Refresh the page and try again.');
// runner setting options
export const ENABLED = 'enabled';
export const DISABLED = 'disabled_and_unoverridable';
export const ALLOW_OVERRIDE = 'disabled_with_override';
import Vue from 'vue';
import UpdateSharedRunnersForm from './components/shared_runners_form.vue';
export default (containerId = 'update-shared-runners-form') => {
const containerEl = document.getElementById(containerId);
return new Vue({
el: containerEl,
render(createElement) {
return createElement(UpdateSharedRunnersForm, {
props: containerEl.dataset,
});
},
});
};
......@@ -4,6 +4,7 @@ import initVariableList from '~/ci_variable_list';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import GroupRunnersFilteredSearchTokenKeys from '~/filtered_search/group_runners_filtered_search_token_keys';
import { FILTERED_SEARCH } from '~/pages/constants';
import initSharedRunnersForm from '~/group_settings/mount_shared_runners';
document.addEventListener('DOMContentLoaded', () => {
// Initialize expandable settings panels
......@@ -29,4 +30,6 @@ document.addEventListener('DOMContentLoaded', () => {
maskableRegex: variableListEl.dataset.maskableRegex,
});
}
initSharedRunnersForm();
});
......@@ -39,6 +39,14 @@ module Ci
runner.contacted_at
end
end
def group_shared_runners_settings_data(group)
{
update_path: api_v4_groups_path(id: group.id),
shared_runners_availability: group.shared_runners_setting,
parent_shared_runners_availability: group.parent&.shared_runners_setting
}
end
end
end
......
......@@ -7,6 +7,8 @@
.row
.col-sm-6
= render 'groups/runners/group_runners'
.col-sm-6
= render 'groups/runners/shared_runners'
%h4.underlined-title
= _('Available Runners: %{runners}').html_safe % { runners: limited_counter_with_delimiter(@all_group_runners) }
......
= render 'shared/runners/shared_runners_description'
#update-shared-runners-form{ data: group_shared_runners_settings_data(@group) }
%h3
= _('Shared Runners')
.bs-callout.shared-runners-description
- if Gitlab::CurrentSettings.shared_runners_text.present?
= markdown_field(Gitlab::CurrentSettings.current_application_settings, :shared_runners_text)
- else
= _('GitLab Shared Runners execute code of different projects on the same Runner unless you configure GitLab Runner Autoscale with MaxBuilds 1 (which it is on GitLab.com).')
= render layout: 'shared/runners/shared_runners_description' do
%hr
- if @project.shared_runners_enabled?
= link_to toggle_shared_runners_project_runners_path(@project), class: 'btn btn-close', method: :post do
= _('Disable shared Runners')
- if @project.group&.shared_runners_setting == 'disabled_and_unoverridable'
%h5.gl-text-red-500
= _('Shared runners disabled on group level')
- else
= link_to toggle_shared_runners_project_runners_path(@project), class: 'btn btn-success', method: :post do
= _('Enable shared Runners')
&nbsp; for this project
- if @project.shared_runners_enabled?
= link_to toggle_shared_runners_project_runners_path(@project), class: 'btn btn-close', method: :post do
= _('Disable shared runners')
- else
= link_to toggle_shared_runners_project_runners_path(@project), class: 'btn btn-success', method: :post do
= _('Enable shared runners')
&nbsp; for this project
- if @shared_runners_count == 0
= _('This GitLab instance does not provide any shared Runners yet. Instance administrators can register shared Runners in the admin area.')
......
- link = link_to _('MaxBuilds'), 'https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-runnersmachine-section', target: '_blank'
%h3
= _('Shared runners')
.bs-callout.shared-runners-description
- if Gitlab::CurrentSettings.shared_runners_text.present?
= markdown_field(Gitlab::CurrentSettings.current_application_settings, :shared_runners_text)
- else
= _('The same shared runner executes code from multiple projects, unless you configure autoscaling with %{link} set to 1 (which it is on GitLab.com).').html_safe % { link: link }
= yield
---
title: UI to disable shared runners by group
merge_request: 39249
author:
type: added
......@@ -759,6 +759,7 @@ Parameters:
| `default_branch_protection` | integer | no | See [Options for `default_branch_protection`](#options-for-default_branch_protection). Default to the global level default branch protection setting. |
| `shared_runners_minutes_limit` | integer | no | **(STARTER ONLY)** Pipeline minutes quota for this group (included in plan). Can be `nil` (default; inherit system default), `0` (unlimited) or `> 0` |
| `extra_shared_runners_minutes_limit` | integer | no | **(STARTER ONLY)** Extra pipeline minutes quota for this group (purchased in addition to the minutes included in the plan). |
| `shared_runners_setting` | string | no | See [Options for `shared_runners_setting`](#options-for-shared_runners_setting). Enable or disable shared runners for a group's subgroups and projects. |
### Options for `default_branch_protection`
......@@ -770,6 +771,16 @@ The `default_branch_protection` attribute determines whether developers and main
| `1` | Partial protection. Developers and maintainers can: <br>- Push new commits |
| `2` | Full protection. Only maintainers can: <br>- Push new commits |
### Options for `shared_runners_setting`
The `shared_runners_setting` attribute determines whether shared runners are enabled for a group's subgroups and projects.
| Value | Description |
|-------|-------------------------------------------------------------------------------------------------------------|
| `enabled` | Enables shared runners for all projects and subgroups in this group. |
| `disabled_with_override` | Disables shared runners for all projects and subgroups in this group, but allows subgroups to override this setting. |
| `disabled_and_unoverridable` | Disables shared runners for all projects and subgroups in this group, and prevents subgroups from overriding this setting. |
## New Subgroup
This is similar to creating a [New group](#new-group). You'll need the `parent_id` from the [List groups](#list-groups) call. You can then enter the desired:
......
......@@ -24,6 +24,7 @@ module API
optional :lfs_enabled, type: Boolean, desc: 'Enable/disable LFS for the projects in this group'
optional :request_access_enabled, type: Boolean, desc: 'Allow users to request member access'
optional :default_branch_protection, type: Integer, values: ::Gitlab::Access.protection_values, desc: 'Determine if developers can push to master'
optional :shared_runners_setting, type: String, values: ::Namespace::SHARED_RUNNERS_SETTINGS, desc: 'Enable/disable shared runners for the group and its subgroups and projects'
end
params :optional_params_ee do
......
......@@ -2599,6 +2599,9 @@ msgstr ""
msgid "Allow owners to manually add users outside of LDAP"
msgstr ""
msgid "Allow projects and subgroups to override the group setting"
msgstr ""
msgid "Allow projects within this group to use Git LFS"
msgstr ""
......@@ -2647,6 +2650,9 @@ msgstr ""
msgid "Allowed to fail"
msgstr ""
msgid "Allows projects or subgroups in this group to override the global setting."
msgstr ""
msgid "Allows you to add and manage Kubernetes clusters."
msgstr ""
......@@ -3010,6 +3016,9 @@ msgstr ""
msgid "An error occurred while updating approvers"
msgstr ""
msgid "An error occurred while updating configuration."
msgstr ""
msgid "An error occurred while updating labels."
msgstr ""
......@@ -9105,7 +9114,7 @@ msgstr ""
msgid "Disable public access to Pages sites"
msgstr ""
msgid "Disable shared Runners"
msgid "Disable shared runners"
msgstr ""
msgid "Disable two-factor authentication"
......@@ -9731,7 +9740,13 @@ msgstr ""
msgid "Enable reCAPTCHA or Akismet and set IP limits. For reCAPTCHA, we currently only support %{recaptcha_v2_link_start}v2%{recaptcha_v2_link_end}"
msgstr ""
msgid "Enable shared Runners"
msgid "Enable shared runners"
msgstr ""
msgid "Enable shared runners for all projects and subgroups in this group."
msgstr ""
msgid "Enable shared runners for this group"
msgstr ""
msgid "Enable snowplow tracking"
......@@ -12073,9 +12088,6 @@ msgstr ""
msgid "GitLab Service Desk is a simple way to allow people to create issues in your GitLab instance without needing their own user account. It provides a unique email address for end users to create issues in a project, and replies can be sent either through the GitLab interface or by email. End users will only see the thread through email."
msgstr ""
msgid "GitLab Shared Runners execute code of different projects on the same Runner unless you configure GitLab Runner Autoscale with MaxBuilds 1 (which it is on GitLab.com)."
msgstr ""
msgid "GitLab Shell"
msgstr ""
......@@ -15765,6 +15777,9 @@ msgstr ""
msgid "Max size 15 MB"
msgstr ""
msgid "MaxBuilds"
msgstr ""
msgid "Maximum Conan package file size in bytes"
msgstr ""
......@@ -21278,6 +21293,9 @@ msgstr ""
msgid "Refresh"
msgstr ""
msgid "Refresh the page and try again."
msgstr ""
msgid "Refreshing in a second to show the updated status..."
msgid_plural "Refreshing in %d seconds to show the updated status..."
msgstr[0] ""
......@@ -23614,6 +23632,9 @@ msgstr ""
msgid "Set up pipeline subscriptions for this project."
msgstr ""
msgid "Set up shared runner availability"
msgstr ""
msgid "Set up your project to automatically push and/or pull changes to/from another repository. Branches, tags, and commits will be synced automatically."
msgstr ""
......@@ -23719,6 +23740,15 @@ msgstr ""
msgid "Shared projects"
msgstr ""
msgid "Shared runners"
msgstr ""
msgid "Shared runners are disabled for the parent group"
msgstr ""
msgid "Shared runners disabled on group level"
msgstr ""
msgid "Shared runners help link"
msgstr ""
......@@ -25857,6 +25887,9 @@ msgstr ""
msgid "The roadmap shows the progress of your epics along a timeline"
msgstr ""
msgid "The same shared runner executes code from multiple projects, unless you configure autoscaling with %{link} set to 1 (which it is on GitLab.com)."
msgstr ""
msgid "The schedule time must be in the future!"
msgstr ""
......
......@@ -173,9 +173,9 @@ RSpec.describe 'Runners' do
it 'user enables shared runners' do
visit project_runners_path(project)
click_on 'Enable shared Runners'
click_on 'Enable shared runners'
expect(page.find('.shared-runners-description')).to have_content('Disable shared Runners')
expect(page.find('.shared-runners-description')).to have_content('Disable shared runners')
end
end
......
import { shallowMount } from '@vue/test-utils';
import { GlLoadingIcon, GlAlert } from '@gitlab/ui';
import MockAxiosAdapter from 'axios-mock-adapter';
import waitForPromises from 'helpers/wait_for_promises';
import SharedRunnersForm from '~/group_settings/components/shared_runners_form.vue';
import { ENABLED, DISABLED, ALLOW_OVERRIDE } from '~/group_settings/constants';
import axios from '~/lib/utils/axios_utils';
const TEST_UPDATE_PATH = '/test/update';
const DISABLED_PAYLOAD = { shared_runners_setting: DISABLED };
const ENABLED_PAYLOAD = { shared_runners_setting: ENABLED };
const OVERRIDE_PAYLOAD = { shared_runners_setting: ALLOW_OVERRIDE };
jest.mock('~/flash');
describe('group_settings/components/shared_runners_form', () => {
let wrapper;
let mock;
const createComponent = (props = {}) => {
wrapper = shallowMount(SharedRunnersForm, {
propsData: {
updatePath: TEST_UPDATE_PATH,
sharedRunnersAvailability: ENABLED,
parentSharedRunnersAvailability: null,
...props,
},
});
};
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findErrorAlert = () => wrapper.find(GlAlert);
const findEnabledToggle = () => wrapper.find('[data-testid="enable-runners-toggle"]');
const findOverrideToggle = () => wrapper.find('[data-testid="override-runners-toggle"]');
const changeToggle = toggle => toggle.vm.$emit('change', !toggle.props('value'));
const getRequestPayload = () => JSON.parse(mock.history.put[0].data);
const isLoadingIconVisible = () => findLoadingIcon().exists();
beforeEach(() => {
mock = new MockAxiosAdapter(axios);
mock.onPut(TEST_UPDATE_PATH).reply(200);
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
mock.restore();
});
describe('with default', () => {
beforeEach(() => {
createComponent();
});
it('loading icon does not exist', () => {
expect(isLoadingIconVisible()).toBe(false);
});
it('enabled toggle exists', () => {
expect(findEnabledToggle().exists()).toBe(true);
});
it('override toggle does not exist', () => {
expect(findOverrideToggle().exists()).toBe(false);
});
});
describe('loading icon', () => {
it('shows and hides the loading icon on request', async () => {
createComponent();
expect(isLoadingIconVisible()).toBe(false);
findEnabledToggle().vm.$emit('change', true);
await wrapper.vm.$nextTick();
expect(isLoadingIconVisible()).toBe(true);
await waitForPromises();
expect(isLoadingIconVisible()).toBe(false);
});
});
describe('enable toggle', () => {
beforeEach(() => {
createComponent();
});
it('enabling the toggle sends correct payload', async () => {
findEnabledToggle().vm.$emit('change', true);
await waitForPromises();
expect(getRequestPayload()).toEqual(ENABLED_PAYLOAD);
expect(findOverrideToggle().exists()).toBe(false);
});
it('disabling the toggle sends correct payload', async () => {
findEnabledToggle().vm.$emit('change', false);
await waitForPromises();
expect(getRequestPayload()).toEqual(DISABLED_PAYLOAD);
expect(findOverrideToggle().exists()).toBe(true);
});
});
describe('override toggle', () => {
beforeEach(() => {
createComponent({ sharedRunnersAvailability: ALLOW_OVERRIDE });
});
it('enabling the override toggle sends correct payload', async () => {
findOverrideToggle().vm.$emit('change', true);
await waitForPromises();
expect(getRequestPayload()).toEqual(OVERRIDE_PAYLOAD);
});
it('disabling the override toggle sends correct payload', async () => {
findOverrideToggle().vm.$emit('change', false);
await waitForPromises();
expect(getRequestPayload()).toEqual(DISABLED_PAYLOAD);
});
});
describe('toggle disabled state', () => {
it(`toggles are not disabled with setting ${DISABLED}`, () => {
createComponent({ sharedRunnersAvailability: DISABLED });
expect(findEnabledToggle().props('disabled')).toBe(false);
expect(findOverrideToggle().props('disabled')).toBe(false);
});
it('toggles are disabled', () => {
createComponent({
sharedRunnersAvailability: DISABLED,
parentSharedRunnersAvailability: DISABLED,
});
expect(findEnabledToggle().props('disabled')).toBe(true);
expect(findOverrideToggle().props('disabled')).toBe(true);
});
});
describe.each`
errorObj | message
${{}} | ${'An error occurred while updating configuration. Refresh the page and try again.'}
${{ error: 'Undefined error' }} | ${'Undefined error Refresh the page and try again.'}
`(`with error $errorObj`, ({ errorObj, message }) => {
beforeEach(async () => {
mock.onPut(TEST_UPDATE_PATH).reply(500, errorObj);
createComponent();
changeToggle(findEnabledToggle());
await waitForPromises();
});
it('error should be shown', () => {
expect(findErrorAlert().text()).toBe(message);
});
});
});
......@@ -53,4 +53,25 @@ RSpec.describe Ci::RunnersHelper do
end
end
end
describe '#group_shared_runners_settings_data' do
let(:group) { create(:group, parent: parent, shared_runners_enabled: false) }
let(:parent) { create(:group) }
it 'returns group data for top level group' do
data = group_shared_runners_settings_data(parent)
expect(data[:update_path]).to eq("/api/v4/groups/#{parent.id}")
expect(data[:shared_runners_availability]).to eq('enabled')
expect(data[:parent_shared_runners_availability]).to eq(nil)
end
it 'returns group data for child group' do
data = group_shared_runners_settings_data(group)
expect(data[:update_path]).to eq("/api/v4/groups/#{group.id}")
expect(data[:shared_runners_availability]).to eq('disabled_and_unoverridable')
expect(data[:parent_shared_runners_availability]).to eq('enabled')
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment