fig 2.0.0.pre.alpha.5 → 2.0.0.pre.alpha.10
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 +4 -4
- data/lib/fig/operating_system.rb +3 -0
- data/lib/fig/protocol/artifactory.rb +389 -0
- data/lib/fig/version.rb +1 -1
- data/spec/protocol/artifactory_spec.rb +599 -0
- metadata +35 -5
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: ffb9d7bc5e0a1723f452acc24d3ed117f700814821c5f6b9be69258edeec4903
|
4
|
+
data.tar.gz: cff46d3b93314fc559e0e3ab549b472b9cdc1474363d758ef18c4020fca675b7
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 947d9c97f725fa69e074b0b580ba4698d4e824278c1ea2c71e036e5146030302de5319fe281171bc1727cd8a0194ace497edc731061fb9314317cb3aa52dacfe
|
7
|
+
data.tar.gz: a4c64c9fdf34e90543a790c3caf649cca749a74ef4b3b151902cfe931af17cc2a52d2f99dea12d090fcf8035c448fa5a8e5834b334683be4982882fe42da5e05
|
data/lib/fig/operating_system.rb
CHANGED
@@ -20,6 +20,7 @@ require 'fig/protocol/ftp'
|
|
20
20
|
require 'fig/protocol/http'
|
21
21
|
require 'fig/protocol/sftp'
|
22
22
|
require 'fig/protocol/ssh'
|
23
|
+
require 'fig/protocol/artifactory'
|
23
24
|
require 'fig/repository_error'
|
24
25
|
require 'fig/url'
|
25
26
|
require 'fig/user_input_error'
|
@@ -83,8 +84,10 @@ class Fig::OperatingSystem
|
|
83
84
|
@protocols['file'] = Fig::Protocol::File.new
|
84
85
|
@protocols['ftp'] = Fig::Protocol::FTP.new login
|
85
86
|
@protocols['http'] = Fig::Protocol::HTTP.new
|
87
|
+
@protocols['https'] = Fig::Protocol::HTTP.new
|
86
88
|
@protocols['sftp'] = Fig::Protocol::SFTP.new
|
87
89
|
@protocols['ssh'] = Fig::Protocol::SSH.new
|
90
|
+
@protocols['art'] = @protocols['artifactory'] = Fig::Protocol::Artifactory.new
|
88
91
|
end
|
89
92
|
|
90
93
|
def list(dir)
|
@@ -0,0 +1,389 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
|
3
|
+
require 'net/http'
|
4
|
+
require 'uri'
|
5
|
+
require 'etc'
|
6
|
+
require 'thread'
|
7
|
+
require 'socket'
|
8
|
+
require 'digest'
|
9
|
+
require 'time'
|
10
|
+
|
11
|
+
require 'artifactory'
|
12
|
+
|
13
|
+
require 'fig/logging'
|
14
|
+
require 'fig/network_error'
|
15
|
+
require 'fig/package_descriptor'
|
16
|
+
require 'fig/protocol'
|
17
|
+
require 'fig/protocol/netrc_enabled'
|
18
|
+
|
19
|
+
|
20
|
+
module Fig; end
|
21
|
+
module Fig::Protocol; end
|
22
|
+
|
23
|
+
|
24
|
+
# file transfers/storage using https as the transport and artifactory as the backing store
|
25
|
+
class Fig::Protocol::Artifactory
|
26
|
+
include Fig::Protocol
|
27
|
+
include Fig::Protocol::NetRCEnabled
|
28
|
+
|
29
|
+
# Artifactory browser API endpoint (undocumented API)
|
30
|
+
BROWSER_API_PATH = 'ui/api/v1/ui/v2/nativeBrowser/'
|
31
|
+
|
32
|
+
# Default number of list entries to fetch on initial iteration
|
33
|
+
INITIAL_LIST_FETCH_SIZE = 20000
|
34
|
+
|
35
|
+
def initialize
|
36
|
+
initialize_netrc
|
37
|
+
end
|
38
|
+
|
39
|
+
# must return a list of strings in the form <package_name>/<version>
|
40
|
+
def download_list(uri)
|
41
|
+
Fig::Logging.info("Downloading list of packages at #{uri}")
|
42
|
+
package_versions = []
|
43
|
+
|
44
|
+
begin
|
45
|
+
# Parse URI to extract base endpoint and repository key
|
46
|
+
# Expected format: https://artifacts.example.com/artifactory/repo-name/
|
47
|
+
parse_uri(uri) => { repo_key:, base_endpoint: }
|
48
|
+
|
49
|
+
# Create Artifactory client instance
|
50
|
+
authentication = get_authentication_for(uri.host, :prompt_for_login)
|
51
|
+
client_config = { endpoint: base_endpoint }
|
52
|
+
if authentication
|
53
|
+
client_config[:username] = authentication.username
|
54
|
+
client_config[:password] = authentication.password
|
55
|
+
end
|
56
|
+
client = ::Artifactory::Client.new(client_config)
|
57
|
+
|
58
|
+
# Use Artifactory browser API to list directories at repo root
|
59
|
+
list_url = URI.join(base_endpoint, BROWSER_API_PATH, "#{repo_key}/")
|
60
|
+
|
61
|
+
packages = get_all_artifactory_entries(list_url, client)
|
62
|
+
|
63
|
+
# Filter to only valid package names upfront
|
64
|
+
valid_packages = packages.select do |package_item|
|
65
|
+
package_item['folder'] &&
|
66
|
+
package_item['name'] =~ Fig::PackageDescriptor::COMPONENT_PATTERN
|
67
|
+
end
|
68
|
+
|
69
|
+
Fig::Logging.debug("Found #{valid_packages.size} valid packages, fetching versions concurrently...")
|
70
|
+
|
71
|
+
# Use concurrent requests to fetch version lists (major performance improvement)
|
72
|
+
package_versions = fetch_versions_concurrently(valid_packages, base_endpoint, repo_key, client_config)
|
73
|
+
|
74
|
+
rescue => e
|
75
|
+
# Follow FTP pattern: log error but don't fail completely
|
76
|
+
Fig::Logging.debug("Could not retrieve package list from #{uri}: #{e.message}")
|
77
|
+
end
|
78
|
+
|
79
|
+
return package_versions.sort
|
80
|
+
end
|
81
|
+
|
82
|
+
def download(art_uri, path, prompt_for_login)
|
83
|
+
Fig::Logging.info("Downloading from artifactory: #{art_uri}")
|
84
|
+
|
85
|
+
uri = httpify_uri(art_uri)
|
86
|
+
|
87
|
+
# Log equivalent curl command for debugging
|
88
|
+
authentication = get_authentication_for(uri.host, prompt_for_login)
|
89
|
+
if authentication
|
90
|
+
Fig::Logging.debug("Equivalent curl: curl -u #{authentication.username}:*** -o '#{path}' '#{uri}'")
|
91
|
+
else
|
92
|
+
Fig::Logging.debug("Equivalent curl: curl -o '#{path}' '#{uri}'")
|
93
|
+
end
|
94
|
+
|
95
|
+
::File.open(path, 'wb') do |file|
|
96
|
+
file.binmode
|
97
|
+
|
98
|
+
begin
|
99
|
+
download_via_http_get(uri.to_s, file)
|
100
|
+
rescue SystemCallError => error
|
101
|
+
Fig::Logging.debug error.message
|
102
|
+
raise Fig::FileNotFoundError.new error.message, uri
|
103
|
+
rescue SocketError => error
|
104
|
+
Fig::Logging.debug error.message
|
105
|
+
raise Fig::FileNotFoundError.new error.message, uri
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
return true
|
110
|
+
end
|
111
|
+
|
112
|
+
def upload(local_file, uri)
|
113
|
+
Fig::Logging.info("Uploading #{local_file} to artifactory at #{uri}")
|
114
|
+
|
115
|
+
begin
|
116
|
+
parse_uri(uri) => { repo_key:, base_endpoint:, target_path: }
|
117
|
+
|
118
|
+
# Configure Artifactory gem globally - unlike other methods that can use client instances,
|
119
|
+
# the artifact.upload() method ignores the client: parameter and only uses global config.
|
120
|
+
# This is a limitation of the artifactory gem's upload implementation.
|
121
|
+
authentication = get_authentication_for(uri.host, :prompt_for_login)
|
122
|
+
::Artifactory.configure do |config|
|
123
|
+
config.endpoint = base_endpoint
|
124
|
+
if authentication
|
125
|
+
config.username = authentication.username
|
126
|
+
config.password = authentication.password
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
# Log equivalent curl command for debugging
|
131
|
+
if authentication
|
132
|
+
Fig::Logging.debug("Equivalent curl: curl -u #{authentication.username}:*** -T '#{local_file}' '#{uri}'")
|
133
|
+
else
|
134
|
+
Fig::Logging.debug("Equivalent curl: curl -T '#{local_file}' '#{uri}'")
|
135
|
+
end
|
136
|
+
|
137
|
+
# Create artifact and upload
|
138
|
+
artifact = ::Artifactory::Resource::Artifact.new(local_path: local_file)
|
139
|
+
|
140
|
+
# Collect metadata for upload
|
141
|
+
metadata = collect_upload_metadata(local_file, target_path, uri)
|
142
|
+
|
143
|
+
# Upload with metadata (no client parameter needed - uses global config)
|
144
|
+
artifact.upload(repo_key, target_path, metadata)
|
145
|
+
|
146
|
+
Fig::Logging.info("Successfully uploaded #{local_file} to #{uri}")
|
147
|
+
|
148
|
+
rescue ArgumentError => e
|
149
|
+
# Let ArgumentError bubble up for invalid URIs
|
150
|
+
raise e
|
151
|
+
rescue => e
|
152
|
+
Fig::Logging.debug("Upload failed: #{e.message}")
|
153
|
+
raise Fig::NetworkError.new("Failed to upload #{local_file} to #{uri}: #{e.message}")
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
# we can know this most of the time with the stat api
|
158
|
+
def path_up_to_date?(uri, path, prompt_for_login)
|
159
|
+
Fig::Logging.info("Checking if #{path} is up to date at #{uri}")
|
160
|
+
|
161
|
+
begin
|
162
|
+
parse_uri(uri) => { repo_key:, base_endpoint:, target_path: }
|
163
|
+
|
164
|
+
|
165
|
+
# Create Artifactory client instance (same as upload method)
|
166
|
+
authentication = get_authentication_for(uri.host, prompt_for_login)
|
167
|
+
client_config = { endpoint: base_endpoint }
|
168
|
+
if authentication
|
169
|
+
client_config[:username] = authentication.username
|
170
|
+
client_config[:password] = authentication.password
|
171
|
+
end
|
172
|
+
client = ::Artifactory::Client.new(client_config)
|
173
|
+
|
174
|
+
# use storage api instead of search - more reliable for virtual repos
|
175
|
+
storage_url = "/api/storage/#{repo_key}/#{target_path}"
|
176
|
+
|
177
|
+
response = client.get(storage_url)
|
178
|
+
|
179
|
+
# compare sizes first
|
180
|
+
if response['size'] != ::File.size(path)
|
181
|
+
return false
|
182
|
+
end
|
183
|
+
|
184
|
+
# compare modification times
|
185
|
+
remote_mtime = Time.parse(response['lastModified'])
|
186
|
+
local_mtime = ::File.mtime(path)
|
187
|
+
|
188
|
+
if remote_mtime <= local_mtime
|
189
|
+
return true
|
190
|
+
end
|
191
|
+
|
192
|
+
return false
|
193
|
+
rescue => error
|
194
|
+
Fig::Logging.debug "Error checking if #{path} is up to date: #{error.message}"
|
195
|
+
return nil
|
196
|
+
end
|
197
|
+
end
|
198
|
+
|
199
|
+
private
|
200
|
+
|
201
|
+
def httpify_uri(art_uri)
|
202
|
+
# if we got into the artifactory protocol, we get to assume that the URI
|
203
|
+
# is related to artifactory, which also means we can be assured that the
|
204
|
+
# only scheme that will work is 'https' rather than the faux scheme used
|
205
|
+
# to signal that the URL is artifactory protocol.
|
206
|
+
|
207
|
+
# this is kind of weird b/c `art_uri` will likely be URI::Generic and
|
208
|
+
# everything downstream of here is going to want the type for the URL to
|
209
|
+
# be URI::HTTPS. So, we hack the scheme for the input to `https` then
|
210
|
+
# parse that string to form the https-based URI with the correct type.
|
211
|
+
art_uri.scheme = 'https'
|
212
|
+
URI(art_uri.to_s)
|
213
|
+
end
|
214
|
+
|
215
|
+
def parse_uri(art_uri)
|
216
|
+
uri = httpify_uri(art_uri)
|
217
|
+
|
218
|
+
uri_path = uri.path.chomp('/')
|
219
|
+
path_parts = uri_path.split('/').reject(&:empty?)
|
220
|
+
|
221
|
+
# Find artifactory in the path and split accordingly
|
222
|
+
artifactory_index = path_parts.index('artifactory')
|
223
|
+
raise ArgumentError, "URI must contain 'artifactory' in path: #{uri}" unless artifactory_index
|
224
|
+
|
225
|
+
repo_key = path_parts[artifactory_index + 1]
|
226
|
+
raise ArgumentError, "No repository key found in URI: #{uri}" unless repo_key
|
227
|
+
|
228
|
+
# Everything after repo_key is the target path within the repository
|
229
|
+
target_path = path_parts[(artifactory_index + 2)..-1]&.join('/') || ''
|
230
|
+
|
231
|
+
# Base endpoint includes everything up to and including /artifactory
|
232
|
+
artifactory_path = path_parts[0..artifactory_index].join('/')
|
233
|
+
the_port = uri.port != uri.default_port ? ":#{uri.port}" : ""
|
234
|
+
base_endpoint = "#{uri.scheme}://#{uri.host}#{the_port}/#{artifactory_path}"
|
235
|
+
|
236
|
+
{ repo_key:, base_endpoint:, target_path: }
|
237
|
+
end
|
238
|
+
|
239
|
+
# Collect metadata for file upload, using fig. prefix instead of upload. prefix
|
240
|
+
def collect_upload_metadata(local_file, target_path, uri)
|
241
|
+
file_stat = ::File.stat(local_file)
|
242
|
+
|
243
|
+
metadata = {
|
244
|
+
# Basic file information
|
245
|
+
'fig.original_path' => local_file,
|
246
|
+
'fig.target_path' => target_path,
|
247
|
+
'fig.file_size' => file_stat.size.to_s,
|
248
|
+
'fig.file_mtime' => file_stat.mtime.iso8601,
|
249
|
+
|
250
|
+
# Upload context
|
251
|
+
'fig.hostname' => Socket.gethostname,
|
252
|
+
'fig.login' => ENV['USER'] || ENV['USERNAME'] || 'unknown',
|
253
|
+
'fig.timestamp' => Time.now.iso8601,
|
254
|
+
'fig.epoch' => Time.now.to_i.to_s,
|
255
|
+
|
256
|
+
# Tool information
|
257
|
+
'fig.tool' => 'fig-artifactory-protocol',
|
258
|
+
'fig.uri' => uri.to_s,
|
259
|
+
|
260
|
+
# Checksums for integrity
|
261
|
+
'fig.sha1' => Digest::SHA1.file(local_file).hexdigest,
|
262
|
+
'fig.md5' => Digest::MD5.file(local_file).hexdigest,
|
263
|
+
|
264
|
+
# TODO: Add package/version metadata from URI path or local_file decoration
|
265
|
+
# TODO: Support user-injected metadata via environment variables or callbacks
|
266
|
+
}
|
267
|
+
|
268
|
+
Fig::Logging.debug("Upload metadata: #{metadata.keys.join(', ')}")
|
269
|
+
metadata
|
270
|
+
end
|
271
|
+
|
272
|
+
# Fetch version lists for packages concurrently for major performance improvement
|
273
|
+
# Reduces ~2700 sequential API calls to concurrent batches
|
274
|
+
def fetch_versions_concurrently(valid_packages, base_endpoint, repo_key, client_config)
|
275
|
+
# Scale thread count based on CPU cores, with reasonable bounds
|
276
|
+
# Testing showed ~25 threads optimal for most servers, so use 3x CPU cores as default
|
277
|
+
default_threads = [Etc.nprocessors * 3, 50].min # cap at 50 to avoid overwhelming servers
|
278
|
+
max_threads = ENV['FIG_ARTIFACTORY_THREADS']&.to_i || default_threads
|
279
|
+
|
280
|
+
Fig::Logging.debug("Using #{max_threads} threads for concurrent version fetching (#{Etc.nprocessors} CPU cores detected)")
|
281
|
+
|
282
|
+
package_versions = []
|
283
|
+
package_versions_mutex = Mutex.new
|
284
|
+
work_queue = Queue.new
|
285
|
+
|
286
|
+
# Add all packages to work queue
|
287
|
+
valid_packages.each { |pkg| work_queue << pkg }
|
288
|
+
|
289
|
+
# Create worker threads
|
290
|
+
threads = []
|
291
|
+
max_threads.times do
|
292
|
+
threads << Thread.new do
|
293
|
+
# Each thread gets its own client to avoid connection issues
|
294
|
+
thread_client = ::Artifactory::Client.new(client_config)
|
295
|
+
|
296
|
+
while !work_queue.empty?
|
297
|
+
begin
|
298
|
+
package_item = work_queue.pop(true) # non-blocking pop
|
299
|
+
rescue ThreadError
|
300
|
+
break # queue is empty
|
301
|
+
end
|
302
|
+
|
303
|
+
package_name = package_item['name']
|
304
|
+
|
305
|
+
begin
|
306
|
+
package_list_url = URI.join(base_endpoint, BROWSER_API_PATH, "#{repo_key}/", "#{package_name}/")
|
307
|
+
versions = get_all_artifactory_entries(package_list_url, thread_client)
|
308
|
+
|
309
|
+
local_package_versions = []
|
310
|
+
versions.each do |version_item|
|
311
|
+
next unless version_item['folder']
|
312
|
+
|
313
|
+
version_name = version_item['name']
|
314
|
+
next unless version_name =~ Fig::PackageDescriptor::COMPONENT_PATTERN
|
315
|
+
|
316
|
+
local_package_versions << "#{package_name}/#{version_name}"
|
317
|
+
end
|
318
|
+
|
319
|
+
# Thread-safe addition to results
|
320
|
+
package_versions_mutex.synchronize do
|
321
|
+
package_versions.concat(local_package_versions)
|
322
|
+
end
|
323
|
+
|
324
|
+
rescue => e
|
325
|
+
# Follow FTP pattern: ignore permission errors and continue processing
|
326
|
+
Fig::Logging.debug("Could not list versions for package #{package_name}: #{e.message}")
|
327
|
+
end
|
328
|
+
end
|
329
|
+
end
|
330
|
+
end
|
331
|
+
|
332
|
+
# Wait for all threads to complete
|
333
|
+
threads.each(&:join)
|
334
|
+
|
335
|
+
package_versions
|
336
|
+
end
|
337
|
+
|
338
|
+
# Get all entries from Artifactory browser API with pagination support
|
339
|
+
# Returns array of all entries, handling continueState pagination
|
340
|
+
def get_all_artifactory_entries(base_url, client)
|
341
|
+
record_num = ENV['FIG_ARTIFACTORY_PAGESIZE']&.to_i || INITIAL_LIST_FETCH_SIZE
|
342
|
+
|
343
|
+
Fig::Logging.debug(">> getting art initial #{record_num} entries from #{base_url}...")
|
344
|
+
|
345
|
+
loop do
|
346
|
+
# Build URL with recordNum parameter
|
347
|
+
url = URI(base_url.to_s)
|
348
|
+
url.query = "recordNum=#{record_num}"
|
349
|
+
|
350
|
+
response = client.get(url)
|
351
|
+
entries = response['data'] || []
|
352
|
+
|
353
|
+
# Check if there are more entries to fetch
|
354
|
+
continue_state = response['continueState']
|
355
|
+
Fig::Logging.debug(">> continue_state is #{continue_state}...")
|
356
|
+
return entries if continue_state.nil? || continue_state.to_i < 0
|
357
|
+
|
358
|
+
# Use continueState as the recordNum for the next request
|
359
|
+
record_num = continue_state
|
360
|
+
end
|
361
|
+
end
|
362
|
+
|
363
|
+
# swiped directly from http.rb; if no changes are required, then consider refactoring
|
364
|
+
def download_via_http_get(uri_string, file, redirection_limit = 10)
|
365
|
+
if redirection_limit < 1
|
366
|
+
Fig::Logging.debug 'Too many HTTP redirects.'
|
367
|
+
raise Fig::FileNotFoundError.new 'Too many HTTP redirects.', uri_string
|
368
|
+
end
|
369
|
+
|
370
|
+
response = Net::HTTP.get_response(URI(uri_string))
|
371
|
+
|
372
|
+
case response
|
373
|
+
when Net::HTTPSuccess then
|
374
|
+
file.write(response.body)
|
375
|
+
when Net::HTTPRedirection then
|
376
|
+
location = response['location']
|
377
|
+
Fig::Logging.debug "Redirecting to #{location}."
|
378
|
+
download_via_http_get(location, file, redirection_limit - 1)
|
379
|
+
else
|
380
|
+
Fig::Logging.debug "Download failed: #{response.code} #{response.message}."
|
381
|
+
raise Fig::FileNotFoundError.new(
|
382
|
+
"Download failed: #{response.code} #{response.message}.", uri_string
|
383
|
+
)
|
384
|
+
end
|
385
|
+
|
386
|
+
return
|
387
|
+
end
|
388
|
+
|
389
|
+
end
|
data/lib/fig/version.rb
CHANGED
@@ -0,0 +1,599 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
|
3
|
+
require 'spec_helper'
|
4
|
+
require 'fig/protocol/artifactory'
|
5
|
+
|
6
|
+
describe Fig::Protocol::Artifactory do
|
7
|
+
let(:artifactory) { Fig::Protocol::Artifactory.new }
|
8
|
+
let(:base_url) { URI('https://artifacts.example.com/artifactory/ui/api/v1/ui/v2/nativeBrowser/repo-name/') }
|
9
|
+
|
10
|
+
describe '#get_all_artifactory_entries' do
|
11
|
+
let(:mock_client) { double('Artifactory::Client') }
|
12
|
+
let(:base_url) { URI('https://artifacts.example.com/artifactory/ui/api/v1/ui/v2/nativeBrowser/repo-name/') }
|
13
|
+
|
14
|
+
before do
|
15
|
+
# Stub netrc authentication
|
16
|
+
allow(artifactory).to receive(:get_authentication_for).and_return(nil)
|
17
|
+
end
|
18
|
+
|
19
|
+
context 'when all entries fit in single page' do
|
20
|
+
it 'returns entries without pagination' do
|
21
|
+
response = {
|
22
|
+
'data' => [
|
23
|
+
{ 'name' => 'package1', 'folder' => true },
|
24
|
+
{ 'name' => 'package2', 'folder' => true }
|
25
|
+
],
|
26
|
+
'continueState' => -1
|
27
|
+
}
|
28
|
+
|
29
|
+
expect(mock_client).to receive(:get)
|
30
|
+
.with(URI("https://artifacts.example.com/artifactory/ui/api/v1/ui/v2/nativeBrowser/repo-name/?recordNum=#{Fig::Protocol::Artifactory::INITIAL_LIST_FETCH_SIZE}"))
|
31
|
+
.and_return(response)
|
32
|
+
|
33
|
+
result = artifactory.send(:get_all_artifactory_entries, base_url, mock_client)
|
34
|
+
expect(result).to eq(response['data'])
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
context 'when entries require pagination' do
|
39
|
+
it 'follows pagination until continueState is -1' do
|
40
|
+
first_response = {
|
41
|
+
'data' => [
|
42
|
+
{ 'name' => 'package1', 'folder' => true },
|
43
|
+
{ 'name' => 'package2', 'folder' => true }
|
44
|
+
],
|
45
|
+
'continueState' => 'cursor123'
|
46
|
+
}
|
47
|
+
|
48
|
+
final_response = {
|
49
|
+
'data' => [
|
50
|
+
{ 'name' => 'package1', 'folder' => true },
|
51
|
+
{ 'name' => 'package2', 'folder' => true },
|
52
|
+
{ 'name' => 'package3', 'folder' => true }
|
53
|
+
],
|
54
|
+
'continueState' => -1
|
55
|
+
}
|
56
|
+
|
57
|
+
expect(mock_client).to receive(:get)
|
58
|
+
.with(URI("https://artifacts.example.com/artifactory/ui/api/v1/ui/v2/nativeBrowser/repo-name/?recordNum=#{Fig::Protocol::Artifactory::INITIAL_LIST_FETCH_SIZE}"))
|
59
|
+
.and_return(first_response)
|
60
|
+
|
61
|
+
expect(mock_client).to receive(:get)
|
62
|
+
.with(URI('https://artifacts.example.com/artifactory/ui/api/v1/ui/v2/nativeBrowser/repo-name/?recordNum=cursor123'))
|
63
|
+
.and_return(final_response)
|
64
|
+
|
65
|
+
result = artifactory.send(:get_all_artifactory_entries, base_url, mock_client)
|
66
|
+
expect(result).to eq(final_response['data'])
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
context 'when continueState is nil' do
|
71
|
+
it 'returns entries and stops pagination' do
|
72
|
+
response = {
|
73
|
+
'data' => [{ 'name' => 'package1', 'folder' => true }],
|
74
|
+
'continueState' => nil
|
75
|
+
}
|
76
|
+
|
77
|
+
expect(mock_client).to receive(:get)
|
78
|
+
.with(URI("https://artifacts.example.com/artifactory/ui/api/v1/ui/v2/nativeBrowser/repo-name/?recordNum=#{Fig::Protocol::Artifactory::INITIAL_LIST_FETCH_SIZE}"))
|
79
|
+
.and_return(response)
|
80
|
+
|
81
|
+
result = artifactory.send(:get_all_artifactory_entries, base_url, mock_client)
|
82
|
+
expect(result).to eq(response['data'])
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
context 'when FIG_ARTIFACTORY_PAGESIZE is set' do
|
87
|
+
it 'uses custom page size' do
|
88
|
+
allow(ENV).to receive(:[]).with('FIG_ARTIFACTORY_PAGESIZE').and_return('5000')
|
89
|
+
|
90
|
+
response = {
|
91
|
+
'data' => [{ 'name' => 'package1', 'folder' => true }],
|
92
|
+
'continueState' => -1
|
93
|
+
}
|
94
|
+
|
95
|
+
expect(mock_client).to receive(:get)
|
96
|
+
.with(URI('https://artifacts.example.com/artifactory/ui/api/v1/ui/v2/nativeBrowser/repo-name/?recordNum=5000'))
|
97
|
+
.and_return(response)
|
98
|
+
|
99
|
+
result = artifactory.send(:get_all_artifactory_entries, base_url, mock_client)
|
100
|
+
expect(result).to eq(response['data'])
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
context 'when response has no data field' do
|
105
|
+
it 'returns empty array' do
|
106
|
+
response = { 'continueState' => -1 }
|
107
|
+
|
108
|
+
expect(mock_client).to receive(:get)
|
109
|
+
.with(URI("https://artifacts.example.com/artifactory/ui/api/v1/ui/v2/nativeBrowser/repo-name/?recordNum=#{Fig::Protocol::Artifactory::INITIAL_LIST_FETCH_SIZE}"))
|
110
|
+
.and_return(response)
|
111
|
+
|
112
|
+
result = artifactory.send(:get_all_artifactory_entries, base_url, mock_client)
|
113
|
+
expect(result).to eq([])
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
describe '#download_list' do
|
119
|
+
let(:uri) { URI('art://artifacts.example.com/artifactory/repo-name/') }
|
120
|
+
let(:artifactory) { Fig::Protocol::Artifactory.new }
|
121
|
+
let(:mock_client) { double('Artifactory::Client') }
|
122
|
+
|
123
|
+
before do
|
124
|
+
allow(::Artifactory::Client).to receive(:new).and_return(mock_client)
|
125
|
+
# Stub netrc authentication
|
126
|
+
allow(artifactory).to receive(:get_authentication_for).and_return(nil)
|
127
|
+
end
|
128
|
+
|
129
|
+
context 'when repository has packages and versions' do
|
130
|
+
it 'returns sorted package/version strings' do
|
131
|
+
# Mock package listing
|
132
|
+
packages_response = {
|
133
|
+
'data' => [
|
134
|
+
{ 'name' => 'package-a', 'folder' => true },
|
135
|
+
{ 'name' => 'package-b', 'folder' => true },
|
136
|
+
{ 'name' => 'readme.txt', 'folder' => false } # Should be ignored
|
137
|
+
],
|
138
|
+
'continueState' => -1
|
139
|
+
}
|
140
|
+
|
141
|
+
# Mock version listings for each package
|
142
|
+
package_a_versions = {
|
143
|
+
'data' => [
|
144
|
+
{ 'name' => '1.0.0', 'folder' => true },
|
145
|
+
{ 'name' => '2.0.0', 'folder' => true },
|
146
|
+
{ 'name' => 'metadata.xml', 'folder' => false } # Should be ignored
|
147
|
+
],
|
148
|
+
'continueState' => -1
|
149
|
+
}
|
150
|
+
|
151
|
+
package_b_versions = {
|
152
|
+
'data' => [
|
153
|
+
{ 'name' => '0.5.0', 'folder' => true }
|
154
|
+
],
|
155
|
+
'continueState' => -1
|
156
|
+
}
|
157
|
+
|
158
|
+
# Expect calls in order
|
159
|
+
expect(mock_client).to receive(:get)
|
160
|
+
.with(URI("https://artifacts.example.com/ui/api/v1/ui/v2/nativeBrowser/repo-name/?recordNum=#{Fig::Protocol::Artifactory::INITIAL_LIST_FETCH_SIZE}"))
|
161
|
+
.and_return(packages_response)
|
162
|
+
|
163
|
+
expect(mock_client).to receive(:get)
|
164
|
+
.with(URI("https://artifacts.example.com/ui/api/v1/ui/v2/nativeBrowser/repo-name/package-a/?recordNum=#{Fig::Protocol::Artifactory::INITIAL_LIST_FETCH_SIZE}"))
|
165
|
+
.and_return(package_a_versions)
|
166
|
+
|
167
|
+
expect(mock_client).to receive(:get)
|
168
|
+
.with(URI("https://artifacts.example.com/ui/api/v1/ui/v2/nativeBrowser/repo-name/package-b/?recordNum=#{Fig::Protocol::Artifactory::INITIAL_LIST_FETCH_SIZE}"))
|
169
|
+
.and_return(package_b_versions)
|
170
|
+
|
171
|
+
result = artifactory.download_list(uri)
|
172
|
+
expect(result).to eq([
|
173
|
+
'package-a/1.0.0',
|
174
|
+
'package-a/2.0.0',
|
175
|
+
'package-b/0.5.0'
|
176
|
+
])
|
177
|
+
end
|
178
|
+
end
|
179
|
+
|
180
|
+
context 'when repository is empty' do
|
181
|
+
it 'returns empty array' do
|
182
|
+
empty_response = {
|
183
|
+
'data' => [],
|
184
|
+
'continueState' => -1
|
185
|
+
}
|
186
|
+
|
187
|
+
expect(mock_client).to receive(:get)
|
188
|
+
.with(URI("https://artifacts.example.com/ui/api/v1/ui/v2/nativeBrowser/repo-name/?recordNum=#{Fig::Protocol::Artifactory::INITIAL_LIST_FETCH_SIZE}"))
|
189
|
+
.and_return(empty_response)
|
190
|
+
|
191
|
+
result = artifactory.download_list(uri)
|
192
|
+
expect(result).to eq([])
|
193
|
+
end
|
194
|
+
end
|
195
|
+
|
196
|
+
context 'when API calls fail' do
|
197
|
+
it 'handles errors gracefully and continues processing' do
|
198
|
+
packages_response = {
|
199
|
+
'data' => [
|
200
|
+
{ 'name' => 'good-package', 'folder' => true },
|
201
|
+
{ 'name' => 'bad-package', 'folder' => true }
|
202
|
+
],
|
203
|
+
'continueState' => -1
|
204
|
+
}
|
205
|
+
|
206
|
+
good_versions = {
|
207
|
+
'data' => [{ 'name' => '1.0.0', 'folder' => true }],
|
208
|
+
'continueState' => -1
|
209
|
+
}
|
210
|
+
|
211
|
+
expect(mock_client).to receive(:get)
|
212
|
+
.with(URI("https://artifacts.example.com/ui/api/v1/ui/v2/nativeBrowser/repo-name/?recordNum=#{Fig::Protocol::Artifactory::INITIAL_LIST_FETCH_SIZE}"))
|
213
|
+
.and_return(packages_response)
|
214
|
+
|
215
|
+
expect(mock_client).to receive(:get)
|
216
|
+
.with(URI("https://artifacts.example.com/ui/api/v1/ui/v2/nativeBrowser/repo-name/good-package/?recordNum=#{Fig::Protocol::Artifactory::INITIAL_LIST_FETCH_SIZE}"))
|
217
|
+
.and_return(good_versions)
|
218
|
+
|
219
|
+
expect(mock_client).to receive(:get)
|
220
|
+
.with(URI("https://artifacts.example.com/ui/api/v1/ui/v2/nativeBrowser/repo-name/bad-package/?recordNum=#{Fig::Protocol::Artifactory::INITIAL_LIST_FETCH_SIZE}"))
|
221
|
+
.and_raise(StandardError.new('API error'))
|
222
|
+
|
223
|
+
# Should continue processing despite error
|
224
|
+
result = artifactory.download_list(uri)
|
225
|
+
expect(result).to eq(['good-package/1.0.0'])
|
226
|
+
end
|
227
|
+
end
|
228
|
+
|
229
|
+
context 'with invalid package/version names' do
|
230
|
+
it 'filters out names that do not match COMPONENT_PATTERN' do
|
231
|
+
packages_response = {
|
232
|
+
'data' => [
|
233
|
+
{ 'name' => 'valid-package', 'folder' => true },
|
234
|
+
{ 'name' => 'invalid package with spaces', 'folder' => true },
|
235
|
+
{ 'name' => 'invalid@symbols', 'folder' => true }
|
236
|
+
],
|
237
|
+
'continueState' => -1
|
238
|
+
}
|
239
|
+
|
240
|
+
valid_versions = {
|
241
|
+
'data' => [
|
242
|
+
{ 'name' => '1.0.0', 'folder' => true },
|
243
|
+
{ 'name' => 'invalid version', 'folder' => true }
|
244
|
+
],
|
245
|
+
'continueState' => -1
|
246
|
+
}
|
247
|
+
|
248
|
+
expect(mock_client).to receive(:get)
|
249
|
+
.with(URI("https://artifacts.example.com/ui/api/v1/ui/v2/nativeBrowser/repo-name/?recordNum=#{Fig::Protocol::Artifactory::INITIAL_LIST_FETCH_SIZE}"))
|
250
|
+
.and_return(packages_response)
|
251
|
+
|
252
|
+
expect(mock_client).to receive(:get)
|
253
|
+
.with(URI("https://artifacts.example.com/ui/api/v1/ui/v2/nativeBrowser/repo-name/valid-package/?recordNum=#{Fig::Protocol::Artifactory::INITIAL_LIST_FETCH_SIZE}"))
|
254
|
+
.and_return(valid_versions)
|
255
|
+
|
256
|
+
result = artifactory.download_list(uri)
|
257
|
+
expect(result).to eq(['valid-package/1.0.0'])
|
258
|
+
end
|
259
|
+
end
|
260
|
+
end
|
261
|
+
|
262
|
+
describe '#download' do
|
263
|
+
let(:uri) { URI('artifactory://artifacts.example.com/artifactory/repo-name/package/version/file.tar.gz') }
|
264
|
+
let(:https_uri) { URI(uri.to_s.sub(/\Aartifactory:/, 'https:')) }
|
265
|
+
let(:path) { '/tmp/test_file.tar.gz' }
|
266
|
+
let(:prompt_for_login) { false }
|
267
|
+
let(:mock_file) { double('File') }
|
268
|
+
let(:mock_auth) { double('Authentication', username: 'testuser', password: 'testpass') }
|
269
|
+
|
270
|
+
before do
|
271
|
+
allow(::File).to receive(:open).with(path, 'wb').and_yield(mock_file)
|
272
|
+
allow(mock_file).to receive(:binmode)
|
273
|
+
end
|
274
|
+
|
275
|
+
context 'with authentication' do
|
276
|
+
it 'downloads file and logs curl equivalent with auth' do
|
277
|
+
allow(artifactory).to receive(:get_authentication_for).with(uri.host, prompt_for_login).and_return(mock_auth)
|
278
|
+
allow(artifactory).to receive(:download_via_http_get).with(https_uri.to_s, mock_file)
|
279
|
+
|
280
|
+
expect(Fig::Logging).to receive(:debug).with("Equivalent curl: curl -u testuser:*** -o '#{path}' '#{https_uri}'")
|
281
|
+
|
282
|
+
result = artifactory.download(uri, path, prompt_for_login)
|
283
|
+
expect(result).to be true
|
284
|
+
end
|
285
|
+
end
|
286
|
+
|
287
|
+
context 'without authentication' do
|
288
|
+
it 'downloads file and logs curl equivalent without auth' do
|
289
|
+
allow(artifactory).to receive(:get_authentication_for).with(uri.host, prompt_for_login).and_return(nil)
|
290
|
+
allow(artifactory).to receive(:download_via_http_get).with(https_uri.to_s, mock_file)
|
291
|
+
|
292
|
+
expect(Fig::Logging).to receive(:debug).with("Equivalent curl: curl -o '#{path}' '#{https_uri}'")
|
293
|
+
|
294
|
+
result = artifactory.download(uri, path, prompt_for_login)
|
295
|
+
expect(result).to be true
|
296
|
+
end
|
297
|
+
end
|
298
|
+
|
299
|
+
context 'when download_via_http_get raises SystemCallError' do
|
300
|
+
it 'wraps error in FileNotFoundError' do
|
301
|
+
allow(artifactory).to receive(:get_authentication_for).and_return(nil)
|
302
|
+
system_error = SystemCallError.new('Connection failed')
|
303
|
+
allow(artifactory).to receive(:download_via_http_get).and_raise(system_error)
|
304
|
+
|
305
|
+
expect(Fig::Logging).to receive(:debug).with("Equivalent curl: curl -o '#{path}' '#{https_uri}'")
|
306
|
+
expect(Fig::Logging).to receive(:debug).with('unknown error - Connection failed')
|
307
|
+
expect { artifactory.download(uri, path, prompt_for_login) }.to raise_error(Fig::FileNotFoundError, 'unknown error - Connection failed')
|
308
|
+
end
|
309
|
+
end
|
310
|
+
|
311
|
+
context 'when download_via_http_get raises SocketError' do
|
312
|
+
it 'wraps error in FileNotFoundError' do
|
313
|
+
allow(artifactory).to receive(:get_authentication_for).and_return(nil)
|
314
|
+
socket_error = SocketError.new('Host not found')
|
315
|
+
allow(artifactory).to receive(:download_via_http_get).and_raise(socket_error)
|
316
|
+
|
317
|
+
expect(Fig::Logging).to receive(:debug).with("Equivalent curl: curl -o '#{path}' '#{https_uri}'")
|
318
|
+
expect(Fig::Logging).to receive(:debug).with('Host not found')
|
319
|
+
expect { artifactory.download(uri, path, prompt_for_login) }.to raise_error(Fig::FileNotFoundError, 'Host not found')
|
320
|
+
end
|
321
|
+
end
|
322
|
+
|
323
|
+
it 'opens file in binary write mode' do
|
324
|
+
allow(artifactory).to receive(:get_authentication_for).and_return(nil)
|
325
|
+
allow(artifactory).to receive(:download_via_http_get)
|
326
|
+
|
327
|
+
expect(::File).to receive(:open).with(path, 'wb')
|
328
|
+
expect(mock_file).to receive(:binmode)
|
329
|
+
|
330
|
+
artifactory.download(uri, path, prompt_for_login)
|
331
|
+
end
|
332
|
+
end
|
333
|
+
|
334
|
+
describe '#httpify_uri' do
|
335
|
+
let(:artifactory) { Fig::Protocol::Artifactory.new }
|
336
|
+
|
337
|
+
it 'converts art:// scheme to https://' do
|
338
|
+
art_uri = URI('art://artifacts.example.com/artifactory/repo-name/')
|
339
|
+
result = artifactory.send(:httpify_uri, art_uri)
|
340
|
+
|
341
|
+
expect(result.scheme).to eq('https')
|
342
|
+
expect(result.host).to eq('artifacts.example.com')
|
343
|
+
expect(result.path).to eq('/artifactory/repo-name/')
|
344
|
+
expect(result).to be_a(URI::HTTPS)
|
345
|
+
end
|
346
|
+
|
347
|
+
it 'converts artifactory:// scheme to https://' do
|
348
|
+
art_uri = URI('artifactory://artifacts.example.com/artifactory/repo-name/package/version/file.tar.gz')
|
349
|
+
result = artifactory.send(:httpify_uri, art_uri)
|
350
|
+
|
351
|
+
expect(result.scheme).to eq('https')
|
352
|
+
expect(result.host).to eq('artifacts.example.com')
|
353
|
+
expect(result.path).to eq('/artifactory/repo-name/package/version/file.tar.gz')
|
354
|
+
expect(result).to be_a(URI::HTTPS)
|
355
|
+
end
|
356
|
+
|
357
|
+
it 'preserves port and query parameters' do
|
358
|
+
art_uri = URI('art://artifacts.example.com:8080/artifactory/repo-name/?param=value')
|
359
|
+
result = artifactory.send(:httpify_uri, art_uri)
|
360
|
+
|
361
|
+
expect(result.scheme).to eq('https')
|
362
|
+
expect(result.host).to eq('artifacts.example.com')
|
363
|
+
expect(result.port).to eq(8080)
|
364
|
+
expect(result.path).to eq('/artifactory/repo-name/')
|
365
|
+
expect(result.query).to eq('param=value')
|
366
|
+
expect(result).to be_a(URI::HTTPS)
|
367
|
+
end
|
368
|
+
|
369
|
+
it 'handles URIs with userinfo' do
|
370
|
+
art_uri = URI('artifactory://user:pass@artifacts.example.com/artifactory/repo-name/')
|
371
|
+
result = artifactory.send(:httpify_uri, art_uri)
|
372
|
+
|
373
|
+
expect(result.scheme).to eq('https')
|
374
|
+
expect(result.userinfo).to eq('user:pass')
|
375
|
+
expect(result.host).to eq('artifacts.example.com')
|
376
|
+
expect(result.path).to eq('/artifactory/repo-name/')
|
377
|
+
expect(result).to be_a(URI::HTTPS)
|
378
|
+
end
|
379
|
+
end
|
380
|
+
|
381
|
+
describe '#parse_uri' do
|
382
|
+
let(:artifactory) { Fig::Protocol::Artifactory.new }
|
383
|
+
|
384
|
+
context 'with basic artifactory URI' do
|
385
|
+
it 'parses repository-level URI correctly' do
|
386
|
+
art_uri = URI('art://artifacts.example.com/artifactory/repo-name/')
|
387
|
+
result = artifactory.send(:parse_uri, art_uri)
|
388
|
+
|
389
|
+
expect(result[:repo_key]).to eq('repo-name')
|
390
|
+
expect(result[:base_endpoint]).to eq('https://artifacts.example.com/artifactory')
|
391
|
+
expect(result[:target_path]).to eq('')
|
392
|
+
end
|
393
|
+
|
394
|
+
it 'parses URI with file path correctly' do
|
395
|
+
art_uri = URI('artifactory://artifacts.example.com/artifactory/repo-name/package/version/file.tar.gz')
|
396
|
+
result = artifactory.send(:parse_uri, art_uri)
|
397
|
+
|
398
|
+
expect(result[:repo_key]).to eq('repo-name')
|
399
|
+
expect(result[:base_endpoint]).to eq('https://artifacts.example.com/artifactory')
|
400
|
+
expect(result[:target_path]).to eq('package/version/file.tar.gz')
|
401
|
+
end
|
402
|
+
|
403
|
+
it 'parses URI with nested directory path correctly' do
|
404
|
+
art_uri = URI('art://artifacts.example.com/artifactory/my-repo/com/example/package/1.0.0/package-1.0.0.jar')
|
405
|
+
result = artifactory.send(:parse_uri, art_uri)
|
406
|
+
|
407
|
+
expect(result[:repo_key]).to eq('my-repo')
|
408
|
+
expect(result[:base_endpoint]).to eq('https://artifacts.example.com/artifactory')
|
409
|
+
expect(result[:target_path]).to eq('com/example/package/1.0.0/package-1.0.0.jar')
|
410
|
+
end
|
411
|
+
end
|
412
|
+
|
413
|
+
context 'with custom port' do
|
414
|
+
it 'includes port in base_endpoint when non-standard' do
|
415
|
+
art_uri = URI('artifactory://artifacts.example.com:8080/artifactory/repo-name/file.txt')
|
416
|
+
result = artifactory.send(:parse_uri, art_uri)
|
417
|
+
|
418
|
+
expect(result[:repo_key]).to eq('repo-name')
|
419
|
+
expect(result[:base_endpoint]).to eq('https://artifacts.example.com:8080/artifactory')
|
420
|
+
expect(result[:target_path]).to eq('file.txt')
|
421
|
+
end
|
422
|
+
|
423
|
+
it 'excludes default HTTPS port from base_endpoint' do
|
424
|
+
art_uri = URI('art://artifacts.example.com:443/artifactory/repo-name/file.txt')
|
425
|
+
result = artifactory.send(:parse_uri, art_uri)
|
426
|
+
|
427
|
+
expect(result[:repo_key]).to eq('repo-name')
|
428
|
+
expect(result[:base_endpoint]).to eq('https://artifacts.example.com/artifactory')
|
429
|
+
expect(result[:target_path]).to eq('file.txt')
|
430
|
+
end
|
431
|
+
end
|
432
|
+
|
433
|
+
context 'with artifactory in different path positions' do
|
434
|
+
it 'handles artifactory in root path' do
|
435
|
+
art_uri = URI('art://artifacts.example.com/artifactory/repo-name/file.txt')
|
436
|
+
result = artifactory.send(:parse_uri, art_uri)
|
437
|
+
|
438
|
+
expect(result[:repo_key]).to eq('repo-name')
|
439
|
+
expect(result[:base_endpoint]).to eq('https://artifacts.example.com/artifactory')
|
440
|
+
expect(result[:target_path]).to eq('file.txt')
|
441
|
+
end
|
442
|
+
|
443
|
+
it 'handles artifactory in nested path' do
|
444
|
+
art_uri = URI('artifactory://artifacts.example.com/some/path/artifactory/repo-name/file.txt')
|
445
|
+
result = artifactory.send(:parse_uri, art_uri)
|
446
|
+
|
447
|
+
expect(result[:repo_key]).to eq('repo-name')
|
448
|
+
expect(result[:base_endpoint]).to eq('https://artifacts.example.com/some/path/artifactory')
|
449
|
+
expect(result[:target_path]).to eq('file.txt')
|
450
|
+
end
|
451
|
+
end
|
452
|
+
|
453
|
+
context 'with trailing slashes' do
|
454
|
+
it 'handles URI with trailing slash' do
|
455
|
+
art_uri = URI('art://artifacts.example.com/artifactory/repo-name/')
|
456
|
+
result = artifactory.send(:parse_uri, art_uri)
|
457
|
+
|
458
|
+
expect(result[:repo_key]).to eq('repo-name')
|
459
|
+
expect(result[:base_endpoint]).to eq('https://artifacts.example.com/artifactory')
|
460
|
+
expect(result[:target_path]).to eq('')
|
461
|
+
end
|
462
|
+
|
463
|
+
it 'handles URI without trailing slash' do
|
464
|
+
art_uri = URI('artifactory://artifacts.example.com/artifactory/repo-name')
|
465
|
+
result = artifactory.send(:parse_uri, art_uri)
|
466
|
+
|
467
|
+
expect(result[:repo_key]).to eq('repo-name')
|
468
|
+
expect(result[:base_endpoint]).to eq('https://artifacts.example.com/artifactory')
|
469
|
+
expect(result[:target_path]).to eq('')
|
470
|
+
end
|
471
|
+
end
|
472
|
+
|
473
|
+
context 'error cases' do
|
474
|
+
it 'raises ArgumentError when artifactory is not in path' do
|
475
|
+
art_uri = URI('art://artifacts.example.com/some/other/path/repo-name/')
|
476
|
+
|
477
|
+
expect {
|
478
|
+
artifactory.send(:parse_uri, art_uri)
|
479
|
+
}.to raise_error(ArgumentError, /URI must contain 'artifactory' in path/)
|
480
|
+
end
|
481
|
+
|
482
|
+
it 'raises ArgumentError when no repository key is found' do
|
483
|
+
art_uri = URI('artifactory://artifacts.example.com/artifactory/')
|
484
|
+
|
485
|
+
expect {
|
486
|
+
artifactory.send(:parse_uri, art_uri)
|
487
|
+
}.to raise_error(ArgumentError, /No repository key found in URI/)
|
488
|
+
end
|
489
|
+
|
490
|
+
it 'raises ArgumentError when artifactory is at end of path' do
|
491
|
+
art_uri = URI('art://artifacts.example.com/some/path/artifactory')
|
492
|
+
|
493
|
+
expect {
|
494
|
+
artifactory.send(:parse_uri, art_uri)
|
495
|
+
}.to raise_error(ArgumentError, /No repository key found in URI/)
|
496
|
+
end
|
497
|
+
end
|
498
|
+
|
499
|
+
context 'edge cases' do
|
500
|
+
it 'handles empty target path correctly' do
|
501
|
+
art_uri = URI('artifactory://artifacts.example.com/artifactory/repo-name')
|
502
|
+
result = artifactory.send(:parse_uri, art_uri)
|
503
|
+
|
504
|
+
expect(result[:target_path]).to eq('')
|
505
|
+
end
|
506
|
+
|
507
|
+
it 'handles single character repo key' do
|
508
|
+
art_uri = URI('art://artifacts.example.com/artifactory/r/file.txt')
|
509
|
+
result = artifactory.send(:parse_uri, art_uri)
|
510
|
+
|
511
|
+
expect(result[:repo_key]).to eq('r')
|
512
|
+
expect(result[:target_path]).to eq('file.txt')
|
513
|
+
end
|
514
|
+
|
515
|
+
it 'handles repo key with special characters' do
|
516
|
+
art_uri = URI('artifactory://artifacts.example.com/artifactory/repo-name-with-dashes_and_underscores/file.txt')
|
517
|
+
result = artifactory.send(:parse_uri, art_uri)
|
518
|
+
|
519
|
+
expect(result[:repo_key]).to eq('repo-name-with-dashes_and_underscores')
|
520
|
+
expect(result[:target_path]).to eq('file.txt')
|
521
|
+
end
|
522
|
+
end
|
523
|
+
end
|
524
|
+
|
525
|
+
describe '#upload' do
|
526
|
+
let(:local_file) { '/tmp/test.txt' }
|
527
|
+
let(:uri) { URI('https://artifacts.example.com/artifactory/repo-name/path/to/file.txt') }
|
528
|
+
let(:mock_client) { double('Artifactory::Client') }
|
529
|
+
let(:mock_artifact) { double('Artifactory::Resource::Artifact') }
|
530
|
+
let(:mock_authentication) { double('authentication', username: 'testuser', password: 'testpass') }
|
531
|
+
|
532
|
+
before do
|
533
|
+
allow(artifactory).to receive(:get_authentication_for).and_return(mock_authentication)
|
534
|
+
# Mock the global configuration - create a config double that responds to all setters
|
535
|
+
config_mock = double('config')
|
536
|
+
allow(config_mock).to receive(:endpoint=)
|
537
|
+
allow(config_mock).to receive(:username=)
|
538
|
+
allow(config_mock).to receive(:password=)
|
539
|
+
allow(::Artifactory).to receive(:configure).and_yield(config_mock)
|
540
|
+
allow(::Artifactory::Resource::Artifact).to receive(:new).and_return(mock_artifact)
|
541
|
+
allow(::File).to receive(:stat).and_return(double('stat', size: 1024, mtime: Time.now))
|
542
|
+
allow(Digest::SHA1).to receive(:file).and_return(double(hexdigest: 'sha1hash'))
|
543
|
+
allow(Digest::MD5).to receive(:file).and_return(double(hexdigest: 'md5hash'))
|
544
|
+
end
|
545
|
+
|
546
|
+
it 'parses URI correctly and uploads file' do
|
547
|
+
config_mock = double('config')
|
548
|
+
expect(::Artifactory).to receive(:configure).and_yield(config_mock)
|
549
|
+
expect(config_mock).to receive(:endpoint=).with('https://artifacts.example.com/artifactory')
|
550
|
+
expect(config_mock).to receive(:username=).with('testuser')
|
551
|
+
expect(config_mock).to receive(:password=).with('testpass')
|
552
|
+
|
553
|
+
expect(mock_artifact).to receive(:upload).with(
|
554
|
+
'repo-name',
|
555
|
+
'path/to/file.txt',
|
556
|
+
hash_including('fig.original_path' => local_file)
|
557
|
+
)
|
558
|
+
|
559
|
+
artifactory.upload(local_file, uri)
|
560
|
+
end
|
561
|
+
|
562
|
+
it 'logs equivalent curl command' do
|
563
|
+
allow(mock_artifact).to receive(:upload)
|
564
|
+
|
565
|
+
expect(Fig::Logging).to receive(:debug).with(
|
566
|
+
"Equivalent curl: curl -u testuser:*** -T '#{local_file}' '#{uri}'"
|
567
|
+
).ordered
|
568
|
+
expect(Fig::Logging).to receive(:debug).with(
|
569
|
+
/Upload metadata:/
|
570
|
+
).ordered
|
571
|
+
|
572
|
+
artifactory.upload(local_file, uri)
|
573
|
+
end
|
574
|
+
|
575
|
+
it 'raises error for invalid URI without artifactory in path' do
|
576
|
+
invalid_uri = URI('https://example.com/repo-name/file.txt')
|
577
|
+
|
578
|
+
expect {
|
579
|
+
artifactory.upload(local_file, invalid_uri)
|
580
|
+
}.to raise_error(ArgumentError, /URI must contain 'artifactory' in path/)
|
581
|
+
end
|
582
|
+
|
583
|
+
it 'collects metadata with fig. prefix' do
|
584
|
+
metadata_hash = nil
|
585
|
+
allow(mock_artifact).to receive(:upload) do |repo, path, metadata|
|
586
|
+
metadata_hash = metadata
|
587
|
+
end
|
588
|
+
|
589
|
+
artifactory.upload(local_file, uri)
|
590
|
+
|
591
|
+
expect(metadata_hash).to include(
|
592
|
+
'fig.original_path' => local_file,
|
593
|
+
'fig.target_path' => 'path/to/file.txt',
|
594
|
+
'fig.tool' => 'fig-artifactory-protocol'
|
595
|
+
)
|
596
|
+
expect(metadata_hash.keys).to all(start_with('fig.'))
|
597
|
+
end
|
598
|
+
end
|
599
|
+
end
|
metadata
CHANGED
@@ -1,13 +1,13 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: fig
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 2.0.0.pre.alpha.
|
4
|
+
version: 2.0.0.pre.alpha.10
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Fig Folks
|
8
8
|
bindir: bin
|
9
9
|
cert_chain: []
|
10
|
-
date: 2025-
|
10
|
+
date: 2025-09-24 00:00:00.000000000 Z
|
11
11
|
dependencies:
|
12
12
|
- !ruby/object:Gem::Dependency
|
13
13
|
name: bcrypt_pbkdf
|
@@ -233,6 +233,34 @@ dependencies:
|
|
233
233
|
- - "~>"
|
234
234
|
- !ruby/object:Gem::Version
|
235
235
|
version: 2.6.0
|
236
|
+
- !ruby/object:Gem::Dependency
|
237
|
+
name: artifactory
|
238
|
+
requirement: !ruby/object:Gem::Requirement
|
239
|
+
requirements:
|
240
|
+
- - "~>"
|
241
|
+
- !ruby/object:Gem::Version
|
242
|
+
version: 3.0.17
|
243
|
+
type: :runtime
|
244
|
+
prerelease: false
|
245
|
+
version_requirements: !ruby/object:Gem::Requirement
|
246
|
+
requirements:
|
247
|
+
- - "~>"
|
248
|
+
- !ruby/object:Gem::Version
|
249
|
+
version: 3.0.17
|
250
|
+
- !ruby/object:Gem::Dependency
|
251
|
+
name: rexml
|
252
|
+
requirement: !ruby/object:Gem::Requirement
|
253
|
+
requirements:
|
254
|
+
- - "~>"
|
255
|
+
- !ruby/object:Gem::Version
|
256
|
+
version: '3.0'
|
257
|
+
type: :runtime
|
258
|
+
prerelease: false
|
259
|
+
version_requirements: !ruby/object:Gem::Requirement
|
260
|
+
requirements:
|
261
|
+
- - "~>"
|
262
|
+
- !ruby/object:Gem::Version
|
263
|
+
version: '3.0'
|
236
264
|
- !ruby/object:Gem::Dependency
|
237
265
|
name: rdoc
|
238
266
|
requirement: !ruby/object:Gem::Requirement
|
@@ -306,7 +334,7 @@ dependencies:
|
|
306
334
|
description: |-
|
307
335
|
Fig is a utility for configuring environments and managing dependencies across a team of developers. Given a list of packages and a command to run, Fig builds environment variables named in those packages (e.g., CLASSPATH), then executes the command in that environment. The caller's environment is not affected.
|
308
336
|
|
309
|
-
Built from git SHA1:
|
337
|
+
Built from git SHA1: 46bcf5e-dirty
|
310
338
|
email: maintainer@figpackagemanager.org
|
311
339
|
executables:
|
312
340
|
- fig
|
@@ -438,6 +466,7 @@ files:
|
|
438
466
|
- lib/fig/parser.rb
|
439
467
|
- lib/fig/parser_package_build_state.rb
|
440
468
|
- lib/fig/protocol.rb
|
469
|
+
- lib/fig/protocol/artifactory.rb
|
441
470
|
- lib/fig/protocol/file.rb
|
442
471
|
- lib/fig/protocol/ftp.rb
|
443
472
|
- lib/fig/protocol/http.rb
|
@@ -504,6 +533,7 @@ files:
|
|
504
533
|
- spec/environment_variables_spec.rb
|
505
534
|
- spec/figrc_spec.rb
|
506
535
|
- spec/parser_spec.rb
|
536
|
+
- spec/protocol/artifactory_spec.rb
|
507
537
|
- spec/repository_spec.rb
|
508
538
|
- spec/runtime_environment_spec.rb
|
509
539
|
- spec/spec_helper.rb
|
@@ -515,7 +545,7 @@ files:
|
|
515
545
|
licenses:
|
516
546
|
- BSD-3-Clause
|
517
547
|
metadata:
|
518
|
-
git_sha:
|
548
|
+
git_sha: 46bcf5e-dirty
|
519
549
|
rdoc_options: []
|
520
550
|
require_paths:
|
521
551
|
- lib
|
@@ -533,5 +563,5 @@ requirements: []
|
|
533
563
|
rubygems_version: 3.6.1
|
534
564
|
specification_version: 4
|
535
565
|
summary: 'Utility for configuring environments and managing dependencies across a
|
536
|
-
team of developers. Built from git SHA1:
|
566
|
+
team of developers. Built from git SHA1: 46bcf5e-dirty'
|
537
567
|
test_files: []
|