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.
- checksums.yaml +7 -0
- data/.dockerignore +220 -0
- data/.rspec +3 -0
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/.standard.yml +3 -0
- data/Dockerfile +20 -0
- data/Gemfile +3 -0
- data/LICENSE +21 -0
- data/README.md +49 -0
- data/Rakefile +8 -0
- data/lib/volume_sweeper/cli.rb +57 -0
- data/lib/volume_sweeper/comparer.rb +56 -0
- data/lib/volume_sweeper/core.rb +73 -0
- data/lib/volume_sweeper/kube/client.rb +138 -0
- data/lib/volume_sweeper/metrics/controller.rb +41 -0
- data/lib/volume_sweeper/metrics/prometheus.rb +4 -0
- data/lib/volume_sweeper/providers/aws.rb +39 -0
- data/lib/volume_sweeper/providers/base.rb +23 -0
- data/lib/volume_sweeper/providers/oci.rb +124 -0
- data/lib/volume_sweeper/utils/log.rb +19 -0
- data/lib/volume_sweeper/utils/notification.rb +148 -0
- data/lib/volume_sweeper/utils/notification_formatter.rb +47 -0
- data/lib/volume_sweeper/version.rb +3 -0
- data/lib/volume_sweeper.rb +6 -0
- metadata +267 -0
@@ -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,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
|
+
|