Commit e0e677db authored by Tiger's avatar Tiger

Add API management for scoped group variables

Extends the existing group variables API to support
EE-only params, and allows setting the environment_scope
if the :group_scoped_ci_variables licensed feature is available.

If the feature is not available (pricing tier is not Premium or
higher), the param is silently removed and the update continues
without presenting an error to the user. This behaviour is the
same as when project level scoped variables were a premium feature.

https://gitlab.com/gitlab-org/gitlab/-/merge_requests/55573
parent d69809a3
...@@ -2,16 +2,14 @@ ...@@ -2,16 +2,14 @@
module Ci module Ci
class VariablesFinder class VariablesFinder
attr_reader :project, :params def initialize(resource, params)
@resource, @params = resource, params
def initialize(project, params)
@project, @params = project, params
raise ArgumentError, 'Please provide params[:key]' if params[:key].blank? raise ArgumentError, 'Please provide params[:key]' if params[:key].blank?
end end
def execute def execute
variables = project.variables variables = resource.variables
variables = by_key(variables) variables = by_key(variables)
variables = by_environment_scope(variables) variables = by_environment_scope(variables)
variables variables
...@@ -19,6 +17,8 @@ module Ci ...@@ -19,6 +17,8 @@ module Ci
private private
attr_reader :resource, :params
def by_key(variables) def by_key(variables)
variables.by_key(params[:key]) variables.by_key(params[:key])
end end
......
...@@ -7,6 +7,9 @@ ...@@ -7,6 +7,9 @@
%tr %tr
%td.gl-text-truncate %td.gl-text-truncate
= variable.key = variable.key
- if Feature.enabled?(:scoped_group_variables, default_enabled: :yaml)
%td.gl-text-truncate
= variable.environment_scope
%td.gl-text-truncate %td.gl-text-truncate
%a.group-origin-link{ href: group_settings_ci_cd_path(variable.group) } %a.group-origin-link{ href: group_settings_ci_cd_path(variable.group) }
= variable.group.name = variable.group.name
%tr %tr
%th %th
= s_('Key') = s_('Key')
- if Feature.enabled?(:scoped_group_variables, default_enabled: :yaml)
%th
= s_('Environments')
%th %th
= s_('Group') = s_('Group')
...@@ -244,6 +244,11 @@ module EE ...@@ -244,6 +244,11 @@ module EE
feature_available?(:group_project_templates) feature_available?(:group_project_templates)
end end
def scoped_variables_available?
::Feature.enabled?(:scoped_group_variables, self, default_enabled: :yaml) &&
feature_available?(:group_scoped_ci_variables)
end
def actual_size_limit def actual_size_limit
return ::Gitlab::CurrentSettings.repository_size_limit if repository_size_limit.nil? return ::Gitlab::CurrentSettings.repository_size_limit if repository_size_limit.nil?
......
...@@ -98,6 +98,7 @@ class License < ApplicationRecord ...@@ -98,6 +98,7 @@ class License < ApplicationRecord
group_repository_analytics group_repository_analytics
group_saml group_saml
group_saml_group_sync group_saml_group_sync
group_scoped_ci_variables
group_wikis group_wikis
incident_sla incident_sla
incident_metric_upload incident_metric_upload
......
# frozen_string_literal: true
module EE
module API
module Helpers
module VariablesHelpers
extend ActiveSupport::Concern
extend ::Gitlab::Utils::Override
prepended do
params :optional_group_variable_params_ee do
optional :environment_scope, type: String, desc: 'The environment scope of the variable'
end
end
override :filter_variable_parameters
def filter_variable_parameters(owner, params)
if owner.is_a?(::Group) && !owner.scoped_variables_available?
params.delete(:environment_scope)
end
params
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe EE::API::Helpers::VariablesHelpers do
let(:klass) { Class.new.include(described_class).new }
describe '#filter_variable_parameters' do
let(:params) { { key: 'KEY', environment_scope: 'production' } }
subject { klass.filter_variable_parameters(owner, params) }
context 'owner is a project' do
let(:owner) { create(:project) }
it { is_expected.to eq(params) }
end
context 'owner is a group' do
let(:owner) { create(:group) }
before do
allow(owner).to receive(:scoped_variables_available?).and_return(scoped_variables_available)
end
context 'scoped variables are available' do
let(:scoped_variables_available) { true }
it { is_expected.to eq(params) }
end
context 'scoped variables are not available' do
let(:scoped_variables_available) { false }
it { is_expected.to eq(params.except(:environment_scope)) }
end
end
end
end
...@@ -676,6 +676,30 @@ RSpec.describe Group do ...@@ -676,6 +676,30 @@ RSpec.describe Group do
end end
end end
describe '#scoped_variables_available?' do
using RSpec::Parameterized::TableSyntax
let(:group) { create(:group) }
subject { group.scoped_variables_available? }
where(:feature_enabled, :feature_available, :scoped_variables_available) do
true | true | true
false | true | false
true | false | false
false | false | false
end
with_them do
before do
stub_feature_flags(scoped_group_variables: feature_enabled)
stub_licensed_features(group_scoped_ci_variables: feature_available)
end
it { is_expected.to eq(scoped_variables_available) }
end
end
describe '#minimal_access_role_allowed?' do describe '#minimal_access_role_allowed?' do
subject { group.minimal_access_role_allowed? } subject { group.minimal_access_role_allowed? }
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe API::GroupVariables do
let(:group) { create(:group) }
let(:user) { create(:user) }
describe 'GET /groups/:id/variables/:key' do
let!(:variable) { create(:ci_group_variable, group: group) }
before do
group.add_owner(user)
end
context 'when there are two variables with the same key on different environments' do
let!(:var1) { create(:ci_group_variable, group: group, key: 'key1', environment_scope: 'staging') }
let!(:var2) { create(:ci_group_variable, group: group, key: 'key1', environment_scope: 'production') }
context 'when filter[environment_scope] is not passed' do
it 'returns 409' do
get api("/groups/#{group.id}/variables/key1", user)
expect(response).to have_gitlab_http_status(:conflict)
end
end
context 'when filter[environment_scope] is passed' do
it 'returns the variable' do
get api("/groups/#{group.id}/variables/key1", user), params: { 'filter[environment_scope]': 'production' }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['value']).to eq(var2.value)
end
end
context 'when wrong filter[environment_scope] is passed' do
it 'returns not_found' do
get api("/groups/#{group.id}/variables/key1", user), params: { 'filter[environment_scope]': 'invalid' }
expect(response).to have_gitlab_http_status(:not_found)
end
context 'when there is only one variable with provided key' do
it 'returns not_found' do
get api("/groups/#{group.id}/variables/#{variable.key}", user), params: { 'filter[environment_scope]': 'invalid' }
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
end
end
describe 'POST /groups/:id/variables' do
context 'scoped variables' do
let(:params) do
{
key: 'KEY',
value: 'VALUE',
environment_scope: 'production'
}
end
subject { post api("/groups/#{group.id}/variables", user), params: params }
before do
group.add_owner(user)
stub_licensed_features(group_scoped_ci_variables: scoped_variables_available)
end
context ':group_scoped_ci_variables licensed feature is available' do
let(:scoped_variables_available) { true }
it 'creates a variable with the provided environment scope' do
expect { subject }.to change { group.variables.count }.by(1)
expect(response).to have_gitlab_http_status(:created)
expect(json_response['environment_scope']).to eq('production')
end
context 'a variable with the same key and scope exists already' do
let!(:variable) { create(:ci_group_variable, group: group, key: 'KEY', environment_scope: 'production')}
it 'does not create a variable' do
expect { subject }.not_to change { group.variables.count }
expect(response).to have_gitlab_http_status(:bad_request)
end
end
end
context ':group_scoped_ci_variables licensed feature is not available' do
let(:scoped_variables_available) { false }
it 'creates a variable, but does not use the provided environment scope' do
expect { subject }.to change { group.variables.count }.by(1)
expect(response).to have_gitlab_http_status(:created)
expect(json_response['environment_scope']).to eq('*')
end
context 'a variable with the same key and scope exists already' do
let!(:variable) { create(:ci_group_variable, group: group, key: 'KEY', environment_scope: '*')}
it 'does not create a variable' do
expect { subject }.not_to change { group.variables.count }
expect(response).to have_gitlab_http_status(:bad_request)
end
end
end
end
end
describe 'PUT /groups/:id/variables/:key' do
let!(:variable) { create(:ci_group_variable, group: group, environment_scope: '*') }
subject { put api("/groups/#{group.id}/variables/#{variable.key}", user), params: { environment_scope: 'production' } }
context 'scoped variables' do
before do
group.add_owner(user)
stub_licensed_features(group_scoped_ci_variables: scoped_variables_available)
end
context ':group_scoped_ci_variables licensed feature is available' do
let(:scoped_variables_available) { true }
it 'updates the variable' do
subject
expect(response).to have_gitlab_http_status(:ok)
expect(variable.reload.environment_scope).to eq('production')
expect(json_response['environment_scope']).to eq('production')
end
context 'a variable with the same key and scope exists already' do
let!(:conflicting_variable) { create(:ci_group_variable, group: group, key: variable.key, environment_scope: 'production')}
it 'does not update the variable' do
subject
expect(response).to have_gitlab_http_status(:bad_request)
expect(variable.reload.environment_scope).to eq('*')
end
end
end
context ':group_scoped_ci_variables licensed feature is not available' do
let(:scoped_variables_available) { false }
it 'does not update the variable' do
subject
expect(response).to have_gitlab_http_status(:ok)
expect(variable.reload.environment_scope).to eq('*')
expect(json_response['environment_scope']).to eq('*')
end
end
end
end
end
...@@ -8,6 +8,8 @@ module API ...@@ -8,6 +8,8 @@ module API
before { authorize! :admin_group, user_group } before { authorize! :admin_group, user_group }
feature_category :continuous_integration feature_category :continuous_integration
helpers Helpers::VariablesHelpers
params do params do
requires :id, type: String, desc: 'The ID of a group' requires :id, type: String, desc: 'The ID of a group'
end end
...@@ -30,16 +32,13 @@ module API ...@@ -30,16 +32,13 @@ module API
params do params do
requires :key, type: String, desc: 'The key of the variable' requires :key, type: String, desc: 'The key of the variable'
end end
# rubocop: disable CodeReuse/ActiveRecord
get ':id/variables/:key' do get ':id/variables/:key' do
key = params[:key] variable = find_variable(user_group, params)
variable = user_group.variables.find_by(key: key)
break not_found!('GroupVariable') unless variable break not_found!('GroupVariable') unless variable
present variable, with: Entities::Ci::Variable present variable, with: Entities::Ci::Variable
end end
# rubocop: enable CodeReuse/ActiveRecord
desc 'Create a new variable in a group' do desc 'Create a new variable in a group' do
success Entities::Ci::Variable success Entities::Ci::Variable
...@@ -50,12 +49,19 @@ module API ...@@ -50,12 +49,19 @@ module API
optional :protected, type: String, desc: 'Whether the variable is protected' optional :protected, type: String, desc: 'Whether the variable is protected'
optional :masked, type: String, desc: 'Whether the variable is masked' optional :masked, type: String, desc: 'Whether the variable is masked'
optional :variable_type, type: String, values: ::Ci::GroupVariable.variable_types.keys, desc: 'The type of variable, must be one of env_var or file. Defaults to env_var' optional :variable_type, type: String, values: ::Ci::GroupVariable.variable_types.keys, desc: 'The type of variable, must be one of env_var or file. Defaults to env_var'
use :optional_group_variable_params_ee
end end
post ':id/variables' do post ':id/variables' do
filtered_params = filter_variable_parameters(
user_group,
declared_params(include_missing: false)
)
variable = ::Ci::ChangeVariableService.new( variable = ::Ci::ChangeVariableService.new(
container: user_group, container: user_group,
current_user: current_user, current_user: current_user,
params: { action: :create, variable_params: declared_params(include_missing: false) } params: { action: :create, variable_params: filtered_params }
).execute ).execute
if variable.valid? if variable.valid?
...@@ -74,13 +80,19 @@ module API ...@@ -74,13 +80,19 @@ module API
optional :protected, type: String, desc: 'Whether the variable is protected' optional :protected, type: String, desc: 'Whether the variable is protected'
optional :masked, type: String, desc: 'Whether the variable is masked' optional :masked, type: String, desc: 'Whether the variable is masked'
optional :variable_type, type: String, values: ::Ci::GroupVariable.variable_types.keys, desc: 'The type of variable, must be one of env_var or file' optional :variable_type, type: String, values: ::Ci::GroupVariable.variable_types.keys, desc: 'The type of variable, must be one of env_var or file'
use :optional_group_variable_params_ee
end end
# rubocop: disable CodeReuse/ActiveRecord
put ':id/variables/:key' do put ':id/variables/:key' do
filtered_params = filter_variable_parameters(
user_group,
declared_params(include_missing: false)
)
variable = ::Ci::ChangeVariableService.new( variable = ::Ci::ChangeVariableService.new(
container: user_group, container: user_group,
current_user: current_user, current_user: current_user,
params: { action: :update, variable_params: declared_params(include_missing: false) } params: { action: :update, variable_params: filtered_params }
).execute ).execute
if variable.valid? if variable.valid?
...@@ -91,7 +103,6 @@ module API ...@@ -91,7 +103,6 @@ module API
rescue ::ActiveRecord::RecordNotFound rescue ::ActiveRecord::RecordNotFound
not_found!('GroupVariable') not_found!('GroupVariable')
end end
# rubocop: enable CodeReuse/ActiveRecord
desc 'Delete an existing variable from a group' do desc 'Delete an existing variable from a group' do
success Entities::Ci::Variable success Entities::Ci::Variable
...@@ -99,21 +110,18 @@ module API ...@@ -99,21 +110,18 @@ module API
params do params do
requires :key, type: String, desc: 'The key of the variable' requires :key, type: String, desc: 'The key of the variable'
end end
# rubocop: disable CodeReuse/ActiveRecord
delete ':id/variables/:key' do delete ':id/variables/:key' do
variable = user_group.variables.find_by!(key: params[:key]) variable = find_variable(user_group, params)
break not_found!('GroupVariable') unless variable
destroy_conditionally!(variable) do |target_variable| destroy_conditionally!(variable) do |target_variable|
::Ci::ChangeVariableService.new( ::Ci::ChangeVariableService.new(
container: user_group, container: user_group,
current_user: current_user, current_user: current_user,
params: { action: :destroy, variable_params: declared_params(include_missing: false) } params: { action: :destroy, variable: variable }
).execute ).execute
end end
rescue ::ActiveRecord::RecordNotFound
not_found!('GroupVariable')
end end
# rubocop: enable CodeReuse/ActiveRecord
end end
end end
end end
# frozen_string_literal: true
module API
module Helpers
module VariablesHelpers
extend ActiveSupport::Concern
extend Grape::API::Helpers
params :optional_group_variable_params_ee do
end
def filter_variable_parameters(_, params)
params # Overridden in EE
end
def find_variable(owner, params)
variables = ::Ci::VariablesFinder.new(owner, params).execute.to_a
return variables.first unless variables.many? # rubocop: disable CodeReuse/ActiveRecord
conflict!("There are multiple variables with provided parameters. Please use 'filter[environment_scope]'")
end
end
end
end
API::Helpers::VariablesHelpers.prepend_if_ee('EE::API::Helpers::VariablesHelpers')
...@@ -9,21 +9,7 @@ module API ...@@ -9,21 +9,7 @@ module API
feature_category :continuous_integration feature_category :continuous_integration
helpers do helpers Helpers::VariablesHelpers
def filter_variable_parameters(params)
# This method exists so that EE can more easily filter out certain
# parameters, without having to modify the source code directly.
params
end
def find_variable(params)
variables = ::Ci::VariablesFinder.new(user_project, params).execute.to_a
return variables.first unless variables.many? # rubocop: disable CodeReuse/ActiveRecord
conflict!("There are multiple variables with provided parameters. Please use 'filter[environment_scope]'")
end
end
params do params do
requires :id, type: String, desc: 'The ID of a project' requires :id, type: String, desc: 'The ID of a project'
...@@ -49,7 +35,7 @@ module API ...@@ -49,7 +35,7 @@ module API
end end
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
get ':id/variables/:key' do get ':id/variables/:key' do
variable = find_variable(params) variable = find_variable(user_project, params)
not_found!('Variable') unless variable not_found!('Variable') unless variable
present variable, with: Entities::Ci::Variable present variable, with: Entities::Ci::Variable
...@@ -71,7 +57,7 @@ module API ...@@ -71,7 +57,7 @@ module API
variable = ::Ci::ChangeVariableService.new( variable = ::Ci::ChangeVariableService.new(
container: user_project, container: user_project,
current_user: current_user, current_user: current_user,
params: { action: :create, variable_params: filter_variable_parameters(declared_params(include_missing: false)) } params: { action: :create, variable_params: declared_params(include_missing: false) }
).execute ).execute
if variable.valid? if variable.valid?
...@@ -95,17 +81,13 @@ module API ...@@ -95,17 +81,13 @@ module API
end end
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
put ':id/variables/:key' do put ':id/variables/:key' do
variable = find_variable(params) variable = find_variable(user_project, params)
not_found!('Variable') unless variable not_found!('Variable') unless variable
variable_params = filter_variable_parameters(
declared_params(include_missing: false)
.except(:key, :filter)
)
variable = ::Ci::ChangeVariableService.new( variable = ::Ci::ChangeVariableService.new(
container: user_project, container: user_project,
current_user: current_user, current_user: current_user,
params: { action: :update, variable: variable, variable_params: variable_params } params: { action: :update, variable: variable, variable_params: declared_params(include_missing: false).except(:key, :filter) }
).execute ).execute
if variable.valid? if variable.valid?
...@@ -125,7 +107,7 @@ module API ...@@ -125,7 +107,7 @@ module API
end end
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
delete ':id/variables/:key' do delete ':id/variables/:key' do
variable = find_variable(params) variable = find_variable(user_project, params)
not_found!('Variable') unless variable not_found!('Variable') unless variable
::Ci::ChangeVariableService.new( ::Ci::ChangeVariableService.new(
......
...@@ -3,42 +3,57 @@ ...@@ -3,42 +3,57 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe Ci::VariablesFinder do RSpec.describe Ci::VariablesFinder do
let!(:project) { create(:project) } shared_examples 'scoped variables' do
let!(:params) { {} } describe '#initialize' do
subject { described_class.new(owner, params) }
let!(:var1) { create(:ci_variable, project: project, key: 'key1', environment_scope: 'staging') } context 'without key filter' do
let!(:var2) { create(:ci_variable, project: project, key: 'key2', environment_scope: 'staging') } let!(:params) { {} }
let!(:var3) { create(:ci_variable, project: project, key: 'key2', environment_scope: 'production') }
describe '#initialize' do it 'raises an error' do
subject { described_class.new(project, params) } expect { subject }.to raise_error(ArgumentError, 'Please provide params[:key]')
end
context 'without key filter' do
let!(:params) { {} }
it 'raises an error' do
expect { subject }.to raise_error(ArgumentError, 'Please provide params[:key]')
end end
end end
end
describe '#execute' do describe '#execute' do
subject { described_class.new(project.reload, params).execute } subject { described_class.new(owner.reload, params).execute }
context 'with key filter' do context 'with key filter' do
let!(:params) { { key: 'key1' } } let!(:params) { { key: 'key1' } }
it 'returns var1' do it 'returns var1' do
expect(subject).to contain_exactly(var1) expect(subject).to contain_exactly(var1)
end
end end
end
context 'with key and environment_scope filter' do context 'with key and environment_scope filter' do
let!(:params) { { key: 'key2', filter: { environment_scope: 'staging' } } } let!(:params) { { key: 'key2', filter: { environment_scope: 'staging' } } }
it 'returns var2' do it 'returns var2' do
expect(subject).to contain_exactly(var2) expect(subject).to contain_exactly(var2)
end
end end
end end
end end
context 'for a project' do
let(:owner) { create(:project) }
let!(:var1) { create(:ci_variable, project: owner, key: 'key1', environment_scope: 'staging') }
let!(:var2) { create(:ci_variable, project: owner, key: 'key2', environment_scope: 'staging') }
let!(:var3) { create(:ci_variable, project: owner, key: 'key2', environment_scope: 'production') }
include_examples 'scoped variables'
end
context 'for a group' do
let(:owner) { create(:group) }
let!(:var1) { create(:ci_group_variable, group: owner, key: 'key1', environment_scope: 'staging') }
let!(:var2) { create(:ci_group_variable, group: owner, key: 'key2', environment_scope: 'staging') }
let!(:var3) { create(:ci_group_variable, group: owner, key: 'key2', environment_scope: 'production') }
include_examples 'scoped variables'
end
end end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe API::Helpers::VariablesHelpers do
let(:helper) { Class.new.include(described_class).new }
describe '#filter_variable_parameters' do
let(:project) { double }
let(:params) { double }
subject { helper.filter_variable_parameters(project, params) }
it 'returns unmodified params (overridden in EE)' do
expect(subject).to eq(params)
end
end
describe '#find_variable' do
let(:owner) { double }
let(:params) { double }
let(:variables) { [double] }
subject { helper.find_variable(owner, params) }
before do
expect(Ci::VariablesFinder).to receive(:new).with(owner, params)
.and_return(double(execute: variables))
end
it { is_expected.to eq(variables.first) }
context 'there are multiple variables with the supplied key' do
let(:variables) { [double, double] }
it 'raises a conflict!' do
expect(helper).to receive(:conflict!).with(/There are multiple variables with provided parameters/)
subject
end
end
end
end
...@@ -55,6 +55,7 @@ RSpec.describe API::GroupVariables do ...@@ -55,6 +55,7 @@ RSpec.describe API::GroupVariables do
expect(json_response['value']).to eq(variable.value) expect(json_response['value']).to eq(variable.value)
expect(json_response['protected']).to eq(variable.protected?) expect(json_response['protected']).to eq(variable.protected?)
expect(json_response['variable_type']).to eq(variable.variable_type) expect(json_response['variable_type']).to eq(variable.variable_type)
expect(json_response['environment_scope']).to eq(variable.environment_scope)
end end
it 'responds with 404 Not Found if requesting non-existing variable' do it 'responds with 404 Not Found if requesting non-existing variable' do
...@@ -98,6 +99,7 @@ RSpec.describe API::GroupVariables do ...@@ -98,6 +99,7 @@ RSpec.describe API::GroupVariables do
expect(json_response['protected']).to be_truthy expect(json_response['protected']).to be_truthy
expect(json_response['masked']).to be_truthy expect(json_response['masked']).to be_truthy
expect(json_response['variable_type']).to eq('env_var') expect(json_response['variable_type']).to eq('env_var')
expect(json_response['environment_scope']).to eq('*')
end end
it 'creates variable with optional attributes' do it 'creates variable with optional attributes' do
...@@ -111,6 +113,7 @@ RSpec.describe API::GroupVariables do ...@@ -111,6 +113,7 @@ RSpec.describe API::GroupVariables do
expect(json_response['protected']).to be_falsey expect(json_response['protected']).to be_falsey
expect(json_response['masked']).to be_falsey expect(json_response['masked']).to be_falsey
expect(json_response['variable_type']).to eq('file') expect(json_response['variable_type']).to eq('file')
expect(json_response['environment_scope']).to eq('*')
end end
it 'does not allow to duplicate variable key' do it 'does not allow to duplicate variable key' do
......
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