dependabot-opentofu 0.348.1

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.
@@ -0,0 +1,82 @@
1
+ # typed: strong
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "dependabot/opentofu/file_filter"
7
+
8
+ module Dependabot
9
+ module Opentofu
10
+ module FileSelector
11
+ extend T::Sig
12
+ extend T::Helpers
13
+
14
+ TF_EXTENSION = ".tf"
15
+ TOFU_EXTENSION = ".tofu"
16
+ OVERRIDE_TF_EXTENSION = "override.tf"
17
+ OVERRIDE_TOFU_EXTENSION = "override.tofu"
18
+
19
+ abstract!
20
+
21
+ sig { abstract.returns(T::Array[Dependabot::DependencyFile]) }
22
+ def dependency_files; end
23
+
24
+ private
25
+
26
+ include FileFilter
27
+
28
+ sig { returns(T::Array[Dependabot::DependencyFile]) }
29
+ def opentofu_files
30
+ dependency_files.select do |f|
31
+ f.name.end_with?(
32
+ TF_EXTENSION,
33
+ TOFU_EXTENSION
34
+ ) && !f.name.end_with?(OVERRIDE_TF_EXTENSION, OVERRIDE_TOFU_EXTENSION)
35
+ end
36
+ end
37
+
38
+ sig { returns(T::Array[Dependabot::DependencyFile]) }
39
+ def override_opentofu_files
40
+ dependency_files.select { |f| f.name.end_with?(OVERRIDE_TF_EXTENSION, OVERRIDE_TOFU_EXTENSION) }
41
+ end
42
+
43
+ sig { returns(T::Array[Dependabot::DependencyFile]) }
44
+ def terragrunt_files
45
+ dependency_files.select { |f| terragrunt_file?(f.name) }
46
+ end
47
+
48
+ sig { returns(T.nilable(Dependabot::DependencyFile)) }
49
+ def lockfile
50
+ dependency_files.find { |f| lockfile?(f.name) }
51
+ end
52
+
53
+ sig do
54
+ params(
55
+ modules: T::Hash[String, T::Array[T::Hash[String, T.untyped]]],
56
+ base_modules: T::Hash[String,
57
+ T::Array[T::Hash[String,
58
+ T.untyped]]]
59
+ )
60
+ .returns(T::Hash[String,
61
+ T::Array[T::Hash[String,
62
+ T.untyped]]])
63
+ end
64
+ def merge_modules(modules, base_modules)
65
+ merged_modules = base_modules.dup
66
+
67
+ modules.each do |key, value|
68
+ merged_modules[key] =
69
+ if merged_modules.key?(key)
70
+ T.must(merged_modules[key]).map do |base_value|
71
+ base_value.merge(T.must(value.first))
72
+ end
73
+ else
74
+ value
75
+ end
76
+ end
77
+
78
+ merged_modules
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,461 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "dependabot/file_updaters"
7
+ require "dependabot/file_updaters/base"
8
+ require "dependabot/errors"
9
+ require "dependabot/opentofu/file_selector"
10
+ require "dependabot/shared_helpers"
11
+
12
+ module Dependabot
13
+ module Opentofu
14
+ class FileUpdater < Dependabot::FileUpdaters::Base
15
+ extend T::Sig
16
+
17
+ include FileSelector
18
+
19
+ PRIVATE_MODULE_ERROR = /Could not download module.*code from\n.*\"(?<repo>\S+)\":/
20
+ MODULE_NOT_INSTALLED_ERROR = /Module not installed.*module\s*\"(?<mod>\S+)\"/m
21
+ GIT_HTTPS_PREFIX = %r{^git::https://}
22
+
23
+ sig { override.returns(T::Array[Dependabot::DependencyFile]) }
24
+ def updated_dependency_files
25
+ updated_files = []
26
+
27
+ [*opentofu_files, *terragrunt_files].each do |file|
28
+ next unless file_changed?(file)
29
+
30
+ updated_content = updated_opentofu_file_content(file)
31
+
32
+ raise "Content didn't change!" if updated_content == file.content
33
+
34
+ updated_file = updated_file(file: file, content: updated_content)
35
+
36
+ updated_files << updated_file unless updated_files.include?(updated_file)
37
+ end
38
+ updated_lockfile_content = update_lockfile_declaration(updated_files)
39
+
40
+ if updated_lockfile_content && T.must(lockfile).content != updated_lockfile_content
41
+ updated_files << updated_file(file: T.must(lockfile), content: updated_lockfile_content)
42
+ end
43
+
44
+ updated_files.compact!
45
+
46
+ raise "No files changed!" if updated_files.none?
47
+
48
+ updated_files
49
+ end
50
+
51
+ private
52
+
53
+ # OpenTofu allows to use a module from the same source multiple times
54
+ # To detect any changes in dependencies we need to overwrite an implementation from the base class
55
+ #
56
+ # Example (for simplicity other parameters are skipped):
57
+ # previous_requirements = [{requirement: "0.9.1"}, {requirement: "0.11.0"}]
58
+ # requirements = [{requirement: "0.11.0"}, {requirement: "0.11.0"}]
59
+ #
60
+ # Simple difference between arrays gives:
61
+ # requirements - previous_requirements
62
+ # => []
63
+ # which loses an information that one of our requirements has changed.
64
+ #
65
+ # By using symmetric difference:
66
+ # (requirements - previous_requirements) | (previous_requirements - requirements)
67
+ # => [{requirement: "0.9.1"}]
68
+ # we can detect that change.
69
+ sig { params(file: Dependabot::DependencyFile, dependency: Dependabot::Dependency).returns(T::Boolean) }
70
+ def requirement_changed?(file, dependency)
71
+ changed_requirements =
72
+ (dependency.requirements - T.must(dependency.previous_requirements)) |
73
+ (T.must(dependency.previous_requirements) - dependency.requirements)
74
+
75
+ changed_requirements.any? { |f| f[:file] == file.name }
76
+ end
77
+
78
+ sig { params(file: Dependabot::DependencyFile).returns(String) }
79
+ def updated_opentofu_file_content(file)
80
+ content = T.must(file.content.dup)
81
+
82
+ reqs = dependency.requirements.zip(T.must(dependency.previous_requirements))
83
+ .reject { |new_req, old_req| new_req == old_req }
84
+
85
+ # Loop through each changed requirement and update the files and lockfile
86
+ reqs.each do |new_req, old_req|
87
+ raise "Bad req match" unless new_req[:file] == old_req&.fetch(:file)
88
+ next unless new_req.fetch(:file) == file.name
89
+
90
+ case new_req[:source][:type]
91
+ when "git"
92
+ update_git_declaration(new_req, old_req, content, file.name)
93
+ when "registry", "provider"
94
+ update_registry_declaration(new_req, old_req, content)
95
+ else
96
+ raise "Don't know how to update a #{new_req[:source][:type]} " \
97
+ "declaration!"
98
+ end
99
+ end
100
+
101
+ content
102
+ end
103
+
104
+ sig do
105
+ params(
106
+ new_req: T::Hash[Symbol, T.untyped],
107
+ old_req: T.nilable(T::Hash[Symbol, T.untyped]),
108
+ updated_content: String,
109
+ filename: String
110
+ )
111
+ .void
112
+ end
113
+ def update_git_declaration(new_req, old_req, updated_content, filename)
114
+ url = old_req&.dig(:source, :url)&.gsub(%r{^https://}, "")
115
+ tag = old_req&.dig(:source, :ref)
116
+ url_regex = /#{Regexp.quote(url)}.*ref=#{Regexp.quote(tag)}/
117
+
118
+ declaration_regex = git_declaration_regex(filename)
119
+
120
+ updated_content.sub!(declaration_regex) do |regex_match|
121
+ regex_match.sub(url_regex) do |url_match|
122
+ url_match.sub(old_req&.dig(:source, :ref), new_req[:source][:ref])
123
+ end
124
+ end
125
+ end
126
+
127
+ sig do
128
+ params(
129
+ new_req: T::Hash[Symbol, T.untyped],
130
+ old_req: T.nilable(T::Hash[Symbol, T.untyped]),
131
+ updated_content: String
132
+ )
133
+ .void
134
+ end
135
+ def update_registry_declaration(new_req, old_req, updated_content)
136
+ regex = if new_req[:source][:type] == "provider"
137
+ provider_declaration_regex(updated_content)
138
+ else
139
+ registry_declaration_regex
140
+ end
141
+
142
+ # Define and break down the version regex for better clarity
143
+ version_key_pattern = /^\s*version\s*=\s*/
144
+ version_value_pattern = /["'].*#{Regexp.escape(old_req&.fetch(:requirement))}.*['"]/
145
+ version_regex = /#{version_key_pattern}#{version_value_pattern}/
146
+
147
+ updated_content.gsub!(regex) do |regex_match|
148
+ regex_match.sub(version_regex) do |req_line_match|
149
+ req_line_match.sub!(old_req&.fetch(:requirement), new_req[:requirement])
150
+ end
151
+ end
152
+ end
153
+
154
+ sig { params(content: String, declaration_regex: Regexp).returns(T::Array[String]) }
155
+ def extract_provider_h1_hashes(content, declaration_regex)
156
+ content.match(declaration_regex).to_s
157
+ .match(hashes_object_regex).to_s
158
+ .split("\n").map { |hash| hash.match(hashes_string_regex).to_s }
159
+ .select { |h| h.match?(/^h1:/) }
160
+ end
161
+
162
+ sig { params(content: String, declaration_regex: Regexp).returns(String) }
163
+ def remove_provider_h1_hashes(content, declaration_regex)
164
+ content.match(declaration_regex).to_s
165
+ .sub(hashes_object_regex, "")
166
+ end
167
+
168
+ sig do
169
+ params(
170
+ new_req: T::Hash[Symbol, T.untyped]
171
+ )
172
+ .returns([String, String, Regexp])
173
+ end
174
+ def lockfile_details(new_req)
175
+ content = T.must(lockfile).content.dup
176
+ provider_source = new_req[:source][:registry_hostname] + "/" + new_req[:source][:module_identifier]
177
+ declaration_regex = lockfile_declaration_regex(provider_source)
178
+
179
+ [T.must(content), provider_source, declaration_regex]
180
+ end
181
+
182
+ sig { returns(T.nilable(T::Array[Symbol])) }
183
+ def lookup_hash_architecture # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity
184
+ new_req = T.must(dependency.requirements.first)
185
+
186
+ # NOTE: Only providers are included in the lockfile, modules are not
187
+ return unless new_req[:source][:type] == "provider"
188
+
189
+ architectures = []
190
+ content, provider_source, declaration_regex = lockfile_details(new_req)
191
+ hashes = extract_provider_h1_hashes(content, declaration_regex)
192
+
193
+ # These are ordered in assumed popularity
194
+ possible_architectures = %w(
195
+ linux_amd64
196
+ darwin_amd64
197
+ windows_amd64
198
+ darwin_arm64
199
+ linux_arm64
200
+ )
201
+
202
+ base_dir = T.must(dependency_files.first).directory
203
+ lockfile_hash_removed = remove_provider_h1_hashes(content, declaration_regex)
204
+
205
+ # This runs in the same directory as the actual lockfile update so
206
+ # the platform must be determined before the updated manifest files
207
+ # are written to disk
208
+ SharedHelpers.in_a_temporary_repo_directory(base_dir, repo_contents_path) do
209
+ possible_architectures.each do |arch|
210
+ # Exit early if we have detected all of the architectures present
211
+ break if architectures.count == hashes.count
212
+
213
+ # OpenTofu will update the lockfile in place so we use a fresh lockfile for each lookup
214
+ File.write(".terraform.lock.hcl", lockfile_hash_removed)
215
+
216
+ SharedHelpers.run_shell_command(
217
+ "tofu providers lock -platform=#{arch} #{provider_source} -no-color",
218
+ fingerprint: "tofu providers lock -platform=<arch> <provider_source> -no-color"
219
+ )
220
+
221
+ updated_lockfile = File.read(".terraform.lock.hcl")
222
+ updated_hashes = extract_provider_h1_hashes(updated_lockfile, declaration_regex)
223
+ next if updated_hashes.nil?
224
+
225
+ # Check if the architecture is present in the original lockfile
226
+ hashes.each do |hash|
227
+ updated_hashes.select { |h| h.match?(/^h1:/) }.each do |updated_hash|
228
+ architectures.append(arch.to_sym) if hash == updated_hash
229
+ end
230
+ end
231
+
232
+ File.delete(".terraform.lock.hcl")
233
+ end
234
+ rescue SharedHelpers::HelperSubprocessFailed => e
235
+ if @retrying_lock && e.message.match?(MODULE_NOT_INSTALLED_ERROR)
236
+ mod = T.must(e.message.match(MODULE_NOT_INSTALLED_ERROR)).named_captures.fetch("mod")
237
+ raise Dependabot::DependencyFileNotResolvable, "Attempt to install module #{mod} failed"
238
+ end
239
+ raise if @retrying_lock || !e.message.include?("tofu init")
240
+
241
+ # NOTE: Modules need to be installed before OpenTofu can update the lockfile
242
+ @retrying_lock = true
243
+ run_opentofu_init
244
+ retry
245
+ end
246
+
247
+ architectures.to_a
248
+ end
249
+
250
+ sig { returns(T::Array[Symbol]) }
251
+ def architecture_type
252
+ @architecture_type ||= T.let(
253
+ if lookup_hash_architecture.nil? || lookup_hash_architecture&.empty?
254
+ [:linux_amd64]
255
+ else
256
+ T.must(lookup_hash_architecture)
257
+ end,
258
+ T.nilable(T::Array[Symbol])
259
+ )
260
+ end
261
+
262
+ sig { params(updated_manifest_files: T::Array[Dependabot::DependencyFile]).returns(T.nilable(String)) }
263
+ def update_lockfile_declaration(updated_manifest_files) # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity
264
+ return if lockfile.nil?
265
+
266
+ new_req = T.must(dependency.requirements.first)
267
+ # NOTE: Only providers are included in the lockfile, modules are not
268
+ return unless new_req[:source][:type] == "provider"
269
+
270
+ content, provider_source, declaration_regex = lockfile_details(new_req)
271
+ lockfile_dependency_removed = content.sub(declaration_regex, "")
272
+
273
+ base_dir = T.must(dependency_files.first).directory
274
+ SharedHelpers.in_a_temporary_repo_directory(base_dir, repo_contents_path) do
275
+ # Determine the provider using the original manifest files
276
+ platforms = architecture_type.map { |arch| "-platform=#{arch}" }.join(" ")
277
+
278
+ # Update the provider requirements in case the previous requirement doesn't allow the new version
279
+ updated_manifest_files.each { |f| File.write(f.name, f.content) }
280
+
281
+ File.write(".terraform.lock.hcl", lockfile_dependency_removed)
282
+
283
+ SharedHelpers.run_shell_command(
284
+ "tofu providers lock #{platforms} #{provider_source}",
285
+ fingerprint: "tofu providers lock <platforms> <provider_source>"
286
+ )
287
+
288
+ updated_lockfile = File.read(".terraform.lock.hcl")
289
+ updated_dependency = T.cast(updated_lockfile.scan(declaration_regex).first, String)
290
+
291
+ # OpenTofu will occasionally update h1 hashes without updating the version of the dependency
292
+ # Here we make sure the dependency's version actually changes in the lockfile
293
+ unless T.cast(updated_dependency.scan(declaration_regex).first, String).scan(/^\s*version\s*=.*/) ==
294
+ T.cast(content.scan(declaration_regex).first, String).scan(/^\s*version\s*=.*/)
295
+ content.sub!(declaration_regex, updated_dependency)
296
+ end
297
+ rescue SharedHelpers::HelperSubprocessFailed => e
298
+ error_handler = FileUpdaterErrorHandler.new
299
+ error_handler.handle_helper_subprocess_failed_error(e)
300
+
301
+ if @retrying_lock && e.message.match?(MODULE_NOT_INSTALLED_ERROR)
302
+ mod = T.must(e.message.match(MODULE_NOT_INSTALLED_ERROR)).named_captures.fetch("mod")
303
+ raise Dependabot::DependencyFileNotResolvable, "Attempt to install module #{mod} failed"
304
+ end
305
+ raise if @retrying_lock || !e.message.include?("tofu init")
306
+
307
+ # NOTE: Modules need to be installed before OpenTofu can update the lockfile
308
+ @retrying_lock = T.let(true, T.nilable(T::Boolean))
309
+ run_opentofu_init
310
+ retry
311
+ end
312
+
313
+ content
314
+ end
315
+
316
+ sig { void }
317
+ def run_opentofu_init
318
+ SharedHelpers.with_git_configured(credentials: credentials) do
319
+ # -backend=false option used to ignore any backend configuration, as these won't be accessible
320
+ # -input=false option used to immediately fail if it needs user input
321
+ # -no-color option used to prevent any color characters being printed in the output
322
+ SharedHelpers.run_shell_command("tofu init -backend=false -input=false -no-color")
323
+ rescue SharedHelpers::HelperSubprocessFailed => e
324
+ output = e.message
325
+
326
+ if output.match?(PRIVATE_MODULE_ERROR)
327
+ repo = T.must(output.match(PRIVATE_MODULE_ERROR)).named_captures.fetch("repo")
328
+ if repo&.match?(GIT_HTTPS_PREFIX)
329
+ repo = repo.sub(GIT_HTTPS_PREFIX, "")
330
+ repo = repo.sub(/\.git$/, "")
331
+ end
332
+ raise PrivateSourceAuthenticationFailure, repo
333
+ end
334
+
335
+ raise Dependabot::DependencyFileNotResolvable, "Error running `tofu init`: #{output}"
336
+ end
337
+ end
338
+
339
+ sig { returns(Dependabot::Dependency) }
340
+ def dependency
341
+ # OpenTofu updates will only ever be updating a single dependency
342
+ T.must(dependencies.first)
343
+ end
344
+
345
+ sig { returns(T::Array[Dependabot::DependencyFile]) }
346
+ def files_with_requirement
347
+ filenames = dependency.requirements.map { |r| r[:file] }
348
+ dependency_files.select { |file| filenames.include?(file.name) }
349
+ end
350
+
351
+ sig { override.void }
352
+ def check_required_files
353
+ return if [*opentofu_files, *terragrunt_files].any?
354
+
355
+ raise "No Opentofu configuration file!"
356
+ end
357
+
358
+ sig { returns(Regexp) }
359
+ def hashes_object_regex
360
+ /hashes\s*=\s*[^\]]*\]/m
361
+ end
362
+
363
+ sig { returns(Regexp) }
364
+ def hashes_string_regex
365
+ /(?<=\").*(?=\")/
366
+ end
367
+
368
+ sig { params(updated_content: String).returns(Regexp) }
369
+ def provider_declaration_regex(updated_content)
370
+ name = Regexp.escape(dependency.name)
371
+ registry_host = Regexp.escape(registry_host_for(dependency))
372
+ regex_version_preceeds = %r{
373
+ (((?<!required_)version\s=\s*["'].*["'])
374
+ (\s*source\s*=\s*["'](#{registry_host}/)?#{name}["']|\s*#{name}\s*=\s*\{.*))
375
+ }mx
376
+ regex_source_preceeds = %r{
377
+ ((source\s*=\s*["'](#{registry_host}/)?#{name}["']|\s*#{name}\s*=\s*\{.*)
378
+ (?:(?!^\}).)+)
379
+ }mx
380
+
381
+ if updated_content.match(regex_version_preceeds)
382
+ regex_version_preceeds
383
+ else
384
+ regex_source_preceeds
385
+ end
386
+ end
387
+
388
+ sig { returns(Regexp) }
389
+ def registry_declaration_regex
390
+ %r{
391
+ (?<=\{)
392
+ (?:(?!^\}).)*
393
+ source\s*=\s*["']
394
+ (#{Regexp.escape(registry_host_for(dependency))}/)?
395
+ #{Regexp.escape(dependency.name)}
396
+ (//modules/\S+)?
397
+ ["']
398
+ (?:(?!^\}).)*
399
+ }mx
400
+ end
401
+
402
+ sig { params(filename: String).returns(Regexp) }
403
+ def git_declaration_regex(filename)
404
+ # For terragrunt dependencies there's not a lot we can base the
405
+ # regex on. Just look for declarations within a `terraform` block
406
+ return /terraform\s*\{(?:(?!^\}).)*/m if terragrunt_file?(filename)
407
+
408
+ # For modules we can do better - filter for module blocks that use the
409
+ # name of the module
410
+ module_name = T.must(dependency.name.split("::").first)
411
+ /
412
+ module\s+["']#{Regexp.escape(module_name)}["']\s*\{
413
+ (?:(?!^\}).)*
414
+ /mx
415
+ end
416
+
417
+ sig { params(dependency: Dependabot::Dependency).returns(String) }
418
+ def registry_host_for(dependency)
419
+ source = dependency.requirements.filter_map { |r| r[:source] }.first
420
+ source[:registry_hostname] || source["registry_hostname"] || "registry.opentofu.org"
421
+ end
422
+
423
+ sig { params(provider_source: String).returns(Regexp) }
424
+ def lockfile_declaration_regex(provider_source)
425
+ /
426
+ (?:(?!^\}).)*
427
+ provider\s*["']#{Regexp.escape(provider_source)}["']\s*\{
428
+ (?:(?!^\}).)*}
429
+ /mix
430
+ end
431
+ end
432
+
433
+ class FileUpdaterErrorHandler
434
+ extend T::Sig
435
+
436
+ RESOLVE_ERROR = /Could not retrieve providers for locking/
437
+ CONSTRAINTS_ERROR = /no available releases match/
438
+
439
+ # Handles errors with specific to yarn error codes
440
+ sig { params(error: SharedHelpers::HelperSubprocessFailed).void }
441
+ def handle_helper_subprocess_failed_error(error)
442
+ unless sanitize_message(error.message).match?(RESOLVE_ERROR) &&
443
+ sanitize_message(error.message).match?(CONSTRAINTS_ERROR)
444
+ return
445
+ end
446
+
447
+ raise Dependabot::DependencyFileNotResolvable,
448
+ "Error while updating lockfile, " \
449
+ "no matching constraints found."
450
+ end
451
+
452
+ sig { params(message: String).returns(String) }
453
+ def sanitize_message(message)
454
+ message.gsub(/\e\[[\d;]*[A-Za-z]/, "").delete("\n").delete("│").squeeze(" ")
455
+ end
456
+ end
457
+ end
458
+ end
459
+
460
+ Dependabot::FileUpdaters
461
+ .register("opentofu", Dependabot::Opentofu::FileUpdater)
@@ -0,0 +1,55 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "excon"
5
+ require "json"
6
+ require "dependabot/metadata_finders"
7
+ require "dependabot/metadata_finders/base"
8
+ require "dependabot/opentofu/registry_client"
9
+ require "dependabot/shared_helpers"
10
+ require "sorbet-runtime"
11
+
12
+ module Dependabot
13
+ module Opentofu
14
+ class MetadataFinder < Dependabot::MetadataFinders::Base
15
+ extend T::Sig
16
+
17
+ private
18
+
19
+ sig { override.returns(T.nilable(Dependabot::Source)) }
20
+ def look_up_source
21
+ case new_source_type
22
+ when "git" then find_source_from_git_url
23
+ when "registry", "provider" then find_source_from_registry_details
24
+ else raise "Unexpected source type: #{new_source_type}"
25
+ end
26
+ end
27
+
28
+ sig { returns(T.nilable(String)) }
29
+ def new_source_type
30
+ dependency.source_type
31
+ end
32
+
33
+ sig { returns(T.nilable(Dependabot::Source)) }
34
+ def find_source_from_git_url
35
+ info = dependency.requirements.filter_map { |r| r[:source] }.first
36
+
37
+ url = info[:url] || info.fetch("url")
38
+ Source.from_url(url)
39
+ end
40
+
41
+ sig { returns(T.nilable(Dependabot::Source)) }
42
+ def find_source_from_registry_details
43
+ info = dependency.requirements.filter_map { |r| r[:source] }.first
44
+ hostname = info[:registry_hostname] || info["registry_hostname"]
45
+
46
+ RegistryClient
47
+ .new(hostname: hostname, credentials: credentials)
48
+ .source(dependency: dependency)
49
+ end
50
+ end
51
+ end
52
+ end
53
+
54
+ Dependabot::MetadataFinders
55
+ .register("opentofu", Dependabot::Opentofu::MetadataFinder)