Commit a55cfc1e authored by Kamil Trzciński's avatar Kamil Trzciński

Merge branch 'ce-39118-dynamic-pipeline-variables-fe' into 'master'

Dynamic CI secret variables -- CE backport

See merge request gitlab-org/gitlab-ce!16842
parents 024c8a42 efcdc269
Pipeline #17206994 passed with stages
in 34 minutes and 58 seconds
import _ from 'underscore';
import axios from '../lib/utils/axios_utils';
import { s__ } from '../locale';
import Flash from '../flash';
import { convertPermissionToBoolean } from '../lib/utils/common_utils';
import statusCodes from '../lib/utils/http_status';
import VariableList from './ci_variable_list';
function generateErrorBoxContent(errors) {
const errorList = [].concat(errors).map(errorString => `
<li>
${_.escape(errorString)}
</li>
`);
return `
<p>
${s__('CiVariable|Validation failed')}
</p>
<ul>
${errorList.join('')}
</ul>
`;
}
// Used for the variable list on CI/CD projects/groups settings page
export default class AjaxVariableList {
constructor({
container,
saveButton,
errorBox,
formField = 'variables',
saveEndpoint,
}) {
this.container = container;
this.saveButton = saveButton;
this.errorBox = errorBox;
this.saveEndpoint = saveEndpoint;
this.variableList = new VariableList({
container: this.container,
formField,
});
this.bindEvents();
this.variableList.init();
}
bindEvents() {
this.saveButton.addEventListener('click', this.onSaveClicked.bind(this));
}
onSaveClicked() {
const loadingIcon = this.saveButton.querySelector('.js-secret-variables-save-loading-icon');
loadingIcon.classList.toggle('hide', false);
this.errorBox.classList.toggle('hide', true);
// We use this to prevent a user from changing a key before we have a chance
// to match it up in `updateRowsWithPersistedVariables`
this.variableList.toggleEnableRow(false);
return axios.patch(this.saveEndpoint, {
variables_attributes: this.variableList.getAllData(),
}, {
// We want to be able to process the `res.data` from a 400 error response
// and print the validation messages such as duplicate variable keys
validateStatus: status => (
status >= statusCodes.OK &&
status < statusCodes.MULTIPLE_CHOICES
) ||
status === statusCodes.BAD_REQUEST,
})
.then((res) => {
loadingIcon.classList.toggle('hide', true);
this.variableList.toggleEnableRow(true);
if (res.status === statusCodes.OK && res.data) {
this.updateRowsWithPersistedVariables(res.data.variables);
} else if (res.status === statusCodes.BAD_REQUEST) {
// Validation failed
this.errorBox.innerHTML = generateErrorBoxContent(res.data);
this.errorBox.classList.toggle('hide', false);
}
})
.catch(() => {
loadingIcon.classList.toggle('hide', true);
this.variableList.toggleEnableRow(true);
Flash(s__('CiVariable|Error occured while saving variables'));
});
}
updateRowsWithPersistedVariables(persistedVariables = []) {
const persistedVariableMap = [].concat(persistedVariables).reduce((variableMap, variable) => ({
...variableMap,
[variable.key]: variable,
}), {});
this.container.querySelectorAll('.js-row').forEach((row) => {
// If we submitted a row that was destroyed, remove it so we don't try
// to destroy it again which would cause a BE error
const destroyInput = row.querySelector('.js-ci-variable-input-destroy');
if (convertPermissionToBoolean(destroyInput.value)) {
row.remove();
// Update the ID input so any future edits and `_destroy` will apply on the BE
} else {
const key = row.querySelector('.js-ci-variable-input-key').value;
const persistedVariable = persistedVariableMap[key];
if (persistedVariable) {
// eslint-disable-next-line no-param-reassign
row.querySelector('.js-ci-variable-input-id').value = persistedVariable.id;
row.setAttribute('data-is-persisted', 'true');
}
}
});
}
}
......@@ -11,7 +11,7 @@ function createEnvironmentItem(value) {
return {
title: value === '*' ? ALL_ENVIRONMENTS_STRING : value,
id: value,
text: value,
text: value === '*' ? s__('CiVariable|* (All environments)') : value,
};
}
......@@ -41,11 +41,11 @@ export default class VariableList {
selector: '.js-ci-variable-input-protected',
default: 'true',
},
environment: {
environment_scope: {
// We can't use a `.js-` class here because
// gl_dropdown replaces the <input> and doesn't copy over the class
// See https://gitlab.com/gitlab-org/gitlab-ce/issues/42458
selector: `input[name="${this.formField}[variables_attributes][][environment]"]`,
selector: `input[name="${this.formField}[variables_attributes][][environment_scope]"]`,
default: '*',
},
_destroy: {
......@@ -104,12 +104,15 @@ export default class VariableList {
setupToggleButtons($row[0]);
// Reset the resizable textarea
$row.find(this.inputMap.value.selector).css('height', '');
const $environmentSelect = $row.find('.js-variable-environment-toggle');
if ($environmentSelect.length) {
const createItemDropdown = new CreateItemDropdown({
$dropdown: $environmentSelect,
defaultToggleLabel: ALL_ENVIRONMENTS_STRING,
fieldName: `${this.formField}[variables_attributes][][environment]`,
fieldName: `${this.formField}[variables_attributes][][environment_scope]`,
getData: (term, callback) => callback(this.getEnvironmentValues()),
createNewItemFromValue: createEnvironmentItem,
onSelect: () => {
......@@ -117,7 +120,7 @@ export default class VariableList {
// so they have the new value we just picked
this.refreshDropdownData();
$row.find(this.inputMap.environment.selector).trigger('trigger-change');
$row.find(this.inputMap.environment_scope.selector).trigger('trigger-change');
},
});
......@@ -143,7 +146,8 @@ export default class VariableList {
$row.after($rowClone);
}
removeRow($row) {
removeRow(row) {
const $row = $(row);
const isPersisted = convertPermissionToBoolean($row.attr('data-is-persisted'));
if (isPersisted) {
......@@ -155,6 +159,10 @@ export default class VariableList {
} else {
$row.remove();
}
// Refresh the other dropdowns in the variable list
// so any value with the variable deleted is gone
this.refreshDropdownData();
}
checkIfRowTouched($row) {
......@@ -165,6 +173,11 @@ export default class VariableList {
});
}
toggleEnableRow(isEnabled = true) {
this.$container.find(this.inputMap.key.selector).attr('disabled', !isEnabled);
this.$container.find('.js-row-remove-button').attr('disabled', !isEnabled);
}
getAllData() {
// Ignore the last empty row because we don't want to try persist
// a blank variable and run into validation problems.
......@@ -185,7 +198,7 @@ export default class VariableList {
}
getEnvironmentValues() {
const valueMap = this.$container.find(this.inputMap.environment.selector).toArray()
const valueMap = this.$container.find(this.inputMap.environment_scope.selector).toArray()
.reduce((prevValueMap, envInput) => ({
...prevValueMap,
[envInput.value]: envInput.value,
......
......@@ -18,3 +18,22 @@ Element.prototype.matches = Element.prototype.matches ||
while (i >= 0 && elms.item(i) !== this) { i -= 1; }
return i > -1;
};
// From the polyfill on MDN, https://developer.mozilla.org/en-US/docs/Web/API/ChildNode/remove#Polyfill
((arr) => {
arr.forEach((item) => {
if (Object.prototype.hasOwnProperty.call(item, 'remove')) {
return;
}
Object.defineProperty(item, 'remove', {
configurable: true,
enumerable: true,
writable: true,
value: function remove() {
if (this.parentNode !== null) {
this.parentNode.removeChild(this);
}
},
});
});
})([Element.prototype, CharacterData.prototype, DocumentType.prototype]);
......@@ -6,4 +6,6 @@ export default {
ABORTED: 0,
NO_CONTENT: 204,
OK: 200,
MULTIPLE_CHOICES: 300,
BAD_REQUEST: 400,
};
import SecretValues from '~/behaviors/secret_values';
import AjaxVariableList from '~/ci_variable_list/ajax_variable_list';
export default () => {
const secretVariableTable = document.querySelector('.js-secret-variable-table');
if (secretVariableTable) {
const secretVariableTableValues = new SecretValues({
container: secretVariableTable,
});
secretVariableTableValues.init();
}
const variableListEl = document.querySelector('.js-ci-variable-list-section');
// eslint-disable-next-line no-new
new AjaxVariableList({
container: variableListEl,
saveButton: variableListEl.querySelector('.js-secret-variables-save-button'),
errorBox: variableListEl.querySelector('.js-ci-variable-error-box'),
saveEndpoint: variableListEl.dataset.saveEndpoint,
});
};
import initSettingsPanels from '~/settings_panels';
import SecretValues from '~/behaviors/secret_values';
import AjaxVariableList from '~/ci_variable_list/ajax_variable_list';
export default function () {
// Initialize expandable settings panels
initSettingsPanels();
const runnerToken = document.querySelector('.js-secret-runner-token');
if (runnerToken) {
const runnerTokenSecretValue = new SecretValues({
......@@ -12,11 +14,12 @@ export default function () {
runnerTokenSecretValue.init();
}
const secretVariableTable = document.querySelector('.js-secret-variable-table');
if (secretVariableTable) {
const secretVariableTableValues = new SecretValues({
container: secretVariableTable,
});
secretVariableTableValues.init();
}
const variableListEl = document.querySelector('.js-ci-variable-list-section');
// eslint-disable-next-line no-new
new AjaxVariableList({
container: variableListEl,
saveButton: variableListEl.querySelector('.js-secret-variables-save-button'),
errorBox: variableListEl.querySelector('.js-ci-variable-error-box'),
saveEndpoint: variableListEl.dataset.saveEndpoint,
});
}
......@@ -8,7 +8,11 @@
.ci-variable-row {
display: flex;
align-items: flex-end;
align-items: flex-start;
@media (max-width: $screen-xs-max) {
align-items: flex-end;
}
&:not(:last-child) {
margin-bottom: $gl-btn-padding;
......@@ -41,6 +45,7 @@
.ci-variable-row-body {
display: flex;
align-items: flex-start;
width: 100%;
@media (max-width: $screen-xs-max) {
......@@ -65,6 +70,8 @@
flex: 0 1 auto;
display: flex;
align-items: center;
padding-top: 5px;
padding-bottom: 5px;
}
.ci-variable-row-remove-button {
......@@ -85,4 +92,8 @@
outline: none;
color: $gl-text-color;
}
&[disabled] {
color: $gl-text-color-disabled;
}
}
module Groups
class VariablesController < Groups::ApplicationController
before_action :variable, only: [:show, :update, :destroy]
before_action :authorize_admin_build!
def index
redirect_to group_settings_ci_cd_path(group)
end
def show
respond_to do |format|
format.json do
render status: :ok, json: { variables: GroupVariableSerializer.new.represent(@group.variables) }
end
end
end
def update
if variable.update(variable_params)
redirect_to group_variables_path(group),
notice: 'Variable was successfully updated.'
if @group.update(group_variables_params)
respond_to do |format|
format.json { return render_group_variables }
end
else
render "show"
respond_to do |format|
format.json { render_error }
end
end
end
def create
@variable = group.variables.create(variable_params)
.present(current_user: current_user)
private
if @variable.persisted?
redirect_to group_settings_ci_cd_path(group),
notice: 'Variable was successfully created.'
else
render "show"
end
def render_group_variables
render status: :ok, json: { variables: GroupVariableSerializer.new.represent(@group.variables) }
end
def destroy
if variable.destroy
redirect_to group_settings_ci_cd_path(group),
status: 302,
notice: 'Variable was successfully removed.'
else
redirect_to group_settings_ci_cd_path(group),
status: 302,
notice: 'Failed to remove the variable.'
end
def render_error
render status: :bad_request, json: @group.errors.full_messages
end
private
def variable_params
params.require(:variable).permit(*variable_params_attributes)
def group_variables_params
params.permit(variables_attributes: [*variable_params_attributes])
end
def variable_params_attributes
%i[key value protected]
end
def variable
@variable ||= group.variables.find(params[:id]).present(current_user: current_user)
%i[id key value protected _destroy]
end
def authorize_admin_build!
......
class Projects::VariablesController < Projects::ApplicationController
before_action :variable, only: [:show, :update, :destroy]
before_action :authorize_admin_build!
layout 'project_settings'
def index
redirect_to project_settings_ci_cd_path(@project)
end
def show
respond_to do |format|
format.json do
render status: :ok, json: { variables: VariableSerializer.new.represent(@project.variables) }
end
end
end
def update
if variable.update(variable_params)
redirect_to project_variables_path(project),
notice: 'Variable was successfully updated.'
if @project.update(variables_params)
respond_to do |format|
format.json { return render_variables }
end
else
render "show"
respond_to do |format|
format.json { render_error }
end
end
end
def create
@variable = project.variables.create(variable_params)
.present(current_user: current_user)
private
if @variable.persisted?
redirect_to project_settings_ci_cd_path(project),
notice: 'Variable was successfully created.'
else
render "show"
end
def render_variables
render status: :ok, json: { variables: VariableSerializer.new.represent(@project.variables) }
end
def destroy
if variable.destroy
redirect_to project_settings_ci_cd_path(project),
status: 302,
notice: 'Variable was successfully removed.'
else
redirect_to project_settings_ci_cd_path(project),
status: 302,
notice: 'Failed to remove the variable.'
end
def render_error
render status: :bad_request, json: @project.errors.full_messages
end
private
def variable_params
params.require(:variable).permit(*variable_params_attributes)
def variables_params
params.permit(variables_attributes: [*variable_params_attributes])
end
def variable_params_attributes
%i[id key value protected _destroy]
end
def variable
@variable ||= project.variables.find(params[:id]).present(current_user: current_user)
end
end
......@@ -31,9 +31,12 @@ class Group < Namespace
has_many :uploads, as: :model, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
accepts_nested_attributes_for :variables, allow_destroy: true
validate :visibility_level_allowed_by_projects
validate :visibility_level_allowed_by_sub_groups
validate :visibility_level_allowed_by_parent
validates :variables, variable_duplicates: true
validates :two_factor_grace_period, presence: true, numericality: { greater_than_or_equal_to: 0 }
......
......@@ -260,6 +260,7 @@ class Project < ActiveRecord::Base
validates :repository_storage,
presence: true,
inclusion: { in: ->(_object) { Gitlab.config.repositories.storages.keys } }
validates :variables, variable_duplicates: true
has_many :uploads, as: :model, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
......
......@@ -7,19 +7,15 @@ module Ci
end
def form_path
if variable.persisted?
group_variable_path(group, variable)
else
group_variables_path(group)
end
group_settings_ci_cd_path(group)
end
def edit_path
group_variable_path(group, variable)
group_variables_path(group)
end
def delete_path
group_variable_path(group, variable)
group_variables_path(group)
end
end
end
......@@ -7,19 +7,15 @@ module Ci
end
def form_path
if variable.persisted?
project_variable_path(project, variable)
else
project_variables_path(project)
end
project_settings_ci_cd_path(project)
end
def edit_path
project_variable_path(project, variable)
project_variables_path(project)
end
def delete_path
project_variable_path(project, variable)
project_variables_path(project)
end
end
end
class GroupVariableEntity < Grape::Entity
expose :id
expose :key
expose :value
expose :protected?, as: :protected
end
class GroupVariableSerializer < BaseSerializer
entity GroupVariableEntity
end
class VariableEntity < Grape::Entity
expose :id
expose :key
expose :value
expose :protected?, as: :protected
end
class VariableSerializer < BaseSerializer
entity VariableEntity
end
%p.append-bottom-default
Variables are applied to environments via the runner. They can be protected by only exposing them to protected branches or tags.
You can use variables for passwords, secret keys, or whatever you want.
= _('Variables are applied to environments via the runner. They can be protected by only exposing them to protected branches or tags. You can use variables for passwords, secret keys, or whatever you want.')
= form_for @variable, as: :variable, url: @variable.form_path do |f|
= form_errors(@variable)
.form-group
= f.label :key, "Key", class: "label-light"
= f.text_field :key, class: "form-control", placeholder: @variable.placeholder, required: true
.form-group
= f.label :value, "Value", class: "label-light"
= f.text_area :value, class: "form-control", placeholder: @variable.placeholder
.form-group
.checkbox
= f.label :protected do
= f.check_box :protected
%strong Protected
.help-block
This variable will be passed only to pipelines running on protected branches and tags
= link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'protected-secret-variables'), target: '_blank'
= f.submit btn_text, class: "btn btn-save"
.row.prepend-top-default.append-bottom-default
.col-lg-12
%h5.prepend-top-0
Add a variable
= render "ci/variables/form", btn_text: "Add new variable"
%hr
%h5.prepend-top-0
Your variables (#{@variables.size})
- if @variables.empty?
%p.settings-message.text-center.append-bottom-0
No variables found, add one with the form above.
- else
.js-secret-variable-table
= render "ci/variables/table"
%button.btn.btn-info.js-secret-value-reveal-button{ data: { secret_reveal_status: 'false' } }
- save_endpoint = local_assigns.fetch(:save_endpoint, nil)
.row
.col-lg-12.js-ci-variable-list-section{ data: { save_endpoint: save_endpoint } }
.hide.alert.alert-danger.js-ci-variable-error-box
%ul.ci-variable-list
- @variables.each.each do |variable|
= render 'ci/variables/variable_row', form_field: 'variables', variable: variable
= render 'ci/variables/variable_row', form_field: 'variables'
.prepend-top-20
%button.btn.btn-success.js-secret-variables-save-button{ type: 'button' }
%span.hide.js-secret-variables-save-loading-icon
= icon('spinner spin')
= _('Save variables')
%button.btn.btn-info.btn-inverted.prepend-left-10.js-secret-value-reveal-button{ type: 'button', data: { secret_reveal_status: "#{@variables.size == 0}" } }
- if @variables.size == 0
= n_('Hide value', 'Hide values', @variables.size)
- else
= n_('Reveal value', 'Reveal values', @variables.size)
- page_title "Variables"
.row.prepend-top-default.append-bottom-default
.col-lg-3
= render "ci/variables/content"
.col-lg-9
%h4.prepend-top-0
Update variable
= render "ci/variables/form", btn_text: "Save variable"
.table-responsive.variables-table
%table.table
%colgroup
%col
%col
%col
%col{ width: 100 }
%thead
%th Key
%th Value
%th Protected
%th
%tbody
- @variables.each do |variable|
- if variable.id?
%tr
%td.variable-key= variable.key
%td.variable-value
%span.js-secret-value-placeholder
= '*' * 6
%span.hide.js-secret-value
= variable.value
%td.variable-protected= Gitlab::Utils.boolean_to_yes_no(variable.protected)
%td.variable-menu
= link_to variable.edit_path, class: "btn btn-transparent btn-variable-edit" do
%span.sr-only
Update
= icon("pencil")