Commit e6ce54f8 authored by Francisco Javier López's avatar Francisco Javier López Committed by Douwe Maan

Add new service to create the web ide terminal

parent 5159d9fc
......@@ -78,7 +78,8 @@ module Ci
enum_with_nil config_source: {
unknown_source: nil,
repository_source: 1,
auto_devops_source: 2
auto_devops_source: 2,
webide_source: 3 ## EE-specific
}
# We use `Ci::PipelineEnums.failure_reasons` here so that EE can more easily
......@@ -182,6 +183,8 @@ module Ci
scope :internal, -> { where(source: internal_sources) }
scope :for_user, -> (user) { where(user: user) }
# Returns the pipelines in descending order (= newest first), optionally
# limited to a number of references.
#
......@@ -507,6 +510,8 @@ module Ci
end
def ci_yaml_file_path
return unless repository_source? || unknown_source?
if project.ci_config_path.blank?
'.gitlab-ci.yml'
else
......@@ -675,6 +680,7 @@ module Ci
def ci_yaml_from_repo
return unless project
return unless sha
return unless ci_yaml_file_path
project.repository.gitlab_ci_yml_for(sha, ci_yaml_file_path)
rescue GRPC::NotFound, GRPC::Internal
......
......@@ -15,7 +15,7 @@ module EE
override :sources
def sources
super.merge(pipeline: 7, chat: 8)
super.merge(pipeline: 7, chat: 8, webide: 9)
end
end
end
......
......@@ -55,6 +55,8 @@ module EE
has_many :source_pipelines, class_name: 'Ci::Sources::Pipeline', foreign_key: :project_id
has_many :webide_pipelines, -> { webide_source }, class_name: 'Ci::Pipeline', inverse_of: :project
has_many :prometheus_alerts, inverse_of: :project
has_many :prometheus_alert_events, inverse_of: :project
......@@ -482,6 +484,10 @@ module EE
end
end
def active_webide_pipelines(user:)
webide_pipelines.running_or_pending.for_user(user)
end
private
def set_override_pull_mirror_available
......
......@@ -92,6 +92,7 @@ class License < ActiveRecord::Base
prometheus_alerts
operations_dashboard
tracing
webide_terminal
].freeze
# List all features available for early adopters,
......
......@@ -201,6 +201,12 @@ module EE
prevent(*::ProjectPolicy.create_update_admin_destroy(feature))
end
end
condition(:web_ide_terminal_available) do
@subject.feature_available?(:webide_terminal)
end
rule { web_ide_terminal_available & can?(:create_pipeline) & can?(:maintainer_access) }.enable :create_web_ide_terminal
end
end
end
# frozen_string_literal: true
module Ci
class CreateWebideTerminalService < ::BaseService
include ::Gitlab::Utils::StrongMemoize
TerminalCreationError = Class.new(StandardError)
TERMINAL_NAME = 'terminal'.freeze
attr_reader :terminal
def execute
check_access!
validate_params!
load_terminal_config!
pipeline = create_pipeline!
success(pipeline: pipeline)
rescue TerminalCreationError => e
error(e.message)
rescue ActiveRecord::RecordInvalid => e
error("Failed to persist the pipeline: #{e.message}")
end
private
def create_pipeline!
build_pipeline.tap do |pipeline|
pipeline.stages << terminal_stage_seed(pipeline).to_resource
pipeline.save!
pipeline.process!
pipeline_created_counter.increment(source: :webide)
end
end
def build_pipeline
Ci::Pipeline.new(
project: project,
user: current_user,
source: :webide,
config_source: :webide_source,
ref: ref,
sha: sha,
tag: false,
before_sha: Gitlab::Git::BLANK_SHA
)
end
def terminal_stage_seed(pipeline)
Gitlab::Ci::Pipeline::Seed::Stage.new(pipeline,
name: TERMINAL_NAME,
index: 0,
builds: [terminal_build_seed])
end
def terminal_build_seed
terminal.merge(
name: TERMINAL_NAME,
stage: TERMINAL_NAME,
user: current_user)
end
def load_terminal_config!
result = ::Ci::WebideConfigService.new(project, current_user, sha: sha).execute
raise TerminalCreationError, result[:message] if result[:status] != :success
@terminal = result[:terminal]
raise TerminalCreationError, 'Terminal is not configured' unless terminal
end
def validate_params!
unless sha
raise TerminalCreationError, 'Ref does not exist'
end
unless branch_exists?
raise TerminalCreationError, 'Ref needs to be a branch'
end
end
def check_access!
unless can?(current_user, :create_web_ide_terminal, project)
raise TerminalCreationError, 'Insufficient permissions to create a terminal'
end
if terminal_active?
raise TerminalCreationError, 'There is already a terminal running'
end
end
def pipeline_created_counter
@pipeline_created_counter ||= Gitlab::Metrics
.counter(:pipelines_created_total, "Counter of pipelines created")
end
def terminal_active?
project.active_webide_pipelines(user: current_user).exists?
end
def ref
strong_memoize(:ref) do
Gitlab::Git.ref_name(params[:ref])
end
end
def branch_exists?
project.repository.branch_exists?(ref)
end
def sha
project.commit(params[:ref]).try(:id)
end
end
end
# frozen_string_literal: true
module Ci
class WebideConfigService < ::BaseService
include ::Gitlab::Utils::StrongMemoize
ValidationError = Class.new(StandardError)
WEBIDE_CONFIG_FILE = '.gitlab/.gitlab-webide.yml'.freeze
attr_reader :config, :config_content
def execute
check_access!
load_config_content!
load_config!
success(terminal: config.terminal_value)
rescue ValidationError => e
error(e.message)
end
private
def check_access!
unless can?(current_user, :download_code, project)
raise ValidationError, 'Insufficient permissions to read configuration'
end
end
def load_config_content!
@config_content = webide_yaml_from_repo
unless config_content
raise ValidationError, "Failed to load Web IDE config file '#{WEBIDE_CONFIG_FILE}' for #{params[:sha]}"
end
end
def load_config!
@config = Gitlab::Webide::Config.new(config_content)
unless @config.valid?
raise ValidationError, @config.errors.first
end
rescue Gitlab::Webide::Config::ConfigError => e
raise ValidationError, e.message
end
def webide_yaml_from_repo
gitlab_webide_yml_for(params[:sha])
rescue GRPC::NotFound, GRPC::Internal
nil
end
def gitlab_webide_yml_for(sha)
project.repository.blob_data_at(sha, WEBIDE_CONFIG_FILE)
end
end
end
# frozen_string_literal: true
module Gitlab
module Webide
#
# Base GitLab Webide Configuration facade
#
class Config
ConfigError = Class.new(StandardError)
def initialize(config, opts = {})
@config = build_config(config, opts)
@global = Entry::Global.new(@config)
@global.compose!
rescue Gitlab::Config::Loader::FormatError => e
raise Config::ConfigError, e.message
end
def valid?
@global.valid?
end
def errors
@global.errors
end
def to_hash
@config
end
def terminal_value
@global.terminal_value
end
private
def build_config(config, opts = {})
Gitlab::Config::Loader::Yaml.new(config).load!
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Webide
class Config
module Entry
##
# This class represents a global entry - root Entry for entire
# GitLab Webide Configuration file.
#
class Global < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Configurable
include ::Gitlab::Config::Entry::Attributable
ALLOWED_KEYS = %i[terminal].freeze
validations do
validates :config, allowed_keys: ALLOWED_KEYS
end
entry :terminal, Entry::Terminal,
description: 'Configuration of the webide terminal.'
helpers :terminal
attributes :terminal
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Webide
class Config
module Entry
##
# Entry that represents a concrete CI/CD job.
#
class Terminal < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Configurable
include ::Gitlab::Config::Entry::Attributable
# By default the build will finish in a few seconds, not giving the webide
# enough time to connect to the terminal. This default script provides
# those seconds blocking the build from finishing inmediately.
DEFAULT_SCRIPT = ['sleep 60'].freeze
ALLOWED_KEYS = %i[image services tags before_script script variables].freeze
validations do
validates :config, allowed_keys: ALLOWED_KEYS
with_options allow_nil: true do
validates :tags, array_of_strings: true
end
end
entry :before_script, ::Gitlab::Ci::Config::Entry::Script,
description: 'Global before script overridden in this job.'
entry :script, ::Gitlab::Ci::Config::Entry::Commands,
description: 'Commands that will be executed in this job.'
entry :image, ::Gitlab::Ci::Config::Entry::Image,
description: 'Image that will be used to execute this job.'
entry :services, ::Gitlab::Ci::Config::Entry::Services,
description: 'Services that will be used to execute this job.'
entry :variables, ::Gitlab::Ci::Config::Entry::Variables,
description: 'Environment variables available for this job.'
helpers :before_script, :script, :image, :variables, :services
attributes :tags
def value
to_hash.compact
end
private
def to_hash
{ tag_list: tags || [],
yaml_variables: yaml_variables,
options: {
image: image_value,
services: services_value,
before_script: before_script_value,
script: script_value || DEFAULT_SCRIPT
}.compact }
end
def yaml_variables
return unless variables_value
variables_value.map do |key, value|
{ key: key.to_s, value: value, public: true }
end
end
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Ci::CreateWebideTerminalService do
set(:project) { create(:project, :repository) }
set(:user) { create(:user) }
let(:ref) { 'master' }
before do
stub_licensed_features(webide_terminal: true)
end
describe '#execute' do
subject { described_class.new(project, user, ref: ref).execute }
context 'for maintainer' do
shared_examples 'be successful' do
it 'returns a success with pipeline object' do
is_expected.to include(status: :success)
expect(subject[:pipeline]).to be_a(Ci::Pipeline)
expect(subject[:pipeline]).to be_persisted
expect(subject[:pipeline].stages.count).to eq(1)
expect(subject[:pipeline].builds.count).to eq(1)
end
end
before do
project.add_maintainer(user)
end
context 'when web-ide has valid configuration' do
before do
stub_webide_config_file(config_content)
end
context 'for empty configuration' do
let(:config_content) do
'terminal: {}'
end
it_behaves_like 'be successful'
end
context 'for configuration with container image' do
let(:config_content) do
'terminal: { image: ruby }'
end
it_behaves_like 'be successful'
end
end
end
context 'error handling' do
shared_examples 'having an error' do |message|
it 'returns an error' do
is_expected.to eq(
status: :error,
message: message
)
end
end
shared_examples 'having insufficient permissions' do
it_behaves_like 'having an error', 'Insufficient permissions to create a terminal'
end
context 'when user is developer' do
before do
project.add_developer(user)
end
it_behaves_like 'having insufficient permissions'
end
context 'when user is maintainer' do
before do
project.add_maintainer(user)
end
context 'when terminal is already running' do
let!(:webide_pipeline) { create(:ee_ci_pipeline, :webide, :running, project: project, user: user) }
it_behaves_like 'having an error', 'There is already a terminal running'
end
context 'when ref is non-existing' do
let(:ref) { 'non-existing-ref' }
it_behaves_like 'having an error', 'Ref does not exist'
end
context 'when ref is a tag' do
let(:ref) { 'v1.0.0' }
it_behaves_like 'having an error', 'Ref needs to be a branch'
end
context 'when terminal config is missing' do
let(:ref) { 'v1.0.0' }
it_behaves_like 'having an error', 'Ref needs to be a branch'
end
context 'when webide config is present' do
before do
stub_webide_config_file(config_content)
end
context 'config has invalid content' do
let(:config_content) { 'invalid' }
it_behaves_like 'having an error', 'Invalid configuration format'
end
context 'config is valid, but does not have terminal' do
let(:config_content) { '{}' }
it_behaves_like 'having an error', 'Terminal is not configured'
end
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Ci::WebideConfigService do
set(:project) { create(:project, :repository) }
set(:user) { create(:user) }
let(:sha) { 'sha' }
describe '#execute' do
subject { described_class.new(project, user, sha: sha).execute }
context 'when insufficient permission' do
it 'returns an error' do
is_expected.to include(
status: :error,
message: 'Insufficient permissions to read configuration')
end
end
context 'for developer' do
before do
project.add_developer(user)
end
context 'when file is missing' do
it 'returns an error' do
is_expected.to include(
status: :error,
message: "Failed to load Web IDE config file '.gitlab/.gitlab-webide.yml' for sha")
end
end
context 'when file is present' do
before do
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 '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
end
# frozen_string_literal: true
FactoryBot.define do
factory :ee_ci_pipeline, class: Ci::Pipeline, parent: :ci_pipeline do
trait :webide do
source :webide
config_source :webide_source
end
end
end
......@@ -68,6 +68,7 @@ project:
- project_registry
- packages
- tracing_setting
- webide_pipelines
prometheus_metrics:
- project
- prometheus_alerts
......
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::Webide::Config::Entry::Global do
let(:global) { 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[terminal])
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'] } }
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 1
end
it 'creates node object using valid class' do
expect(global.descendants.first)
.to be_an_instance_of Gitlab::Webide::Config::Entry::Terminal
end
it 'sets correct description for nodes' do
expect(global.descendants.first.description)
.to eq 'Configuration of the webide terminal.'
end
describe '#leaf?' do
it 'is not leaf' do
expect(global).not_to be_leaf
end
end
end
context 'when not composed' do
describe '#terminal_value' do
it 'returns nil' do
expect(global.terminal_value).to be nil
end
end
describe '#leaf?' do
it 'is leaf' do
expect(global).to be_leaf
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 '#terminal_value' do
it 'returns correct script' do
expect(global.terminal_value).to eq({
tag_list: [],
yaml_variables: [],
options: {
before_script: ['ls'],
script: ['sleep 10s'],
services: [{ name: "mysql" }]
}
})
end
end
end
end
end
context 'when configuration is not valid' do
before do
global.compose!
end
context 'when job does not have valid before script' do
let(:hash) do
{ terminal: { before_script: 100 } }
end
describe '#errors' do
it 'reports errors about missing script' do
expect(global.errors)
.to include "terminal:before_script 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(global).not_to be_valid
end
end
describe '#errors' do
it 'returns error about invalid type' do
expect(global.errors.first).to match /should be a hash/
end
end
end
describe '#specified?' do
it 'is concrete entry that is defined' do
expect(global.specified?).to be true
end
end
describe '#[]' do
before do
global.compose!
end
let(:hash) do
{ terminal: { before_script: ['ls'] } }
end
context 'when entry exists' do
it 'returns correct entry' do
expect(global[:terminal])
.to be_an_instance_of Gitlab::Webide::Config::Entry::Terminal
expect(global[:terminal][:before_script].value).to eq ['ls']
end
end
context 'when entry does not exist' do
it 'always return unspecified node' do
expect(global[:some][:unknown][:node])
.not_to be_specified
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::Webide::Config::Entry::Terminal do
let(:entry) { described_class.new(config) }
describe '.nodes' do
context 'when filtering all the entry/node names' do
subject { described_class.nodes.keys }
let(:result) do
%i[before_script script image services variables]
end
it { is_expected.to match_array result }
end
end
describe 'validations' do
before do
entry.compose!
end
context 'when entry config value is correct' do
let(:config) { { script: 'rspec' } }
describe '#valid?' do
it 'is valid' do
expect(entry).to be_valid
end
end
end
context 'when entry value is not correct' do
context 'incorrect config value type' do
let(:config) { ['incorrect'] }
describe '#errors' do
it 'reports error about a config type' do
expect(entry.errors)
.to include 'terminal config should be a hash'
end
end
end
context 'when config is empty' do
let(:config) { {} }
describe '#valid' do
it 'is valid' do
expect(entry).to be_valid
end
end
end
context 'when unknown keys detected' do
let(:config) { { unknown: true } }
describe '#valid' do
it 'is not valid' do
expect(entry).not_to be_valid
end
end
end
end
end
describe '#relevant?' do
it 'is a relevant entry' do
entry = described_class.new({ script: 'rspec' })
expect(entry).to be_relevant
end
end
context 'when composed' do
before do
entry.compose!
end
describe '#value' do
before do
entry.compose!
end
context 'when entry is correct' do
let(:config) do
{ before_script: %w[ls pwd],
script: 'sleep 100',
tags: ['webide'],
image: 'ruby:2.5',
services: ['mysql'],
variables: { KEY: 'value' } }
end
it 'returns correct value' do
expect(entry.value)
.to eq(
tag_list: ['webide'],
yaml_variables: [{ key: 'KEY', value: 'value', public: true }],
options: {
image: { name: "ruby:2.5" },
services: [{ name: "mysql" }],
before_script: %w[ls pwd],
script: ['sleep 100']
}
)
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::Webide::Config do
let(:config) do
described_class.new(yml)
end
context 'when config is valid' do
let(:yml) do
<<-EOS
terminal:
image: ruby:2.2
before_script:
- gem install rspec
EOS
end
describe '#to_hash' do
it 'returns hash created from string' do
hash = {
terminal: {
image: 'ruby:2.2',
before_script: ['gem install rspec']
}
}
expect(config.to_hash).to eq hash
end
describe '#valid?' do
it 'is valid' do
expect(config).to be_valid
end
it 'has no errors' do
expect(config.errors).to be_empty
end
end
end
end
context 'when config is invalid' do
context 'when yml is incorrect' do
let(:yml) { '// invalid' }
describe '.new' do
it 'raises error' do
expect { config }.to raise_error(
described_class::ConfigError,
/Invalid configuration format/
)
end
end
end
context 'when config logic is incorrect' do
let(:yml) { 'terminal: { before_script: "ls" }' }
describe '#valid?' do
it 'is not valid' do
expect(config).not_to be_valid
end
it 'has errors' do
expect(config.errors).not_to be_empty
end
end
describe '#errors' do
it 'returns an array of strings' do
expect(config.errors).to all(be_an_instance_of(String))
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe API::Runner, :clean_gitlab_redis_shared_state do
include StubGitlabCalls
include RedisHelpers
set(:project) { create(:project, :repository) }
describe '/api/v4/jobs' do
let(:runner) { create(:ci_runner, :project, projects: [project]) }
describe 'POST /api/v4/jobs/request' do
context 'for web-ide job' do
let(:user) { create(:user) }
let(:service) { Ci::CreateWebideTerminalService.new(project, user, ref: 'master').execute }
let(:pipeline) { service[:pipeline] }
let(:build) { pipeline.builds.first }
before do
stub_licensed_features(webide_terminal: true)
stub_webide_config_file(config_content)
project.add_maintainer(user)
pipeline
end
let(:config_content) do
'terminal: { image: ruby, services: [mysql], before_script: [ls], tags: [tag-1], variables: { KEY: value } }'
end
context 'when runner has matching tag' do
before do
runner.update!(tag_list: ['tag-1'])
end
it 'successfully picks job' do
request_job
build.reload
expect(build).to be_running
expect(build.runner).to eq(runner)
expect(response).to have_http_status(:created)
expect(json_response).to include(
"id" => build.id,
"variables" => include("key" => 'KEY', "value" => 'value', "public" => true),
"image" => a_hash_including("name" => 'ruby'),
"services" => all(a_hash_including("name" => 'mysql')),
"job_info" => a_hash_including("name" => 'terminal', "stage" => 'terminal'))
end
end
context 'when runner does not have matching tags' do
it 'does not pick a job' do
request_job
build.reload
expect(build).to be_pending
expect(response).to have_http_status(204)
end
end
end
def request_job(token = runner.token, **params)
post api('/jobs/request'), params.merge(token: token)
end
end
end
end
module EE
module StubGitlabCalls
def stub_webide_config_file(content, sha: anything)
allow_any_instance_of(Repository)
.to receive(:blob_data_at).with(sha, '.gitlab/.gitlab-webide.yml')
.and_return(content)
end
end
end
......@@ -1256,22 +1256,40 @@ describe Ci::Pipeline, :mailer do
describe '#ci_yaml_file_path' do
subject { pipeline.ci_yaml_file_path }
it 'returns the path from project' do
allow(pipeline.project).to receive(:ci_config_path) { 'custom/path' }
%i[unknown_source repository_source].each do |source|
context source.to_s do
before do
pipeline.config_source = described_class.config_sources.fetch(source)
end
is_expected.to eq('custom/path')
end
it 'returns the path from project' do
allow(pipeline.project).to receive(:ci_config_path) { 'custom/path' }
it 'returns default when custom path is nil' do
allow(pipeline.project).to receive(:ci_config_path) { nil }
is_expected.to eq('custom/path')
end
it 'returns default when custom path is nil' do
allow(pipeline.project).to receive(:ci_config_path) { nil }
is_expected.to eq('.gitlab-ci.yml')
end
is_expected.to eq('.gitlab-ci.yml')
it 'returns default when custom path is empty' do
allow(pipeline.project).to receive(:ci_config_path) { '' }
is_expected.to eq('.gitlab-ci.yml')
end
end
end
it 'returns default when custom path is empty' do
allow(pipeline.project).to receive(:ci_config_path) { '' }
context 'when pipeline is for auto-devops' do
before do
pipeline.config_source = 'auto_devops_source'
end
is_expected.to eq('.gitlab-ci.yml')
it 'does not return config file' do
is_expected.to be_nil
end
end
end
......
module StubGitlabCalls
prepend EE::StubGitlabCalls
def stub_gitlab_calls
stub_user
stub_project_8
......
......@@ -11,6 +11,7 @@ describe PipelineScheduleWorker do
end
before do
stub_application_setting(auto_devops_enabled: false)
stub_ci_pipeline_to_return_yaml_file
pipeline_schedule.update_column(:next_run_at, 1.day.ago)
......
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