Commit 62d832bb authored by Shinya Maeda's avatar Shinya Maeda

Merge branch 'environment-multi-stop-actions' into 'master'

Multiple on_stop action for an environment

See merge request gitlab-org/gitlab!84922
parents e138c828 d886be07
...@@ -104,11 +104,11 @@ class Projects::EnvironmentsController < Projects::ApplicationController ...@@ -104,11 +104,11 @@ class Projects::EnvironmentsController < Projects::ApplicationController
def stop def stop
return render_404 unless @environment.available? return render_404 unless @environment.available?
stop_action = @environment.stop_with_action!(current_user) stop_actions = @environment.stop_with_actions!(current_user)
action_or_env_url = action_or_env_url =
if stop_action if stop_actions&.count == 1
polymorphic_url([project, stop_action]) polymorphic_url([project, stop_actions.first])
else else
project_environment_url(project, @environment) project_environment_url(project, @environment)
end end
......
...@@ -59,7 +59,7 @@ class Environment < ApplicationRecord ...@@ -59,7 +59,7 @@ class Environment < ApplicationRecord
allow_nil: true, allow_nil: true,
addressable_url: true addressable_url: true
delegate :stop_action, :manual_actions, to: :last_deployment, allow_nil: true delegate :manual_actions, to: :last_deployment, allow_nil: true
delegate :auto_rollback_enabled?, to: :project delegate :auto_rollback_enabled?, to: :project
scope :available, -> { with_state(:available) } scope :available, -> { with_state(:available) }
...@@ -191,6 +191,23 @@ class Environment < ApplicationRecord ...@@ -191,6 +191,23 @@ class Environment < ApplicationRecord
last_deployment&.deployable last_deployment&.deployable
end end
def last_deployment_pipeline
last_deployable&.pipeline
end
# This method returns the deployment records of the last deployment pipeline, that successfully executed to this environment.
# e.g.
# A pipeline contains
# - deploy job A => production environment
# - deploy job B => production environment
# In this case, `last_deployment_group` returns both deployments, whereas `last_deployable` returns only B.
def last_deployment_group
return Deployment.none unless last_deployment_pipeline
successful_deployments.where(
deployable_id: last_deployment_pipeline.latest_builds.pluck(:id))
end
# NOTE: Below assocation overrides is a workaround for issue https://gitlab.com/gitlab-org/gitlab/-/issues/339908 # NOTE: Below assocation overrides is a workaround for issue https://gitlab.com/gitlab-org/gitlab/-/issues/339908
# It helps to avoid cross joins with the CI database. # It helps to avoid cross joins with the CI database.
# Caveat: It also overrides and losses the default AR caching mechanism. # Caveat: It also overrides and losses the default AR caching mechanism.
...@@ -261,8 +278,8 @@ class Environment < ApplicationRecord ...@@ -261,8 +278,8 @@ class Environment < ApplicationRecord
external_url.gsub(%r{\A.*?://}, '') external_url.gsub(%r{\A.*?://}, '')
end end
def stop_action_available? def stop_actions_available?
available? && stop_action.present? available? && stop_actions.present?
end end
def cancel_deployment_jobs! def cancel_deployment_jobs!
...@@ -275,18 +292,34 @@ class Environment < ApplicationRecord ...@@ -275,18 +292,34 @@ class Environment < ApplicationRecord
end end
end end
def stop_with_action!(current_user) def stop_with_actions!(current_user)
return unless available? return unless available?
stop! stop!
return unless stop_action actions = []
stop_actions.each do |stop_action|
Gitlab::OptimisticLocking.retry_lock( Gitlab::OptimisticLocking.retry_lock(
stop_action, stop_action,
name: 'environment_stop_with_action' name: 'environment_stop_with_actions'
) do |build| ) do |build|
build&.play(current_user) actions << build.play(current_user)
end
end
actions
end
def stop_actions
strong_memoize(:stop_actions) do
if ::Feature.enabled?(:environment_multiple_stop_actions, project, default_enabled: :yaml)
# Fix N+1 queries it brings to the serializer.
# Tracked in https://gitlab.com/gitlab-org/gitlab/-/issues/358780
last_deployment_group.map(&:stop_action).compact
else
[last_deployment&.stop_action].compact
end
end end
end end
......
...@@ -4,12 +4,12 @@ class EnvironmentPolicy < BasePolicy ...@@ -4,12 +4,12 @@ class EnvironmentPolicy < BasePolicy
delegate { @subject.project } delegate { @subject.project }
condition(:stop_with_deployment_allowed) do condition(:stop_with_deployment_allowed) do
@subject.stop_action_available? && @subject.stop_actions_available? &&
can?(:create_deployment) && can?(:update_build, @subject.stop_action) can?(:create_deployment) && can?(:update_build, @subject.stop_actions.last)
end end
condition(:stop_with_update_allowed) do condition(:stop_with_update_allowed) do
!@subject.stop_action_available? && can?(:update_environment, @subject) !@subject.stop_actions_available? && can?(:update_environment, @subject)
end end
condition(:stopped) do condition(:stopped) do
......
...@@ -18,7 +18,7 @@ class EnvironmentEntity < Grape::Entity ...@@ -18,7 +18,7 @@ class EnvironmentEntity < Grape::Entity
expose :environment_type expose :environment_type
expose :name_without_type expose :name_without_type
expose :last_deployment, using: DeploymentEntity expose :last_deployment, using: DeploymentEntity
expose :stop_action_available?, as: :has_stop_action expose :stop_actions_available?, as: :has_stop_action
expose :rollout_status, if: -> (*) { can_read_deploy_board? }, using: RolloutStatusEntity expose :rollout_status, if: -> (*) { can_read_deploy_board? }, using: RolloutStatusEntity
expose :tier expose :tier
......
...@@ -7,7 +7,7 @@ module Environments ...@@ -7,7 +7,7 @@ module Environments
def execute(environment) def execute(environment)
return unless can?(current_user, :stop_environment, environment) return unless can?(current_user, :stop_environment, environment)
environment.stop_with_action!(current_user) environment.stop_with_actions!(current_user)
end end
def execute_for_branch(branch_name) def execute_for_branch(branch_name)
......
...@@ -10,8 +10,10 @@ module Environments ...@@ -10,8 +10,10 @@ module Environments
def perform(environment_id, params = {}) def perform(environment_id, params = {})
Environment.find_by_id(environment_id).try do |environment| Environment.find_by_id(environment_id).try do |environment|
user = environment.stop_action&.user stop_actions = environment.stop_actions
environment.stop_with_action!(user)
user = stop_actions.last&.user
environment.stop_with_actions!(user)
end end
end end
end end
......
---
name: environment_multiple_stop_actions
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/84922
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/358911
milestone: '14.10'
type: development
group: group::release
default_enabled: false
...@@ -131,7 +131,7 @@ module API ...@@ -131,7 +131,7 @@ module API
environment = user_project.environments.find(params[:environment_id]) environment = user_project.environments.find(params[:environment_id])
authorize! :stop_environment, environment authorize! :stop_environment, environment
environment.stop_with_action!(current_user) environment.stop_with_actions!(current_user)
status 200 status 200
present environment, with: Entities::Environment, current_user: current_user present environment, with: Entities::Environment, current_user: current_user
......
...@@ -254,38 +254,54 @@ RSpec.describe Projects::EnvironmentsController do ...@@ -254,38 +254,54 @@ RSpec.describe Projects::EnvironmentsController do
end end
describe 'PATCH #stop' do describe 'PATCH #stop' do
subject { patch :stop, params: environment_params(format: :json) }
context 'when env not available' do context 'when env not available' do
it 'returns 404' do it 'returns 404' do
allow_any_instance_of(Environment).to receive(:available?) { false } allow_any_instance_of(Environment).to receive(:available?) { false }
patch :stop, params: environment_params(format: :json) subject
expect(response).to have_gitlab_http_status(:not_found) expect(response).to have_gitlab_http_status(:not_found)
end end
end end
context 'when stop action' do context 'when stop action' do
it 'returns action url' do it 'returns action url for single stop action' do
action = create(:ci_build, :manual) action = create(:ci_build, :manual)
allow_any_instance_of(Environment) allow_any_instance_of(Environment)
.to receive_messages(available?: true, stop_with_action!: action) .to receive_messages(available?: true, stop_with_actions!: [action])
patch :stop, params: environment_params(format: :json) subject
expect(response).to have_gitlab_http_status(:ok) expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to eq( expect(json_response).to eq(
{ 'redirect_url' => { 'redirect_url' =>
project_job_url(project, action) }) project_job_url(project, action) })
end end
it 'returns environment url for multiple stop actions' do
actions = create_list(:ci_build, 2, :manual)
allow_any_instance_of(Environment)
.to receive_messages(available?: true, stop_with_actions!: actions)
subject
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to eq(
{ 'redirect_url' =>
project_environment_url(project, environment) })
end
end end
context 'when no stop action' do context 'when no stop action' do
it 'returns env url' do it 'returns env url' do
allow_any_instance_of(Environment) allow_any_instance_of(Environment)
.to receive_messages(available?: true, stop_with_action!: nil) .to receive_messages(available?: true, stop_with_actions!: nil)
patch :stop, params: environment_params(format: :json) subject
expect(response).to have_gitlab_http_status(:ok) expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to eq( expect(json_response).to eq(
......
...@@ -23,7 +23,6 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do ...@@ -23,7 +23,6 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do
it { is_expected.to have_one(:upcoming_deployment) } it { is_expected.to have_one(:upcoming_deployment) }
it { is_expected.to have_one(:latest_opened_most_severe_alert) } it { is_expected.to have_one(:latest_opened_most_severe_alert) }
it { is_expected.to delegate_method(:stop_action).to(:last_deployment) }
it { is_expected.to delegate_method(:manual_actions).to(:last_deployment) } it { is_expected.to delegate_method(:manual_actions).to(:last_deployment) }
it { is_expected.to validate_presence_of(:name) } it { is_expected.to validate_presence_of(:name) }
...@@ -472,8 +471,8 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do ...@@ -472,8 +471,8 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do
end end
end end
describe '#stop_action_available?' do describe '#stop_actions_available?' do
subject { environment.stop_action_available? } subject { environment.stop_actions_available? }
context 'when no other actions' do context 'when no other actions' do
it { is_expected.to be_falsey } it { is_expected.to be_falsey }
...@@ -512,10 +511,10 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do ...@@ -512,10 +511,10 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do
end end
end end
describe '#stop_with_action!' do describe '#stop_with_actions!' do
let(:user) { create(:user) } let(:user) { create(:user) }
subject { environment.stop_with_action!(user) } subject { environment.stop_with_actions!(user) }
before do before do
expect(environment).to receive(:available?).and_call_original expect(environment).to receive(:available?).and_call_original
...@@ -528,9 +527,10 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do ...@@ -528,9 +527,10 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do
end end
it do it do
subject actions = subject
expect(environment).to be_stopped expect(environment).to be_stopped
expect(actions).to match_array([])
end end
end end
...@@ -549,18 +549,18 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do ...@@ -549,18 +549,18 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do
context 'when matching action is defined' do context 'when matching action is defined' do
let(:pipeline) { create(:ci_pipeline, project: project) } let(:pipeline) { create(:ci_pipeline, project: project) }
let(:build) { create(:ci_build, pipeline: pipeline) } let(:build_a) { create(:ci_build, pipeline: pipeline) }
let!(:deployment) do before do
create(:deployment, :success, create(:deployment, :success,
environment: environment, environment: environment,
deployable: build, deployable: build_a,
on_stop: 'close_app') on_stop: 'close_app_a')
end end
context 'when user is not allowed to stop environment' do context 'when user is not allowed to stop environment' do
let!(:close_action) do let!(:close_action) do
create(:ci_build, :manual, pipeline: pipeline, name: 'close_app') create(:ci_build, :manual, pipeline: pipeline, name: 'close_app_a')
end end
it 'raises an exception' do it 'raises an exception' do
...@@ -578,36 +578,39 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do ...@@ -578,36 +578,39 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do
context 'when action did not yet finish' do context 'when action did not yet finish' do
let!(:close_action) do let!(:close_action) do
create(:ci_build, :manual, pipeline: pipeline, name: 'close_app') create(:ci_build, :manual, pipeline: pipeline, name: 'close_app_a')
end end
it 'returns the same action' do it 'returns the same action' do
expect(subject).to eq(close_action) action = subject.first
expect(subject.user).to eq(user) expect(action).to eq(close_action)
expect(action.user).to eq(user)
end end
end end
context 'if action did finish' do context 'if action did finish' do
let!(:close_action) do let!(:close_action) do
create(:ci_build, :manual, :success, create(:ci_build, :manual, :success,
pipeline: pipeline, name: 'close_app') pipeline: pipeline, name: 'close_app_a')
end end
it 'returns a new action of the same type' do it 'returns a new action of the same type' do
expect(subject).to be_persisted action = subject.first
expect(subject.name).to eq(close_action.name)
expect(subject.user).to eq(user) expect(action).to be_persisted
expect(action.name).to eq(close_action.name)
expect(action.user).to eq(user)
end end
end end
context 'close action does not raise ActiveRecord::StaleObjectError' do context 'close action does not raise ActiveRecord::StaleObjectError' do
let!(:close_action) do let!(:close_action) do
create(:ci_build, :manual, pipeline: pipeline, name: 'close_app') create(:ci_build, :manual, pipeline: pipeline, name: 'close_app_a')
end end
before do before do
# preload the build # preload the build
environment.stop_action environment.stop_actions
# Update record as the other process. This makes `environment.stop_action` stale. # Update record as the other process. This makes `environment.stop_action` stale.
close_action.drop! close_action.drop!
...@@ -626,6 +629,147 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do ...@@ -626,6 +629,147 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do
end end
end end
end end
context 'when there are more then one stop action for the environment' do
let(:pipeline) { create(:ci_pipeline, project: project) }
let(:build_a) { create(:ci_build, pipeline: pipeline) }
let(:build_b) { create(:ci_build, pipeline: pipeline) }
let!(:close_actions) do
[
create(:ci_build, :manual, pipeline: pipeline, name: 'close_app_a'),
create(:ci_build, :manual, pipeline: pipeline, name: 'close_app_b')
]
end
before do
project.add_developer(user)
create(:deployment, :success,
environment: environment,
deployable: build_a,
finished_at: 5.minutes.ago,
on_stop: 'close_app_a')
create(:deployment, :success,
environment: environment,
deployable: build_b,
finished_at: 1.second.ago,
on_stop: 'close_app_b')
end
it 'returns the same actions' do
actions = subject
expect(actions.count).to eq(close_actions.count)
expect(actions.pluck(:id)).to match_array(close_actions.pluck(:id))
expect(actions.pluck(:user)).to match_array(close_actions.pluck(:user))
end
context 'when there are failed deployment jobs' do
before do
create(:ci_build, pipeline: pipeline, name: 'close_app_c')
create(:deployment, :failed,
environment: environment,
deployable: create(:ci_build, pipeline: pipeline),
on_stop: 'close_app_c')
end
it 'returns only stop actions from successful deployment jobs' do
actions = subject
expect(actions).to match_array(close_actions)
expect(actions.count).to eq(environment.successful_deployments.count)
end
end
context 'when the feature is disabled' do
before do
stub_feature_flags(environment_multiple_stop_actions: false)
end
it 'returns the last deployment job stop action' do
stop_actions = subject
expect(stop_actions.first).to eq(close_actions[1])
expect(stop_actions.count).to eq(1)
end
end
end
end
describe '#stop_actions' do
subject { environment.stop_actions }
context 'when there are no deployments and builds' do
it 'returns empty array' do
is_expected.to match_array([])
end
end
context 'when there are multiple deployments with actions' do
let(:pipeline) { create(:ci_pipeline, project: project) }
let(:ci_build_a) { create(:ci_build, project: project, pipeline: pipeline) }
let(:ci_build_b) { create(:ci_build, project: project, pipeline: pipeline) }
let!(:ci_build_c) { create(:ci_build, :manual, project: project, pipeline: pipeline, name: 'close_app_a') }
let!(:ci_build_d) { create(:ci_build, :manual, project: project, pipeline: pipeline, name: 'close_app_b') }
let!(:deployment_a) do
create(:deployment,
:success, project: project, environment: environment, deployable: ci_build_a, on_stop: 'close_app_a')
end
let!(:deployment_b) do
create(:deployment,
:success, project: project, environment: environment, deployable: ci_build_b, on_stop: 'close_app_b')
end
before do
# Create failed deployment without stop_action.
build = create(:ci_build, project: project, pipeline: pipeline)
create(:deployment, :failed, project: project, environment: environment, deployable: build)
end
it 'returns only the stop actions' do
expect(subject.pluck(:id)).to contain_exactly(ci_build_c.id, ci_build_d.id)
end
end
end
describe '#last_deployment_group' do
subject { environment.last_deployment_group }
context 'when there are no deployments and builds' do
it do
is_expected.to eq(Deployment.none)
end
end
context 'when there are deployments for multiple pipelines' do
let(:pipeline_a) { create(:ci_pipeline, project: project) }
let(:pipeline_b) { create(:ci_pipeline, project: project) }
let(:ci_build_a) { create(:ci_build, project: project, pipeline: pipeline_a) }
let(:ci_build_b) { create(:ci_build, project: project, pipeline: pipeline_b) }
let(:ci_build_c) { create(:ci_build, project: project, pipeline: pipeline_a) }
let(:ci_build_d) { create(:ci_build, project: project, pipeline: pipeline_a) }
# Successful deployments for pipeline_a
let!(:deployment_a) { create(:deployment, :success, project: project, environment: environment, deployable: ci_build_a) }
let!(:deployment_b) { create(:deployment, :success, project: project, environment: environment, deployable: ci_build_c) }
before do
# Failed deployment for pipeline_a
create(:deployment, :failed, project: project, environment: environment, deployable: ci_build_d)
# Failed deployment for pipeline_b
create(:deployment, :failed, project: project, environment: environment, deployable: ci_build_b)
end
it 'returns the successful deployment jobs for the last deployment pipeline' do
expect(subject.pluck(:id)).to contain_exactly(deployment_a.id, deployment_b.id)
end
end
end end
describe 'recently_updated_on_branch?' do describe 'recently_updated_on_branch?' do
...@@ -785,6 +929,26 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do ...@@ -785,6 +929,26 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do
end end
end end
describe '#last_deployment_pipeline' do
subject { environment.last_deployment_pipeline }
let(:pipeline_a) { create(:ci_pipeline, project: project) }
let(:pipeline_b) { create(:ci_pipeline, project: project) }
let(:ci_build_a) { create(:ci_build, project: project, pipeline: pipeline_a) }
let(:ci_build_b) { create(:ci_build, project: project, pipeline: pipeline_b) }
before do
create(:deployment, :success, project: project, environment: environment, deployable: ci_build_a)
create(:deployment, :failed, project: project, environment: environment, deployable: ci_build_b)
end
it 'does not join across databases' do
with_cross_joins_prevented do
expect(subject.id).to eq(pipeline_a.id)
end
end
end
describe '#last_visible_deployment' do describe '#last_visible_deployment' do
subject { environment.last_visible_deployment } subject { environment.last_visible_deployment }
......
...@@ -8,7 +8,11 @@ RSpec.shared_examples 'avoid N+1 on environments serialization' do |ee: false| ...@@ -8,7 +8,11 @@ RSpec.shared_examples 'avoid N+1 on environments serialization' do |ee: false|
create_environment_with_associations(project) create_environment_with_associations(project)
create_environment_with_associations(project) create_environment_with_associations(project)
expect { serialize(grouping: true) }.not_to exceed_query_limit(control.count) # Fix N+1 queries introduced by multi stop_actions for environment.
# Tracked in https://gitlab.com/gitlab-org/gitlab/-/issues/358780
relax_count = 14
expect { serialize(grouping: true) }.not_to exceed_query_limit(control.count + relax_count)
end end
it 'avoids N+1 database queries without grouping', :request_store do it 'avoids N+1 database queries without grouping', :request_store do
...@@ -19,7 +23,11 @@ RSpec.shared_examples 'avoid N+1 on environments serialization' do |ee: false| ...@@ -19,7 +23,11 @@ RSpec.shared_examples 'avoid N+1 on environments serialization' do |ee: false|
create_environment_with_associations(project) create_environment_with_associations(project)
create_environment_with_associations(project) create_environment_with_associations(project)
expect { serialize(grouping: false) }.not_to exceed_query_limit(control.count) # Fix N+1 queries introduced by multi stop_actions for environment.
# Tracked in https://gitlab.com/gitlab-org/gitlab/-/issues/358780
relax_count = 14
expect { serialize(grouping: false) }.not_to exceed_query_limit(control.count + relax_count)
end end
it 'does not preload for environments that does not exist in the page', :request_store do it 'does not preload for environments that does not exist in the page', :request_store 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