artifactory-cleaner 1.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (117) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +4 -0
  3. data/.idea/.gitignore +2 -0
  4. data/.idea/checkstyle-idea.xml +16 -0
  5. data/.idea/dictionaries/jgitlin.xml +7 -0
  6. data/.idea/misc.xml +6 -0
  7. data/.idea/modules.xml +8 -0
  8. data/.idea/vcs.xml +6 -0
  9. data/.rspec +3 -0
  10. data/.rspec_status +39 -0
  11. data/.travis.yml +6 -0
  12. data/CODE_OF_CONDUCT.md +74 -0
  13. data/Gemfile +12 -0
  14. data/README.md +73 -0
  15. data/Rakefile +20 -0
  16. data/artifactory-cleaner.gemspec +43 -0
  17. data/artifactory-cleaner.iml +9 -0
  18. data/bin/console +14 -0
  19. data/bin/setup +8 -0
  20. data/doc/rdoc/Artifactory.html +94 -0
  21. data/doc/rdoc/Artifactory/Cleaner.html +108 -0
  22. data/doc/rdoc/Artifactory/Cleaner/ArtifactBucket.html +504 -0
  23. data/doc/rdoc/Artifactory/Cleaner/ArtifactBucketCollection.html +570 -0
  24. data/doc/rdoc/Artifactory/Cleaner/ArtifactFilter.html +712 -0
  25. data/doc/rdoc/Artifactory/Cleaner/ArtifactFilterRule.html +519 -0
  26. data/doc/rdoc/Artifactory/Cleaner/CLI.html +625 -0
  27. data/doc/rdoc/Artifactory/Cleaner/Controller.html +1014 -0
  28. data/doc/rdoc/Artifactory/Cleaner/DiscoveredArtifact.html +400 -0
  29. data/doc/rdoc/Artifactory/Cleaner/DiscoveryWorker.html +466 -0
  30. data/doc/rdoc/Artifactory/Cleaner/Error.html +101 -0
  31. data/doc/rdoc/Artifactory/Cleaner/SpecHelpers.html +190 -0
  32. data/doc/rdoc/Artifactory/Cleaner/Util.html +157 -0
  33. data/doc/rdoc/CODE_OF_CONDUCT_md.html +228 -0
  34. data/doc/rdoc/Float.html +94 -0
  35. data/doc/rdoc/Gemfile.html +144 -0
  36. data/doc/rdoc/Gemfile_lock.html +217 -0
  37. data/doc/rdoc/Object.html +112 -0
  38. data/doc/rdoc/README_md.html +241 -0
  39. data/doc/rdoc/Rakefile.html +151 -0
  40. data/doc/rdoc/artifactory-cleaner_gemspec.html +173 -0
  41. data/doc/rdoc/artifactory-cleaner_iml.html +139 -0
  42. data/doc/rdoc/bin/setup.html +134 -0
  43. data/doc/rdoc/created.rid +219 -0
  44. data/doc/rdoc/css/fonts.css +167 -0
  45. data/doc/rdoc/css/rdoc.css +590 -0
  46. data/doc/rdoc/filterlist_yaml.html +149 -0
  47. data/doc/rdoc/filters/clean-amzn_yaml.html +133 -0
  48. data/doc/rdoc/filters/snapshots_yaml.html +137 -0
  49. data/doc/rdoc/filters/test-filter_yaml.html +137 -0
  50. data/doc/rdoc/filters/yum-test_yaml.html +141 -0
  51. data/doc/rdoc/fonts/Lato-Light.ttf +0 -0
  52. data/doc/rdoc/fonts/Lato-LightItalic.ttf +0 -0
  53. data/doc/rdoc/fonts/Lato-Regular.ttf +0 -0
  54. data/doc/rdoc/fonts/Lato-RegularItalic.ttf +0 -0
  55. data/doc/rdoc/fonts/SourceCodePro-Bold.ttf +0 -0
  56. data/doc/rdoc/fonts/SourceCodePro-Regular.ttf +0 -0
  57. data/doc/rdoc/images/add.png +0 -0
  58. data/doc/rdoc/images/arrow_up.png +0 -0
  59. data/doc/rdoc/images/brick.png +0 -0
  60. data/doc/rdoc/images/brick_link.png +0 -0
  61. data/doc/rdoc/images/bug.png +0 -0
  62. data/doc/rdoc/images/bullet_black.png +0 -0
  63. data/doc/rdoc/images/bullet_toggle_minus.png +0 -0
  64. data/doc/rdoc/images/bullet_toggle_plus.png +0 -0
  65. data/doc/rdoc/images/date.png +0 -0
  66. data/doc/rdoc/images/delete.png +0 -0
  67. data/doc/rdoc/images/find.png +0 -0
  68. data/doc/rdoc/images/loadingAnimation.gif +0 -0
  69. data/doc/rdoc/images/macFFBgHack.png +0 -0
  70. data/doc/rdoc/images/package.png +0 -0
  71. data/doc/rdoc/images/page_green.png +0 -0
  72. data/doc/rdoc/images/page_white_text.png +0 -0
  73. data/doc/rdoc/images/page_white_width.png +0 -0
  74. data/doc/rdoc/images/plugin.png +0 -0
  75. data/doc/rdoc/images/ruby.png +0 -0
  76. data/doc/rdoc/images/tag_blue.png +0 -0
  77. data/doc/rdoc/images/tag_green.png +0 -0
  78. data/doc/rdoc/images/transparent.png +0 -0
  79. data/doc/rdoc/images/wrench.png +0 -0
  80. data/doc/rdoc/images/wrench_orange.png +0 -0
  81. data/doc/rdoc/images/zoom.png +0 -0
  82. data/doc/rdoc/index.html +166 -0
  83. data/doc/rdoc/js/darkfish.js +161 -0
  84. data/doc/rdoc/js/jquery.js +4 -0
  85. data/doc/rdoc/js/navigation.js +141 -0
  86. data/doc/rdoc/js/navigation.js.gz +0 -0
  87. data/doc/rdoc/js/search.js +109 -0
  88. data/doc/rdoc/js/search_index.js +1 -0
  89. data/doc/rdoc/js/search_index.js.gz +0 -0
  90. data/doc/rdoc/js/searcher.js +229 -0
  91. data/doc/rdoc/js/searcher.js.gz +0 -0
  92. data/doc/rdoc/results/archive-test-4_log.html +762 -0
  93. data/doc/rdoc/results/buckets-2020-01-31_txt.html +233 -0
  94. data/doc/rdoc/results/clean-test-2_log.html +598 -0
  95. data/doc/rdoc/results/clean-test-3_log.html +128 -0
  96. data/doc/rdoc/results/clean-test-5_log.html +2721 -0
  97. data/doc/rdoc/results/clean-test-6_log.html +135 -0
  98. data/doc/rdoc/results/clean-test-7_log.html +137 -0
  99. data/doc/rdoc/results/clean-test-8-real_log.html +131 -0
  100. data/doc/rdoc/results/clean-test-9_log.html +131 -0
  101. data/doc/rdoc/results/clean-test1_log.html +1759 -0
  102. data/doc/rdoc/results/yum-test_2020-01-31_log.html +2854 -0
  103. data/doc/rdoc/results/yum-test_dry-run_log.html +1074 -0
  104. data/doc/rdoc/table_of_contents.html +581 -0
  105. data/exe/artifactory-cleaner +12 -0
  106. data/lib/artifactory/cleaner.rb +17 -0
  107. data/lib/artifactory/cleaner/artifact_bucket.rb +102 -0
  108. data/lib/artifactory/cleaner/artifact_bucket_collection.rb +118 -0
  109. data/lib/artifactory/cleaner/artifact_filter.rb +146 -0
  110. data/lib/artifactory/cleaner/artifact_filter_rule.rb +81 -0
  111. data/lib/artifactory/cleaner/cli.rb +415 -0
  112. data/lib/artifactory/cleaner/controller.rb +466 -0
  113. data/lib/artifactory/cleaner/discovered_artifact.rb +71 -0
  114. data/lib/artifactory/cleaner/discovery_worker.rb +126 -0
  115. data/lib/artifactory/cleaner/util.rb +21 -0
  116. data/lib/artifactory/cleaner/version.rb +7 -0
  117. metadata +252 -0
