volume_sweeper 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,41 @@
1
+
2
+ module Prometheus
3
+ module Controller
4
+ # TODO: ..
5
+ def self.setup_metrics
6
+ metrics_dir = Rails.root.join 'tmp', 'prometheus'
7
+ Dir["#{metrics_dir}/*.bin"].each { |file_path| File.unlink(file_path) }
8
+ Prometheus::Client.config.data_store = Prometheus::Client::DataStores::DirectFileStore.new(dir: metrics_dir)
9
+
10
+ @prometheus ||= Prometheus::Client.registry
11
+
12
+ register_gauge :available_block_volume_count,
13
+ 'The total of unattached block volumes.',
14
+ :available_block_volume_count
15
+ register_gauge :released_pv_count,
16
+ 'The total of released persistent volumes.',
17
+ :released_pv_count
18
+ register_gauge :inactive_block_volume_count,
19
+ 'The number of block volumes count that are unused (no instance or PV bound).',
20
+ :inactive_block_volume_count
21
+
22
+ end
23
+
24
+ def self.clear_metrics
25
+ @prometheus ||= Prometheus::Client.registry
26
+ unregister_gauge :available_block_volume_count
27
+ unregister_gauge :released_pv_count
28
+ unregister_gauge :inactive_block_volume_count
29
+ end
30
+
31
+ def self.register_gauge key, docstring, *labels
32
+ gauge = Prometheus::Client::Gauge.new key, docstring: docstring, labels: labels
33
+ @prometheus.register(gauge)
34
+ gauge
35
+ end
36
+
37
+ def self.unregister_gauge key
38
+ @prometheus.unregister key
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,4 @@
1
+ module VolumeSweeper
2
+ module Prometheus
3
+ end
4
+ end
@@ -0,0 +1,39 @@
1
+ require 'active_support/core_ext/object/blank'
2
+ require_relative 'base'
3
+ require_relative '../utils/log'
4
+
5
+ module VolumeSweeper
6
+ module Providers
7
+
8
+ class Aws < Base
9
+ DEFAULT_REGION = 'us-west-2'
10
+
11
+ def initialize config_path: nil, region: nil, mode: :audit, **kwargs
12
+ super
13
+ @region ||= DEFAULT_REGION
14
+ validate_attrs
15
+ end
16
+
17
+ def scan_block_volumes
18
+ raise NotImplementedError
19
+ end
20
+
21
+ def delete_block_volumes ids_list
22
+ return if ids_list.blank? || @run_mode != :delete
23
+ raise NotImplementedError
24
+ end
25
+
26
+ private
27
+
28
+ def prepare_config
29
+ end
30
+
31
+ def validate_attrs
32
+ return unless @account_id.blank?
33
+ @log.msg "provider error: aws account id is not assigned", level: :error
34
+ exit 1
35
+ end
36
+ end
37
+
38
+ end
39
+ end
@@ -0,0 +1,23 @@
1
+ module VolumeSweeper
2
+ module Providers
3
+
4
+ class Base
5
+ attr_reader :base_link
6
+
7
+ def initialize **kwargs
8
+ @run_mode = kwargs[:mode]&.to_sym || :audit
9
+ @config_location = kwargs[:config_path]
10
+ @account_id = kwargs[:account_id]
11
+ @compartment_id = kwargs[:account_id]
12
+ @region = kwargs[:region]
13
+
14
+ @log = Utils::Log.instance
15
+ @log.msg "#{self.class.name.downcase.split(":").last}: running in :#{@run_mode} mode."
16
+ end
17
+
18
+ def scan_volumes; end
19
+ def delete_volumes ids_list; end
20
+ end
21
+
22
+ end
23
+ end
@@ -0,0 +1,124 @@
1
+ require 'oci/common'
2
+ require 'oci/auth/auth'
3
+ require 'oci/core/core'
4
+ require 'active_support/core_ext/object/blank'
5
+ require_relative 'base'
6
+ require_relative '../utils/log'
7
+
8
+ module VolumeSweeper
9
+ module Providers
10
+
11
+ class Oci < Base
12
+ DEFAULT_REGION = 'me-jeddah-1'
13
+
14
+ DEFAULT_PAGE_SIZE = 30
15
+ VOLUME_ATTRS = %i{ id displayName volumeId lifecycleState sizeInGBs timeCreated definedTags freeformTags }
16
+
17
+ attr_accessor :config_location
18
+
19
+ def initialize config_path: nil, region: nil, mode: :audit, **kwargs
20
+ super
21
+ @region ||= DEFAULT_REGION
22
+ @base_link = "https://cloud.oracle.com/block-storage/volumes"
23
+ validate_attrs
24
+ end
25
+
26
+ def scan_block_volumes
27
+ volumes = Array.new
28
+ opts = { compartment_id: @compartment_id, limit: DEFAULT_PAGE_SIZE, lifecycle_state: 'AVAILABLE' }
29
+
30
+ run_api_call do |config|
31
+ api = OCI::Core::BlockstorageClient.new config: config, region: @region
32
+ page = nil
33
+ begin
34
+ output = api.list_volumes **opts.merge({ page: page })
35
+ page = output.headers['opc-next-page']
36
+ output.data.map { |v| volumes << v.to_hash.compact.slice(*VOLUME_ATTRS) }
37
+ sleep 2
38
+ end until page.nil? || page&.empty?
39
+ @log.msg "oci: collected #{volumes.size} block volumes from the compartment."
40
+ end
41
+
42
+ volume_attachments = Array.new
43
+ opts = { limit: DEFAULT_PAGE_SIZE }
44
+ run_api_call do |config|
45
+ api = OCI::Core::ComputeClient.new config: config, region: @region
46
+ page = nil
47
+ begin
48
+ output = api.list_volume_attachments @compartment_id, **opts.merge({ page: page })
49
+ page = output.headers['opc-next-page']
50
+ output.data.map do |v|
51
+ volume_attachments << v.to_hash.compact.slice(*VOLUME_ATTRS) if v.lifecycle_state =~ /ATTACH/
52
+ end
53
+ sleep 2
54
+ end until page.nil? || page&.empty?
55
+ @log.msg "oci: collected #{volume_attachments.size} block volume attachements from the compartment."
56
+ end
57
+
58
+ @log.msg "oci: filtering out any block volume with an active attachment."
59
+ result = volumes.reject do |v|
60
+ volume_attachments.any? { |va| va[:volumeId] == v[:id] }
61
+ end || []
62
+
63
+ @log.msg "oci: found #{result.size} unattached block volumes."
64
+ [volumes.size, result]
65
+ end
66
+
67
+ def delete_block_volumes ids_list
68
+ @log.msg "oci: #{ids_list&.count || 0} block volumes are eligible for cleanup."
69
+ return if ids_list.blank?
70
+
71
+ unless @run_mode == :delete
72
+ @log.msg "oci: running in :#{@run_mode} mode, exiting without delete operations."
73
+ return
74
+ end
75
+
76
+ @log.msg "oci: unused volume clean-up operation started."
77
+
78
+ ids_list.each do |id|
79
+ @log.msg "oci: deleting block volume #{id} .."
80
+ run_api_call do |config|
81
+ api = OCI::Core::BlockstorageClient.new config: config, region: @region
82
+ output = api.delete_volume id, compartment_id: @compartment_id
83
+ if output.status.to_s =~ /2\d\d/
84
+ @log.msg "oci: block volume #{id} is deleted successfully."
85
+ else
86
+ @log.msg "oci: block volume #{id} has failed."
87
+ end
88
+ sleep 2.5
89
+ end
90
+ end
91
+ end
92
+
93
+ private
94
+
95
+ def prepare_config
96
+ @config_location ||= '~/.oci/config'
97
+ @oci_configuration ||= OCI::ConfigFileLoader.load_config(
98
+ config_file_location: self.config_location,
99
+ profile_name: 'DEFAULT'
100
+ )
101
+ end
102
+
103
+ def run_api_call
104
+ prepare_config
105
+ yield @oci_configuration if block_given?
106
+ rescue OCI::Errors::ServiceError => err
107
+ @log.msg err, level: :error
108
+ raise if err.status_code != 304
109
+ rescue OCI::Errors::NetworkError,
110
+ OCI::Errors::ResponseParsingError,
111
+ StandardError => err
112
+ @log.msg err, level: :error
113
+ raise
114
+ end
115
+
116
+ def validate_attrs
117
+ return unless @compartment_id.blank?
118
+ @log.msg "provider error: oci compartment id is not assigned", level: :error
119
+ exit 1
120
+ end
121
+ end
122
+
123
+ end
124
+ end
@@ -0,0 +1,19 @@
1
+ require 'logger'
2
+ require 'singleton'
3
+
4
+ module VolumeSweeper
5
+ module Utils
6
+ class Log
7
+ include Singleton
8
+
9
+ def initialize
10
+ @logger = Logger.new STDOUT
11
+ @logger.level = Logger::DEBUG
12
+ end
13
+
14
+ def msg *message, level: :info
15
+ @logger.send level.to_s, message.join('').to_s if @logger.respond_to?(level)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,148 @@
1
+ require 'mail'
2
+ require 'network-client'
3
+ require 'active_support/core_ext/object/blank'
4
+
5
+ module VolumeSweeper
6
+ module Utils
7
+ class Notification
8
+
9
+ attr_reader :default_subject
10
+
11
+ def initialize **kwargs
12
+ @log = Utils::Log.instance
13
+
14
+ setup_configuration **kwargs
15
+ configure_mailer
16
+ configure_ms_teams
17
+ end
18
+
19
+ def send_mail text
20
+ return unless smtp_configured?
21
+ @log.msg "#{self_name}: sending mail notification."
22
+
23
+ sender, receiver = @smtp_sender, @smtp_receiver
24
+ subject = message_subject
25
+ content = build_message_content text
26
+ Mail.deliver do
27
+ from sender
28
+ to receiver
29
+ subject subject
30
+ content_type 'text/html; charset=UTF-8'
31
+ body content
32
+ end
33
+
34
+ @log.msg "#{self_name}: email is sent successfully.", level: :info
35
+
36
+ rescue Exception => e
37
+ @log.msg "#{self_name}: mail notification failed.", level: :error
38
+ @log.msg "#{self_name}: #{e.message}.", level: :error
39
+ end
40
+
41
+ def send_ms_teams_notice text
42
+ return unless @webhook_url.present?
43
+
44
+ @log.msg "#{self_name}: sending ms teams notification."
45
+
46
+ request = Net::HTTP::Post.new @webhook_url.request_uri
47
+ request['Content-Type'] = 'application/json'
48
+ request.body = { title: message_subject, text: text }.to_json
49
+
50
+ http = Net::HTTP.new @webhook_url.host, @webhook_url.port
51
+ http.use_ssl = true
52
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE
53
+
54
+ body = http.request(request)&.body
55
+ @log.msg "#{self_name}: ms teams notification is sent."
56
+ body
57
+ rescue StandardError => e
58
+ @log.msg "#{self_name}: ms teams notification failed.", level: :error
59
+ @log.msg "#{self_name}: #{e.message}.", level: :error
60
+ end
61
+
62
+ private
63
+
64
+ def self_name
65
+ @klass ||= self.class.name.downcase.split(":").last
66
+ end
67
+
68
+ def setup_configuration **opts
69
+ %i[smtp_host smtp_port smtp_username smtp_password notification_subject
70
+ smtp_tls smtp_sender smtp_receiver ms_teams_webhook].each do |sym|
71
+ @log.msg "#{self_name}: argument #{sym} is empty.", level: :warn if opts[sym].blank?
72
+ instance_variable_set "@#{sym.to_s}", opts[sym]
73
+ end
74
+ default_subject = "Notification: block volume operation."
75
+ end
76
+
77
+ def smtp_configured?
78
+ @smtp_host.present? && @smtp_port.present?
79
+ end
80
+
81
+ def configure_mailer
82
+ return unless smtp_configured?
83
+
84
+ host, port = @smtp_host, @smtp_port
85
+ username = @smtp_username
86
+ password = @smtp_password
87
+ tls_flag = @smtp_tls&.downcase == 'true'
88
+ auth = username.present? && password.present? ? 'login' : nil
89
+
90
+ Mail.defaults do
91
+ delivery_method :smtp, address: host, port: port, user_name: username,
92
+ password: password, authentication: auth,
93
+ enable_starttls_auto: tls_flag
94
+ end
95
+ end
96
+
97
+ def configure_ms_teams
98
+ return unless @ms_teams_webhook.present?
99
+ @webhook_url = URI.parse @ms_teams_webhook
100
+ end
101
+
102
+ def message_subject
103
+ @notification_subject || default_subject
104
+ end
105
+
106
+ def build_message_content text
107
+ <<~EOD
108
+ <style>
109
+ body {
110
+ background-color: whitesmoke;
111
+ font-family: Georgia, Times;
112
+ font-size: 15px;
113
+ }
114
+ .content {
115
+ margin: 20px 20px 0 20px;
116
+ padding: 10px;
117
+ background-color: white;
118
+ }
119
+ .foot {
120
+ margin: 0 20px 20px 20px;
121
+ padding: 5px;
122
+ border: 0;
123
+ text-align: center;
124
+ background-color: black;
125
+ color: white;
126
+ font-family: Arial, Times;
127
+ font-size: 13px;
128
+ }
129
+ </style>
130
+ <div class="content">
131
+ Hello,
132
+ <br><br>
133
+
134
+ #{text}
135
+ <br>
136
+
137
+ Regards,<br>
138
+ <span style="font-weight: 600">Automation</span>
139
+ <br>
140
+ </div>
141
+ <div class="foot">
142
+ © - Volume Sweeper Notification.
143
+ </div>
144
+ EOD
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,47 @@
1
+ require 'erb'
2
+ require 'active_support/core_ext/object/blank'
3
+
4
+ module VolumeSweeper
5
+ module Utils
6
+ class NotificationFormatter
7
+
8
+ def initialize provider_base_url, run_mode
9
+ @provider_base_url = provider_base_url
10
+ @run_mode = run_mode || :audit
11
+ end
12
+
13
+ def formlate_meessage volumes, active_count: nil
14
+ active_list = volumes[:active_ids]
15
+ unused_list = volumes[:unused_ids]
16
+ active_count = active_count || volumes[:active_ids] || 0
17
+
18
+ if unused_list.blank? || unused_list.none?
19
+ <<~EOD
20
+ The environment is scanned and no unused block volumes found.<br>
21
+ * Active volumes: #{active_count}<br>
22
+ * Unused volumes: #{unused_list&.count || 0}<br>
23
+ EOD
24
+ else
25
+ notice = @run_mode == :delete ? "scheduled for deletion" : "eligibile for deletion"
26
+ ERB.new(
27
+ <<~HTML
28
+ The environment is scanned.<br>
29
+ * Active volumes: #{active_count}<br>
30
+ * Unused volumes: #{unused_list&.count || 0}<br>
31
+
32
+ Found the following volumes without instance bound or K8S PV relation.<br>
33
+ <u>(#{notice}):</u> <br>
34
+ <ul style="color: #400707">
35
+ <% unused_list.each do |vol| %>
36
+ <li>volume: <a href="#{@provider_base_url}/<%= vol %>"><%= vol %></a>.</li>
37
+ <% end %>
38
+ </ul>
39
+ HTML
40
+ ).result(binding)
41
+ end
42
+ end
43
+
44
+ end
45
+ end
46
+ end
47
+
@@ -0,0 +1,3 @@
1
+ module VolumeSweeper
2
+ VERSION = "1.0.0"
3
+ end
@@ -0,0 +1,6 @@
1
+ require_relative 'volume_sweeper/version'
2
+ require_relative 'volume_sweeper/core'
3
+ require_relative 'volume_sweeper/cli'
4
+
5
+ module VolumeSweeper
6
+ end