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.
- checksums.yaml +7 -0
- data/helpers/build +23 -0
- data/lib/dependabot/opentofu/file_fetcher.rb +130 -0
- data/lib/dependabot/opentofu/file_filter.rb +24 -0
- data/lib/dependabot/opentofu/file_parser.rb +483 -0
- data/lib/dependabot/opentofu/file_selector.rb +82 -0
- data/lib/dependabot/opentofu/file_updater.rb +461 -0
- data/lib/dependabot/opentofu/metadata_finder.rb +55 -0
- data/lib/dependabot/opentofu/package/package_details_fetcher.rb +144 -0
- data/lib/dependabot/opentofu/package_manager.rb +41 -0
- data/lib/dependabot/opentofu/registry_client.rb +246 -0
- data/lib/dependabot/opentofu/requirement.rb +59 -0
- data/lib/dependabot/opentofu/requirements_updater.rb +223 -0
- data/lib/dependabot/opentofu/update_checker/latest_version_resolver.rb +217 -0
- data/lib/dependabot/opentofu/update_checker.rb +264 -0
- data/lib/dependabot/opentofu/version.rb +61 -0
- data/lib/dependabot/opentofu.rb +31 -0
- metadata +283 -0
|
@@ -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)
|