diff --git a/app/controllers/clusters/applications_controller.rb b/app/controllers/clusters/applications_controller.rb index 73c744efeba5af3db6944f94b9899c5a05f0e1f1..16c2365f85d78bc39475884a8464d0e74e01e745 100644 --- a/app/controllers/clusters/applications_controller.rb +++ b/app/controllers/clusters/applications_controller.rb @@ -4,6 +4,7 @@ class Clusters::ApplicationsController < Clusters::BaseController before_action :cluster before_action :authorize_create_cluster!, only: [:create] before_action :authorize_update_cluster!, only: [:update] + before_action :authorize_admin_cluster!, only: [:destroy] def create request_handler do @@ -21,6 +22,14 @@ class Clusters::ApplicationsController < Clusters::BaseController end end + def destroy + request_handler do + Clusters::Applications::DestroyService + .new(@cluster, current_user, cluster_application_destroy_params) + .execute(request) + end + end + private def request_handler @@ -40,4 +49,8 @@ class Clusters::ApplicationsController < Clusters::BaseController def cluster_application_params params.permit(:application, :hostname, :email) end + + def cluster_application_destroy_params + params.permit(:application) + end end diff --git a/app/models/clusters/applications/cert_manager.rb b/app/models/clusters/applications/cert_manager.rb index ac0e7eb03bc417a462cc32f424a72c77dba19a4c..d6a7d1d2bdd9222508b7bde03c9dbe2d9d9ce7d1 100644 --- a/app/models/clusters/applications/cert_manager.rb +++ b/app/models/clusters/applications/cert_manager.rb @@ -24,6 +24,12 @@ module Clusters 'stable/cert-manager' end + # We will implement this in future MRs. + # Need to reverse postinstall step + def allowed_to_uninstall? + false + end + def install_command Gitlab::Kubernetes::Helm::InstallCommand.new( name: 'certmanager', diff --git a/app/models/clusters/applications/helm.rb b/app/models/clusters/applications/helm.rb index 71aff00077defb4c2e1878e9c4c9cd6618ae1e2d..a83d06c4b00d74b0152b4b2b6269b69238503ffa 100644 --- a/app/models/clusters/applications/helm.rb +++ b/app/models/clusters/applications/helm.rb @@ -29,6 +29,13 @@ module Clusters self.status = 'installable' if cluster&.platform_kubernetes_active? end + # We will implement this in future MRs. + # Basically we need to check all other applications are not installed + # first. + def allowed_to_uninstall? + false + end + def install_command Gitlab::Kubernetes::Helm::InitCommand.new( name: name, diff --git a/app/models/clusters/applications/ingress.rb b/app/models/clusters/applications/ingress.rb index 376d54aab2cd1cee74b217977239864924da67a0..a1023f44049e840146d6b06d5839231140f5dc58 100644 --- a/app/models/clusters/applications/ingress.rb +++ b/app/models/clusters/applications/ingress.rb @@ -35,6 +35,13 @@ module Clusters 'stable/nginx-ingress' end + # We will implement this in future MRs. + # Basically we need to check all dependent applications are not installed + # first. + def allowed_to_uninstall? + false + end + def install_command Gitlab::Kubernetes::Helm::InstallCommand.new( name: name, diff --git a/app/models/clusters/applications/jupyter.rb b/app/models/clusters/applications/jupyter.rb index f86ff3551a17c40fab7e68512b3a81b1e99b49b8..987c057ad6d6cc7530568cc962f08b7c3936941b 100644 --- a/app/models/clusters/applications/jupyter.rb +++ b/app/models/clusters/applications/jupyter.rb @@ -38,6 +38,12 @@ module Clusters content_values.to_yaml end + # Will be addressed in future MRs + # We need to investigate and document what will be permenantly deleted. + def allowed_to_uninstall? + false + end + def install_command Gitlab::Kubernetes::Helm::InstallCommand.new( name: name, diff --git a/app/models/clusters/applications/knative.rb b/app/models/clusters/applications/knative.rb index 38cbc9ce8eb65c2fd59d8695570d54b4b744307f..9fbf5d8af045d1c677b9adfbcea00dc7258a59c4 100644 --- a/app/models/clusters/applications/knative.rb +++ b/app/models/clusters/applications/knative.rb @@ -51,6 +51,12 @@ module Clusters { "domain" => hostname }.to_yaml end + # Handled in a new issue: + # https://gitlab.com/gitlab-org/gitlab-ce/issues/59369 + def allowed_to_uninstall? + false + end + def install_command Gitlab::Kubernetes::Helm::InstallCommand.new( name: name, diff --git a/app/models/clusters/applications/prometheus.rb b/app/models/clusters/applications/prometheus.rb index 954c29da196f06462f8af8345b7e94f4f98cdbb2..a6b7617b830ce95d2bb98166cdbcb274ff07e1bd 100644 --- a/app/models/clusters/applications/prometheus.rb +++ b/app/models/clusters/applications/prometheus.rb @@ -16,10 +16,12 @@ module Clusters default_value_for :version, VERSION + after_destroy :disable_prometheus_integration + state_machine :status do after_transition any => [:installed] do |application| application.cluster.projects.each do |project| - project.find_or_initialize_service('prometheus').update(active: true) + project.find_or_initialize_service('prometheus').update!(active: true) end end end @@ -47,6 +49,14 @@ module Clusters ) end + def uninstall_command + Gitlab::Kubernetes::Helm::DeleteCommand.new( + name: name, + rbac: cluster.platform_kubernetes_rbac?, + files: files + ) + end + def upgrade_command(values) ::Gitlab::Kubernetes::Helm::InstallCommand.new( name: name, @@ -82,6 +92,12 @@ module Clusters private + def disable_prometheus_integration + cluster.projects.each do |project| + project.prometheus_service&.update!(active: false) + end + end + def kube_client cluster&.kubeclient&.core_client end diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb index 3ebf7a5cfbaa7810e49642e9275eb6ae43bc8a5b..af648db370873c631de66070784926606ecfc899 100644 --- a/app/models/clusters/applications/runner.rb +++ b/app/models/clusters/applications/runner.rb @@ -29,6 +29,13 @@ module Clusters content_values.to_yaml end + # Need to investigate if pipelines run by this runner will stop upon the + # executor pod stopping + # I.e.run a pipeline, and uninstall runner while pipeline is running + def allowed_to_uninstall? + false + end + def install_command Gitlab::Kubernetes::Helm::InstallCommand.new( name: name, diff --git a/app/models/clusters/concerns/application_core.rb b/app/models/clusters/concerns/application_core.rb index ee964fb7c93f7c6a1d3e273fbcacece8b38fb1ee..4514498b84b8881fd97b330a513852d0d8db6b37 100644 --- a/app/models/clusters/concerns/application_core.rb +++ b/app/models/clusters/concerns/application_core.rb @@ -18,6 +18,16 @@ module Clusters self.status = 'installable' if cluster&.application_helm_available? end + def can_uninstall? + allowed_to_uninstall? + end + + # All new applications should uninstall by default + # Override if there's dependencies that needs to be uninstalled first + def allowed_to_uninstall? + true + end + def self.application_name self.to_s.demodulize.underscore end diff --git a/app/models/clusters/concerns/application_status.rb b/app/models/clusters/concerns/application_status.rb index 1273ed83abec9529cc830961a97bbf0d84a7b4d9..54a3dda6d755acc12f1d7f32470fc9acc5cddcc7 100644 --- a/app/models/clusters/concerns/application_status.rb +++ b/app/models/clusters/concerns/application_status.rb @@ -25,9 +25,11 @@ module Clusters state :updating, value: 4 state :updated, value: 5 state :update_errored, value: 6 + state :uninstalling, value: 7 + state :uninstall_errored, value: 8 event :make_scheduled do - transition [:installable, :errored, :installed, :updated, :update_errored] => :scheduled + transition [:installable, :errored, :installed, :updated, :update_errored, :uninstall_errored] => :scheduled end event :make_installing do @@ -40,8 +42,9 @@ module Clusters end event :make_errored do - transition any - [:updating] => :errored + transition any - [:updating, :uninstalling] => :errored transition [:updating] => :update_errored + transition [:uninstalling] => :uninstall_errored end event :make_updating do @@ -52,6 +55,10 @@ module Clusters transition any => :update_errored end + event :make_uninstalling do + transition [:scheduled] => :uninstalling + end + before_transition any => [:scheduled] do |app_status, _| app_status.status_reason = nil end @@ -65,7 +72,7 @@ module Clusters app_status.status_reason = nil end - before_transition any => [:update_errored] do |app_status, transition| + before_transition any => [:update_errored, :uninstall_errored] do |app_status, transition| status_reason = transition.args.first app_status.status_reason = status_reason if status_reason end diff --git a/app/serializers/cluster_application_entity.rb b/app/serializers/cluster_application_entity.rb index a4a2c015c4ef8a116c4196dc6fa7316f9073e9e8..2a916b13f521dd886a536f3dd29f4f4706887fbd 100644 --- a/app/serializers/cluster_application_entity.rb +++ b/app/serializers/cluster_application_entity.rb @@ -10,4 +10,5 @@ class ClusterApplicationEntity < Grape::Entity expose :hostname, if: -> (e, _) { e.respond_to?(:hostname) } expose :email, if: -> (e, _) { e.respond_to?(:email) } expose :update_available?, as: :update_available, if: -> (e, _) { e.respond_to?(:update_available?) } + expose :can_uninstall?, as: :can_uninstall end diff --git a/app/services/clusters/applications/check_installation_progress_service.rb b/app/services/clusters/applications/check_installation_progress_service.rb index c592d608b89bf805089656169bdcb5f2c5dbe192..3c6803d24e6fb390a9920c75e64d8c0911d97aec 100644 --- a/app/services/clusters/applications/check_installation_progress_service.rb +++ b/app/services/clusters/applications/check_installation_progress_service.rb @@ -37,7 +37,7 @@ module Clusters end def check_timeout - if timeouted? + if timed_out? begin app.make_errored!("Operation timed out. Check pod logs for #{pod_name} for more details.") end @@ -51,8 +51,8 @@ module Clusters install_command.pod_name end - def timeouted? - Time.now.utc - app.updated_at.to_time.utc > ClusterWaitForAppInstallationWorker::TIMEOUT + def timed_out? + Time.now.utc - app.updated_at.utc > ClusterWaitForAppInstallationWorker::TIMEOUT end def remove_installation_pod diff --git a/app/services/clusters/applications/check_uninstall_progress_service.rb b/app/services/clusters/applications/check_uninstall_progress_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..8786d295d6a23d4f224c8b59b35e8dda171ec294 --- /dev/null +++ b/app/services/clusters/applications/check_uninstall_progress_service.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module Clusters + module Applications + class CheckUninstallProgressService < BaseHelmService + def execute + return unless app.uninstalling? + + case installation_phase + when Gitlab::Kubernetes::Pod::SUCCEEDED + on_success + when Gitlab::Kubernetes::Pod::FAILED + on_failed + else + check_timeout + end + rescue Kubeclient::HttpError => e + log_error(e) + + app.make_errored!(_('Kubernetes error: %{error_code}') % { error_code: e.error_code }) + end + + private + + def on_success + app.destroy! + rescue StandardError => e + app.make_errored!(_('Application uninstalled but failed to destroy: %{error_message}') % { error_message: e.message }) + ensure + remove_installation_pod + end + + def on_failed + app.make_errored!(_('Operation failed. Check pod logs for %{pod_name} for more details.') % { pod_name: pod_name }) + end + + def check_timeout + if timed_out? + app.make_errored!(_('Operation timed out. Check pod logs for %{pod_name} for more details.') % { pod_name: pod_name }) + else + WaitForUninstallAppWorker.perform_in(WaitForUninstallAppWorker::INTERVAL, app.name, app.id) + end + end + + def pod_name + app.uninstall_command.pod_name + end + + def timed_out? + Time.now.utc - app.updated_at.utc > WaitForUninstallAppWorker::TIMEOUT + end + + def remove_installation_pod + helm_api.delete_pod!(pod_name) + end + + def installation_phase + helm_api.status(pod_name) + end + end + end +end diff --git a/app/services/clusters/applications/create_service.rb b/app/services/clusters/applications/create_service.rb index ae36da7b3ddeda222b78dd817c95e88af2550bf7..f723c42c04959ecf7892503638852863ae5b6c54 100644 --- a/app/services/clusters/applications/create_service.rb +++ b/app/services/clusters/applications/create_service.rb @@ -10,8 +10,8 @@ module Clusters end def builder - cluster.method("application_#{application_name}").call || - cluster.method("build_application_#{application_name}").call + cluster.public_send(:"application_#{application_name}") || # rubocop:disable GitlabSecurity/PublicSend + cluster.public_send(:"build_application_#{application_name}") # rubocop:disable GitlabSecurity/PublicSend end end end diff --git a/app/services/clusters/applications/destroy_service.rb b/app/services/clusters/applications/destroy_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..f3a4c4f754a937be4ecf45c7272aa6e36e38aa5e --- /dev/null +++ b/app/services/clusters/applications/destroy_service.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Clusters + module Applications + class DestroyService < ::Clusters::Applications::BaseService + def execute(_request) + instantiate_application.tap do |application| + break unless application.can_uninstall? + + application.make_scheduled! + + Clusters::Applications::UninstallWorker.perform_async(application.name, application.id) + end + end + + private + + def builder + cluster.public_send(:"application_#{application_name}") # rubocop:disable GitlabSecurity/PublicSend + end + end + end +end diff --git a/app/services/clusters/applications/uninstall_service.rb b/app/services/clusters/applications/uninstall_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..50c8d806c14a084c03b7294c7b9a5657415a9027 --- /dev/null +++ b/app/services/clusters/applications/uninstall_service.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Clusters + module Applications + class UninstallService < BaseHelmService + def execute + return unless app.scheduled? + + app.make_uninstalling! + uninstall + end + + private + + def uninstall + helm_api.uninstall(app.uninstall_command) + + Clusters::Applications::WaitForUninstallAppWorker.perform_in( + Clusters::Applications::WaitForUninstallAppWorker::INTERVAL, app.name, app.id) + rescue Kubeclient::HttpError => e + log_error(e) + app.make_errored!("Kubernetes error: #{e.error_code}") + rescue StandardError => e + log_error(e) + app.make_errored!('Failed to uninstall.') + end + end + end +end diff --git a/app/services/clusters/applications/update_service.rb b/app/services/clusters/applications/update_service.rb index 5071c31839c00c8782531449a345268b849d2643..0fa937da86514f23cf178d636a2b0aa366b225ec 100644 --- a/app/services/clusters/applications/update_service.rb +++ b/app/services/clusters/applications/update_service.rb @@ -10,7 +10,7 @@ module Clusters end def builder - cluster.method("application_#{application_name}").call + cluster.public_send(:"application_#{application_name}") # rubocop:disable GitlabSecurity/PublicSend end end end diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index d01bbbf269e96cd48367d94cf88ea56fa8427bb0..7cf2e7100d59200c3e534b650152e95490a07f2c 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -32,6 +32,8 @@ - gcp_cluster:cluster_wait_for_ingress_ip_address - gcp_cluster:cluster_configure - gcp_cluster:cluster_project_configure +- gcp_cluster:clusters_applications_wait_for_uninstall_app +- gcp_cluster:clusters_applications_uninstall - github_import_advance_stage - github_importer:github_import_import_diff_note diff --git a/app/workers/clusters/applications/uninstall_worker.rb b/app/workers/clusters/applications/uninstall_worker.rb new file mode 100644 index 0000000000000000000000000000000000000000..85e8ecc4ad593d33b9ed72f0dc4e3496b6950594 --- /dev/null +++ b/app/workers/clusters/applications/uninstall_worker.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Clusters + module Applications + class UninstallWorker + include ApplicationWorker + include ClusterQueue + include ClusterApplications + + def perform(app_name, app_id) + find_application(app_name, app_id) do |app| + Clusters::Applications::UninstallService.new(app).execute + end + end + end + end +end diff --git a/app/workers/clusters/applications/wait_for_uninstall_app_worker.rb b/app/workers/clusters/applications/wait_for_uninstall_app_worker.rb new file mode 100644 index 0000000000000000000000000000000000000000..163c99d3c3ca9feff537e11352c7d496e1cb6421 --- /dev/null +++ b/app/workers/clusters/applications/wait_for_uninstall_app_worker.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Clusters + module Applications + class WaitForUninstallAppWorker + include ApplicationWorker + include ClusterQueue + include ClusterApplications + + INTERVAL = 10.seconds + TIMEOUT = 20.minutes + + def perform(app_name, app_id) + find_application(app_name, app_id) do |app| + Clusters::Applications::CheckUninstallProgressService.new(app).execute + end + end + end + end +end diff --git a/config/routes.rb b/config/routes.rb index bbf002085455175fdc6a23de6cd9fcb12404cc7c..f5957f43655ffc193ce34d1e41eb7a8435569847 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -103,6 +103,7 @@ Rails.application.routes.draw do scope :applications do post '/:application', to: 'clusters/applications#create', as: :install_applications patch '/:application', to: 'clusters/applications#update', as: :update_applications + delete '/:application', to: 'clusters/applications#destroy', as: :uninstall_applications end get :cluster_status, format: :json diff --git a/lib/gitlab/kubernetes/helm/api.rb b/lib/gitlab/kubernetes/helm/api.rb index 7dfd9ed4f3507c7825acb2399991e0ea1bae36ef..ff1dadf924782920540011b8b52832282e2927e2 100644 --- a/lib/gitlab/kubernetes/helm/api.rb +++ b/lib/gitlab/kubernetes/helm/api.rb @@ -22,6 +22,13 @@ module Gitlab alias_method :update, :install + def uninstall(command) + namespace.ensure_exists! + + delete_pod!(command.pod_name) + kubeclient.create_pod(command.pod_resource) + end + ## # Returns Pod phase # diff --git a/locale/gitlab.pot b/locale/gitlab.pot index c8b583575c864bb684ebb4e72aedaf15832f1ca4..3d56efa98342f4742ed6332957a1c03722ab45d7 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -942,6 +942,9 @@ msgstr "" msgid "Application settings saved successfully" msgstr "" +msgid "Application uninstalled but failed to destroy: %{error_message}" +msgstr "" + msgid "Application was successfully destroyed." msgstr "" @@ -6274,6 +6277,12 @@ msgstr "" msgid "Opens in a new window" msgstr "" +msgid "Operation failed. Check pod logs for %{pod_name} for more details." +msgstr "" + +msgid "Operation timed out. Check pod logs for %{pod_name} for more details." +msgstr "" + msgid "Operations" msgstr "" diff --git a/spec/controllers/projects/clusters/applications_controller_spec.rb b/spec/controllers/projects/clusters/applications_controller_spec.rb index cd1a01f8accce480df6e6076df075b723bcb4a8e..70b34f071c86dbd222fa2f19f2ce4e724d9dd84a 100644 --- a/spec/controllers/projects/clusters/applications_controller_spec.rb +++ b/spec/controllers/projects/clusters/applications_controller_spec.rb @@ -145,4 +145,66 @@ describe Projects::Clusters::ApplicationsController do it_behaves_like 'a secure endpoint' end end + + describe 'DELETE destroy' do + subject do + delete :destroy, params: params.merge(namespace_id: project.namespace, project_id: project) + end + + let(:cluster) { create(:cluster, :project, :provided_by_gcp) } + let(:project) { cluster.project } + let!(:application) { create(:clusters_applications_prometheus, :installed, cluster: cluster) } + let(:application_name) { application.name } + let(:params) { { application: application_name, id: cluster.id } } + let(:worker_class) { Clusters::Applications::UninstallWorker } + + describe 'functionality' do + let(:user) { create(:user) } + + before do + project.add_maintainer(user) + sign_in(user) + end + + context "when cluster and app exists" do + it "schedules an application update" do + expect(worker_class).to receive(:perform_async).with(application.name, application.id).once + + is_expected.to have_http_status(:no_content) + + expect(cluster.application_prometheus).to be_scheduled + end + end + + context 'when cluster do not exists' do + before do + cluster.destroy! + end + + it { is_expected.to have_http_status(:not_found) } + end + + context 'when application is unknown' do + let(:application_name) { 'unkwnown-app' } + + it { is_expected.to have_http_status(:not_found) } + end + + context 'when application is already scheduled' do + before do + application.make_scheduled! + end + + it { is_expected.to have_http_status(:bad_request) } + end + end + + describe 'security' do + before do + allow(worker_class).to receive(:perform_async) + end + + it_behaves_like 'a secure endpoint' + end + end end diff --git a/spec/factories/clusters/applications/helm.rb b/spec/factories/clusters/applications/helm.rb index fe56ac5b71d4b371f5af5d496bdd5cab35280663..d78f01828d7cfe850d5124585535e76f124a63c5 100644 --- a/spec/factories/clusters/applications/helm.rb +++ b/spec/factories/clusters/applications/helm.rb @@ -6,6 +6,11 @@ FactoryBot.define do status(-2) end + trait :errored do + status(-1) + status_reason 'something went wrong' + end + trait :installable do status 0 end @@ -30,17 +35,21 @@ FactoryBot.define do status 5 end - trait :errored do - status(-1) + trait :update_errored do + status(6) status_reason 'something went wrong' end - trait :update_errored do - status(6) + trait :uninstalling do + status 7 + end + + trait :uninstall_errored do + status(8) status_reason 'something went wrong' end - trait :timeouted do + trait :timed_out do installing updated_at { ClusterWaitForAppInstallationWorker::TIMEOUT.ago } end diff --git a/spec/fixtures/api/schemas/cluster_status.json b/spec/fixtures/api/schemas/cluster_status.json index 9da07a0b2531299a597bb8a15314d2c1dc481f2f..695175689b95b68ce25ee8cb72f25b6c5d0bf95c 100644 --- a/spec/fixtures/api/schemas/cluster_status.json +++ b/spec/fixtures/api/schemas/cluster_status.json @@ -36,7 +36,8 @@ "external_hostname": { "type": ["string", "null"] }, "hostname": { "type": ["string", "null"] }, "email": { "type": ["string", "null"] }, - "update_available": { "type": ["boolean", "null"] } + "update_available": { "type": ["boolean", "null"] }, + "can_uninstall": { "type": "boolean" } }, "required" : [ "name", "status" ] } diff --git a/spec/lib/gitlab/kubernetes/helm/api_spec.rb b/spec/lib/gitlab/kubernetes/helm/api_spec.rb index 8433d40b2ea5bcd89f88040fe75e7e89aa135fc8..24ce397ec3d71f7d8687ebbe6d6932694e66f962 100644 --- a/spec/lib/gitlab/kubernetes/helm/api_spec.rb +++ b/spec/lib/gitlab/kubernetes/helm/api_spec.rb @@ -33,6 +33,28 @@ describe Gitlab::Kubernetes::Helm::Api do end end + describe '#uninstall' do + before do + allow(client).to receive(:create_pod).and_return(nil) + allow(client).to receive(:delete_pod).and_return(nil) + allow(namespace).to receive(:ensure_exists!).once + end + + it 'ensures the namespace exists before creating the POD' do + expect(namespace).to receive(:ensure_exists!).once.ordered + expect(client).to receive(:create_pod).once.ordered + + subject.uninstall(command) + end + + it 'removes an existing pod before installing' do + expect(client).to receive(:delete_pod).with('install-app-name', 'gitlab-managed-apps').once.ordered + expect(client).to receive(:create_pod).once.ordered + + subject.uninstall(command) + end + end + describe '#install' do before do allow(client).to receive(:create_pod).and_return(nil) diff --git a/spec/models/clusters/applications/cert_manager_spec.rb b/spec/models/clusters/applications/cert_manager_spec.rb index 5cd80edb3a134eb2108255dedae38c2b0bf4b3b7..8d853a04e332e6ca391117b6ac6d9ce3cddf6040 100644 --- a/spec/models/clusters/applications/cert_manager_spec.rb +++ b/spec/models/clusters/applications/cert_manager_spec.rb @@ -10,6 +10,12 @@ describe Clusters::Applications::CertManager do include_examples 'cluster application version specs', :clusters_applications_cert_managers include_examples 'cluster application initial status specs' + describe '#can_uninstall?' do + subject { cert_manager.can_uninstall? } + + it { is_expected.to be_falsey } + end + describe '#install_command' do let(:cert_email) { 'admin@example.com' } diff --git a/spec/models/clusters/applications/helm_spec.rb b/spec/models/clusters/applications/helm_spec.rb index f177d493a2e8274d782f84f6f144fa9ba28320b8..6ea6c110d62e532452444c0236d06202d3f03d46 100644 --- a/spec/models/clusters/applications/helm_spec.rb +++ b/spec/models/clusters/applications/helm_spec.rb @@ -18,6 +18,14 @@ describe Clusters::Applications::Helm do it { is_expected.to contain_exactly(installed_cluster, updated_cluster) } end + describe '#can_uninstall?' do + let(:helm) { create(:clusters_applications_helm) } + + subject { helm.can_uninstall? } + + it { is_expected.to be_falsey } + end + describe '#issue_client_cert' do let(:application) { create(:clusters_applications_helm) } subject { application.issue_client_cert } diff --git a/spec/models/clusters/applications/ingress_spec.rb b/spec/models/clusters/applications/ingress_spec.rb index 113d29b5551c29ac10527eab90a6e8135f46d7a9..292ddabd2d8f7119b51c06c8a0ffa437d1b49506 100644 --- a/spec/models/clusters/applications/ingress_spec.rb +++ b/spec/models/clusters/applications/ingress_spec.rb @@ -18,6 +18,12 @@ describe Clusters::Applications::Ingress do allow(ClusterWaitForIngressIpAddressWorker).to receive(:perform_async) end + describe '#can_uninstall?' do + subject { ingress.can_uninstall? } + + it { is_expected.to be_falsey } + end + describe '#make_installed!' do before do application.make_installed! diff --git a/spec/models/clusters/applications/jupyter_spec.rb b/spec/models/clusters/applications/jupyter_spec.rb index 1a7363b64f96b56b06edcd0ee1bb614f97e65da0..fc9ebed863ead5693d95fb52ebf455ef32495f7b 100644 --- a/spec/models/clusters/applications/jupyter_spec.rb +++ b/spec/models/clusters/applications/jupyter_spec.rb @@ -10,6 +10,15 @@ describe Clusters::Applications::Jupyter do it { is_expected.to belong_to(:oauth_application) } + describe '#can_uninstall?' do + let(:ingress) { create(:clusters_applications_ingress, :installed, external_hostname: 'localhost.localdomain') } + let(:jupyter) { create(:clusters_applications_jupyter, cluster: ingress.cluster) } + + subject { jupyter.can_uninstall? } + + it { is_expected.to be_falsey } + end + describe '#set_initial_status' do before do jupyter.set_initial_status diff --git a/spec/models/clusters/applications/knative_spec.rb b/spec/models/clusters/applications/knative_spec.rb index 405b5ad691c65fb0062656e89e2b50329c187875..d5974f47190764c3e6850f6dfa927c03686219d3 100644 --- a/spec/models/clusters/applications/knative_spec.rb +++ b/spec/models/clusters/applications/knative_spec.rb @@ -39,6 +39,12 @@ describe Clusters::Applications::Knative do end end + describe '#can_uninstall?' do + subject { knative.can_uninstall? } + + it { is_expected.to be_falsey } + end + describe '#schedule_status_update with external_ip' do let(:application) { create(:clusters_applications_knative, :installed) } diff --git a/spec/models/clusters/applications/prometheus_spec.rb b/spec/models/clusters/applications/prometheus_spec.rb index e8ba9737c23d3607bc09844e3b0e56a5678a13e7..26267c64112fe4afca5a5208822c680aae0e4efb 100644 --- a/spec/models/clusters/applications/prometheus_spec.rb +++ b/spec/models/clusters/applications/prometheus_spec.rb @@ -11,6 +11,21 @@ describe Clusters::Applications::Prometheus do include_examples 'cluster application helm specs', :clusters_applications_prometheus include_examples 'cluster application initial status specs' + describe 'after_destroy' do + let(:project) { create(:project) } + let(:cluster) { create(:cluster, :with_installed_helm, projects: [project]) } + let!(:application) { create(:clusters_applications_prometheus, :installed, cluster: cluster) } + let!(:prometheus_service) { project.create_prometheus_service(active: true) } + + it 'deactivates prometheus_service after destroy' do + expect do + application.destroy! + + prometheus_service.reload + end.to change(prometheus_service, :active).from(true).to(false) + end + end + describe 'transition to installed' do let(:project) { create(:project) } let(:cluster) { create(:cluster, :with_installed_helm, projects: [project]) } @@ -23,12 +38,20 @@ describe Clusters::Applications::Prometheus do end it 'ensures Prometheus service is activated' do - expect(prometheus_service).to receive(:update).with(active: true) + expect(prometheus_service).to receive(:update!).with(active: true) subject.make_installed end end + describe '#can_uninstall?' do + let(:prometheus) { create(:clusters_applications_prometheus) } + + subject { prometheus.can_uninstall? } + + it { is_expected.to be_truthy } + end + describe '#prometheus_client' do context 'cluster is nil' do it 'returns nil' do @@ -134,6 +157,34 @@ describe Clusters::Applications::Prometheus do end end + describe '#uninstall_command' do + let(:prometheus) { create(:clusters_applications_prometheus) } + + subject { prometheus.uninstall_command } + + it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::DeleteCommand) } + + it 'has the application name' do + expect(subject.name).to eq('prometheus') + end + + it 'has files' do + expect(subject.files).to eq(prometheus.files) + end + + it 'is rbac' do + expect(subject).to be_rbac + end + + context 'on a non rbac enabled cluster' do + before do + prometheus.cluster.platform_kubernetes.abac! + end + + it { is_expected.not_to be_rbac } + end + end + describe '#upgrade_command' do let(:prometheus) { build(:clusters_applications_prometheus) } let(:values) { prometheus.values } diff --git a/spec/models/clusters/applications/runner_spec.rb b/spec/models/clusters/applications/runner_spec.rb index b66acf13135126384c7ccf98ab10611203c3a9f7..bdc0cb8ed86001c1eb1acc4cb1c3d1d44c89ea1a 100644 --- a/spec/models/clusters/applications/runner_spec.rb +++ b/spec/models/clusters/applications/runner_spec.rb @@ -13,6 +13,14 @@ describe Clusters::Applications::Runner do it { is_expected.to belong_to(:runner) } + describe '#can_uninstall?' do + let(:gitlab_runner) { create(:clusters_applications_runner, runner: ci_runner) } + + subject { gitlab_runner.can_uninstall? } + + it { is_expected.to be_falsey } + end + describe '#install_command' do let(:kubeclient) { double('kubernetes client') } let(:gitlab_runner) { create(:clusters_applications_runner, runner: ci_runner) } diff --git a/spec/serializers/cluster_application_entity_spec.rb b/spec/serializers/cluster_application_entity_spec.rb index 7e151c3744e654247224a9641f9d9c22f748cee9..f38a18fcf590dfe5c36c9c88dc3981c40c567309 100644 --- a/spec/serializers/cluster_application_entity_spec.rb +++ b/spec/serializers/cluster_application_entity_spec.rb @@ -21,6 +21,10 @@ describe ClusterApplicationEntity do expect(subject[:status_reason]).to be_nil end + it 'has can_uninstall' do + expect(subject[:can_uninstall]).to be_falsey + end + context 'non-helm application' do let(:application) { build(:clusters_applications_runner, version: '0.0.0') } diff --git a/spec/services/clusters/applications/check_installation_progress_service_spec.rb b/spec/services/clusters/applications/check_installation_progress_service_spec.rb index 8ad90aaf7203d838ca2c47a2d47cc53c4fd40c8d..a54bd85a11ae3bb7c7fd5b6eb200e359a5d0f527 100644 --- a/spec/services/clusters/applications/check_installation_progress_service_spec.rb +++ b/spec/services/clusters/applications/check_installation_progress_service_spec.rb @@ -18,7 +18,7 @@ describe Clusters::Applications::CheckInstallationProgressService, '#execute' do end context "when phase is #{a_phase}" do - context 'when not timeouted' do + context 'when not timed_out' do it 'reschedule a new check' do expect(ClusterWaitForAppInstallationWorker).to receive(:perform_in).once expect(service).not_to receive(:remove_installation_pod) @@ -113,7 +113,7 @@ describe Clusters::Applications::CheckInstallationProgressService, '#execute' do end context 'when timed out' do - let(:application) { create(:clusters_applications_helm, :timeouted, :updating) } + let(:application) { create(:clusters_applications_helm, :timed_out, :updating) } before do expect(service).to receive(:installation_phase).once.and_return(phase) @@ -174,7 +174,7 @@ describe Clusters::Applications::CheckInstallationProgressService, '#execute' do end context 'when timed out' do - let(:application) { create(:clusters_applications_helm, :timeouted) } + let(:application) { create(:clusters_applications_helm, :timed_out) } before do expect(service).to receive(:installation_phase).once.and_return(phase) diff --git a/spec/services/clusters/applications/check_uninstall_progress_service_spec.rb b/spec/services/clusters/applications/check_uninstall_progress_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..9ab83d913f5b38064ce909897d8693290a06010b --- /dev/null +++ b/spec/services/clusters/applications/check_uninstall_progress_service_spec.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Clusters::Applications::CheckUninstallProgressService do + RESCHEDULE_PHASES = Gitlab::Kubernetes::Pod::PHASES - [Gitlab::Kubernetes::Pod::SUCCEEDED, Gitlab::Kubernetes::Pod::FAILED].freeze + + let(:application) { create(:clusters_applications_prometheus, :uninstalling) } + let(:service) { described_class.new(application) } + let(:phase) { Gitlab::Kubernetes::Pod::UNKNOWN } + let(:errors) { nil } + let(:worker_class) { Clusters::Applications::WaitForUninstallAppWorker } + + before do + allow(service).to receive(:installation_errors).and_return(errors) + allow(service).to receive(:remove_installation_pod) + end + + shared_examples 'a not yet terminated installation' do |a_phase| + let(:phase) { a_phase } + + before do + expect(service).to receive(:installation_phase).once.and_return(phase) + end + + context "when phase is #{a_phase}" do + context 'when not timed_out' do + it 'reschedule a new check' do + expect(worker_class).to receive(:perform_in).once + expect(service).not_to receive(:remove_installation_pod) + + expect do + service.execute + + application.reload + end.not_to change(application, :status) + + expect(application.status_reason).to be_nil + end + end + end + end + + context 'when application is installing' do + RESCHEDULE_PHASES.each { |phase| it_behaves_like 'a not yet terminated installation', phase } + + context 'when installation POD succeeded' do + let(:phase) { Gitlab::Kubernetes::Pod::SUCCEEDED } + before do + expect(service).to receive(:installation_phase).once.and_return(phase) + end + + it 'removes the installation POD' do + expect(service).to receive(:remove_installation_pod).once + + service.execute + end + + it 'destroys the application' do + expect(worker_class).not_to receive(:perform_in) + + service.execute + + expect(application).to be_destroyed + end + + context 'an error occurs while destroying' do + before do + expect(application).to receive(:destroy!).once.and_raise("destroy failed") + end + + it 'still removes the installation POD' do + expect(service).to receive(:remove_installation_pod).once + + service.execute + end + + it 'makes the application uninstall_errored' do + service.execute + + expect(application).to be_uninstall_errored + expect(application.status_reason).to eq('Application uninstalled but failed to destroy: destroy failed') + end + end + end + + context 'when installation POD failed' do + let(:phase) { Gitlab::Kubernetes::Pod::FAILED } + let(:errors) { 'test installation failed' } + + before do + expect(service).to receive(:installation_phase).once.and_return(phase) + end + + it 'make the application errored' do + service.execute + + expect(application).to be_uninstall_errored + expect(application.status_reason).to eq('Operation failed. Check pod logs for uninstall-prometheus for more details.') + end + end + + context 'when timed out' do + let(:application) { create(:clusters_applications_prometheus, :timed_out, :uninstalling) } + + before do + expect(service).to receive(:installation_phase).once.and_return(phase) + end + + it 'make the application errored' do + expect(worker_class).not_to receive(:perform_in) + + service.execute + + expect(application).to be_uninstall_errored + expect(application.status_reason).to eq('Operation timed out. Check pod logs for uninstall-prometheus for more details.') + end + end + + context 'when installation raises a Kubeclient::HttpError' do + let(:cluster) { create(:cluster, :provided_by_user, :project) } + let(:logger) { service.send(:logger) } + let(:error) { Kubeclient::HttpError.new(401, 'Unauthorized', nil) } + + before do + application.update!(cluster: cluster) + + expect(service).to receive(:installation_phase).and_raise(error) + end + + include_examples 'logs kubernetes errors' do + let(:error_name) { 'Kubeclient::HttpError' } + let(:error_message) { 'Unauthorized' } + let(:error_code) { 401 } + end + + it 'shows the response code from the error' do + service.execute + + expect(application).to be_uninstall_errored + expect(application.status_reason).to eq('Kubernetes error: 401') + end + end + end +end diff --git a/spec/services/clusters/applications/destroy_service_spec.rb b/spec/services/clusters/applications/destroy_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..8d9dc6a0f1125eb6a17d7d6d0bf2de6fb12f74dd --- /dev/null +++ b/spec/services/clusters/applications/destroy_service_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Clusters::Applications::DestroyService, '#execute' do + let(:cluster) { create(:cluster, :project, :provided_by_gcp) } + let(:user) { create(:user) } + let(:params) { { application: 'prometheus' } } + let(:service) { described_class.new(cluster, user, params) } + let(:test_request) { double } + let(:worker_class) { Clusters::Applications::UninstallWorker } + + subject { service.execute(test_request) } + + before do + allow(worker_class).to receive(:perform_async) + end + + context 'application is not installed' do + it 'raises Clusters::Applications::BaseService::InvalidApplicationError' do + expect(worker_class).not_to receive(:perform_async) + + expect { subject } + .to raise_exception { Clusters::Applications::BaseService::InvalidApplicationError } + .and not_change { Clusters::Applications::Prometheus.count } + .and not_change { Clusters::Applications::Prometheus.with_status(:scheduled).count } + end + end + + context 'application is installed' do + context 'application is schedulable' do + let!(:application) do + create(:clusters_applications_prometheus, :installed, cluster: cluster) + end + + it 'makes application scheduled!' do + subject + + expect(application.reload).to be_scheduled + end + + it 'schedules UninstallWorker' do + expect(worker_class).to receive(:perform_async).with(application.name, application.id) + + subject + end + end + + context 'application is not schedulable' do + let!(:application) do + create(:clusters_applications_prometheus, :updating, cluster: cluster) + end + + it 'raises StateMachines::InvalidTransition' do + expect(worker_class).not_to receive(:perform_async) + + expect { subject } + .to raise_exception { StateMachines::InvalidTransition } + .and not_change { Clusters::Applications::Prometheus.with_status(:scheduled).count } + end + end + end +end diff --git a/spec/services/clusters/applications/uninstall_service_spec.rb b/spec/services/clusters/applications/uninstall_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..16497d752b2fc014bc743ee5886a93f597be5a6b --- /dev/null +++ b/spec/services/clusters/applications/uninstall_service_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Clusters::Applications::UninstallService, '#execute' do + let(:application) { create(:clusters_applications_prometheus, :scheduled) } + let(:service) { described_class.new(application) } + let(:helm_client) { instance_double(Gitlab::Kubernetes::Helm::Api) } + let(:worker_class) { Clusters::Applications::WaitForUninstallAppWorker } + + before do + allow(service).to receive(:helm_api).and_return(helm_client) + end + + context 'when there are no errors' do + before do + expect(helm_client).to receive(:uninstall).with(kind_of(Gitlab::Kubernetes::Helm::DeleteCommand)) + allow(worker_class).to receive(:perform_in).and_return(nil) + end + + it 'make the application to be uninstalling' do + expect(application.cluster).not_to be_nil + service.execute + + expect(application).to be_uninstalling + end + + it 'schedule async installation status check' do + expect(worker_class).to receive(:perform_in).once + + service.execute + end + end + + context 'when k8s cluster communication fails' do + let(:error) { Kubeclient::HttpError.new(500, 'system failure', nil) } + + before do + expect(helm_client).to receive(:uninstall).with(kind_of(Gitlab::Kubernetes::Helm::DeleteCommand)).and_raise(error) + end + + include_examples 'logs kubernetes errors' do + let(:error_name) { 'Kubeclient::HttpError' } + let(:error_message) { 'system failure' } + let(:error_code) { 500 } + end + + it 'make the application errored' do + service.execute + + expect(application).to be_uninstall_errored + expect(application.status_reason).to match('Kubernetes error: 500') + end + end + + context 'a non kubernetes error happens' do + let(:application) { create(:clusters_applications_prometheus, :scheduled) } + let(:error) { StandardError.new('something bad happened') } + + before do + expect(helm_client).to receive(:uninstall).with(kind_of(Gitlab::Kubernetes::Helm::DeleteCommand)).and_raise(error) + end + + include_examples 'logs kubernetes errors' do + let(:error_name) { 'StandardError' } + let(:error_message) { 'something bad happened' } + let(:error_code) { nil } + end + + it 'make the application errored' do + service.execute + + expect(application).to be_uninstall_errored + expect(application.status_reason).to eq('Failed to uninstall.') + end + end +end diff --git a/spec/support/shared_examples/models/cluster_application_core_shared_examples.rb b/spec/support/shared_examples/models/cluster_application_core_shared_examples.rb index 1f76b981292914731bc14d433e6641a435b94696..d6490a808ce1ac3e54d3fa42a73697ffc1d97184 100644 --- a/spec/support/shared_examples/models/cluster_application_core_shared_examples.rb +++ b/spec/support/shared_examples/models/cluster_application_core_shared_examples.rb @@ -2,6 +2,14 @@ shared_examples 'cluster application core specs' do |application_name| it { is_expected.to belong_to(:cluster) } it { is_expected.to validate_presence_of(:cluster) } + describe '#can_uninstall?' do + it 'calls allowed_to_uninstall?' do + expect(subject).to receive(:allowed_to_uninstall?).and_return(true) + + expect(subject.can_uninstall?).to be_truthy + end + end + describe '#name' do it 'is .application_name' do expect(subject.name).to eq(described_class.application_name) diff --git a/spec/support/shared_examples/models/cluster_application_status_shared_examples.rb b/spec/support/shared_examples/models/cluster_application_status_shared_examples.rb index b8c19cab0c42c9b0170ecb03832c0d4c1e81929a..4525c03837f61ee8e6884e082db9b7ae0beee32a 100644 --- a/spec/support/shared_examples/models/cluster_application_status_shared_examples.rb +++ b/spec/support/shared_examples/models/cluster_application_status_shared_examples.rb @@ -114,6 +114,17 @@ shared_examples 'cluster application status specs' do |application_name| expect(subject.status_reason).to eq(reason) end end + + context 'application is uninstalling' do + subject { create(application_name, :uninstalling) } + + it 'is uninstall_errored' do + subject.make_errored(reason) + + expect(subject).to be_uninstall_errored + expect(subject.status_reason).to eq(reason) + end + end end describe '#make_scheduled' do @@ -125,6 +136,16 @@ shared_examples 'cluster application status specs' do |application_name| expect(subject).to be_scheduled end + describe 'when installed' do + subject { create(application_name, :installed) } + + it 'is scheduled' do + subject.make_scheduled + + expect(subject).to be_scheduled + end + end + describe 'when was errored' do subject { create(application_name, :errored) } @@ -148,6 +169,28 @@ shared_examples 'cluster application status specs' do |application_name| expect(subject.status_reason).to be_nil end end + + describe 'when was uninstall_errored' do + subject { create(application_name, :uninstall_errored) } + + it 'clears #status_reason' do + expect(subject.status_reason).not_to be_nil + + subject.make_scheduled! + + expect(subject.status_reason).to be_nil + end + end + end + + describe '#make_uninstalling' do + subject { create(application_name, :scheduled) } + + it 'is uninstalling' do + subject.make_uninstalling! + + expect(subject).to be_uninstalling + end end end @@ -155,16 +198,18 @@ shared_examples 'cluster application status specs' do |application_name| using RSpec::Parameterized::TableSyntax where(:trait, :available) do - :not_installable | false - :installable | false - :scheduled | false - :installing | false - :installed | true - :updating | false - :updated | true - :errored | false - :update_errored | false - :timeouted | false + :not_installable | false + :installable | false + :scheduled | false + :installing | false + :installed | true + :updating | false + :updated | true + :errored | false + :update_errored | false + :uninstalling | false + :uninstall_errored | false + :timed_out | false end with_them do diff --git a/spec/workers/clusters/applications/wait_for_uninstall_app_worker_spec.rb b/spec/workers/clusters/applications/wait_for_uninstall_app_worker_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..aaf5c9defc4ecd0bcc2415e61853c369a8b4e852 --- /dev/null +++ b/spec/workers/clusters/applications/wait_for_uninstall_app_worker_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Clusters::Applications::WaitForUninstallAppWorker, '#perform' do + let(:app) { create(:clusters_applications_helm) } + let(:app_name) { app.name } + let(:app_id) { app.id } + + subject { described_class.new.perform(app_name, app_id) } + + context 'app exists' do + let(:service) { instance_double(Clusters::Applications::CheckUninstallProgressService) } + + it 'calls the check service' do + expect(Clusters::Applications::CheckUninstallProgressService).to receive(:new).with(app).and_return(service) + expect(service).to receive(:execute).once + + subject + end + end + + context 'app does not exist' do + let(:app_id) { 0 } + + it 'does not call the check service' do + expect(Clusters::Applications::CheckUninstallProgressService).not_to receive(:new) + + expect { subject }.to raise_error(ActiveRecord::RecordNotFound) + end + end +end