Commit a5aa40c5 authored by Matija Čupić's avatar Matija Čupić Committed by Phil Hughes

Add Job specific variables

Adds Job specific variables to facilitate specifying variables when
running manual jobs.
parent 946f7c06
<script> <script>
import { GlLink } from '@gitlab/ui'; import { GlLink } from '@gitlab/ui';
import ManualVariablesForm from './manual_variables_form.vue';
export default { export default {
components: { components: {
GlLink, GlLink,
ManualVariablesForm,
}, },
props: { props: {
illustrationPath: { illustrationPath: {
...@@ -23,6 +25,21 @@ export default { ...@@ -23,6 +25,21 @@ export default {
required: false, required: false,
default: null, default: null,
}, },
playable: {
type: Boolean,
required: true,
default: false,
},
scheduled: {
type: Boolean,
required: false,
default: false,
},
variablesSettingsUrl: {
type: String,
required: false,
default: null,
},
action: { action: {
type: Object, type: Object,
required: false, required: false,
...@@ -37,28 +54,40 @@ export default { ...@@ -37,28 +54,40 @@ export default {
}, },
}, },
}, },
computed: {
shouldRenderManualVariables() {
return this.playable && !this.scheduled;
},
},
}; };
</script> </script>
<template> <template>
<div class="row empty-state"> <div class="row empty-state">
<div class="col-12"> <div class="col-12">
<div :class="illustrationSizeClass" class="svg-content"><img :src="illustrationPath" /></div> <div :class="illustrationSizeClass" class="svg-content">
<img :src="illustrationPath" />
</div>
</div> </div>
<div class="col-12"> <div class="col-12">
<div class="text-content"> <div class="text-content">
<h4 class="js-job-empty-state-title text-center">{{ title }}</h4> <h4 class="js-job-empty-state-title text-center">{{ title }}</h4>
<p v-if="content" class="js-job-empty-state-content text-center">{{ content }}</p> <p v-if="content" class="js-job-empty-state-content">{{ content }}</p>
</div>
<manual-variables-form
v-if="shouldRenderManualVariables"
:action="action"
:variables-settings-url="variablesSettingsUrl"
/>
<div class="text-content">
<div v-if="action" class="text-center"> <div v-if="action" class="text-center">
<gl-link <gl-link
:href="action.path" :href="action.path"
:data-method="action.method" :data-method="action.method"
class="js-job-empty-state-action btn btn-primary" class="js-job-empty-state-action btn btn-primary"
>{{ action.button_title }}</gl-link
> >
{{ action.button_title }}
</gl-link>
</div> </div>
</div> </div>
</div> </div>
......
...@@ -45,6 +45,11 @@ export default { ...@@ -45,6 +45,11 @@ export default {
required: false, required: false,
default: null, default: null,
}, },
variablesSettingsUrl: {
type: String,
required: false,
default: null,
},
runnerHelpUrl: { runnerHelpUrl: {
type: String, type: String,
required: false, required: false,
...@@ -313,6 +318,9 @@ export default { ...@@ -313,6 +318,9 @@ export default {
:title="emptyStateTitle" :title="emptyStateTitle"
:content="emptyStateIllustration.content" :content="emptyStateIllustration.content"
:action="emptyStateAction" :action="emptyStateAction"
:playable="job.playable"
:scheduled="job.scheduled"
:variables-settings-url="variablesSettingsUrl"
/> />
<!-- EO empty state --> <!-- EO empty state -->
......
<script>
import _ from 'underscore';
import { mapActions } from 'vuex';
import { GlButton } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
export default {
name: 'ManualVariablesForm',
components: {
GlButton,
Icon,
},
props: {
action: {
type: Object,
required: false,
default: null,
validator(value) {
return (
value === null ||
(_.has(value, 'path') && _.has(value, 'method') && _.has(value, 'button_title'))
);
},
},
variablesSettingsUrl: {
type: String,
required: true,
default: '',
},
},
inputTypes: {
key: 'key',
value: 'value',
},
i18n: {
keyPlaceholder: s__('CiVariables|Input variable key'),
valuePlaceholder: s__('CiVariables|Input variable value'),
},
data() {
return {
variables: [],
key: '',
secretValue: '',
};
},
computed: {
helpText() {
return sprintf(
s__(
'CiVariables|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used as default',
),
{
linkStart: `<a href="${this.variablesSettingsUrl}">`,
linkEnd: '</a>',
},
false,
);
},
},
watch: {
key(newVal) {
this.handleValueChange(newVal, this.$options.inputTypes.key);
},
secretValue(newVal) {
this.handleValueChange(newVal, this.$options.inputTypes.value);
},
},
methods: {
...mapActions(['triggerManualJob']),
handleValueChange(newValue, type) {
if (newValue !== '') {
this.createNewVariable(type);
this.resetForm();
}
},
createNewVariable(type) {
const newVariable = {
key: this.key,
secret_value: this.secretValue,
id: _.uniqueId(),
};
this.variables.push(newVariable);
return this.$nextTick().then(() => {
this.$refs[`${this.$options.inputTypes[type]}-${newVariable.id}`][0].focus();
});
},
resetForm() {
this.key = '';
this.secretValue = '';
},
deleteVariable(id) {
this.variables.splice(this.variables.findIndex(el => el.id === id), 1);
},
},
};
</script>
<template>
<div class="js-manual-vars-form col-12">
<label>{{ s__('CiVariables|Variables') }}</label>
<div class="ci-table">
<div class="gl-responsive-table-row table-row-header pb-0 pt-0 border-0" role="row">
<div class="table-section section-50" role="rowheader">{{ s__('CiVariables|Key') }}</div>
<div class="table-section section-50" role="rowheader">{{ s__('CiVariables|Value') }}</div>
</div>
<div v-for="variable in variables" :key="variable.id" class="gl-responsive-table-row">
<div class="table-section section-50">
<div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Key') }}</div>
<div class="table-mobile-content append-right-10">
<input
:ref="`${$options.inputTypes.key}-${variable.id}`"
v-model="variable.key"
:placeholder="$options.i18n.keyPlaceholder"
class="ci-variable-body-item form-control"
/>
</div>
</div>
<div class="table-section section-50">
<div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Value') }}</div>
<div class="table-mobile-content append-right-10">
<input
:ref="`${$options.inputTypes.value}-${variable.id}`"
v-model="variable.secret_value"
:placeholder="$options.i18n.valuePlaceholder"
class="ci-variable-body-item form-control"
/>
</div>
</div>
<div class="table-section section-10">
<div class="table-mobile-header" role="rowheader"></div>
<div class="table-mobile-content justify-content-end">
<gl-button class="btn-transparent btn-blank w-25" @click="deleteVariable(variable.id)">
<icon name="clear" />
</gl-button>
</div>
</div>
</div>
<div class="gl-responsive-table-row">
<div class="table-section section-50">
<div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Key') }}</div>
<div class="table-mobile-content append-right-10">
<input
ref="inputKey"
v-model="key"
class="js-input-key form-control"
:placeholder="$options.i18n.keyPlaceholder"
/>
</div>
</div>
<div class="table-section section-50">
<div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Value') }}</div>
<div class="table-mobile-content append-right-10">
<input
ref="inputSecretValue"
v-model="secretValue"
class="ci-variable-body-item form-control"
:placeholder="$options.i18n.valuePlaceholder"
/>
</div>
</div>
</div>
</div>
<div class="d-flex prepend-top-default justify-content-center">
<p class="text-muted" v-html="helpText"></p>
</div>
<div class="d-flex justify-content-center">
<gl-button variant="primary" @click="triggerManualJob(variables)">
{{ action.button_title }}
</gl-button>
</div>
</div>
</template>
...@@ -15,6 +15,7 @@ export default () => { ...@@ -15,6 +15,7 @@ export default () => {
deploymentHelpUrl: element.dataset.deploymentHelpUrl, deploymentHelpUrl: element.dataset.deploymentHelpUrl,
runnerHelpUrl: element.dataset.runnerHelpUrl, runnerHelpUrl: element.dataset.runnerHelpUrl,
runnerSettingsUrl: element.dataset.runnerSettingsUrl, runnerSettingsUrl: element.dataset.runnerSettingsUrl,
variablesSettingsUrl: element.dataset.variablesSettingsUrl,
endpoint: element.dataset.endpoint, endpoint: element.dataset.endpoint,
pagePath: element.dataset.buildOptionsPagePath, pagePath: element.dataset.buildOptionsPagePath,
logState: element.dataset.buildOptionsLogState, logState: element.dataset.buildOptionsLogState,
......
...@@ -209,5 +209,19 @@ export const receiveJobsForStageError = ({ commit }) => { ...@@ -209,5 +209,19 @@ export const receiveJobsForStageError = ({ commit }) => {
flash(__('An error occurred while fetching the jobs.')); flash(__('An error occurred while fetching the jobs.'));
}; };
export const triggerManualJob = ({ state }, variables) => {
const parsedVariables = variables.map(variable => {
const copyVar = Object.assign({}, variable);
delete copyVar.id;
return copyVar;
});
axios
.post(state.job.status.action.path, {
job_variables_attributes: parsedVariables,
})
.catch(() => flash(__('An error occurred while triggering the job.')));
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests // prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {}; export default () => {};
...@@ -94,7 +94,7 @@ class Projects::JobsController < Projects::ApplicationController ...@@ -94,7 +94,7 @@ class Projects::JobsController < Projects::ApplicationController
def play def play
return respond_422 unless @build.playable? return respond_422 unless @build.playable?
build = @build.play(current_user) build = @build.play(current_user, play_params[:job_variables_attributes])
redirect_to build_path(build) redirect_to build_path(build)
end end
...@@ -190,6 +190,10 @@ class Projects::JobsController < Projects::ApplicationController ...@@ -190,6 +190,10 @@ class Projects::JobsController < Projects::ApplicationController
{ query: { 'response-content-type' => 'text/plain; charset=utf-8', 'response-content-disposition' => 'inline' } } { query: { 'response-content-type' => 'text/plain; charset=utf-8', 'response-content-disposition' => 'inline' } }
end end
def play_params
params.permit(job_variables_attributes: %i[key secret_value])
end
def trace_artifact_file def trace_artifact_file
@trace_artifact_file ||= build.job_artifacts_trace&.file @trace_artifact_file ||= build.job_artifacts_trace&.file
end end
......
...@@ -40,6 +40,7 @@ module Ci ...@@ -40,6 +40,7 @@ module Ci
has_many :trace_chunks, class_name: 'Ci::BuildTraceChunk', foreign_key: :build_id has_many :trace_chunks, class_name: 'Ci::BuildTraceChunk', foreign_key: :build_id
has_many :job_artifacts, class_name: 'Ci::JobArtifact', foreign_key: :job_id, dependent: :destroy, inverse_of: :job # rubocop:disable Cop/ActiveRecordDependent has_many :job_artifacts, class_name: 'Ci::JobArtifact', foreign_key: :job_id, dependent: :destroy, inverse_of: :job # rubocop:disable Cop/ActiveRecordDependent
has_many :job_variables, class_name: 'Ci::JobVariable', foreign_key: :job_id
Ci::JobArtifact.file_types.each do |key, value| Ci::JobArtifact.file_types.each do |key, value|
has_one :"job_artifacts_#{key}", -> { where(file_type: value) }, class_name: 'Ci::JobArtifact', inverse_of: :job, foreign_key: :job_id has_one :"job_artifacts_#{key}", -> { where(file_type: value) }, class_name: 'Ci::JobArtifact', inverse_of: :job, foreign_key: :job_id
...@@ -48,6 +49,7 @@ module Ci ...@@ -48,6 +49,7 @@ module Ci
has_one :runner_session, class_name: 'Ci::BuildRunnerSession', validate: true, inverse_of: :build has_one :runner_session, class_name: 'Ci::BuildRunnerSession', validate: true, inverse_of: :build
accepts_nested_attributes_for :runner_session accepts_nested_attributes_for :runner_session
accepts_nested_attributes_for :job_variables
delegate :url, to: :runner_session, prefix: true, allow_nil: true delegate :url, to: :runner_session, prefix: true, allow_nil: true
delegate :terminal_specification, to: :runner_session, allow_nil: true delegate :terminal_specification, to: :runner_session, allow_nil: true
...@@ -331,10 +333,10 @@ module Ci ...@@ -331,10 +333,10 @@ module Ci
end end
# rubocop: disable CodeReuse/ServiceClass # rubocop: disable CodeReuse/ServiceClass
def play(current_user) def play(current_user, job_variables_attributes = nil)
Ci::PlayBuildService Ci::PlayBuildService
.new(project, current_user) .new(project, current_user)
.execute(self) .execute(self, job_variables_attributes)
end end
# rubocop: enable CodeReuse/ServiceClass # rubocop: enable CodeReuse/ServiceClass
...@@ -432,6 +434,7 @@ module Ci ...@@ -432,6 +434,7 @@ module Ci
Gitlab::Ci::Variables::Collection.new Gitlab::Ci::Variables::Collection.new
.concat(persisted_variables) .concat(persisted_variables)
.concat(scoped_variables) .concat(scoped_variables)
.concat(job_variables)
.concat(persisted_environment_variables) .concat(persisted_environment_variables)
.to_runner_variables .to_runner_variables
end end
......
# frozen_string_literal: true
module Ci
class JobVariable < ApplicationRecord
extend Gitlab::Ci::Model
include NewHasVariable
belongs_to :job, class_name: "Ci::Build", foreign_key: :job_id
alias_attribute :secret_value, :value
validates :key, uniqueness: { scope: :job_id }
end
end
# frozen_string_literal: true
module NewHasVariable
extend ActiveSupport::Concern
include HasVariable
included do
attr_encrypted :value,
mode: :per_attribute_iv,
algorithm: 'aes-256-gcm',
key: Settings.attr_encrypted_db_key_base_32,
insecure_mode: false
end
end
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
module Ci module Ci
class PlayBuildService < ::BaseService class PlayBuildService < ::BaseService
def execute(build) def execute(build, job_variables_attributes = nil)
unless can?(current_user, :update_build, build) unless can?(current_user, :update_build, build)
raise Gitlab::Access::AccessDeniedError raise Gitlab::Access::AccessDeniedError
end end
...@@ -10,7 +10,7 @@ module Ci ...@@ -10,7 +10,7 @@ module Ci
# Try to enqueue the build, otherwise create a duplicate. # Try to enqueue the build, otherwise create a duplicate.
# #
if build.enqueue if build.enqueue
build.tap { |action| action.update(user: current_user) } build.tap { |action| action.update(user: current_user, job_variables_attributes: job_variables_attributes || []) }
else else
Ci::Build.retry(build, current_user) Ci::Build.retry(build, current_user)
end end
......
...@@ -11,4 +11,5 @@ ...@@ -11,4 +11,5 @@
deployment_help_url: help_page_path('user/project/clusters/index.html', anchor: 'troubleshooting-failed-deployment-jobs'), deployment_help_url: help_page_path('user/project/clusters/index.html', anchor: 'troubleshooting-failed-deployment-jobs'),
runner_help_url: help_page_path('ci/runners/README.html', anchor: 'setting-maximum-job-timeout-for-a-runner'), runner_help_url: help_page_path('ci/runners/README.html', anchor: 'setting-maximum-job-timeout-for-a-runner'),
runner_settings_url: project_runners_path(@build.project, anchor: 'js-runners-settings'), runner_settings_url: project_runners_path(@build.project, anchor: 'js-runners-settings'),
variables_settings_url: project_variables_path(@build.project, anchor: 'js-cicd-variables-settings'),
build_options: javascript_build_options } } build_options: javascript_build_options } }
...@@ -41,7 +41,7 @@ ...@@ -41,7 +41,7 @@
.settings-content .settings-content
= render 'projects/runners/index' = render 'projects/runners/index'
%section.settings.no-animate{ class: ('expanded' if expanded), data: { qa_selector: 'variables_settings_content' } } %section.qa-variables-settings.settings.no-animate#js-cicd-variables-settings{ class: ('expanded' if expanded), data: { qa_selector: 'variables_settings_content' } }
.settings-header .settings-header
= render 'ci/variables/header', expanded: expanded = render 'ci/variables/header', expanded: expanded
.settings-content .settings-content
......
---
title: Allow specifying variables when running manual jobs
merge_request: 30485
author:
type: added
# frozen_string_literal: true
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class CreateJobVariables < ActiveRecord::Migration[5.1]
include Gitlab::Database::MigrationHelpers
# Set this constant to true if this migration requires downtime.
DOWNTIME = false
def change
create_table :ci_job_variables do |t|
t.string :key, null: false
t.text :encrypted_value
t.string :encrypted_value_iv
t.references :job, null: false, index: true, foreign_key: { to_table: :ci_builds, on_delete: :cascade }
t.integer :variable_type, null: false, limit: 2, default: 1
end
add_index :ci_job_variables, [:key, :job_id], unique: true
end
end
...@@ -605,6 +605,16 @@ ActiveRecord::Schema.define(version: 2019_07_25_012225) do ...@@ -605,6 +605,16 @@ ActiveRecord::Schema.define(version: 2019_07_25_012225) do
t.index ["project_id"], name: "index_ci_job_artifacts_on_project_id" t.index ["project_id"], name: "index_ci_job_artifacts_on_project_id"
end end
create_table "ci_job_variables", force: :cascade do |t|
t.string "key", null: false
t.text "encrypted_value"
t.string "encrypted_value_iv"
t.bigint "job_id", null: false
t.integer "variable_type", limit: 2, default: 1, null: false
t.index ["job_id"], name: "index_ci_job_variables_on_job_id"
t.index ["key", "job_id"], name: "index_ci_job_variables_on_key_and_job_id", unique: true
end
create_table "ci_pipeline_chat_data", force: :cascade do |t| create_table "ci_pipeline_chat_data", force: :cascade do |t|
t.integer "pipeline_id", null: false t.integer "pipeline_id", null: false
t.integer "chat_name_id", null: false t.integer "chat_name_id", null: false
...@@ -3637,6 +3647,7 @@ ActiveRecord::Schema.define(version: 2019_07_25_012225) do ...@@ -3637,6 +3647,7 @@ ActiveRecord::Schema.define(version: 2019_07_25_012225) do
add_foreign_key "ci_group_variables", "namespaces", column: "group_id", name: "fk_33ae4d58d8", on_delete: :cascade add_foreign_key "ci_group_variables", "namespaces", column: "group_id", name: "fk_33ae4d58d8", on_delete: :cascade
add_foreign_key "ci_job_artifacts", "ci_builds", column: "job_id", on_delete: :cascade add_foreign_key "ci_job_artifacts", "ci_builds", column: "job_id", on_delete: :cascade
add_foreign_key "ci_job_artifacts", "projects", on_delete: :cascade add_foreign_key "ci_job_artifacts", "projects", on_delete: :cascade
add_foreign_key "ci_job_variables", "ci_builds", column: "job_id", on_delete: :cascade
add_foreign_key "ci_pipeline_chat_data", "chat_names", on_delete: :cascade add_foreign_key "ci_pipeline_chat_data", "chat_names", on_delete: :cascade
add_foreign_key "ci_pipeline_chat_data", "ci_pipelines", column: "pipeline_id", on_delete: :cascade add_foreign_key "ci_pipeline_chat_data", "ci_pipelines", column: "pipeline_id", on_delete: :cascade
add_foreign_key "ci_pipeline_schedule_variables", "ci_pipeline_schedules", column: "pipeline_schedule_id", name: "fk_41c35fda51", on_delete: :cascade add_foreign_key "ci_pipeline_schedule_variables", "ci_pipeline_schedules", column: "pipeline_schedule_id", name: "fk_41c35fda51", on_delete: :cascade
......
...@@ -323,6 +323,20 @@ stage has a job with a manual action. ...@@ -323,6 +323,20 @@ stage has a job with a manual action.
![Pipelines example](img/pipelines.png) ![Pipelines example](img/pipelines.png)
### Specifying variables when running manual jobs
> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/30485) in GitLab 12.2.
When running manual jobs you can supply additional job specific variables.
You can do this from the job page of the manual job you want to run with
additional variables.
This is useful when you want to alter the execution of a job by using
environment variables.
![Manual job variables](img/manual_job_variables.png)
### Delay a job in a pipeline graph ### Delay a job in a pipeline graph
> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/21767) in GitLab 11.4. > [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/21767) in GitLab 11.4.
......
...@@ -10,7 +10,7 @@ module Gitlab ...@@ -10,7 +10,7 @@ module Gitlab
image: 'illustrations/manual_action.svg', image: 'illustrations/manual_action.svg',
size: 'svg-394', size: 'svg-394',
title: _('This job requires a manual action'), title: _('This job requires a manual action'),
content: _('This job depends on a user to trigger its process. Often they are used to deploy code to production environments') content: _('This job requires manual intervention to start. Before starting this job, you can add variables below for last-minute configuration changes.')
} }
end end
......
...@@ -1080,6 +1080,9 @@ msgstr "" ...@@ -1080,6 +1080,9 @@ msgstr ""
msgid "An error occurred while saving assignees" msgid "An error occurred while saving assignees"
msgstr "" msgstr ""
msgid "An error occurred while triggering the job."
msgstr ""
msgid "An error occurred while validating username" msgid "An error occurred while validating username"
msgstr "" msgstr ""
...@@ -2221,6 +2224,9 @@ msgstr "" ...@@ -2221,6 +2224,9 @@ msgstr ""
msgid "CiVariables|Remove variable row" msgid "CiVariables|Remove variable row"
msgstr "" msgstr ""
msgid "CiVariables|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used as default"
msgstr ""
msgid "CiVariables|State" msgid "CiVariables|State"
msgstr "" msgstr ""
...@@ -2230,6 +2236,9 @@ msgstr "" ...@@ -2230,6 +2236,9 @@ msgstr ""
msgid "CiVariables|Value" msgid "CiVariables|Value"
msgstr "" msgstr ""
msgid "CiVariables|Variables"
msgstr ""
msgid "CiVariable|* (All environments)" msgid "CiVariable|* (All environments)"
msgstr "" msgstr ""
...@@ -7697,6 +7706,9 @@ msgstr "" ...@@ -7697,6 +7706,9 @@ msgstr ""
msgid "Pipeline|Existing branch name or tag" msgid "Pipeline|Existing branch name or tag"
msgstr "" msgstr ""
msgid "Pipeline|Key"
msgstr ""
msgid "Pipeline|Pipeline" msgid "Pipeline|Pipeline"
msgstr "" msgstr ""
...@@ -7727,6 +7739,9 @@ msgstr "" ...@@ -7727,6 +7739,9 @@ msgstr ""
msgid "Pipeline|Triggerer" msgid "Pipeline|Triggerer"
msgstr "" msgstr ""
msgid "Pipeline|Value"
msgstr ""
msgid "Pipeline|Variables" msgid "Pipeline|Variables"
msgstr "" msgstr ""
...@@ -11056,9 +11071,6 @@ msgstr "" ...@@ -11056,9 +11071,6 @@ msgstr ""
msgid "This issue is locked." msgid "This issue is locked."
msgstr "" msgstr ""
msgid "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
msgstr ""
msgid "This job depends on upstream jobs that need to succeed in order for this job to be triggered" msgid "This job depends on upstream jobs that need to succeed in order for this job to be triggered"
msgstr "" msgstr ""
...@@ -11113,6 +11125,9 @@ msgstr "" ...@@ -11113,6 +11125,9 @@ msgstr ""
msgid "This job requires a manual action" msgid "This job requires a manual action"
msgstr "" msgstr ""
msgid "This job requires manual intervention to start. Before starting this job, you can add variables below for last-minute configuration changes."
msgstr ""
msgid "This job will automatically run after its timer finishes. Often they are used for incremental roll-out deploys to production environments. When unscheduled it converts into a manual action." msgid "This job will automatically run after its timer finishes. Often they are used for incremental roll-out deploys to production environments. When unscheduled it converts into a manual action."
msgstr "" msgstr ""
......
...@@ -676,6 +676,8 @@ describe Projects::JobsController, :clean_gitlab_redis_shared_state do ...@@ -676,6 +676,8 @@ describe Projects::JobsController, :clean_gitlab_redis_shared_state do
end end
describe 'POST play' do describe 'POST play' do
let(:variable_attributes) { [] }
before do before do
project.add_developer(user) project.add_developer(user)
...@@ -698,6 +700,14 @@ describe Projects::JobsController, :clean_gitlab_redis_shared_state do ...@@ -698,6 +700,14 @@ describe Projects::JobsController, :clean_gitlab_redis_shared_state do
it 'transits to pending' do it 'transits to pending' do
expect(job.reload).to be_pending expect(job.reload).to be_pending
end end
context 'when job variables are specified' do
let(:variable_attributes) { [{ key: 'first', secret_value: 'first' }] }
it 'assigns the job variables' do
expect(job.reload.job_variables.map(&:key)).to contain_exactly('first')
end
end
end end
context 'when job is not playable' do context 'when job is not playable' do
...@@ -712,7 +722,8 @@ describe Projects::JobsController, :clean_gitlab_redis_shared_state do ...@@ -712,7 +722,8 @@ describe Projects::JobsController, :clean_gitlab_redis_shared_state do
post :play, params: { post :play, params: {
namespace_id: project.namespace, namespace_id: project.namespace,
project_id: project, project_id: project,
id: job.id id: job.id,
job_variables_attributes: variable_attributes
} }
end end
end end
......
# frozen_string_literal: true
FactoryBot.define do
factory :ci_job_variable, class: Ci::JobVariable do
sequence(:key) { |n| "VARIABLE_#{n}" }
value 'VARIABLE_VALUE'
job factory: :ci_build
end
end
...@@ -701,12 +701,12 @@ describe 'Jobs', :clean_gitlab_redis_shared_state do ...@@ -701,12 +701,12 @@ describe 'Jobs', :clean_gitlab_redis_shared_state do
it 'shows manual action empty state', :js do it 'shows manual action empty state', :js do
expect(page).to have_content(job.detailed_status(user).illustration[:title]) expect(page).to have_content(job.detailed_status(user).illustration[:title])
expect(page).to have_content('This job requires a manual action') expect(page).to have_content('This job requires a manual action')
expect(page).to have_content('This job depends on a user to trigger its process. Often they are used to deploy code to production environments') expect(page).to have_content('This job requires manual intervention to start. Before starting this job, you can add variables below for last-minute configuration changes.')
expect(page).to have_link('Trigger this manual action') expect(page).to have_button('Trigger this manual action')
end end
it 'plays manual action and shows pending status', :js do it 'plays manual action and shows pending status', :js do
click_link 'Trigger this manual action' click_button 'Trigger this manual action'
wait_for_requests wait_for_requests
expect(page).to have_content('This job has not started yet') expect(page).to have_content('This job has not started yet')
...@@ -734,8 +734,8 @@ describe 'Jobs', :clean_gitlab_redis_shared_state do ...@@ -734,8 +734,8 @@ describe 'Jobs', :clean_gitlab_redis_shared_state do
wait_for_requests wait_for_requests
expect(page).to have_content('This job requires a manual action') expect(page).to have_content('This job requires a manual action')
expect(page).to have_content('This job depends on a user to trigger its process. Often they are used to deploy code to production environments') expect(page).to have_content('This job requires manual intervention to start. Before starting this job, you can add variables below for last-minute configuration changes.')
expect(page).to have_link('Trigger this manual action') expect(page).to have_button('Trigger this manual action')
end end
end end
......
...@@ -10,6 +10,8 @@ describe('Empty State', () => { ...@@ -10,6 +10,8 @@ describe('Empty State', () => {
illustrationPath: 'illustrations/pending_job_empty.svg', illustrationPath: 'illustrations/pending_job_empty.svg',
illustrationSizeClass: 'svg-430', illustrationSizeClass: 'svg-430',
title: 'This job has not started yet', title: 'This job has not started yet',
playable: false,
variablesSettingsUrl: '',
}; };
const content = 'This job is in pending state and is waiting to be picked by a runner'; const content = 'This job is in pending state and is waiting to be picked by a runner';
...@@ -90,4 +92,44 @@ describe('Empty State', () => { ...@@ -90,4 +92,44 @@ describe('Empty State', () => {
expect(vm.$el.querySelector('.js-job-empty-state-action')).toBeNull(); expect(vm.$el.querySelector('.js-job-empty-state-action')).toBeNull();
}); });
}); });
describe('without playbale action', () => {
it('does not render manual variables form', () => {
vm = mountComponent(Component, {
...props,
content,
});
expect(vm.$el.querySelector('.js-manual-vars-form')).toBeNull();
});
});
describe('with playbale action and not scheduled job', () => {
it('renders manual variables form', () => {
vm = mountComponent(Component, {
...props,
content,
playable: true,
scheduled: false,
action: {
path: 'runner',
button_title: 'Check runner',
method: 'post',
},
});
expect(vm.$el.querySelector('.js-manual-vars-form')).not.toBeNull();
});
});
describe('with playbale action and scheduled job', () => {
it('does not render manual variables form', () => {
vm = mountComponent(Component, {
...props,
content,
});
expect(vm.$el.querySelector('.js-manual-vars-form')).toBeNull();
});
});
}); });
...@@ -19,6 +19,7 @@ describe('Job App ', () => { ...@@ -19,6 +19,7 @@ describe('Job App ', () => {
runnerHelpUrl: 'help/runner', runnerHelpUrl: 'help/runner',
deploymentHelpUrl: 'help/deployment', deploymentHelpUrl: 'help/deployment',
runnerSettingsUrl: 'settings/ci-cd/runners', runnerSettingsUrl: 'settings/ci-cd/runners',
variablesSettingsUrl: 'settings/ci-cd/variables',
terminalPath: 'jobs/123/terminal', terminalPath: 'jobs/123/terminal',
pagePath: `${gl.TEST_HOST}jobs/123`, pagePath: `${gl.TEST_HOST}jobs/123`,
logState: logState:
......
import { shallowMount } from '@vue/test-utils';
import { GlButton } from '@gitlab/ui';
import Form from '~/jobs/components/manual_variables_form.vue';
describe('Manual Variables Form', () => {
let wrapper;
const requiredProps = {
action: {
path: '/play',
method: 'post',
button_title: 'Trigger this manual action',
},
variablesSettingsUrl: '/settings',
};
const factory = (props = {}) => {
wrapper = shallowMount(Form, {
propsData: props,
});
};
beforeEach(() => {
factory(requiredProps);
});
afterEach(() => {
wrapper.destroy();
});
it('renders empty form with correct placeholders', () => {
expect(wrapper.find({ ref: 'inputKey' }).attributes('placeholder')).toBe('Input variable key');
expect(wrapper.find({ ref: 'inputSecretValue' }).attributes('placeholder')).toBe(
'Input variable value',
);
});
it('renders help text with provided link', () => {
expect(wrapper.find('p').text()).toBe(
'Specify variable values to be used in this run. The values specified in CI/CD settings will be used as default',
);
expect(wrapper.find('a').attributes('href')).toBe(requiredProps.variablesSettingsUrl);
});
describe('when adding a new variable', () => {
it('creates a new variable when user types a new key and resets the form', done => {
wrapper.vm
.$nextTick()
.then(() => wrapper.find({ ref: 'inputKey' }).setValue('new key'))
.then(() => {
expect(wrapper.vm.variables.length).toBe(1);
expect(wrapper.vm.variables[0].key).toBe('new key');
expect(wrapper.find({ ref: 'inputKey' }).attributes('value')).toBe(undefined);
})
.then(done)
.catch(done.fail);
});
it('creates a new variable when user types a new value and resets the form', done => {
wrapper.vm
.$nextTick()
.then(() => wrapper.find({ ref: 'inputSecretValue' }).setValue('new value'))
.then(() => {
expect(wrapper.vm.variables.length).toBe(1);
expect(wrapper.vm.variables[0].secret_value).toBe('new value');
expect(wrapper.find({ ref: 'inputSecretValue' }).attributes('value')).toBe(undefined);
})
.then(done)
.catch(done.fail);
});
});
describe('when deleting a variable', () => {
it('removes the variable row', () => {
wrapper.vm.variables = [
{
key: 'new key',
secret_value: 'value',
id: '1',
},
];
wrapper.find(GlButton).vm.$emit('click');
expect(wrapper.vm.variables.length).toBe(0);
});
});
});
...@@ -21,7 +21,8 @@ describe Ci::Build do ...@@ -21,7 +21,8 @@ describe Ci::Build do
it { is_expected.to belong_to(:erased_by) } it { is_expected.to belong_to(:erased_by) }
it { is_expected.to have_many(:trace_sections)} it { is_expected.to have_many(:trace_sections)}
it { is_expected.to have_one(:deployment) } it { is_expected.to have_one(:deployment) }
it { is_expected.to have_one(:runner_session)} it { is_expected.to have_one(:runner_session) }
it { is_expected.to have_many(:job_variables) }
it { is_expected.to validate_presence_of(:ref) } it { is_expected.to validate_presence_of(:ref) }
it { is_expected.to respond_to(:has_trace?) } it { is_expected.to respond_to(:has_trace?) }
it { is_expected.to respond_to(:trace) } it { is_expected.to respond_to(:trace) }
...@@ -2258,6 +2259,16 @@ describe Ci::Build do ...@@ -2258,6 +2259,16 @@ describe Ci::Build do
it { is_expected.to include(manual_variable) } it { is_expected.to include(manual_variable) }
end end
context 'when job variable is defined' do
let(:job_variable) { { key: 'first', value: 'first', public: false, masked: false } }
before do
create(:ci_job_variable, job_variable.slice(:key, :value).merge(job: build))
end
it { is_expected.to include(job_variable) }
end
context 'when build is for tag' do context 'when build is for tag' do
let(:tag_variable) do let(:tag_variable) do
{ key: 'CI_COMMIT_TAG', value: 'master', public: true, masked: false } { key: 'CI_COMMIT_TAG', value: 'master', public: true, masked: false }
......
# frozen_string_literal: true
require 'spec_helper'
describe Ci::JobVariable do
subject { build(:ci_job_variable) }
it_behaves_like "CI variable"
it { is_expected.to belong_to(:job) }
it { is_expected.to validate_uniqueness_of(:key).scoped_to(:job_id) }
end
...@@ -60,6 +60,19 @@ describe Ci::PlayBuildService, '#execute' do ...@@ -60,6 +60,19 @@ describe Ci::PlayBuildService, '#execute' do
expect(build.reload.user).to eq user expect(build.reload.user).to eq user
end end
context 'when variables are supplied' do
let(:job_variables) do
[{ key: 'first', secret_value: 'first' },
{ key: 'second', secret_value: 'second' }]
end
it 'assigns the variables to the build' do
service.execute(build, job_variables)
expect(build.reload.job_variables.map(&:key)).to contain_exactly('first', 'second')
end
end
end end
context 'when build is not a playable manual action' do context 'when build is not a playable manual action' do
......
...@@ -30,7 +30,8 @@ describe Ci::RetryBuildService do ...@@ -30,7 +30,8 @@ describe Ci::RetryBuildService do
job_artifacts_sast job_artifacts_dependency_scanning job_artifacts_sast job_artifacts_dependency_scanning
job_artifacts_container_scanning job_artifacts_dast job_artifacts_container_scanning job_artifacts_dast
job_artifacts_license_management job_artifacts_performance job_artifacts_license_management job_artifacts_performance
job_artifacts_codequality job_artifacts_metrics scheduled_at].freeze job_artifacts_codequality job_artifacts_metrics scheduled_at
job_variables].freeze
IGNORE_ACCESSORS = IGNORE_ACCESSORS =
%i[type lock_version target_url base_tags trace_sections %i[type lock_version target_url base_tags trace_sections
...@@ -65,6 +66,8 @@ describe Ci::RetryBuildService do ...@@ -65,6 +66,8 @@ describe Ci::RetryBuildService do
file_type: file_type, job: build, expire_at: build.artifacts_expire_at) file_type: file_type, job: build, expire_at: build.artifacts_expire_at)
end end
create(:ci_job_variable, job: build)
build.reload build.reload
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