Commit 961005be authored by Mark Chao's avatar Mark Chao

Merge branch '226982-custom-schemas-backend' into 'master'

[BE] WebIDE: Custom schema validation [RUN AS-IF-FOSS]

Closes #232761

See merge request gitlab-org/gitlab!40496
parents d27dcf38 7f26f6e9
# frozen_string_literal: true
class Projects::WebIdeSchemasController < Projects::ApplicationController
before_action :authenticate_user!
def show
return respond_422 unless branch_sha
result = ::Ide::SchemasConfigService.new(project, current_user, sha: branch_sha, filename: params[:filename]).execute
if result[:status] == :success
render json: result[:schema]
else
render json: result, status: :unprocessable_entity
end
end
private
def branch_sha
return unless params[:branch].present?
project.commit(params[:branch])&.id
end
end
......@@ -11,7 +11,7 @@ class Projects::WebIdeTerminalsController < Projects::ApplicationController
def check_config
return respond_422 unless branch_sha
result = ::Ci::WebIdeConfigService.new(project, current_user, sha: branch_sha).execute
result = ::Ide::TerminalConfigService.new(project, current_user, sha: branch_sha).execute
if result[:status] == :success
head :ok
......
......@@ -70,7 +70,7 @@ module Ci
end
def load_terminal_config!
result = ::Ci::WebIdeConfigService.new(project, current_user, sha: sha).execute
result = ::Ide::TerminalConfigService.new(project, current_user, sha: sha).execute
raise TerminalCreationError, result[:message] if result[:status] != :success
@terminal = result[:terminal]
......
# frozen_string_literal: true
module Ci
class WebIdeConfigService < ::BaseService
include ::Gitlab::Utils::StrongMemoize
module Ide
class BaseConfigService < ::BaseService
ValidationError = Class.new(StandardError)
WEBIDE_CONFIG_FILE = '.gitlab/.gitlab-webide.yml'.freeze
......@@ -11,15 +9,21 @@ module Ci
attr_reader :config, :config_content
def execute
check_access!
load_config_content!
load_config!
check_access_and_load_config!
success(terminal: config.terminal_value)
success
rescue ValidationError => e
error(e.message)
end
protected
def check_access_and_load_config!
check_access!
load_config_content!
load_config!
end
private
def check_access!
......
# frozen_string_literal: true
module Ide
class SchemasConfigService < ::Ide::BaseConfigService
PREDEFINED_SCHEMAS = [{
uri: 'https://json.schemastore.org/gitlab-ci',
match: ['*.gitlab-ci.yml']
}].freeze
def execute
schema = predefined_schema_for(params[:filename]) || {}
success(schema: schema)
rescue => e
error(e.message)
end
private
def find_schema(filename, schemas)
match_flags = ::File::FNM_DOTMATCH | ::File::FNM_PATHNAME
schemas.each do |schema|
match = schema[:match].any? { |pattern| ::File.fnmatch?(pattern, filename, match_flags) }
return Gitlab::Json.parse(get_cached(schema[:uri])) if match
end
nil
end
def predefined_schema_for(filename)
find_schema(filename, predefined_schemas)
end
def predefined_schemas
return PREDEFINED_SCHEMAS if Feature.enabled?(:schema_linting)
[]
end
def get_cached(url)
Rails.cache.fetch("services:ide:schema:#{url}", expires_in: 1.day) do
Gitlab::HTTP.get(url).body
end
end
end
end
Ide::SchemasConfigService.prepend_if_ee('::EE::Ide::SchemasConfigService')
# frozen_string_literal: true
module Ide
class TerminalConfigService < ::Ide::BaseConfigService
private
def success(pass_back = {})
result = super(pass_back)
result[:terminal] = config.terminal_value
result
end
end
end
......@@ -378,6 +378,11 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
post :reset_token
end
resources :feature_flags_user_lists, param: :iid, only: [:new, :edit, :show]
get '/schema/:branch/*filename',
to: 'web_ide_schemas#show',
format: false,
as: :schema
end
# End of the /-/ scope.
......
......@@ -85,6 +85,7 @@ class License < ApplicationRecord
group_project_templates
group_repository_analytics
group_saml
ide_schema_config
issues_analytics
jira_issues_integration
ldap_group_sync_filter
......
# frozen_string_literal: true
module EE
module Ide
module SchemasConfigService
extend ::Gitlab::Utils::Override
override :execute
def execute
result = super
return result if result[:status] == :success && !result[:schema].empty?
check_access_and_load_config!
success(schema: schema_from_config_for(params[:filename]) || {})
rescue => e
error(e.message)
end
private
def schema_from_config_for(filename)
return {} unless project.feature_available?(:ide_schema_config)
find_schema(filename, config.schemas_value || [])
end
end
end
end
# frozen_string_literal: true
module EE
module Gitlab
module WebIde
module Config
module Entry
module Global
extend ActiveSupport::Concern
class_methods do
def allowed_keys
%i[terminal schemas].freeze
end
end
prepended do
entry :schemas, ::Gitlab::WebIde::Config::Entry::Schemas,
description: 'Configuration of JSON/YAML schemas.'
end
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module WebIde
class Config
module Entry
##
# Entry that represents a JSON/YAML schema.
#
class Schema < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Configurable
include ::Gitlab::Config::Entry::Attributable
ALLOWED_KEYS = %i[uri match].freeze
validations do
validates :config, allowed_keys: ALLOWED_KEYS
end
entry :uri, Entry::Schema::Uri,
description: 'The URI of the schema.'
entry :match, Entry::Schema::Match,
description: 'A list of glob expressions to match against the target file.'
def value
to_hash.compact
end
private
def to_hash
{ uri: uri_value,
match: match_value || [] }
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module WebIde
class Config
module Entry
class Schema
##
# Entry that represents a list of glob expressions to match against the target file.
#
class Match < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
validations do
validates :config, array_of_strings: true, presence: true
end
def self.default
[]
end
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module WebIde
class Config
module Entry
class Schema
##
# Entry that represents the URI of a schema
#
class Uri < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
validations do
validates :config, presence: true, type: String
end
def self.default
''
end
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module WebIde
class Config
module Entry
##
# Entry that represents an array of JSON/YAML schemas
#
class Schemas < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Configurable
include ::Gitlab::Config::Entry::Validatable
entry :schema, Entry::Schema, description: 'A JSON/YAML schema definition'
validations do
validates :config, type: Array
end
def skip_config_hash_validation?
true
end
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::WebIde::Config::Entry::Global do
let(:global) { described_class.new(hash) }
describe '.nodes' do
context 'when filtering all the entry/node names' do
it 'contains the expected node names' do
expect(described_class.nodes.keys).to match_array(%i[terminal schemas])
end
end
end
context 'when configuration is valid' do
context 'when some entries defined' do
let(:hash) do
{
terminal: { before_script: ['ls'], variables: {}, script: 'sleep 10s', services: ['mysql'] },
schemas: [{ uri: 'https://someurl.com', match: ['*.gitlab-ci.yml'] }]
}
end
describe '#compose!' do
before do
global.compose!
end
it 'creates nodes hash' do
expect(global.descendants).to be_an Array
end
it 'creates node object for each entry' do
expect(global.descendants.count).to eq 2
end
it 'creates node object using valid class' do
expect(global.descendants.first)
.to be_an_instance_of Gitlab::WebIde::Config::Entry::Terminal
expect(global.descendants.second)
.to be_an_instance_of Gitlab::WebIde::Config::Entry::Schemas
end
it 'sets correct description for nodes' do
expect(global.descendants.first.description)
.to eq 'Configuration of the webide terminal.'
expect(global.descendants.second.description)
.to eq 'Configuration of JSON/YAML schemas.'
end
end
context 'when not composed' do
describe '#schemas_value' do
it 'returns nil' do
expect(global.schemas_value).to be nil
end
end
end
context 'when composed' do
before do
global.compose!
end
describe '#errors' do
it 'has no errors' do
expect(global.errors).to be_empty
end
end
describe '#schemas_value' do
it 'returns correct value for schemas' do
expect(global.schemas_value).to eq([{ uri: 'https://someurl.com', match: ['*.gitlab-ci.yml'] }])
end
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::WebIde::Config::Entry::Schema::Match do
let(:match) { described_class.new(config) }
describe 'validations' do
context 'when match config value is correct' do
let(:config) { ['*.json'] }
describe '#value' do
it 'returns the match glob pattern defined' do
expect(match.value).to eq config
end
end
describe '#valid?' do
it 'is valid' do
expect(match).to be_valid
end
end
end
context 'when value has a wrong type' do
let(:config) { { test: true } }
it 'reports errors about wrong type' do
expect(match.errors)
.to include 'match config should be an array of strings'
end
end
end
describe '.default' do
it 'returns empty array' do
expect(described_class.default).to eq []
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::WebIde::Config::Entry::Schema::Uri do
let(:uri) { described_class.new(config) }
describe 'validations' do
context 'when uri config value is correct' do
let(:config) { 'https://someurl.com' }
describe '#value' do
it 'returns the url defined' do
expect(uri.value).to eq config
end
end
describe '#valid?' do
it 'is valid' do
expect(uri).to be_valid
end
end
end
context 'when value has a wrong type' do
let(:config) { { test: true } }
it 'reports errors about wrong type' do
expect(uri.errors)
.to include 'uri config should be a string'
end
end
end
describe '.default' do
it 'returns empty string' do
expect(described_class.default).to eq ''
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::WebIde::Config::Entry::Schema do
let(:schema) { described_class.new(hash) }
describe '.nodes' do
it 'returns a hash' do
expect(described_class.nodes).to be_a(Hash)
end
context 'when filtering all the entry/node names' do
it 'contains the expected node names' do
expect(described_class.nodes.keys)
.to match_array(%i[uri match])
end
end
end
context 'when configuration is valid' do
context 'when some entries defined' do
let(:hash) do
{ uri: 'https://someurl.com', match: ['*.gitlab-ci.yml'] }
end
describe '#compose!' do
before do
schema.compose!
end
it 'creates node object for each entry' do
expect(schema.descendants.count).to eq 2
end
it 'creates node object using valid class' do
expect(schema.descendants.first)
.to be_an_instance_of Gitlab::WebIde::Config::Entry::Schema::Uri
expect(schema.descendants.second)
.to be_an_instance_of Gitlab::WebIde::Config::Entry::Schema::Match
end
it 'sets correct description for nodes' do
expect(schema.descendants.first.description)
.to eq 'The URI of the schema.'
expect(schema.descendants.second.description)
.to eq 'A list of glob expressions to match against the target file.'
end
describe '#leaf?' do
it 'is not leaf' do
expect(schema).not_to be_leaf
end
end
end
context 'when composed' do
before do
schema.compose!
end
describe '#errors' do
it 'has no errors' do
expect(schema.errors).to be_empty
end
end
describe '#uri_value' do
it 'returns correct uri' do
expect(schema.uri_value).to eq('https://someurl.com')
end
end
describe '#match_value' do
it 'returns correct value for schemas' do
expect(schema.match_value).to eq(['*.gitlab-ci.yml'])
end
end
end
end
end
context 'when configuration is not valid' do
before do
schema.compose!
end
context 'when the config does not have all the required entries' do
let(:hash) do
{}
end
describe '#errors' do
it 'reports errors about the invalid entries' do
expect(schema.errors)
.to eq [
"uri config can't be blank",
"match config can't be blank"
]
end
end
end
context 'when the config has invalid entries' do
let(:hash) do
{ uri: 1, match: [2] }
end
describe '#errors' do
it 'reports errors about the invalid entries' do
expect(schema.errors)
.to eq [
"uri config should be a string",
"match config should be an array of strings"
]
end
end
end
end
context 'when value is not a hash' do
let(:hash) { [] }
describe '#valid?' do
it 'is not valid' do
expect(schema).not_to be_valid
end
end
describe '#errors' do
it 'returns error about invalid type' do
expect(schema.errors.first).to match /should be a hash/
end
end
end
describe '#specified?' do
it 'is concrete entry that is defined' do
expect(schema.specified?).to be true
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::WebIde::Config::Entry::Schemas do
let(:entry) { described_class.new(config) }
describe 'validations' do
before do
entry.compose!
end
context 'when entry config value is correct' do
let(:config) { [{ uri: 'http://test.uri', match: '*-config.yml' }] }
it 'is valid' do
expect(entry).to be_valid
end
end
context 'when entry config value is incorrect' do
let(:config) { { incorrect: 'schemas config' } }
it 'is not valid' do
expect(entry).not_to be_valid
expect(entry.errors.first)
.to match /schema/
end
describe '#errors' do
it 'reports error about a config type' do
expect(entry.errors)
.to include 'schemas config should be a array'
end
end
end
end
context 'when composed' do
before do
entry.compose!
end
describe '#value' do
context 'when entry is correct' do
let(:config) do
[
{
uri: 'http://test.uri',
match: '*-config.yml'
}
]
end
it 'returns correct value' do
expect(entry.value)
.to eq([{
uri: 'http://test.uri',
match: '*-config.yml'
}])
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Ide::SchemasConfigService do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { create(:user) }
let(:sha) { 'sha' }
let(:filename) { 'sample.yml' }
let(:schema_content) { double(body: '{"title":"Sample schema"}') }
describe '#execute' do
before do
project.add_developer(user)
allow(project.repository).to receive(:blob_data_at).with('sha', anything) do
config_content
end
allow(Gitlab::HTTP).to receive(:get).with(anything) do
schema_content
end
end
subject { described_class.new(project, user, sha: sha, filename: filename).execute }
context 'content is not valid' do
let(:config_content) { 'invalid content' }
it 'returns an error' do
is_expected.to include(
status: :error,
message: "Invalid configuration format")
end
end
context 'when a predefined schema exists for the given filename' do
let(:filename) { '.gitlab-ci.yml' }
before do
stub_feature_flags(schema_linting: true)
end
context 'with valid config content' do
let(:config_content) { 'schemas: [{uri: "https://someurl.com", match: ["*.yml"]}]' }
it 'uses predefined schema matches' do
expect(Gitlab::HTTP).to receive(:get).with('https://json.schemastore.org/gitlab-ci')
expect(Gitlab::HTTP).not_to receive(:get).with('https://someurl.com')
expect(subject[:schema]['title']).to eq "Sample schema"
end
end
context 'with invalid config content' do
let(:config_content) { '' }
it 'uses predefined schema matches' do
expect(Gitlab::HTTP).to receive(:get).with('https://json.schemastore.org/gitlab-ci')
expect(subject[:schema]['title']).to eq "Sample schema"
end
end
end
context 'no schemas are defined' do
let(:config_content) { '{}' }
it 'returns success with an empty object' do
is_expected.to include(
status: :success,
schema: {})
end
end
context 'feature :ide_schema_config is not available' do
let(:config_content) { 'schemas: [{uri: "https://someurl.com", match: ["*.yml"]}]' }
it 'returns empty object, despite config being defined' do
expect(Gitlab::HTTP).not_to receive(:get).with("https://someurl.com")
expect(subject[:schema]).to eq({})
end
end
context 'feature :ide_schema_config is available' do
before do
allow(project).to receive(:feature_available?).with(:ide_schema_config) { true }
end
context 'schemas are defined and a matching schema is found and valid' do
let(:config_content) { 'schemas: [{uri: "https://someurl.com", match: ["*.yml"]}]' }
it 'returns schema successfully' do
expect(Gitlab::HTTP).to receive(:get).with("https://someurl.com")
expect(subject[:schema]['title']).to eq "Sample schema"
end
end
context 'schemas are defined and a matching schema is found and but the schema is not a valid JSON' do
let(:config_content) { 'schemas: [{uri: "https://someurl.com", match: ["*.yml"]}]' }
let(:schema_content) { double(body: 'invalid json!') }
it 'returns schema successfully' do
expect(Gitlab::HTTP).to receive(:get).with("https://someurl.com")
expect(subject[:status]).to eq(:error)
expect(subject[:message]).to include('unexpected character () at line 1, column 1')
end
end
context 'schemas are defined and but no matching schema found' do
let(:config_content) { 'schemas: [{uri: "https://someurl.com", match: ["*.json"]}]' }
it 'returns empty schema object' do
expect(Gitlab::HTTP).not_to receive(:get).with("https://someurl.com")
expect(subject[:schema]).to eq({})
end
end
context 'nested schema filename with "**" in match uri' do
let(:config_content) { 'schemas: [{uri: "https://someurl.com", match: ["data/somepath/**/*.yml"]}]' }
let(:filename) { 'data/somepath/unreleased/changelog/path/changelog.yml' }
it 'returns schema successfully' do
expect(Gitlab::HTTP).to receive(:get).with("https://someurl.com")
expect(subject[:schema]['title']).to eq "Sample schema"
end
end
end
end
end
......@@ -34,6 +34,10 @@ module Gitlab
@global.terminal_value
end
def schemas_value
@global.schemas_value
end
private
def build_config(config, opts = {})
......
......@@ -12,18 +12,22 @@ module Gitlab
include ::Gitlab::Config::Entry::Configurable
include ::Gitlab::Config::Entry::Attributable
ALLOWED_KEYS = %i[terminal].freeze
def self.allowed_keys
%i[terminal].freeze
end
validations do
validates :config, allowed_keys: ALLOWED_KEYS
validates :config, allowed_keys: Global.allowed_keys
end
attributes allowed_keys
entry :terminal, Entry::Terminal,
description: 'Configuration of the webide terminal.'
attributes :terminal
end
end
end
end
end
::Gitlab::WebIde::Config::Entry::Global.prepend_if_ee('EE::Gitlab::WebIde::Config::Entry::Global')
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Projects::WebIdeSchemasController do
let_it_be(:developer) { create(:user) }
let_it_be(:project) { create(:project, :private, :repository, namespace: developer.namespace) }
before do
project.add_developer(developer)
sign_in(user)
end
describe 'GET show' do
let(:user) { developer }
let(:branch) { 'master' }
subject do
get :show, params: {
namespace_id: project.namespace.to_param,
project_id: project,
branch: branch,
filename: 'package.json'
}
end
before do
allow_next_instance_of(::Ide::SchemasConfigService) do |instance|
allow(instance).to receive(:execute).and_return(result)
end
end
context 'when branch is invalid' do
let(:branch) { 'non-existent' }
it 'returns 422' do
subject
expect(response).to have_gitlab_http_status(:unprocessable_entity)
end
end
context 'when a valid schema exists' do
let(:result) { { status: :success, schema: { schema: 'Sample Schema' } } }
it 'returns the schema' do
subject
expect(response).to have_gitlab_http_status(:ok)
expect(response.body).to eq('{"schema":"Sample Schema"}')
end
end
context 'when an error occurs parsing the schema' do
let(:result) { { status: :error, message: 'Some error occured' } }
it 'returns 422 with the error' do
subject
expect(response).to have_gitlab_http_status(:unprocessable_entity)
expect(response.body).to eq('{"status":"error","message":"Some error occured"}')
end
end
end
end
......@@ -113,7 +113,7 @@ RSpec.describe Projects::WebIdeTerminalsController do
let(:result) { { status: :success } }
before do
allow_next_instance_of(::Ci::WebIdeConfigService) do |instance|
allow_next_instance_of(::Ide::TerminalConfigService) do |instance|
allow(instance).to receive(:execute).and_return(result)
end
......
......@@ -12,8 +12,7 @@ RSpec.describe Gitlab::WebIde::Config::Entry::Global do
context 'when filtering all the entry/node names' do
it 'contains the expected node names' do
expect(described_class.nodes.keys)
.to match_array(%i[terminal])
expect(described_class.nodes.keys).to match_array(described_class.allowed_keys)
end
end
end
......@@ -34,7 +33,7 @@ RSpec.describe Gitlab::WebIde::Config::Entry::Global do
end
it 'creates node object for each entry' do
expect(global.descendants.count).to eq 1
expect(global.descendants.count).to eq described_class.allowed_keys.length
end
it 'creates node object using valid class' do
......
......@@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe Ci::WebIdeConfigService do
RSpec.describe Ide::BaseConfigService do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { create(:user) }
let(:sha) { 'sha' }
......@@ -47,44 +47,6 @@ RSpec.describe Ci::WebIdeConfigService do
message: "Invalid configuration format")
end
end
context 'content is valid, but terminal not defined' do
let(:config_content) { '{}' }
it 'returns success' do
is_expected.to include(
status: :success,
terminal: nil)
end
end
context 'content is valid, with enabled terminal' do
let(:config_content) { 'terminal: {}' }
it 'returns success' do
is_expected.to include(
status: :success,
terminal: {
tag_list: [],
yaml_variables: [],
options: { script: ["sleep 60"] }
})
end
end
context 'content is valid, with custom terminal' do
let(:config_content) { 'terminal: { before_script: [ls] }' }
it 'returns success' do
is_expected.to include(
status: :success,
terminal: {
tag_list: [],
yaml_variables: [],
options: { before_script: ["ls"], script: ["sleep 60"] }
})
end
end
end
end
end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Ide::SchemasConfigService do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { create(:user) }
let(:filename) { 'sample.yml' }
let(:schema_content) { double(body: '{"title":"Sample schema"}') }
describe '#execute' do
before do
project.add_developer(user)
allow(Gitlab::HTTP).to receive(:get).with(anything) do
schema_content
end
end
subject { described_class.new(project, user, filename: filename).execute }
context 'feature flag schema_linting is enabled', unless: Gitlab.ee? do
before do
stub_feature_flags(schema_linting: true)
end
context 'when no predefined schema exists for the given filename' do
it 'returns an empty object' do
is_expected.to include(
status: :success,
schema: {})
end
end
context 'when a predefined schema exists for the given filename' do
let(:filename) { '.gitlab-ci.yml' }
it 'uses predefined schema matches' do
expect(Gitlab::HTTP).to receive(:get).with('https://json.schemastore.org/gitlab-ci')
expect(subject[:schema]['title']).to eq "Sample schema"
end
end
end
context 'feature flag schema_linting is disabled', unless: Gitlab.ee? do
it 'returns an empty object' do
is_expected.to include(
status: :success,
schema: {})
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Ide::TerminalConfigService do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { create(:user) }
let(:sha) { 'sha' }
describe '#execute' do
subject { described_class.new(project, user, sha: sha).execute }
before do
project.add_developer(user)
allow(project.repository).to receive(:blob_data_at).with('sha', anything) do
config_content
end
end
context 'content is not valid' do
let(:config_content) { 'invalid content' }
it 'returns an error' do
is_expected.to include(
status: :error,
message: "Invalid configuration format")
end
end
context 'terminal not defined' do
let(:config_content) { '{}' }
it 'returns success' do
is_expected.to include(
status: :success,
terminal: nil)
end
end
context 'terminal enabled' do
let(:config_content) { 'terminal: {}' }
it 'returns success' do
is_expected.to include(
status: :success,
terminal: {
tag_list: [],
yaml_variables: [],
options: { script: ["sleep 60"] }
})
end
end
context 'custom terminal enabled' do
let(:config_content) { 'terminal: { before_script: [ls] }' }
it 'returns success' do
is_expected.to include(
status: :success,
terminal: {
tag_list: [],
yaml_variables: [],
options: { before_script: ["ls"], script: ["sleep 60"] }
})
end
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