fig 2.0.0.pre.alpha.4 → 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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/lib/fig/operating_system.rb +3 -0
  3. data/lib/fig/protocol/artifactory.rb +389 -0
  4. data/lib/fig/spec_utils.rb +312 -0
  5. data/lib/fig/version.rb +1 -1
  6. data/spec/application_configuration_spec.rb +73 -0
  7. data/spec/command/clean_spec.rb +62 -0
  8. data/spec/command/command_line_vs_package_spec.rb +32 -0
  9. data/spec/command/dump_package_definition_spec.rb +104 -0
  10. data/spec/command/environment_variables_spec.rb +62 -0
  11. data/spec/command/grammar_asset_spec.rb +391 -0
  12. data/spec/command/grammar_command_spec.rb +88 -0
  13. data/spec/command/grammar_environment_variable_spec.rb +384 -0
  14. data/spec/command/grammar_retrieve_spec.rb +74 -0
  15. data/spec/command/grammar_spec.rb +87 -0
  16. data/spec/command/grammar_spec_helper.rb +23 -0
  17. data/spec/command/include_file_spec.rb +73 -0
  18. data/spec/command/listing_spec.rb +1574 -0
  19. data/spec/command/miscellaneous_spec.rb +145 -0
  20. data/spec/command/publish_local_and_updates_spec.rb +32 -0
  21. data/spec/command/publishing_retrieval_spec.rb +423 -0
  22. data/spec/command/publishing_spec.rb +596 -0
  23. data/spec/command/running_commands_spec.rb +354 -0
  24. data/spec/command/suppress_includes_spec.rb +65 -0
  25. data/spec/command/suppress_warning_include_statement_missing_version_spec.rb +134 -0
  26. data/spec/command/update_lock_response_spec.rb +47 -0
  27. data/spec/command/usage_errors_spec.rb +481 -0
  28. data/spec/command_options_spec.rb +184 -0
  29. data/spec/command_spec.rb +49 -0
  30. data/spec/deparser/v1_spec.rb +64 -0
  31. data/spec/environment_variables_spec.rb +91 -0
  32. data/spec/figrc_spec.rb +144 -0
  33. data/spec/parser_spec.rb +398 -0
  34. data/spec/protocol/artifactory_spec.rb +599 -0
  35. data/spec/repository_spec.rb +117 -0
  36. data/spec/runtime_environment_spec.rb +357 -0
  37. data/spec/spec_helper.rb +1 -0
  38. data/spec/split_repo_url_spec.rb +190 -0
  39. data/spec/statement/asset_spec.rb +203 -0
  40. data/spec/statement/configuration_spec.rb +41 -0
  41. data/spec/support/formatters/seed_spitter.rb +12 -0
  42. data/spec/working_directory_maintainer_spec.rb +102 -0
  43. metadata +72 -5
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e7b0a1e7e7c54f5641e35e7ae6cb79f68d99ecbf29fcea10db478169599ae532
4
- data.tar.gz: d13a64acc3c9f804a96454039a54005098b3869f83af7e1f037fa61e445e6dab
3
+ metadata.gz: ffb9d7bc5e0a1723f452acc24d3ed117f700814821c5f6b9be69258edeec4903
4
+ data.tar.gz: cff46d3b93314fc559e0e3ab549b472b9cdc1474363d758ef18c4020fca675b7
5
5
  SHA512:
6
- metadata.gz: e62dcf96e9bb214fb48a4d88428006ef893b7c7d0e93c4fc63752e3931261f5eaf1549eade14ac836d5f022bbcddc5f165f1315d773fb5748f275ca799cf963b
7
- data.tar.gz: de2f11e4a62585e3c23d1ca6193d8564be023bb3765b9e9c2e1baec8e2e05952a6b9c2d3ad5c719a5c40368450c786c71b019dd6cfab6a4eb50105b72b9e6ffa
6
+ metadata.gz: 947d9c97f725fa69e074b0b580ba4698d4e824278c1ea2c71e036e5146030302de5319fe281171bc1727cd8a0194ace497edc731061fb9314317cb3aa52dacfe
7
+ data.tar.gz: a4c64c9fdf34e90543a790c3caf649cca749a74ef4b3b151902cfe931af17cc2a52d2f99dea12d090fcf8035c448fa5a8e5834b334683be4982882fe42da5e05
@@ -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