volume_sweeper 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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