vagrant-s3-multidownloader 0.3.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c4319db5427dee9a2ddaeb79f3494e2847bf9cdb261985a82a74fe86c462abc8
4
+ data.tar.gz: f33293ee80907e491d1d8c3c61c60984e0e7df3d9cea370d01a916e42d78da14
5
+ SHA512:
6
+ metadata.gz: ef026874b3e549060199c63ff83f88e3dbe8af4d348b6084f95c626500145ebecf71eb7070a610b24b9f9965a2cd0f17879edfa24fa7b268fe09797d0f8ffaba
7
+ data.tar.gz: c400a6303e0d084b984c0ac4f14617bf22aa639a0282fedd9246e4af4e665e2e27aafa4f08a874a7cb7ca0303f57e3509a6df01e7cfeab35b2679eedda1dad94
@@ -0,0 +1,488 @@
1
+ require 'vagrant'
2
+ require 'aws-sdk-s3'
3
+ require 'uri'
4
+ require 'yaml'
5
+ require 'thread'
6
+ require 'fileutils'
7
+ require 'json'
8
+ require 'tmpdir'
9
+ require_relative 'metadata_handler'
10
+
11
+ module VagrantPlugins
12
+ module S3MultiDownloader
13
+ class Downloader
14
+
15
+ def self.patch_vagrant_downloader
16
+ ::Vagrant::Util::Downloader.class_eval do
17
+ alias_method :original_download!, :download! unless method_defined?(:original_download!)
18
+
19
+ def download!
20
+ if @source.to_s.start_with?("s3://")
21
+ logger = Log4r::Logger.new("vagrant::downloaders::s3")
22
+ logger.info("Detected S3 URL: #{@source}")
23
+
24
+ # Use our custom S3 downloader
25
+ downloader = VagrantPlugins::S3MultiD ownloader::Downloader.new(
26
+ @source, @destination, @ui, continue: @continue, headers: @headers
27
+ )
28
+ return downloader.download!
29
+ end
30
+ original_download!
31
+ end
32
+ end
33
+ end
34
+
35
+ # Automatically patch the downloader when this module is loaded
36
+ self.patch_vagrant_downloader
37
+
38
+ def initialize(source, destination, ui, options = {})
39
+ @source = source
40
+ @destination = destination
41
+ @ui = ui
42
+ @options = options
43
+ @env = options[:env]
44
+ @logger = Log4r::Logger.new("vagrant::s3-multidownloader")
45
+ @metadata_handler = MetadataHandler.new(ui)
46
+
47
+ @logger.info("S3MultiDownloader initialized")
48
+ @logger.info("Source: #{@source}")
49
+ @logger.info("Destination: #{@destination}")
50
+ end
51
+
52
+ def download!
53
+ # Parse the S3 URL
54
+ uri, bucket, key = parse_s3_url(@source)
55
+
56
+ # Log download information
57
+ log_download_info(bucket, key)
58
+
59
+ # Create S3 client
60
+ client = create_s3_client
61
+
62
+ begin
63
+ # Check for requested version from Vagrantfile
64
+ requested_version = @metadata_handler.get_requested_version(@env)
65
+ log_message(:info, "Box version requested: #{requested_version || 'latest'}") if requested_version
66
+
67
+ # Check if this is a metadata.json file
68
+ is_metadata = key.end_with?("metadata.json")
69
+ log_message(:detail, "File type: #{is_metadata ? 'metadata.json' : 'box file'}")
70
+
71
+ if is_metadata
72
+ # Handle metadata.json specially
73
+ handle_metadata_file(client, bucket, key, requested_version)
74
+ else
75
+ # Direct box download
76
+ download_s3_object(client, bucket, key)
77
+ log_message(:success, "Successfully downloaded #{@source} to #{@destination}")
78
+ end
79
+ rescue Aws::S3::Errors::Forbidden => e
80
+ handle_forbidden_error(e)
81
+ rescue Aws::S3::Errors::NotFound => e
82
+ handle_not_found_error(e, bucket, key)
83
+ rescue Aws::S3::Errors::ServiceError => e
84
+ handle_service_error(e)
85
+ rescue StandardError => e
86
+ handle_standard_error(e)
87
+ end
88
+ end
89
+
90
+ private
91
+
92
+ def parse_s3_url(url)
93
+ uri = URI.parse(url)
94
+ bucket = uri.host
95
+ key = uri.path[1..-1]
96
+ return uri, bucket, key
97
+ end
98
+
99
+ def s3_url_is_metadata?(url)
100
+ return false unless url
101
+ uri = URI.parse(url)
102
+ path = uri.path
103
+ path.end_with?('metadata.json')
104
+ end
105
+
106
+ def log_download_info(bucket, key)
107
+ log_message(:info, "Starting download from S3: #{@source}")
108
+ log_message(:detail, "Bucket: #{bucket}")
109
+ log_message(:detail, "Key: #{key}")
110
+
111
+ # Log headers if available for debugging
112
+ if @options[:headers]
113
+ @logger.debug("Request headers: #{@options[:headers].inspect}")
114
+ if @options[:headers].is_a?(Hash) && @options[:headers]["User-Agent"]
115
+ log_message(:detail, "User agent: #{@options[:headers]["User-Agent"]}")
116
+ end
117
+ end
118
+ end
119
+
120
+ def create_s3_client
121
+ # Load AWS config (credentials, region)
122
+ aws_config = load_aws_config
123
+ profile = aws_config[:profile]
124
+ log_message(:detail, "AWS config loaded with region: #{aws_config[:region]}")
125
+ log_message(:detail, "Credentials available: #{!aws_config[:access_key_id].nil?}")
126
+
127
+ # Make sure the destination directory exists
128
+ FileUtils.mkdir_p(File.dirname(@destination))
129
+
130
+ # Initialize client options
131
+ client_options = {
132
+ force_path_style: true, # Required for most S3-compatible storage systems
133
+ ssl_verify_peer: false # Don't verify SSL certificates
134
+ }
135
+
136
+ # Get endpoint URL from AWS config file
137
+ endpoint_url = find_endpoint_url(profile)
138
+
139
+ # If not found in config file, check environment as fallback
140
+ if endpoint_url.nil? && ENV['AWS_ENDPOINT_URL']
141
+ endpoint_url = ENV['AWS_ENDPOINT_URL']
142
+ log_message(:detail, "Using S3 endpoint from environment: #{endpoint_url}")
143
+ end
144
+
145
+ if endpoint_url
146
+ log_message(:info, "Using S3 endpoint URL: #{endpoint_url}")
147
+ # Add endpoint URL to client options
148
+ client_options[:endpoint] = endpoint_url
149
+ else
150
+ log_message(:detail, "No endpoint URL configured, will use AWS default endpoints")
151
+ end
152
+
153
+ log_message(:info, "Attempting to download actual box from S3")
154
+
155
+ # Setup credentials from config files first, not from environment
156
+ if aws_config[:access_key_id] && aws_config[:secret_access_key]
157
+ # Use explicit credentials from config files
158
+ client_options[:credentials] = Aws::Credentials.new(
159
+ aws_config[:access_key_id],
160
+ aws_config[:secret_access_key]
161
+ )
162
+ log_message(:detail, "Using credentials from config files")
163
+ elsif profile && !profile.empty?
164
+ # Try to use profile directly through SharedCredentials
165
+ begin
166
+ credentials = Aws::SharedCredentials.new(profile_name: profile)
167
+ client_options[:credentials] = credentials
168
+ log_message(:detail, "Using AWS shared credentials from profile: #{profile}")
169
+ rescue Aws::Errors::NoSuchProfileError => e
170
+ log_message(:warn, "AWS profile '#{profile}' not found in credentials file: #{e.message}")
171
+ log_message(:warn, "Falling back to default credential provider chain")
172
+ end
173
+ else
174
+ log_message(:detail, "No specific credentials provided, falling back to default credential provider chain")
175
+ # Let the AWS SDK use its default credential provider chain
176
+ end
177
+
178
+ # Ensure we have a region set (required by AWS SDK)
179
+ client_options[:region] = aws_config[:region] || 'us-east-1'
180
+
181
+ log_message(:detail, "Creating S3 client with profile credentials and options: #{client_options.inspect}")
182
+ Aws::S3::Client.new(client_options)
183
+ end
184
+
185
+ def download_s3_object(client, bucket, key, destination = nil)
186
+ dest = destination || @destination
187
+ log_message(:info, "Downloading #{bucket}/#{key} using single thread")
188
+
189
+ begin
190
+ # Make sure the destination directory exists
191
+ FileUtils.mkdir_p(File.dirname(dest))
192
+
193
+ # Get the entire object
194
+ response = client.get_object(bucket: bucket, key: key)
195
+
196
+ # Write the file to destination
197
+ File.open(dest, 'wb') do |file|
198
+ file.write(response.body.read)
199
+ end
200
+
201
+ log_message(:detail, "Download completed")
202
+ rescue StandardError => e
203
+ log_message(:error, "Download error: #{e.message}")
204
+ raise e
205
+ end
206
+ end
207
+
208
+ def handle_metadata_file(client, bucket, key, requested_version = nil)
209
+ log_message(:info, "Processing metadata.json file for versioning")
210
+
211
+ # First download the metadata file
212
+ download_s3_object(client, bucket, key)
213
+
214
+ # Process the metadata file
215
+ if File.exist?(@destination)
216
+ metadata = @metadata_handler.process_metadata_file(@destination)
217
+
218
+ if metadata
219
+ # Find the requested or latest version
220
+ version_data = @metadata_handler.find_version_in_metadata(metadata, requested_version)
221
+
222
+ if version_data
223
+ # Find the provider URL
224
+ provider_name = @env&.dig(:machine)&.provider&.name&.to_s
225
+ provider_url = @metadata_handler.find_provider_url(version_data, provider_name)
226
+
227
+ if provider_url && provider_url.start_with?('s3://')
228
+ log_message(:info, "Found box URL for version #{version_data['version']}: #{provider_url}")
229
+
230
+ # Create a temp path for the box file
231
+ box_temp_path = File.join(Dir.tmpdir, "vagrant_box_#{Time.now.to_i}")
232
+
233
+ # Download the actual box
234
+ box_uri, box_bucket, box_key = parse_s3_url(provider_url)
235
+
236
+ log_message(:info, "Downloading box from: #{provider_url}")
237
+ download_s3_object(client, box_bucket, box_key, box_temp_path)
238
+
239
+ # Copy the downloaded box to the destination
240
+ FileUtils.cp(box_temp_path, @destination)
241
+ FileUtils.rm(box_temp_path) if File.exist?(box_temp_path)
242
+
243
+ log_message(:success, "Successfully downloaded box version #{version_data['version']} to #{@destination}")
244
+ else
245
+ log_message(:warn, "No S3 URL found for provider #{provider_name || 'any'} in version #{version_data['version']}")
246
+ end
247
+ else
248
+ log_message(:warn, "No suitable version found in metadata.json")
249
+ end
250
+ end
251
+ else
252
+ log_message(:error, "Failed to download metadata.json file")
253
+ end
254
+ end
255
+
256
+ def process_metadata_file(metadata_path)
257
+ @metadata_handler.process_metadata_file(metadata_path)
258
+ end
259
+
260
+ def handle_forbidden_error(error)
261
+ log_message(:error, "S3 access forbidden. Check your AWS credentials.")
262
+ log_message(:error, "Error details: #{error.message}")
263
+ raise Vagrant::Errors::DownloaderError.new(message: "S3 access forbidden: #{error.message}. Please check your AWS credentials.")
264
+ end
265
+
266
+ def handle_not_found_error(error, bucket, key)
267
+ log_message(:error, "S3 file not found: #{error.message}")
268
+ raise Vagrant::Errors::DownloaderError.new(message: "S3 file not found: #{bucket}/#{key}. Please check the S3 URL.")
269
+ end
270
+
271
+ def handle_service_error(error)
272
+ log_message(:error, "S3 error: #{error.message}")
273
+ raise Vagrant::Errors::DownloaderError.new(message: "S3 error: #{error.message}")
274
+ end
275
+
276
+ def handle_standard_error(error)
277
+ log_message(:error, "Error: #{error.class.name} - #{error.message}")
278
+ raise Vagrant::Errors::DownloaderError.new(message: "Download error: #{error.message}")
279
+ end
280
+
281
+ # Helper method to log messages to UI or logger
282
+ def log_message(level, message)
283
+ if @ui.respond_to?(level)
284
+ @ui.send(level, message)
285
+ else
286
+ # Default to using the logger with an appropriate log level
287
+ case level
288
+ when :detail
289
+ @logger.info(message)
290
+ when :success
291
+ @logger.info("SUCCESS: #{message}")
292
+ when :warn
293
+ @logger.warn(message)
294
+ when :error
295
+ @logger.error(message)
296
+ else
297
+ @logger.send(level, message)
298
+ end
299
+ end
300
+ end
301
+
302
+ def load_aws_config
303
+ aws_config_file = File.expand_path("~/.aws/config")
304
+ aws_credentials_file = File.expand_path("~/.aws/credentials")
305
+
306
+ # Initialize with empty config
307
+ config = { region: "default" }
308
+
309
+ # Get profile from environment variable or use default
310
+ profile = ENV['AWS_PROFILE'] || ENV['AWS_DEFAULT_PROFILE'] || 'default'
311
+ config[:profile] = profile
312
+
313
+ log_message(:info, "Loading AWS credentials from profile: #{profile}")
314
+
315
+ # Try to load credentials from AWS credentials file first
316
+ if File.exist?(aws_credentials_file)
317
+ begin
318
+ # Parse AWS credentials file (INI format)
319
+ credentials_content = File.read(aws_credentials_file)
320
+ section = 'default'
321
+ credentials = {}
322
+
323
+ credentials_content.each_line do |line|
324
+ line = line.strip
325
+ next if line.empty? || line.start_with?('#')
326
+
327
+ if line =~ /^\[([^\]]+)\]/
328
+ section = $1
329
+ credentials[section] ||= {}
330
+ elsif line =~ /^([^=]+)=(.*)$/
331
+ key = $1.strip
332
+ value = $2.strip
333
+ credentials[section] ||= {}
334
+ credentials[section][key] = value
335
+ end
336
+ end
337
+
338
+ # First try to load the specified profile, then fall back to default
339
+ if credentials[profile]
340
+ log_message(:detail, "Found credentials for profile: #{profile}")
341
+ config[:access_key_id] = credentials[profile]['aws_access_key_id']
342
+ config[:secret_access_key] = credentials[profile]['aws_secret_access_key']
343
+ elsif credentials['default'] && profile != 'default'
344
+ log_message(:detail, "Profile #{profile} not found, using default profile")
345
+ config[:access_key_id] = credentials['default']['aws_access_key_id']
346
+ config[:secret_access_key] = credentials['default']['aws_secret_access_key']
347
+ end
348
+ rescue => e
349
+ # Log the error but continue
350
+ log_message(:warn, "Error reading AWS credentials file: #{e.message}")
351
+ end
352
+ else
353
+ log_message(:detail, "AWS credentials file not found at #{aws_credentials_file}")
354
+ end
355
+
356
+ # Fall back to environment variables if no credentials found in file
357
+ if config[:access_key_id].nil? && ENV['AWS_ACCESS_KEY_ID'] && ENV['AWS_SECRET_ACCESS_KEY']
358
+ log_message(:info, "Using AWS credentials from environment variables")
359
+ config[:access_key_id] = ENV['AWS_ACCESS_KEY_ID']
360
+ config[:secret_access_key] = ENV['AWS_SECRET_ACCESS_KEY']
361
+ end
362
+
363
+ # Load region from config file first
364
+ if File.exist?(aws_config_file)
365
+ begin
366
+ # Parse AWS config file (INI format)
367
+ config_content = File.read(aws_config_file)
368
+ section = 'default'
369
+ aws_configs = {}
370
+
371
+ config_content.each_line do |line|
372
+ line = line.strip
373
+ next if line.empty? || line.start_with?('#')
374
+
375
+ if line =~ /^\[([^\]]+)\]/
376
+ section = $1
377
+ # Handle "profile" prefix in config file
378
+ if section.start_with?('profile ')
379
+ section = section.sub('profile ', '')
380
+ end
381
+ aws_configs[section] ||= {}
382
+ elsif line =~ /^([^=]+)=(.*)$/
383
+ key = $1.strip
384
+ value = $2.strip
385
+ aws_configs[section] ||= {}
386
+ aws_configs[section][key] = value
387
+ end
388
+ end
389
+
390
+ # Try to get region from the specified profile first
391
+ if aws_configs[profile] && aws_configs[profile]['region']
392
+ config[:region] = aws_configs[profile]['region']
393
+ log_message(:detail, "Using region '#{config[:region]}' from profile '#{profile}'")
394
+ # Then try the default profile
395
+ elsif aws_configs['default'] && aws_configs['default']['region']
396
+ config[:region] = aws_configs['default']['region']
397
+ log_message(:detail, "Using region '#{config[:region]}' from default profile")
398
+ end
399
+ rescue => e
400
+ # Log the error but continue
401
+ log_message(:warn, "Error reading AWS config file: #{e.message}")
402
+ end
403
+ else
404
+ log_message(:detail, "AWS config file not found at #{aws_config_file}")
405
+ end
406
+
407
+ # Fall back to environment variable if no region in config file
408
+ if config[:region].nil? && ENV['AWS_REGION']
409
+ config[:region] = ENV['AWS_REGION']
410
+ log_message(:detail, "Using region from environment: #{config[:region]}")
411
+ end
412
+
413
+ # Default to us-east-1 if no region specified
414
+ config[:region] ||= "us-east-1"
415
+ log_message(:info, "Using AWS region: #{config[:region]}")
416
+
417
+ config
418
+ end
419
+
420
+ def find_endpoint_url(profile)
421
+ aws_config_file = File.expand_path("~/.aws/config")
422
+
423
+ if File.exist?(aws_config_file)
424
+ begin
425
+ # Parse AWS config file (INI format)
426
+ config_content = File.read(aws_config_file)
427
+ section = 'default'
428
+ aws_configs = {}
429
+
430
+ config_content.each_line do |line|
431
+ line = line.strip
432
+ next if line.empty? || line.start_with?('#')
433
+
434
+ if line =~ /^\[([^\]]+)\]/
435
+ section = $1
436
+ # Handle "profile" prefix in config file
437
+ if section.start_with?('profile ')
438
+ section = section.sub('profile ', '')
439
+ end
440
+ aws_configs[section] ||= {}
441
+ elsif line =~ /^([^=]+)=(.*)$/
442
+ key = $1.strip
443
+ value = $2.strip
444
+ aws_configs[section] ||= {}
445
+ aws_configs[section][key] = value
446
+ end
447
+ end
448
+
449
+ # Check for endpoint_url or endpoint in the specified profile
450
+ if aws_configs[profile]
451
+ endpoint = aws_configs[profile]['endpoint_url'] ||
452
+ aws_configs[profile]['endpoint'] ||
453
+ aws_configs[profile]['s3_endpoint_url'] ||
454
+ aws_configs[profile]['s3_endpoint']
455
+
456
+ if endpoint
457
+ log_message(:detail, "Found endpoint URL in profile '#{profile}': #{endpoint}")
458
+ return endpoint
459
+ end
460
+ end
461
+
462
+ # Check default profile if no endpoint found in specified profile
463
+ if aws_configs['default'] && profile != 'default'
464
+ endpoint = aws_configs['default']['endpoint_url'] ||
465
+ aws_configs['default']['endpoint'] ||
466
+ aws_configs['default']['s3_endpoint_url'] ||
467
+ aws_configs['default']['s3_endpoint']
468
+
469
+ if endpoint
470
+ log_message(:detail, "Found endpoint URL in default profile: #{endpoint}")
471
+ return endpoint
472
+ end
473
+ end
474
+
475
+ log_message(:detail, "No endpoint URL found in AWS config profiles")
476
+ return nil
477
+ rescue => e
478
+ log_message(:warn, "Error reading AWS config file for endpoint: #{e.message}")
479
+ return nil
480
+ end
481
+ else
482
+ log_message(:detail, "AWS config file not found at #{aws_config_file}")
483
+ return nil
484
+ end
485
+ end
486
+ end
487
+ end
488
+ end
@@ -0,0 +1,187 @@
1
+ require 'json'
2
+ require 'vagrant/util/downloader'
3
+ require 'uri'
4
+
5
+ module VagrantPlugins
6
+ module S3MultiDownloader
7
+ class MetadataHandler
8
+ attr_reader :ui, :logger
9
+
10
+ def initialize(ui)
11
+ @ui = ui
12
+ @logger = Log4r::Logger.new("vagrant::s3-multidownloader::metadata")
13
+ end
14
+
15
+ # Extract version from the environment if available
16
+ def get_requested_version(env)
17
+ return nil if !env || !env[:machine] || !env[:machine].config || !env[:machine].config.vm
18
+
19
+ if env[:machine].config.vm.box_version
20
+ version = env[:machine].config.vm.box_version
21
+ @ui.detail("Found box_version in Vagrantfile: #{version}")
22
+ return version
23
+ end
24
+
25
+ nil
26
+ end
27
+
28
+ # Check if the URL points to a metadata.json file
29
+ def metadata_url?(url)
30
+ return false unless url
31
+ url.to_s.end_with?('metadata.json')
32
+ end
33
+
34
+ # Download and parse a metadata.json file
35
+ def download_metadata(url, destination, client, bucket, key)
36
+ @ui.info("Downloading metadata from S3: #{url}")
37
+
38
+ # Use the client directly to download the metadata file
39
+ client.get_object(
40
+ response_target: destination,
41
+ bucket: bucket,
42
+ key: key
43
+ )
44
+
45
+ # Parse the metadata
46
+ begin
47
+ metadata = JSON.parse(File.read(destination))
48
+ return metadata
49
+ rescue JSON::ParserError => e
50
+ @ui.error("Failed to parse metadata.json: #{e.message}")
51
+ return nil
52
+ rescue StandardError => e
53
+ @ui.error("Error processing metadata.json: #{e.message}")
54
+ return nil
55
+ end
56
+ end
57
+
58
+ # Find a specific version in the metadata
59
+ def find_version_in_metadata(metadata, version = nil)
60
+ return nil unless metadata && metadata.key?('versions') && metadata['versions'].is_a?(Array)
61
+
62
+ available_versions = metadata['versions']
63
+ @ui.detail("Available versions: #{available_versions.map { |v| v['version'] }.join(', ')}")
64
+
65
+ if version
66
+ # This handles both exact version number and version constraints
67
+ if version.include?('>') || version.include?('<') || version.include?('~>')
68
+ @ui.detail("Detected version constraint: #{version}")
69
+ # Parse the constraint and find the highest satisfying version
70
+ begin
71
+ require 'vagrant/util/template_renderer'
72
+ version_constraint = Gem::Requirement.new(version)
73
+
74
+ # Find all versions that satisfy the constraint
75
+ satisfying_versions = available_versions.select do |v|
76
+ begin
77
+ version_number = Gem::Version.new(v['version'])
78
+ version_constraint.satisfied_by?(version_number)
79
+ rescue
80
+ false
81
+ end
82
+ end
83
+
84
+ if satisfying_versions.empty?
85
+ @ui.warn("No versions matching constraint #{version} found in metadata")
86
+ return nil
87
+ end
88
+
89
+ # Select the highest version that satisfies the constraint
90
+ selected_version = satisfying_versions.sort_by { |v| Gem::Version.new(v['version']) rescue Gem::Version.new('0.0.0') }.last
91
+ @ui.success("Selected version #{selected_version['version']} for constraint #{version}")
92
+ return selected_version
93
+ rescue => e
94
+ @ui.error("Error parsing version constraint: #{e.message}")
95
+ # Fall back to exact match
96
+ @ui.warn("Falling back to exact version match")
97
+ end
98
+ end
99
+
100
+ # Find the requested version (exact match)
101
+ requested_version = available_versions.find { |v| v['version'] == version }
102
+ if requested_version
103
+ @ui.success("Found requested version: #{version}")
104
+ return requested_version
105
+ else
106
+ @ui.warn("Requested version #{version} not found in metadata")
107
+ return nil
108
+ end
109
+ else
110
+ # No specific version requested, use the latest version
111
+ latest_version = available_versions.sort_by { |v| Gem::Version.new(v['version']) rescue Gem::Version.new('0.0.0') }.last
112
+ @ui.detail("Using latest version: #{latest_version['version']}")
113
+ return latest_version
114
+ end
115
+ end
116
+
117
+ # Find the provider URL in a version
118
+ def find_provider_url(version_data, provider_name = nil)
119
+ return nil unless version_data && version_data.key?('providers') && version_data['providers'].is_a?(Array)
120
+
121
+ providers = version_data['providers']
122
+
123
+ # If provider_name is specified, find that specific provider
124
+ if provider_name && !provider_name.empty?
125
+ provider = providers.find { |p| p['name'] == provider_name }
126
+ if provider && provider.key?('url')
127
+ @ui.detail("Found URL for provider #{provider_name}: #{provider['url']}")
128
+ return provider['url']
129
+ end
130
+ end
131
+
132
+ # Otherwise, just return the first provider with a URL
133
+ providers.each do |provider|
134
+ if provider.key?('url')
135
+ @ui.detail("Using provider #{provider['name']}: #{provider['url']}")
136
+ return provider['url']
137
+ end
138
+ end
139
+
140
+ nil
141
+ end
142
+
143
+ # Process a metadata file to ensure all S3 URLs are properly formatted
144
+ def process_metadata_file(metadata_path)
145
+ @ui.info("Processing metadata.json file for S3 URLs")
146
+ begin
147
+ # Read and parse the metadata.json file
148
+ metadata = JSON.parse(File.read(metadata_path))
149
+
150
+ # Check if the metadata has versions
151
+ if metadata.key?('versions') && metadata['versions'].is_a?(Array)
152
+ @ui.detail("Found #{metadata['versions'].size} versions in metadata")
153
+
154
+ # Update all S3 URLs in the metadata to ensure they'll be handled correctly
155
+ metadata['versions'].each_with_index do |version, index|
156
+ if version.key?('providers') && version['providers'].is_a?(Array)
157
+ version['providers'].each do |provider|
158
+ if provider.key?('url') && provider['url'].to_s.start_with?('s3://')
159
+ @ui.detail("Found S3 URL in metadata: #{provider['url']}")
160
+ # No need to modify the URL as the plugin already handles s3:// URLs
161
+ end
162
+ end
163
+ end
164
+ end
165
+
166
+ # Write the updated metadata back to the file
167
+ File.open(metadata_path, 'w') do |file|
168
+ file.write(JSON.pretty_generate(metadata))
169
+ end
170
+
171
+ @ui.detail("Metadata processing complete")
172
+ return metadata
173
+ else
174
+ @ui.detail("No versions found in metadata.json")
175
+ return nil
176
+ end
177
+ rescue JSON::ParserError => e
178
+ @ui.error("Failed to parse metadata.json: #{e.message}")
179
+ return nil
180
+ rescue StandardError => e
181
+ @ui.error("Error processing metadata.json: #{e.message}")
182
+ return nil
183
+ end
184
+ end
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,87 @@
1
+ require 'vagrant'
2
+ require 'vagrant/action/builtin/box_add'
3
+
4
+ module VagrantPlugins
5
+ module S3MultiDownloader
6
+ class HandleMetadataVersions
7
+ def initialize(app, env)
8
+ @app = app
9
+ @env = env
10
+ @logger = Log4r::Logger.new("vagrant::s3-multidownloader::metadata_versions")
11
+ end
12
+
13
+ def call(env)
14
+ @env = env
15
+
16
+ # Patch Vagrant's box add action to recognize S3 URLs as metadata URLs
17
+ if !defined? @@patched_box_add
18
+ patch_box_add_action
19
+ @@patched_box_add = true
20
+ end
21
+
22
+ @app.call(env)
23
+ end
24
+
25
+ private
26
+
27
+ def patch_box_add_action
28
+ @logger.info("Patching Vagrant::Action::Builtin::BoxAdd to support S3 metadata URLs")
29
+
30
+ # Override the method that determines if a URL is a metadata URL
31
+ if Vagrant::Action::Builtin::BoxAdd.instance_methods.include?(:metadata_url?)
32
+ Vagrant::Action::Builtin::BoxAdd.class_eval do
33
+ alias_method :original_metadata_url?, :metadata_url?
34
+
35
+ def metadata_url?(url, env=nil)
36
+ if url.to_s.start_with?("s3://")
37
+ # For S3 URLs, consider them as metadata URLs if they end with metadata.json
38
+ # or if they don't have a file extension (implying they might be a base URL)
39
+ path = URI.parse(url.to_s).path
40
+ return true if path.end_with?("metadata.json")
41
+ return true if File.extname(path).empty?
42
+ end
43
+
44
+ # Otherwise use the original logic
45
+ original_metadata_url?(url, env)
46
+ end
47
+ end
48
+ end
49
+
50
+ # Override the check that prevents version constraints with direct file URLs
51
+ if Vagrant::Action::Builtin::BoxAdd.instance_methods.include?(:validate_box_url)
52
+ Vagrant::Action::Builtin::BoxAdd.class_eval do
53
+ alias_method :original_validate_box_url, :validate_box_url
54
+
55
+ def validate_box_url
56
+ if @url && @url.to_s.start_with?("s3://")
57
+ # Allow version constraints with S3 URLs
58
+ return true
59
+ end
60
+
61
+ # Otherwise use the original validation
62
+ original_validate_box_url
63
+ end
64
+ end
65
+ end
66
+
67
+ # Also patch the method that validates version constraints
68
+ if Vagrant::Action::Builtin::BoxAdd.instance_methods.include?(:validate_version)
69
+ Vagrant::Action::Builtin::BoxAdd.class_eval do
70
+ alias_method :original_validate_version, :validate_version
71
+
72
+ def validate_version
73
+ # If we have an S3 URL, skip the version validation completely
74
+ if @url && @url.to_s.start_with?("s3://")
75
+ @logger.debug("Skipping version validation for S3 URL")
76
+ return true
77
+ end
78
+
79
+ # Fall back to the original validation for non-S3 URLs
80
+ original_validate_version
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,106 @@
1
+ require 'uri'
2
+ require 'vagrant/util/downloader'
3
+
4
+ module VagrantPlugins
5
+ module S3MultiDownloader
6
+ class HandleS3Urls
7
+ def initialize(app, env)
8
+ @app = app
9
+ @env = env
10
+ @logger = Log4r::Logger.new("vagrant::s3-multidownloader")
11
+ end
12
+
13
+ def call(env)
14
+ @env = env
15
+
16
+ # Save references to the original Downloader methods
17
+ unless defined? @@original_curl_execute
18
+ @@original_curl_execute = Vagrant::Util::Downloader.instance_method(:execute_curl)
19
+ end
20
+
21
+ unless defined? @@original_download
22
+ @@original_download = Vagrant::Util::Downloader.instance_method(:download!)
23
+ end
24
+
25
+ # Override both download! and execute_curl to handle s3:// URLs
26
+ # We need to intercept at both levels to ensure we catch all S3 URL usage
27
+ unless Vagrant::Util::Downloader.instance_methods.include?(:original_download)
28
+ Vagrant::Util::Downloader.class_eval do
29
+ alias_method :original_download, :download!
30
+
31
+ def download!
32
+ if @source.to_s.start_with?("s3://")
33
+ ui = @ui || @logger
34
+ ui.info("Intercepted S3 download at download! level: #{@source}")
35
+ downloader = VagrantPlugins::S3MultiDownloader::Downloader.new(
36
+ @source, @destination, ui,
37
+ continue: @continue,
38
+ headers: @headers,
39
+ env: @env
40
+ )
41
+ return downloader.download!
42
+ end
43
+ original_download
44
+ end
45
+ end
46
+ end
47
+
48
+ # Also override execute_curl to catch any direct curl executions with S3 URLs
49
+ unless Vagrant::Util::Downloader.instance_methods.include?(:original_execute_curl)
50
+ Vagrant::Util::Downloader.class_eval do
51
+ alias_method :original_execute_curl, :execute_curl
52
+
53
+ def execute_curl(options, subprocess_options, &data_proc)
54
+ # More aggressively check for S3 URLs in the options array
55
+ # This needs to catch any occurrence of s3:// in the curl command
56
+ s3_url = nil
57
+ options.each do |opt|
58
+ if opt.to_s =~ /^s3:\/\//
59
+ s3_url = opt.to_s
60
+ break
61
+ end
62
+ end
63
+
64
+ if s3_url
65
+ ui = @ui || @logger
66
+ ui.info("Intercepted S3 URL in curl command: #{s3_url}")
67
+
68
+ # Find the output path from the curl options
69
+ output_index = options.find_index("--output")
70
+ output_path = output_index ? options[output_index + 1] : nil
71
+
72
+ if output_path
73
+ ui.detail("Output path: #{output_path}")
74
+ # Use our S3 downloader instead
75
+ downloader = VagrantPlugins::S3MultiDownloader::Downloader.new(
76
+ s3_url, output_path, ui, env: @env
77
+ )
78
+
79
+ begin
80
+ ui.detail("Downloading from S3 via custom downloader: #{s3_url}")
81
+ downloader.download!
82
+ ui.detail("S3 download completed successfully")
83
+ return 0 # Return success status code
84
+ rescue => e
85
+ ui.error("S3 download error: #{e.message}")
86
+ ui.error(e.backtrace.join("\n"))
87
+ return 1 # Return error status code
88
+ end
89
+ else
90
+ ui.error("Could not determine output path for S3 download")
91
+ return 1
92
+ end
93
+ end
94
+
95
+ # For non-S3 URLs, use the original method
96
+ original_execute_curl(options, subprocess_options, &data_proc)
97
+ end
98
+ end
99
+ end
100
+
101
+ # Continue middleware chain
102
+ @app.call(env)
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,40 @@
1
+ require 'vagrant'
2
+ require_relative 'downloader'
3
+ require_relative 'middleware/handle_s3_urls'
4
+ require_relative 'middleware/handle_metadata_versions'
5
+
6
+ module VagrantPlugins
7
+ module S3MultiDownloader
8
+ class Plugin < Vagrant.plugin('2')
9
+ name 'S3MultiDownloader'
10
+ description 'Enables downloading Vagrant boxes from S3-compatible storage with multithreading'
11
+
12
+ # Hook into all possible places where box downloads might be triggered
13
+ action_hook(:s3_multi_downloader_box_url, :authenticate_box_url) do |hook|
14
+ hook.prepend(HandleS3Urls)
15
+ hook.prepend(HandleMetadataVersions)
16
+ end
17
+
18
+ action_hook(:s3_multi_downloader_box_add, :box_add) do |hook|
19
+ hook.prepend(HandleS3Urls)
20
+ hook.prepend(HandleMetadataVersions)
21
+ end
22
+
23
+ action_hook(:s3_multi_downloader_box_update, :box_update) do |hook|
24
+ hook.prepend(HandleS3Urls)
25
+ hook.prepend(HandleMetadataVersions)
26
+ end
27
+
28
+ # Add more hooks to ensure coverage of all possible download paths
29
+ action_hook(:s3_multi_downloader_initialize, :environment_load) do |hook|
30
+ hook.append(HandleS3Urls)
31
+ hook.append(HandleMetadataVersions)
32
+ end
33
+
34
+ action_hook(:s3_multi_downloader_download, :download_box) do |hook|
35
+ hook.prepend(HandleS3Urls)
36
+ hook.prepend(HandleMetadataVersions)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,31 @@
1
+ begin
2
+ require 'vagrant'
3
+ rescue LoadError
4
+ raise 'The Vagrant S3 Multi Downloader plugin must be run within Vagrant.'
5
+ end
6
+
7
+ # Load downloader first so it can be used by the plugin
8
+ require_relative 'vagrant-s3-multidownloader/downloader'
9
+ require_relative 'vagrant-s3-multidownloader/middleware/handle_s3_urls'
10
+ require_relative 'vagrant-s3-multidownloader/middleware/handle_metadata_versions'
11
+ require_relative 'vagrant-s3-multidownloader/plugin'
12
+
13
+ module VagrantPlugins
14
+ module S3MultiDownloader
15
+ # Register S3 protocol handler early
16
+ def self.register_handlers
17
+ # Apply patches to Vagrant's downloader system immediately
18
+ VagrantPlugins::S3MultiDownloader::Downloader.patch_vagrant_downloader
19
+
20
+ # Log that we've loaded
21
+ logger = Log4r::Logger.new("vagrant::s3-multidownloader")
22
+ logger.info("S3 Multi Downloader plugin loaded")
23
+ logger.info("S3 URL protocol handler registered")
24
+ end
25
+
26
+ # Automatically register our handlers when this module is loaded
27
+ register_handlers
28
+ end
29
+ end
30
+
31
+
@@ -0,0 +1,12 @@
1
+ Gem::Specification.new do |spec|
2
+ spec.name = 'vagrant-s3-multidownloader'
3
+ spec.version = '0.3.0'
4
+ spec.summary = 'Download Vagrant boxes from S3-compatible storage using AWS SDK.'
5
+ spec.description = 'Registers a custom downloader for the s3:// protocol to fetch boxes from any S3 API endpoint.'
6
+ spec.authors = ['Maksim Razumov']
7
+ spec.email = ['maksim.razumov@xiag.ch']
8
+ spec.files = Dir.glob('lib/**/*') + ['vagrant-s3-multidownloader.gemspec']
9
+ spec.require_paths = ['lib']
10
+ spec.add_runtime_dependency 'vagrant', '>= 2.2.0'
11
+ spec.add_runtime_dependency 'aws-sdk-s3', '~> 1.0'
12
+ end
metadata ADDED
@@ -0,0 +1,78 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: vagrant-s3-multidownloader
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.0
5
+ platform: ruby
6
+ authors:
7
+ - Maksim Razumov
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-05-14 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: vagrant
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 2.2.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 2.2.0
27
+ - !ruby/object:Gem::Dependency
28
+ name: aws-sdk-s3
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.0'
41
+ description: Registers a custom downloader for the s3:// protocol to fetch boxes from
42
+ any S3 API endpoint.
43
+ email:
44
+ - maksim.razumov@xiag.ch
45
+ executables: []
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - lib/vagrant-s3-multidownloader.rb
50
+ - lib/vagrant-s3-multidownloader/downloader.rb
51
+ - lib/vagrant-s3-multidownloader/metadata_handler.rb
52
+ - lib/vagrant-s3-multidownloader/middleware/handle_metadata_versions.rb
53
+ - lib/vagrant-s3-multidownloader/middleware/handle_s3_urls.rb
54
+ - lib/vagrant-s3-multidownloader/plugin.rb
55
+ - vagrant-s3-multidownloader.gemspec
56
+ homepage:
57
+ licenses: []
58
+ metadata: {}
59
+ post_install_message:
60
+ rdoc_options: []
61
+ require_paths:
62
+ - lib
63
+ required_ruby_version: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ required_rubygems_version: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: '0'
73
+ requirements: []
74
+ rubygems_version: 3.3.27
75
+ signing_key:
76
+ specification_version: 4
77
+ summary: Download Vagrant boxes from S3-compatible storage using AWS SDK.
78
+ test_files: []