@@ -0,0 +1,415 @@
1
+ require 'thor'
2
+ require 'yaml'
3
+ require 'sysexits'
4
+ require 'time'
5
+
6
+ module Artifactory
7
+ module Cleaner
8
+
9
+ ##
10
+ # Command Line Interface class, powers the artifactory-cleaner terminal command
11
+ # ---
12
+ # A single Artifactory::Cleaner::CLI instance is created by the bin/artifactory_cleaner command for parsing options and
13
+ # executing the command specified by the user. The Artifactory::Cleaner::CLI uses {Thor}[https://github.com/erikhuda/thor]
14
+ # to provide git command/subcommand style ARGV parsing
15
+ #
16
+ # @see exe/artifactory-cleaner
17
+ #
18
+ # @see https://github.com/erikhuda/thor
19
+ class CLI < Thor
20
+ class_option :verbose, :aliases => %w(-v), :type => :boolean, :desc => "Verbose mode; print additional information to STDERR"
21
+ class_option :conf_file, :aliases => %w(-c), :type => :string, :desc => "Provide a path to configuration file with endpoint and API key"
22
+ class_option :endpoint, :aliases => %w(-e), :type => :string, :desc => "Artifatcory endpoint URL"
23
+ class_option :api_key, :aliases => %w(-k), :type => :string, :desc => "Artifactory API key"
24
+
25
+ RepoTableCol = Struct.new(:method, :heading, :only)
26
+ def self.repo_table_cols
27
+ [
28
+ RepoTableCol.new(:key, 'ID', nil),
29
+ RepoTableCol.new(:package_type, 'Type', nil),
30
+ RepoTableCol.new(:rclass, 'Class', :local),
31
+ RepoTableCol.new(:url, 'URL', :remote),
32
+ RepoTableCol.new(:description, 'Description', nil),
33
+ RepoTableCol.new(:notes, 'Notes', nil),
34
+ RepoTableCol.new(:blacked_out?, 'Blacked Out', nil),
35
+ RepoTableCol.new(:yum_root_depth, 'YUM Root Depth', nil),
36
+ RepoTableCol.new(:checksum_policy_type, 'Checksum Policy', nil),
37
+ RepoTableCol.new(:includes_pattern, 'Includes Pattern', nil),
38
+ RepoTableCol.new(:excludes_pattern, 'Excludes Pattern', nil),
39
+ RepoTableCol.new(:handle_releases, 'Releases', nil),
40
+ RepoTableCol.new(:handle_snapshots, 'Snapshots', nil),
41
+ RepoTableCol.new(:property_sets, 'Property Sets', nil),
42
+ RepoTableCol.new(:repo_layout_ref, 'Layout', nil),
43
+ RepoTableCol.new(:repositories, 'Included Repos', nil),
44
+ RepoTableCol.new(:inspect, 'Inspection'),
45
+ ]
46
+ end
47
+
48
+ def self.default_repo_table_cols
49
+ [:key, :package_type, :rclass, :url, :description]
50
+ end
51
+
52
+ ##
53
+ # Constructor for a new CLI interface
54
+ def initialize(*args)
55
+ super
56
+ @config = {}
57
+ begin
58
+ load_conf_file options.conf_file if options.conf_file
59
+ rescue => ex
60
+ STDERR.puts "Unable to load config from #{options.conf_file}: #{ex}"
61
+ exit Sysexits::EX_DATAERR
62
+ end
63
+ @artifactory_config = {
64
+ endpoint: options[:endpoint] || @config['endpoint'],
65
+ api_key: options[:api_key] || @config['api-key'],
66
+ }
67
+ @repo_table_cols = Artifactory::Cleaner::CLI.repo_table_cols
68
+ #invoke :create_controller
69
+ create_controller
70
+ end
71
+
72
+ desc "version", "Show version information"
73
+ ##
74
+ # Show version information
75
+ def version
76
+ STDERR.puts "Artifactory::Cleaner version #{Artifactory::Cleaner::VERSION}"
77
+ STDERR.puts "Copyright (C) 2020 Pinnacle 21, inc. All Rights Reserved"
78
+ end
79
+
80
+ desc "list-repos", "List all available repos"
81
+ option :details, :type => :boolean
82
+ option :no_headers, :aliases => %w(-H), :type => :boolean, :desc => "Used for scripting mode. Do not print headers and separate fields by a single tab instead of arbitrary white space."
83
+ option :output, :aliases => %w(-o), :type => :string, :desc => " A comma-separated list of properties to display. Available properties are: #{(Artifactory::Cleaner::CLI.repo_table_cols.map &:method ).join(',')}"
84
+ option :local, :type => :boolean, :default => true, :desc => "Include local repositories"
85
+ option :remote, :type => :boolean, :default => false, :desc => "Include remote (replication) repositories"
86
+ option :virtual, :type => :boolean, :default => false, :desc => "Include virtual (union) repositories"
87
+ ##
88
+ # List all available repos
89
+ def list_repos()
90
+ repo_info_table = []
91
+ repos = @controller.discover_repos
92
+ repo_kinds = []
93
+ repo_kinds << :local if options.local?
94
+ repo_kinds << :remote if options.remote?
95
+ repo_kinds << :virtual if options.virtual?
96
+ include_cols = get_repo_cols(repo_kinds)
97
+ repos[:local].each {|k, r| repo_info_table << repo_cols(r, include_cols)} if options.local?
98
+ repos[:remote].each {|k, r| repo_info_table << repo_cols(r, include_cols)} if options.remote?
99
+ repos[:virtual].each {|k, r| repo_info_table << repo_cols(r, include_cols)} if options.virtual?
100
+ print_repo_list repo_info_table, include_cols
101
+ end
102
+
103
+ desc "usage-report", "Analyze usage and report where space is used"
104
+ option :details, :type => :boolean, :desc => "Produce a detailed report listing all artifacts"
105
+ option :buckets, :type => :string, :desc => "Comma separated list of bucket sizes (age in days) to group artifacts by"
106
+ option :repos, :type => :array, :desc => "List of repos to analyze; will analyze all repos if omitted"
107
+ option :from, :type => :string, :default => (Time.now - 2*365*24*3600).to_s
108
+ option :to, :type => :string, :default => (Time.now).to_s
109
+ option :threads, :type => :numeric, :default => 4
110
+ ##
111
+ # Analyze usage and report where space is used
112
+ def usage_report
113
+ begin
114
+ from = Time.parse(options.from)
115
+ to = Time.parse(options.to)
116
+ rescue => ex
117
+ STDERR.puts "Unable to parse time format. Please use: YYYY-MM-DD HH:II:SS"
118
+ STDERR.puts ex
119
+ exit Sysexits::EX_USAGE
120
+ end
121
+
122
+ begin
123
+ STDERR.puts "[DEBUG] controller.bucketize_artifacts from #{from} to #{to} repos #{options.repos}" if options.verbose?
124
+ buckets = @controller.bucketize_artifacts(
125
+ from: from,
126
+ to: to,
127
+ repos: options.repos,
128
+ threads: options.threads,
129
+ )
130
+
131
+ @controller.bucketized_artifact_report(buckets).each { |l| STDERR.puts l }
132
+ if options.details?
133
+ puts "# Detailed Bucket Report:"
134
+ puts "buckets:"
135
+ buckets.each do |bucket|
136
+ puts "#-- #{bucket.length} artifacts between #{bucket.min} and #{bucket.max} days old repo_info_table #{bucket.filesize} bytes --"
137
+ puts " - min: #{bucket.min} # days old"
138
+ puts " max: #{bucket.max} # days old"
139
+ if bucket.empty?
140
+ puts " artifacts: []"
141
+ else
142
+ puts " artifacts:"
143
+ bucket.each { |pkg| puts " - #{@controller.yaml_format(pkg,6)}" }
144
+ end
145
+ end
146
+ end
147
+ rescue => err
148
+ STDERR.puts "An exception occured while generating the usage report: #{err}"
149
+ STDERR.puts err.full_message
150
+ STDERR.puts "Caused by: #{err.cause.full_message}" if err.cause
151
+ Pry::rescued(err) if defined?(Pry::rescue)
152
+ exit Sysexits::EX_UNAVAILABLE
153
+ end
154
+ end
155
+
156
+ desc "archive", "Download artifacts meeting specific criteria"
157
+ option :dry_run, :aliases => '-n', :type => :boolean, :desc => "Do not actually download anything, only show what actions would have been taken"
158
+ option :repos, :type => :array, :desc => "List of repos to download from; will download from all repos if omitted"
159
+ option :from, :type => :string, :default => (Time.now - 2*365*24*3600).to_s, :desc => "Earliest date to include in search; defaults to 2 years ago"
160
+ option :created_before, :type => :string, :desc => "Archive artifacts with a created date earlier than the provided value"
161
+ option :modified_before, :type => :string, :desc => "Archive artifacts with a last modified date earlier than the provided value"
162
+ option :downloaded_before, :type => :string, :desc => "Archive artifacts with a last downloaded date earlier than the provided value"
163
+ option :last_used_before, :type => :string, :desc => "Archive artifacts which were created, last modified and last downloaded before the provided date"
164
+ option :threads, :type => :numeric, :default => 4, :desc => "Number of threads to use for fetching artifact info"
165
+ option :archive_to, :type => :string, :desc => "Save artifacts to the provided path before deletion"
166
+ option :filter, :type => :string, :desc => "Specify a YAML file containing filter rules to use"
167
+ ##
168
+ # Download artifacts meeting specific criteria
169
+ #
170
+ # **WARNING:** This method will cause the `last_downloaded` property of all the matching artifacts to be updated;
171
+ # therefore, using this method with a `last_used_before` switch may not be idempotent as it will cause the set of
172
+ # artifacts matching the search to change
173
+ #
174
+ # Consider using `clean --archive` instead
175
+ def archive
176
+ dates = parse_date_options
177
+ filter = load_artifact_filter
178
+ archive_to = parse_archive_option
179
+ if archive_to.nil?
180
+ STDERR.puts "Missing required `--archive-to` option specifying a a valid, existing directory under which to store archived artifacts"
181
+ exit Sysexits::EX_USAGE
182
+ end
183
+
184
+ report = {
185
+ archived: {
186
+ artifact_count: 0,
187
+ bytes: 0
188
+ },
189
+ skipped: {
190
+ artifact_count: 0,
191
+ bytes: 0
192
+ }
193
+ }
194
+
195
+ @controller.with_discovered_artifacts(from: dates[:from], to: dates[:to], repos: options.repos, threads: options.threads) do |artifact|
196
+ if artifact_meets_criteria(artifact, dates, filter)
197
+ if options.dry_run?
198
+ STDERR.puts "Would archive #{artifact} to #{archive_to}"
199
+ else
200
+ @controller.archive_artifact artifact, archive_to
201
+ end
202
+
203
+ report[:archived][:artifact_count] += 1
204
+ report[:archived][:bytes] += artifact.size
205
+ else
206
+ STDERR.puts "[DEBUG] Skipped #{artifact.inspect} because it did not meet the criteria" if options.verbose?
207
+ report[:skipped][:artifact_count] += 1
208
+ report[:skipped][:bytes] += artifact.size
209
+ end
210
+ end
211
+ report.each do |key,values|
212
+ puts "#{key} #{values[:artifact_count]} artifacts totaling #{Util.filesize values[:bytes]}"
213
+ end
214
+ end
215
+
216
+ desc "clean", "Delete artifacts meeting specific criteria"
217
+ option :dry_run, :aliases => '-n', :type => :boolean, :desc => "Do not actually delete anything, only show what actions would have ben taken"
218
+ option :repos, :type => :array, :desc => "List of repos to clean; will delete from all repos if omitted"
219
+ option :from, :type => :string, :default => (Time.now - 2*365*24*3600).to_s, :desc => "Earliest date to include in search; defaults to 2 years ago"
220
+ option :created_before, :type => :string, :desc => "Delete artifacts with a created date earlier than the provided value"
221
+ option :modified_before, :type => :string, :desc => "Delete artifacts with a last modified date earlier than the provided value"
222
+ option :downloaded_before, :type => :string, :desc => "Delete artifacts with a last downloaded date earlier than the provided value"
223
+ option :last_used_before, :type => :string, :desc => "Delete artifacts which were created, last modified and last downloaded before the provided date"
224
+ option :threads, :type => :numeric, :default => 4, :desc => "Number of threads to use for fetching artifact info"
225
+ option :archive_to, :type => :string, :desc => "Save artifacts to the provided path before deletion"
226
+ option :filter, :type => :string, :desc => "Specify a YAML file containing filter rules to use"
227
+ ##
228
+ # Delete artifacts meeting specific criteria
229
+ #
230
+ # Clean up an Artifactory instance by deleting old, unused artifacts which meet given criteria
231
+ #
232
+ # This is a CLI interface to Artifactory::Cleaner's primary function: deleting artifacts which have not been used
233
+ # in a long time (or which meet other criteria, determined by the powerful regex-based filters)
234
+ def clean
235
+ dates = parse_date_options
236
+ filter = load_artifact_filter
237
+ archive_to = parse_archive_option
238
+
239
+ # Ready to locate and delete artifacts
240
+ report = {
241
+ deleted: {
242
+ artifact_count: 0,
243
+ bytes: 0
244
+ },
245
+ archived: {
246
+ artifact_count: 0,
247
+ bytes: 0
248
+ },
249
+ skipped: {
250
+ artifact_count: 0,
251
+ bytes: 0
252
+ }
253
+ }
254
+
255
+ STDERR.puts "[DEBUG] controller.bucketize_artifacts from #{dates[:from]} to #{dates[:to]} repos #{options.repos}" if options.verbose?
256
+ @controller.with_discovered_artifacts(from: dates[:from], to: dates[:to], repos: options.repos, threads: options.threads) do |artifact|
257
+ if artifact_meets_criteria(artifact, dates, filter)
258
+ if archive_to
259
+ if options.dry_run?
260
+ STDERR.puts "Would archive #{artifact} to #{archive_to}"
261
+ else
262
+ @controller.archive_artifact artifact, archive_to
263
+ end
264
+
265
+ report[:archived][:artifact_count] += 1
266
+ report[:archived][:bytes] += artifact.size
267
+ end
268
+
269
+ if options.dry_run?
270
+ STDERR.puts "Would delete #{artifact}"
271
+ else
272
+ @controller.delete_artifact artifact
273
+ end
274
+
275
+ report[:deleted][:artifact_count] += 1
276
+ report[:deleted][:bytes] += artifact.size
277
+ else
278
+ STDERR.puts "[DEBUG] Skipped #{artifact.inspect} because it did not meet the criteria" if options.verbose?
279
+ report[:skipped][:artifact_count] += 1
280
+ report[:skipped][:bytes] += artifact.size
281
+ end
282
+ end
283
+ report.each do |key,values|
284
+ puts "#{key} #{values[:artifact_count]} artifacts totaling #{Util.filesize values[:bytes]}"
285
+ end
286
+ end
287
+
288
+ private
289
+
290
+ ##
291
+ # Loads the Artifactory configuration from a YAML file
292
+ def load_conf_file(path)
293
+ config = YAML.load_file path
294
+ config.each do |key, val|
295
+ @config[key] = val
296
+ end
297
+ end
298
+
299
+ ##
300
+ # Initialize our Artifactory::Cleaner::Controller
301
+ def create_controller
302
+ @controller = Artifactory::Cleaner::Controller.new(@artifactory_config)
303
+ @controller.verbose = true if options.verbose?
304
+ end
305
+
306
+ ##
307
+ # return Ruby Time objects formed from CLI switches `--to`, `--from`, `--ctreated-before` etc
308
+ def parse_date_options
309
+ dates = {}
310
+ dates[:from] = Time.parse(options.from) if options.from
311
+ dates[:created_before] = Time.parse(options.created_before) if options.created_before
312
+ dates[:modified_before] = Time.parse(options.modified_before) if options.modified_before
313
+ dates[:last_used_before] = Time.parse(options.last_used_before) if options.last_used_before
314
+ dates[:to] = [dates[:created_before], dates[:modified_before], dates[:downloaded_before], dates[:last_used_before]].compact.sort.first
315
+
316
+ if dates[:to].nil?
317
+ STDERR.puts "At least one end date for search must be provided (--created-before, --modified-before, --downloaded-before or --last-used-before)"
318
+ exit Sysexits::EX_USAGE
319
+ end
320
+
321
+ dates
322
+ end
323
+
324
+ ##
325
+ # Parse and validate value for the `--archive-to` CLI switch, ensuring it points to a valid, writable directory
326
+ def parse_archive_option
327
+ archive_to = options.archive_to
328
+ if archive_to
329
+ unless File.directory? archive_to
330
+ STDERR.puts "#{archive_to} is not a directory. `--archive-to` expects a valid, existing directory under which to store archived artifacts"
331
+ exit Sysexits::EX_USAGE
332
+ end
333
+ archive_to = File.realpath(archive_to)
334
+ unless File.directory? archive_to and File.writable? archive_to
335
+ STDERR.puts "Unable to write to directory #{archive_to} -- check permissions"
336
+ exit Sysexits::EX_CANTCREAT
337
+ end
338
+ end
339
+ archive_to
340
+ end
341
+
342
+ ##
343
+ # Load Artifactory::Cleaner::ArtifactFilter objects from a YAML file
344
+ def load_artifact_filter
345
+ filter = ArtifactFilter.new
346
+ if options.filter
347
+ unless File.exist? options.filter and File.readable? options.filter
348
+ STDERR.puts "Unable to read specified filter file #{options.filter}"
349
+ exit Sysexits::EX_USAGE
350
+ end
351
+ rules = YAML.load_file options.filter
352
+ rules.each {|rule| filter << rule}
353
+ end
354
+ filter
355
+ end
356
+
357
+ ##
358
+ # Check if a given artifact meets our CLI search criteria and filters
359
+ def artifact_meets_criteria(artifact, dates, filter)
360
+ (dates.has_key?(:created_before) ? artifact.created < dates[:created_before] : true) and
361
+ (dates.has_key?(:modified_before) ? artifact.last_modified < dates[:modified_before] : true) and
362
+ (dates.has_key?(:last_used_before) ? artifact.latest_date < dates[:last_used_before] : true) and
363
+ (filter.action_for(artifact) == :include)
364
+ end
365
+
366
+ ##
367
+ #
368
+ def repo_cols(repo, include_cols)
369
+ include_cols.map do |col|
370
+ repo.send(col.method).to_s
371
+ end
372
+ end
373
+
374
+ ##
375
+ # Helper method for generating CLI terminal-friendly tables of output
376
+ def get_repo_cols(repo_kinds)
377
+ if options.details?
378
+ selected_cols =
379
+ if options.output.nil?
380
+ Artifactory::Cleaner::CLI.default_repo_table_cols
381
+ else
382
+ options.output.split(',').map &:to_sym
383
+ end
384
+ @repo_table_cols.select do |col|
385
+ (col.only.nil? or repo_kinds.include? col.only) and (selected_cols.include? col.method)
386
+ end
387
+ else
388
+ @repo_table_cols.select {|col| col.method == :key}
389
+ end
390
+ end
391
+
392
+ ##
393
+ # CLI helper method for printing details of discovered repositories to a terminal
394
+ def print_repo_list(repo_info_table, include_cols)
395
+ if options.no_headers || !options.details
396
+ repo_info_table.each {|row| puts row.join("\t")}
397
+ else
398
+ headers = include_cols.map &:heading
399
+ widths = headers.map {|h| h.length + 1}
400
+ repo_info_table.each do |row|
401
+ row.each_with_index { |val, index| widths[index] = [widths[index], val.length + 1].max }
402
+ end
403
+ total_width = widths.reduce(0) {|s,v| s+v}
404
+ headers.each_with_index { |h, i| print h.ljust(widths[i], ' ') }
405
+ puts ""
406
+ puts '-'.ljust(total_width, '-')
407
+ repo_info_table.each do |row|
408
+ row.each_with_index { |v, i| print v.ljust(widths[i], ' ') }
409
+ puts ""
410
+ end
411
+ end
412
+ end
413
+ end
414
+ end
415
+ end
@@ -0,0 +1,466 @@
1
+ require 'benchmark'
2
+
3
+ module Artifactory
4
+ module Cleaner
5
+ ##
6
+ # Artifactory Cleaner Logic Controller
7
+ #
8
+ # The Artifactory::Cleaner::Controller class provides logic central to Artifactory Cleaner.
9
+ # Artifactory::Cleaner::Controller manages the Artifactory API client, performs searches, discovers artifacts, and
10
+ # more. It is capable of executing tasks in a multi-threaded fashion, making multiple requests to the Artifactory
11
+ # server in parallel.
12
+ class Controller
13
+ ##
14
+ # Struct to contain the processing queues used internally within the controller
15
+ # ---
16
+ # The controller contains two {Queues}[https://ruby-doc.org/core-2.6/Queue.html] which are used for multi-threaded
17
+ # processing of artifacts. The :incoming queue is fed tasks to be done, which Artifactory::Cleaner::DiscoveryWorker
18
+ # instances pop from, process, and push the results back into the :outgoing queue. The Artifactory::Cleaner::Controller
19
+ # will then pop from the outgoing queue and send the results back to the caller
20
+ ProcessingQueues = Struct.new(:incoming, :outgoing)
21
+
22
+ ##
23
+ # Initialize and configure a new Artifactory::Cleaner::Controller
24
+ # Params:
25
+ # +artifactory_config+:: Hash of configuration for the Artifactory client. Used as a splat for a call to Artifactory::Client.new
26
+ def initialize(artifactory_config)
27
+ @artifactory_client = client = Artifactory::Client.new(**artifactory_config)
28
+ @verbose = false
29
+ initialize_queues
30
+ @workers = []
31
+ @num_workers = 6
32
+ end
33
+
34
+ ##
35
+ # Is verbose output enabled? If so, the controller will print debugging and status information to STDERR
36
+ def verbose?
37
+ @verbose
38
+ end
39
+
40
+ ##
41
+ # Enable or disable verbose mode (see Controller#verbose?)
42
+ # When verbose mode is enabled, the controller will print debugging and status information to STDERR
43
+ def verbose=(val)
44
+ @verbose = !!val
45
+ end
46
+
47
+ ##
48
+ # Return an ordered structure of repositories from the Artifactory server.
49
+ #
50
+ # This method will query Artifactory and fetch information about all available repositories. The result returned
51
+ # is a Hash with three keys, one for each repo type: `:local`, `:remote` and `:virtual`
52
+ # Under each of these keys is a hash mapping repo keys to their Artifactory::Resource::Repository objects
53
+ #
54
+ # This method may raise network errors from the underlying Artifactory client
55
+ #
56
+ # This method is not multi-threaded
57
+ def discover_repos
58
+ timing = {}
59
+ @repos = {
60
+ local: {},
61
+ remote: {},
62
+ virtual: {},
63
+ }
64
+ i = 0
65
+ timing[:loop] = Benchmark.measure do
66
+ @artifactory_client.repository_all.each do |repo|
67
+ debuglog "[DEBUG] Found #{repo.package_type} repo: #{repo.key}"
68
+ if repo.rclass == 'remote' && repo.url
69
+ debuglog " +-> repo #{repo.key} is a mirror of remote at #{repo.url}"
70
+ @repos[:remote][repo.key] = repo
71
+ elsif repo.rclass == 'virtual' && repo.repositories
72
+ debuglog " +-> repo #{repo.key} is a virtual repo containing: #{repo.repositories.join ', '}"
73
+ @repos[:remote][repo.key] = repo
74
+ else
75
+ @repos[:local][repo.key] = repo
76
+ end
77
+ i += 1
78
+ end
79
+ end
80
+ debuglog("[DEBUG][Perfdata] Fetched #{i} repos; timing: #{timing[:loop]}")
81
+ @repos
82
+ end
83
+
84
+ ##
85
+ # Given a list of Artifacts, fetch information about them and return a list of Artifactory::Cleaner::DiscoveredArtifact instances
86
+ #
87
+ # This is a helper function for #artifact_usage_search
88
+ #
89
+ # TODO: Document format of the `artifact_list` parameter
90
+ #
91
+ # This method may throw network errors from the underlying Artifactory client
92
+ #
93
+ # This method is multi-threaded and will spawn workers in order to make multiple concurrent HTTP connections to
94
+ # the Artifactory API. The number of threads can be tuned with the +`threads`+ parameter. Be careful not to
95
+ # cause excessive load on the Artifactory API!
96
+ def discover_artifacts_from_search(artifact_list, threads: 4)
97
+ result = []
98
+ timing = {}
99
+ #kill_threads
100
+ @num_workers = threads
101
+ timing[:enqueue] = Benchmark.measure do
102
+ artifact_list.each {|a| queue_discovery_of_artifact a}
103
+ end
104
+
105
+ timing[:dequeue] = Benchmark.measure do
106
+ until @discovery_queues.incoming.empty? and @discovery_queues.outgoing.empty? and not @workers.any? &:working?
107
+ begin
108
+ item = @discovery_queues.outgoing.pop
109
+ if item.kind_of? Artifactory::Resource::Artifact
110
+ result << item
111
+ #debuglog "[DEBUG] Discovered #{item} from a child thread"
112
+ elsif item.kind_of? Error
113
+ STDERR.puts "[ERROR] Error from artifact fetch: #{item}"
114
+ STDERR.puts item.full_message
115
+ STDERR.puts "Caused by #{item.cause.full_message}" if item.cause
116
+ elsif !artifact.nil?
117
+ STDERR.puts "[ERROR] Got #{item} back from the discovery queue, expected an Artifactory::Resource::Artifact"
118
+ end
119
+ rescue => processing_ex
120
+ STDERR.puts "[ERROR] Caught an exception when processing from the outgoing discovery queue: #{processing_ex}"
121
+ STDERR.puts processing_ex.full_message
122
+ STDERR.puts "Caused by #{processing_ex.cause.full_message}" if processing_ex.cause
123
+ end
124
+ end
125
+ end
126
+
127
+ begin
128
+ kill_threads
129
+ rescue => ex
130
+ STDERR.puts "[ERROR] Caught an exception when killing threads: #{ex}"
131
+ STDERR.puts ex.full_message
132
+ STDERR.puts "Caused by #{ex.cause.full_message}" if ex.cause
133
+ end
134
+
135
+ debuglog("[DEBUG][Perfdata] Enqueue URLs for workers to discover: #{timing[:enqueue]}")
136
+ debuglog("[DEBUG][Perfdata] Dequeue found Artifacts from workers: #{timing[:dequeue]}")
137
+ total_time = timing.values.reduce(0) {|s,t| s + t.real}
138
+ debuglog("[DEBUG] #{result.length} artifacts fetched in #{total_time.round 2} seconds")
139
+ result
140
+ end
141
+
142
+ ##
143
+ # Search for an artifact by its usage
144
+ #
145
+ # @example Search for all repositories with the given usage statistics
146
+ # Artifact.usage_search(
147
+ # notUsedSince: 1388534400000,
148
+ # createdBefore: 1388534400000,
149
+ # )
150
+ #
151
+ # @example Search for all artifacts with the given usage statistics in a repo
152
+ # Artifact.usage_search(
153
+ # notUsedSince: 1388534400000,
154
+ # createdBefore: 1388534400000,
155
+ # repos: 'libs-release-local',
156
+ # )
157
+ #
158
+ # @param [Hash] options
159
+ # the list of options to search with
160
+ #
161
+ # @option options [Artifactory::Client] :client
162
+ # the client object to make the request with
163
+ # @option options [Long] :notUsedSince
164
+ # the last downloaded cutoff date of the artifact to search for (millis since epoch)
165
+ # @option options [Long] :createdBefore
166
+ # the creation cutoff date of the artifact to search for (millis since epoch)
167
+ # @option options [String, Array<String>] :repos
168
+ # the list of repos to search
169
+ #
170
+ # @return [Array<Resource::Artifact>]
171
+ # a list of artifacts that match the query
172
+ #
173
+ def artifact_usage_search(from: nil, to: nil, repos: nil, threads: 4)
174
+ to = Time.now if to.nil?
175
+
176
+ params = {
177
+ dateFields: 'created,lastModified,lastDownloaded',
178
+ from: from.is_a?(Time) ? from.to_i * 1000 : from.to_i,
179
+ to: to.is_a?(Time) ? to.to_i * 1000 : to.to_i
180
+ }
181
+ repos = repos.compact.join(",") unless repos.nil?
182
+ params[:repos] = repos unless repos.nil?
183
+
184
+ result = nil
185
+
186
+ debuglog("[DEBUG] Making Artifactory request /api/search/dates for #{params.inspect}")
187
+ timing = {}
188
+ timing[:search] = Benchmark.measure do
189
+ begin
190
+ result = @artifactory_client.get("/api/search/dates", params)
191
+ rescue Artifactory::Error::HTTPError => err
192
+ if err.code == 404
193
+ debuglog " HTTP 404 Not Found fetching: /api/search/dates -- assuming no assets for this date range"
194
+ result = []
195
+ #Pry::rescued(err) if defined?(Pry::rescue)
196
+ else
197
+ STDERR.puts "HTTP Error while performing an artifact usage search: #{err}"
198
+ STDERR.puts err.full_message
199
+ STDERR.puts "Parameters were: #{params.inspect}"
200
+ STDERR.puts "Caused by #{err.cause.full_message}" if err.cause
201
+ Pry::rescued(err) if defined?(Pry::rescue)
202
+ end
203
+ end
204
+ end
205
+ debuglog("[DEBUG] Got #{result["results"].length} results from search in #{timing[:search].real} seconds") unless result.nil? or result.empty?
206
+ timing[:fetch] = Benchmark.measure do
207
+ if threads > 1
208
+ unless result.nil? or result.empty?
209
+ result = discover_artifacts_from_search(result["results"], threads: threads)
210
+ end
211
+ else
212
+ unless result.nil? or result.empty?
213
+ result = result["results"].map do |artifact|
214
+ a = nil
215
+ retries = 10
216
+ while a.nil? and retries > 0
217
+ begin
218
+ retries -= 1
219
+ a = Artifactory::Cleaner::DiscoveredArtifact.from_url(artifact["uri"], client: @artifactory_client)
220
+ a.last_downloaded = Time.parse(artifact["lastDownloaded"]) unless artifact["lastDownloaded"].to_s.empty?
221
+ rescue Net::OpenTimeout, Artifactory::Error::ConnectionError => err
222
+ STDERR.puts "[WARN] Connection Failure attempting to reach Artifactory API: #{err}"
223
+ debuglog " Parameters were: #{params.inspect}"
224
+ debuglog " Caused by #{err.cause.full_message}" if err.cause
225
+ STDERR.puts " Retrying in 10 seconds" if retries
226
+ sleep 10
227
+ rescue Artifactory::Error::HTTPError => err
228
+ if err.code == 404
229
+ STDERR.puts "[WARN] HTTP 404 Not Found fetching: #{artifact["uri"]}"
230
+ retries = 0
231
+ else
232
+ retries = min(retries, 1)
233
+ STDERR.puts "[ERROR] HTTP Error while fetching an artifact from a usage search: #{err}"
234
+ debuglog err.full_message
235
+ debuglog " Artifact was: #{artifact.inspect}"
236
+ debuglog " Parameters were: #{params.inspect}"
237
+ debuglog " Caused by #{err.cause.full_message}" if err.cause
238
+ Pry::rescued(err) if defined?(Pry::rescue)
239
+ STDERR.puts " Will retry download once" if retries
240
+ end
241
+ end
242
+ end
243
+ a
244
+ end
245
+ end
246
+ result.compact!
247
+ end
248
+ end
249
+ debuglog("[DEBUG][Perfdata] Artifactory request /api/search/dates timing: #{timing[:search]}")
250
+ debuglog("[DEBUG][Perfdata] Fetching artifacts timing: #{timing[:fetch]}")
251
+ total_time = timing.values.reduce(0) {|s,t| s + t.real}
252
+ debuglog("[DEBUG] #{result.length} artifacts fetched in #{total_time.round 2} seconds")
253
+ result
254
+ end
255
+
256
+ ##
257
+ # Iterator method for an artifact search
258
+ #
259
+ # the `with_discovered_artifacts` method is used to iterate over artifacts from a search which potentially covers
260
+ # a large period of time. This method will break the period up into small chunks of time defined by the
261
+ # `increment` argument (defaulting to 30 days) and will perform multiple searches to avoid large searches which
262
+ # may time out or overload the Artifactory server.
263
+ #
264
+ # Pass a block and the block will be called with every Artifactory::Cleaner::DiscoveredArtifact that is found
265
+ #
266
+ # This method is not mult-threaded however it calls artifact_usage_search which is multi-threaded; number of
267
+ # threads is controlled by the `threads` argument
268
+ #
269
+ # This method calls artifact_usage_search which may raise network exceptions
270
+ #
271
+ # Params:
272
+ # +from+:: Time instance for the start date of the search
273
+ # +to+:: Time instance for the end date of the search; defaults to Time.now
274
+ # +repos+:: Optional array of repository names to search within; searches all repositories if omitted
275
+ # +increment+:: Integer number of seconds to chunk the search period into, defaults to 30 days
276
+ # +threads+:: Number of threads to use to fetch artifacts; defayult is 4 (passed to artifact_usage_search)
277
+ def with_discovered_artifacts(from: nil, to: nil, repos: nil, increment: 30 * 24 * 3600, threads: 4)
278
+ chunk_end = to || Time.now
279
+ while chunk_end > from
280
+ chunk_start = chunk_end - increment
281
+ chunk_start = from if chunk_start < from
282
+ artifact_usage_search(from: chunk_start, to: chunk_end, repos: repos, threads: threads).each do |pkg|
283
+ yield pkg
284
+ end
285
+ chunk_end = chunk_start
286
+ end
287
+ end
288
+
289
+ def bucketize_artifacts(from: nil, to: nil, increment: 30 * 24 * 3600, repos: nil, buckets: nil, threads: 4)
290
+ buckets = ArtifactBucketCollection.new unless buckets.is_a? ArtifactBucketCollection
291
+ with_discovered_artifacts(from: from, to: to, repos: repos, increment: increment, threads: threads) do |artifact|
292
+ buckets << artifact
293
+ end
294
+ buckets
295
+ end
296
+
297
+ ##
298
+ # Given a Artifactory::Cleaner::ArtifactBucketCollection, return a String summarizing the contents
299
+ #
300
+ # TODO: This really should be a method on Artifactory::Cleaner::ArtifactBucketCollection
301
+ def bucketized_artifact_report(buckets)
302
+ total_size = 0
303
+ total_count = 0
304
+ lines = buckets.map do |bucket|
305
+ total_size += bucket.filesize
306
+ total_count += bucket.length
307
+ "#{bucket.length} artifacts between #{bucket.min} and #{bucket.max} days, totaling #{Artifactory::Cleaner::Util::filesize bucket.filesize}"
308
+ end
309
+ lines << "Total: #{Artifactory::Cleaner::Util::filesize total_size} across #{total_count} artifacts"
310
+ end
311
+
312
+ ##
313
+ # Return a YAML representation of a module Artifactory::Cleaner::DiscoveredArtifact
314
+ #
315
+ # Provide a Artifactory::Cleaner::DiscoveredArtifact and this method will return a String containing a YAML
316
+ # representation of the properties of the DiscoveredArtifact. If the `indent` parameter is provided, then a YAML
317
+ # fragment will be returned, indented by `indent` spaces. This allows for "streaming" a list of Artifact YAML to
318
+ # an IOStream
319
+ def yaml_format(artifact, indent = 0)
320
+ properties = [:uri, :last_downloaded, :repo, :created, :last_modified, :last_updated, :download_uri, :mime_type, :size, :checksums ]
321
+ result = YAML.dump(properties.each_with_object({}) {|prop,export| export[prop] = artifact.send(prop) })
322
+ if indent
323
+ i = 0
324
+ result.each_line.reduce('') do |str,line|
325
+ if (i += 1) > 2
326
+ str + (' ' * indent) + line
327
+ elsif i == 2
328
+ str + line
329
+ else
330
+ str
331
+ end
332
+ end
333
+ end
334
+ end
335
+
336
+ ##
337
+ # Download a copy of an artifact to the local filesystem prior to deletion
338
+ #
339
+ # Given an Artifactory::Resource::Artifact `artifact`, download the artifact to the local filesystem directory
340
+ # specified by the `path` param
341
+ #
342
+ # **Note:** Downloading an artifact will update the artifact's last_downloaded date so it may no longer match the
343
+ # same search criteria it originally die (if last_downlaoded was used to discover this artifact)
344
+ #
345
+ # This method is meant to be used prior to calling `delete_artifact`
346
+ def archive_artifact(artifact, path)
347
+ debuglog "[DEBUG] downloading #{artifact} (#{artifact.uri}) to #{path}"
348
+ timing = Benchmark.measure do
349
+ artifact.download(path)
350
+ end
351
+ debuglog "[DEBUG] #{artifact.uri} #{Util.filesize artifact.size} downloaded in #{timing.real.round(2)} seconds (#{Util.filesize(artifact.size/timing.real)})/s"
352
+ end
353
+
354
+ ##
355
+ # Delete an artifact from the Artifactory server
356
+ #
357
+ # Given an Artifactory::Resource::Artifact `artifact`, delete it from the Artifactory server. **This is a
358
+ # destructive operation -- use with caution!**
359
+ #
360
+ # Consider using `archive_artifact` first to save artifacts locally
361
+ #
362
+ # This function writes to the remote Artifactory server (specifically it makes a delete call)
363
+ def delete_artifact(artifact)
364
+ debuglog "[DEBUG] DELETE Artifact #{artifact} at #{artifact.uri}!"
365
+ artifact.delete
366
+ end
367
+
368
+ ##
369
+ # Deprecated, do not use
370
+ def catagorize_old_assets(days)
371
+ buckets = {
372
+ 730 => {count: 0, size: 0},
373
+ 365 => {count: 0, size: 0},
374
+ 180 => {count: 0, size: 0},
375
+ 90 => {count: 0, size: 0},
376
+ 30 => {count: 0, size: 0},
377
+ }
378
+ discover_repos
379
+ @repos[:local].each_pair do |id,repo|
380
+ begin
381
+ pkgs = 0
382
+ purgable = 0
383
+ timings = Benchmark.bm(12) do |bm|
384
+ debuglog "Searching Repo #{id}:"
385
+ old_packages = nil
386
+ bm.report('api call') {
387
+ old_packages = @artifactory_client.artifact_usage_search(
388
+ notUsedSince: (Time.now.to_i - 24 * 3600 * days) * 1000,
389
+ createdBefore: (Time.now.to_i - 24 * 3600 * days) * 1000,
390
+ repos: id
391
+ )
392
+ }
393
+ debuglog " Artifactory search returned #{old_packages.length} assets older than #{days}..."
394
+ bm.report('loop') { old_packages.each_with_index do |pkg,i|
395
+ pkgs += 1
396
+ uri = URI(pkg.uri)
397
+ purgable += pkg.size
398
+ # Calculate the age of this package in days and increment the bucket it belongs in
399
+ age = (Time.now - pkg.last_modified)/(3600*24)
400
+ if (bucket = buckets.keys.find {|v| age >= v })
401
+ buckets[bucket][:count] += 1
402
+ buckets[bucket][:size] += pkg.size
403
+ end
404
+ debuglog " ##{i}: #{File.basename(uri.path)} #{Util.filesize pkg.size} Created #{pkg.created} Modified #{pkg.last_modified}"
405
+ end }
406
+ end
407
+ debuglog "Found #{pkgs} assets from #{id} older than #{days} days totaling #{Util.filesize purgable} in #{timings.reduce {|sum, t| sum + t.real}} seconds"
408
+ rescue => ex
409
+ STDERR.puts "Caught an exception trying to handle repo #{id}: #{ex}"
410
+ STDERR.puts ex.full_message
411
+ STDERR.puts "Caused by #{ex.cause.full_message}" if ex.cause
412
+ end
413
+ end
414
+
415
+ buckets.each_pair do |age,bucket|
416
+ debuglog "#{bucket[:count]} packages older than #{age} days, totaling #{Util.filesize bucket[:size]}"
417
+ end
418
+
419
+ buckets
420
+ end
421
+
422
+ ##################################################################################################################
423
+
424
+ private
425
+
426
+ ##
427
+ # debug/verbose logging
428
+ def debuglog(msg)
429
+ STDERR.puts msg if @verbose
430
+ end
431
+
432
+ ##
433
+ # Initialize empty artifact discovery queues
434
+ def initialize_queues
435
+ @discovery_queues = ProcessingQueues.new
436
+ @discovery_queues.incoming = Queue.new
437
+ @discovery_queues.outgoing = Queue.new
438
+ end
439
+
440
+ ##
441
+ # make sure we have the desired number of worker threads
442
+ def spawn_threads
443
+ while @workers.length < @num_workers
444
+ @workers << DiscoveryWorker.new(@discovery_queues, @artifactory_client).start
445
+ debuglog "[DEBUG] Spawned #{@workers.last} to process discovery calls"
446
+ end
447
+ end
448
+
449
+ ##
450
+ # given artifact data, add it to the queue for processing and make sure we have workers to process it
451
+ def queue_discovery_of_artifact(artifact_data)
452
+ @discovery_queues.incoming.push(artifact_data)
453
+ #debuglog "[DEBUG] Queued #{artifact_data['uri']} for discovery"
454
+ spawn_threads
455
+ end
456
+
457
+ ##
458
+ # Forcibly terminate all threads
459
+ # TODO: add a graceful terminate method
460
+ def kill_threads
461
+ @workers.each &:kill
462
+ @workers = []
463
+ end
464
+ end
465
+ end
466
+ end