1# frozen_string_literal: true
2
3require 'json'
4require 'time'
5require_relative '../../../lib/gitlab/popen' unless defined?(Gitlab::Popen)
6
7module Tooling
8  class KubernetesClient
9    RESOURCE_LIST = 'ingress,svc,pdb,hpa,deploy,statefulset,job,pod,secret,configmap,pvc,secret,clusterrole,clusterrolebinding,role,rolebinding,sa,crd'
10    CommandFailedError = Class.new(StandardError)
11
12    attr_reader :namespace
13
14    def initialize(namespace:)
15      @namespace = namespace
16    end
17
18    def cleanup_by_release(release_name:, wait: true)
19      delete_by_selector(release_name: release_name, wait: wait)
20      delete_by_matching_name(release_name: release_name)
21    end
22
23    def cleanup_by_created_at(resource_type:, created_before:, wait: true)
24      resource_names = resource_names_created_before(resource_type: resource_type, created_before: created_before)
25      return if resource_names.empty?
26
27      delete_by_exact_names(resource_type: resource_type, resource_names: resource_names, wait: wait)
28    end
29
30    def cleanup_review_app_namespaces(created_before:, wait: true)
31      namespaces = review_app_namespaces_created_before(created_before: created_before)
32      return if namespaces.empty?
33
34      delete_namespaces_by_exact_names(resource_names: namespaces, wait: wait)
35    end
36
37    private
38
39    def delete_by_selector(release_name:, wait:)
40      selector = case release_name
41                 when String
42                   %(-l release="#{release_name}")
43                 when Array
44                   %(-l 'release in (#{release_name.join(', ')})')
45                 else
46                   raise ArgumentError, 'release_name must be a string or an array'
47                 end
48
49      command = [
50        'delete',
51        RESOURCE_LIST,
52        %(--namespace "#{namespace}"),
53        '--now',
54        '--ignore-not-found',
55        %(--wait=#{wait}),
56        selector
57      ]
58
59      run_command(command)
60    end
61
62    def delete_by_exact_names(resource_names:, wait:, resource_type: nil)
63      command = [
64        'delete',
65        resource_type,
66        %(--namespace "#{namespace}"),
67        '--now',
68        '--ignore-not-found',
69        %(--wait=#{wait}),
70        resource_names.join(' ')
71      ]
72
73      run_command(command)
74    end
75
76    def delete_namespaces_by_exact_names(resource_names:, wait:)
77      command = [
78        'delete',
79        'namespace',
80        '--now',
81        '--ignore-not-found',
82        %(--wait=#{wait}),
83        resource_names.join(' ')
84      ]
85
86      run_command(command)
87    end
88
89    def delete_by_matching_name(release_name:)
90      resource_names = raw_resource_names
91      command = [
92        'delete',
93        %(--namespace "#{namespace}"),
94        '--ignore-not-found'
95      ]
96
97      Array(release_name).each do |release|
98        resource_names
99          .select { |resource_name| resource_name.include?(release) }
100          .each { |matching_resource| run_command(command + [matching_resource]) }
101      end
102    end
103
104    def raw_resource_names
105      command = [
106        'get',
107        RESOURCE_LIST,
108        %(--namespace "#{namespace}"),
109        '-o name'
110      ]
111      run_command(command).lines.map(&:strip)
112    end
113
114    def resource_names_created_before(resource_type:, created_before:)
115      command = [
116        'get',
117        resource_type,
118        %(--namespace "#{namespace}"),
119        "--sort-by='{.metadata.creationTimestamp}'",
120        '-o json'
121      ]
122
123      response = run_command(command)
124
125      resources_created_before_date(response, created_before)
126    end
127
128    def review_app_namespaces_created_before(created_before:)
129      command = [
130        'get',
131        'namespace',
132        "-l tls=review-apps-tls", # Get only namespaces used for review-apps
133        "--sort-by='{.metadata.creationTimestamp}'",
134        '-o json'
135      ]
136
137      response = run_command(command)
138
139      resources_created_before_date(response, created_before)
140    end
141
142    def resources_created_before_date(response, date)
143      items = JSON.parse(response)['items'] # rubocop:disable Gitlab/Json
144
145      items.each_with_object([]) do |item, result|
146        item_created_at = Time.parse(item.dig('metadata', 'creationTimestamp'))
147
148        if item_created_at < date
149          resource_name = item.dig('metadata', 'name')
150          result << resource_name
151        end
152      end
153    rescue ::JSON::ParserError => ex
154      puts "Ignoring this JSON parsing error: #{ex}\n\nResponse was:\n#{response}" # rubocop:disable Rails/Output
155      []
156    end
157
158    def run_command(command)
159      final_command = ['kubectl', *command.compact].join(' ')
160      puts "Running command: `#{final_command}`" # rubocop:disable Rails/Output
161
162      result = Gitlab::Popen.popen_with_detail([final_command])
163
164      if result.status.success?
165        result.stdout.chomp.freeze
166      else
167        raise CommandFailedError, "The `#{final_command}` command failed (status: #{result.status}) with the following error:\n#{result.stderr}"
168      end
169    end
170  end
171end
172