dependabot-opentofu 0.373.0 → 0.374.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6933f74905bb89917d0fe59c946b4cc8a91b59ea8f49d260cdd104621ba54070
4
- data.tar.gz: 9c244107e1d9e785bf4e7a787ca226faa00358aac5340319f9e79ef1d44a8c83
3
+ metadata.gz: aa3a44a842816fbe0b288ec3a69f126b289901963dec69e46f2c81ee090aa22e
4
+ data.tar.gz: eb62f10d778dcc79fd4faa71618671c590c33623c712c06cb80dd48ecd94620d
5
5
  SHA512:
6
- metadata.gz: 4bbe6adf898b1766be9d674df58edd40ec27aa87b1dcf30b34a946dd776e847fea01c95df907f84a5b52a6bc4a89b0ede5efe909fdefbcae3317e332e69e256f
7
- data.tar.gz: 26d0ea01b13a161243108a6bde3afc911b0569f9b70d2db5ed702e62f6732d09d50302ee8949403e3b481a891bf814e691930e9fb488673649b0e1e8389c71a5
6
+ metadata.gz: 1db6d58ed3b8210058685d3db3819bb50e161168a57b7febad44bfcc4a8bdf72bf71441b049cbd2b7d184cd01f9db054e4b39d349e6c762da013c268f19ed071
7
+ data.tar.gz: 449c4e645fe70e7dc48a27ba1e01ea4bc3369619d2e97c42d8b614fe92bcb72368cc102db2edbb20ffde07fb124dc3f455385f2755a8986d9be49d2821d910a1
@@ -30,6 +30,18 @@ module Dependabot
30
30
  # https://opentofu.org/docs/language/providers/requirements/#source-addresses
31
31
  PROVIDER_SOURCE_ADDRESS = %r{\A((?<hostname>.+)/)?(?<namespace>.+)/(?<name>.+)\z}
32
32
 
33
+ # Namespaces reserved for providers bundled with the OpenTofu/Terraform
34
+ # binary. Providers in these namespaces cannot be updated independently
35
+ # because their version tracks the binary itself.
36
+ # See: https://pkg.go.dev/github.com/opentofu/registry-address/v2#Provider.IsBuiltIn
37
+ BUILTIN_PROVIDER_NAMESPACES = T.let(
38
+ %w(
39
+ terraform.io/builtin
40
+ opentofu.org/builtin
41
+ ).freeze,
42
+ T::Array[String]
43
+ )
44
+
33
45
  sig { override.returns(T::Array[Dependabot::Dependency]) }
34
46
  def parse
35
47
  dependency_set = DependencySet.new
@@ -56,6 +68,17 @@ module Dependabot
56
68
 
57
69
  private
58
70
 
71
+ sig { params(details: T.any(String, T::Hash[String, T.untyped])).returns(T::Boolean) }
72
+ def builtin_provider?(details)
73
+ return false unless details.is_a?(Hash)
74
+
75
+ source_address = details["source"]
76
+ return false unless source_address.is_a?(String)
77
+
78
+ normalized = source_address.downcase
79
+ BUILTIN_PROVIDER_NAMESPACES.any? { |ns| normalized.start_with?("#{ns}/") }
80
+ end
81
+
59
82
  # rubocop:disable Metrics/PerceivedComplexity
60
83
  sig { params(dependency_set: Dependabot::FileParsers::Base::DependencySet).void }
61
84
  def parse_opentofu_files(dependency_set)
@@ -75,24 +98,29 @@ module Dependabot
75
98
  details = details.first
76
99
 
77
100
  source = source_from(details)
78
- # Cannot update local path modules, skip
79
- next if source && source[:type] == "path"
101
+ # nil sources are unpinned (e.g. OCI without a tag/digest); paths are local.
102
+ next if source.nil? || source[:type] == "path"
80
103
 
81
104
  # Cannot update modules using early evaluation yet
82
- if T.must(source)[:type] == "interpolation"
105
+ if source[:type] == "interpolation"
83
106
  Dependabot.logger.warn(
84
107
  "Cannot parse module source name with early evaluation for #{name} in #{file.name}."
85
108
  )
86
109
  next
87
110
  end
88
111
 
89
- dependency_set << build_opentofu_dependency(file, name, T.must(source), details)
112
+ dependency_set << build_opentofu_dependency(file, name, source, details)
90
113
  end
91
114
 
92
115
  parsed_file(file).fetch("terraform", []).each do |opentofu|
93
116
  required_providers = opentofu.fetch("required_providers", {})
94
117
  required_providers.each do |provider|
95
118
  provider.each do |name, details|
119
+ if builtin_provider?(details)
120
+ Dependabot.logger.info("Skipping built-in provider #{name} in #{file.name}")
121
+ next
122
+ end
123
+
96
124
  dependency_set << build_provider_dependency(file, name, details)
97
125
  end
98
126
  end
@@ -137,6 +165,7 @@ module Dependabot
137
165
  version_req = details["version"]&.strip
138
166
  version =
139
167
  if source[:type] == "git" then version_from_ref(source[:ref])
168
+ elsif source[:type] == "oci" then source[:version]
140
169
  elsif version_req&.match?(/^\d/) then version_req
141
170
  end
142
171
 
@@ -230,18 +259,21 @@ module Dependabot
230
259
 
231
260
  source_details =
232
261
  case source_type
233
- # TODO: add support for OCI Registries https://opentofu.org/docs/cli/oci_registries/
234
262
  when :http_archive, :path, :mercurial, :s3
235
263
  { type: source_type.to_s, url: bare_source }
236
264
  when :github, :bitbucket, :git
237
265
  git_source_details_from(bare_source)
266
+ when :oci
267
+ oci_source_details_from(bare_source)
238
268
  when :registry
239
269
  registry_source_details_from(bare_source)
240
270
  when :interpolation
241
271
  { type: source_type.to_s, name: bare_source }
242
272
  end
243
273
 
244
- T.must(source_details)[:proxy_url] = raw_source if raw_source != bare_source
274
+ return nil if source_details.nil?
275
+
276
+ source_details[:proxy_url] = raw_source if raw_source != bare_source
245
277
  source_details
246
278
  end
247
279
 
@@ -260,6 +292,38 @@ module Dependabot
260
292
  ]
261
293
  end
262
294
 
295
+ sig { params(source_string: T.untyped).returns(T.nilable(T::Hash[Symbol, T.nilable(String)])) }
296
+ def oci_source_details_from(source_string)
297
+ uri_part, query_part = source_string.split("oci://").last.split("?", 2)
298
+
299
+ # Sources with no query string are unpinned — nothing to update.
300
+ return nil if query_part.nil?
301
+
302
+ # `//` (not preceded by `:`) separates the bare repository from a
303
+ # sub-module path; only the bare repo is queryable for tags.
304
+ artifact_identifier, subdirectory = uri_part.split(%r{(?<!:)//}, 2)
305
+
306
+ qs = CGI.parse(query_part)
307
+ # Treat `?tag=` or `?digest=` (empty value) the same as the param
308
+ # being absent, so we don't propagate "" as a usable version.
309
+ tag = qs["tag"].first&.then { |v| v.empty? ? nil : v }
310
+ digest = qs["digest"].first&.then { |v| v.empty? ? nil : v }
311
+
312
+ if tag && digest
313
+ raise DependencyFileNotEvaluatable,
314
+ "Invalid OCI source '#{source_string}': only one of `tag` or `digest` may be specified"
315
+ end
316
+
317
+ {
318
+ type: "oci",
319
+ artifact_identifier: artifact_identifier,
320
+ subdirectory: subdirectory,
321
+ tag: tag,
322
+ digest: digest,
323
+ version: tag || digest
324
+ }
325
+ end
326
+
263
327
  sig { params(source_string: T.untyped).returns(T::Hash[Symbol, String]) }
264
328
  def registry_source_details_from(source_string)
265
329
  parts = source_string.split("//").first.split("/")
@@ -333,7 +397,7 @@ module Dependabot
333
397
  # rubocop:disable Metrics/CyclomaticComplexity
334
398
  sig { params(source_string: String).returns(Symbol) }
335
399
  def source_type(source_string)
336
- # TODO: add support for OCI Registries https://opentofu.org/docs/cli/oci_registries/
400
+ return :oci if source_string.include?("oci://")
337
401
  return :interpolation if source_string.include?("${")
338
402
  return :path if source_string.start_with?(".")
339
403
  return :github if source_string.start_with?("github.com/")
@@ -92,6 +92,8 @@ module Dependabot
92
92
  update_git_declaration(new_req, old_req, content, file.name)
93
93
  when "registry", "provider"
94
94
  update_registry_declaration(new_req, old_req, content)
95
+ when "oci"
96
+ update_oci_declaration(new_req, old_req, content)
95
97
  else
96
98
  raise "Don't know how to update a #{new_req[:source][:type]} " \
97
99
  "declaration!"
@@ -124,6 +126,29 @@ module Dependabot
124
126
  end
125
127
  end
126
128
 
129
+ sig do
130
+ params(
131
+ new_req: T::Hash[Symbol, T.untyped],
132
+ old_req: T.nilable(T::Hash[Symbol, T.untyped]),
133
+ updated_content: String
134
+ )
135
+ .void
136
+ end
137
+ def update_oci_declaration(new_req, old_req, updated_content)
138
+ old_tag = old_req&.dig(:source, :tag)
139
+ new_tag = new_req[:source][:tag]
140
+ artifact = old_req&.dig(:source, :artifact_identifier)
141
+ return if old_tag.nil? || new_tag.nil? || artifact.nil? || old_tag == new_tag
142
+
143
+ # Scoped to this artifact's source string so unrelated modules with
144
+ # the same tag value aren't touched.
145
+ oci_source_re = %r{
146
+ (["']oci://#{Regexp.escape(artifact)}(?://[^"'?]*)?\?[^"']*\btag=)
147
+ #{Regexp.escape(old_tag)}
148
+ }x
149
+ updated_content.gsub!(oci_source_re) { T.must(Regexp.last_match(1)) + new_tag }
150
+ end
151
+
127
152
  sig do
128
153
  params(
129
154
  new_req: T::Hash[Symbol, T.untyped],
@@ -1,6 +1,9 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
+ require "base64"
5
+ require "uri"
6
+
4
7
  require "dependabot/dependency"
5
8
  require "dependabot/errors"
6
9
  require "dependabot/registry_client"
@@ -29,7 +32,12 @@ module Dependabot
29
32
  @api_base_url = T.let(API_BASE_URL, String)
30
33
  @tokens = T.let(
31
34
  credentials.each_with_object({}) do |item, memo|
32
- memo[item["host"]] = item["token"] if item["type"] == "opentofu_registry"
35
+ # Only Bearer-token shaped creds belong here; OCI-only entries
36
+ # (username/password) would otherwise store a nil token and
37
+ # trigger a malformed `Authorization: Bearer ` header.
38
+ next unless item["type"] == "opentofu_registry" && item["token"]
39
+
40
+ memo[item["host"]] = item["token"]
33
41
  end,
34
42
  T::Hash[String, String]
35
43
  )
@@ -69,6 +77,52 @@ module Dependabot
69
77
  # rubocop:enable Metrics/AbcSize
70
78
  # rubocop:enable Metrics/PerceivedComplexity
71
79
 
80
+ # Fetch all tags for an OCI module artifact via the Distribution v2
81
+ # `GET /v2/<repo>/tags/list` endpoint. Returns Version objects whose
82
+ # to_s preserves the original tag string (so `v1.0.0` round-trips).
83
+ #
84
+ # @param artifact_identifier [String] "<host[:port]>/<repo>"
85
+ # @param credentials [Array<Dependabot::Credential>]
86
+ # @return [Array<Dependabot::Opentofu::Version>]
87
+ sig do
88
+ params(
89
+ artifact_identifier: String,
90
+ credentials: T::Array[Dependabot::Credential]
91
+ ).returns(T::Array[Dependabot::Opentofu::Version])
92
+ end
93
+ def self.all_oci_tags(artifact_identifier:, credentials: [])
94
+ host, _, repo = artifact_identifier.partition("/")
95
+ if host.empty? || repo.empty?
96
+ raise Dependabot::DependabotError, "Invalid OCI artifact: '#{artifact_identifier}'"
97
+ end
98
+
99
+ scheme = oci_scheme_for(host)
100
+ next_url = T.let("#{scheme}://#{host}/v2/#{repo}/tags/list", T.nilable(String))
101
+ tags = T.let([], T::Array[String])
102
+
103
+ while next_url
104
+ response = oci_get(next_url, host: host, credentials: credentials)
105
+ case response.status
106
+ when 200
107
+ body = JSON.parse(response.body)
108
+ tags.concat(Array(body["tags"]))
109
+ when 401, 403
110
+ raise Dependabot::PrivateSourceAuthenticationFailure, host
111
+ when 404
112
+ raise Dependabot::DependabotError,
113
+ "OCI repository '#{repo}' not found on registry '#{host}'"
114
+ else
115
+ raise Dependabot::DependabotError,
116
+ "OCI registry '#{host}' returned HTTP #{response.status} listing tags for '#{repo}'"
117
+ end
118
+ next_url = oci_next_page_url(response.headers["Link"], base: "#{scheme}://#{host}")
119
+ end
120
+
121
+ tags.filter_map { |t| Version.new(t) if Version.correct?(t) }
122
+ rescue Excon::Error::Socket, Excon::Error::Timeout
123
+ raise Dependabot::PrivateSourceBadResponse, host
124
+ end
125
+
72
126
  # Fetch all the versions of a provider, and return a Version
73
127
  # representation of them.
74
128
  #
@@ -156,6 +210,124 @@ module Dependabot
156
210
  "Available services: #{available}"
157
211
  end
158
212
 
213
+ # localhost registries are commonly served over plain HTTP in dev.
214
+ sig { params(host: String).returns(String) }
215
+ def self.oci_scheme_for(host)
216
+ bare_host = host.split(":").first
217
+ %w(localhost 127.0.0.1).include?(bare_host) ? "http" : "https"
218
+ end
219
+ private_class_method :oci_scheme_for
220
+
221
+ sig do
222
+ params(
223
+ url: String,
224
+ host: String,
225
+ credentials: T::Array[Dependabot::Credential]
226
+ ).returns(Excon::Response)
227
+ end
228
+ def self.oci_get(url, host:, credentials:)
229
+ cred = credentials.find do |c|
230
+ c["type"] == "opentofu_registry" && (c["host"] == host || c["registry"] == host)
231
+ end
232
+
233
+ headers = {}
234
+ headers["Authorization"] = oci_auth_header(cred) if cred
235
+
236
+ response = Dependabot::RegistryClient.get(url: url, headers: headers)
237
+
238
+ # OCI Distribution Spec: on 401 without explicit credentials, attempt the
239
+ # WWW-Authenticate Bearer challenge to access public registries anonymously.
240
+ if response.status == 401 && cred.nil?
241
+ token = oci_anonymous_bearer_token(response.headers["WWW-Authenticate"])
242
+ if token
243
+ response = Dependabot::RegistryClient.get(
244
+ url: url,
245
+ headers: { "Authorization" => "Bearer #{token}" }
246
+ )
247
+ end
248
+ end
249
+
250
+ response
251
+ end
252
+ private_class_method :oci_get
253
+
254
+ sig { params(www_authenticate: T.nilable(String)).returns(T.nilable(String)) }
255
+ def self.oci_anonymous_bearer_token(www_authenticate)
256
+ token_url = oci_bearer_token_url(www_authenticate)
257
+ return nil unless token_url
258
+
259
+ cached = oci_token_cache[token_url]
260
+ return cached[:token] if cached && T.cast(cached[:expires_at], Float) > Time.now.to_f
261
+
262
+ token_response = Dependabot::RegistryClient.get(url: token_url, headers: {})
263
+ return nil unless token_response.status == 200
264
+
265
+ body = JSON.parse(token_response.body)
266
+ token = body["token"]
267
+ expires_in = body.fetch("expires_in", 60).to_i
268
+ oci_token_cache[token_url] = { token: token, expires_at: Time.now.to_f + expires_in - 10 } if token
269
+ token
270
+ rescue StandardError
271
+ nil
272
+ end
273
+ private_class_method :oci_anonymous_bearer_token
274
+
275
+ # Parses a `WWW-Authenticate: Bearer realm="...",service="...",scope="..."`
276
+ # challenge and returns the token endpoint URL, or nil if not a Bearer challenge.
277
+ sig { params(www_authenticate: T.nilable(String)).returns(T.nilable(String)) }
278
+ def self.oci_bearer_token_url(www_authenticate)
279
+ return nil unless www_authenticate&.start_with?("Bearer ")
280
+
281
+ realm = www_authenticate[/realm="([^"]+)"/, 1]
282
+ service = www_authenticate[/service="([^"]+)"/, 1]
283
+ scope = www_authenticate[/scope="([^"]+)"/, 1]
284
+ return nil unless realm
285
+
286
+ params = {}
287
+ params["service"] = service if service
288
+ params["scope"] = scope if scope
289
+ "#{realm}?#{URI.encode_www_form(params)}"
290
+ end
291
+ private_class_method :oci_bearer_token_url
292
+
293
+ sig { returns(T::Hash[String, T::Hash[Symbol, T.untyped]]) }
294
+ def self.oci_token_cache
295
+ @oci_token_cache = T.let(
296
+ @oci_token_cache,
297
+ T.nilable(T::Hash[String, T::Hash[Symbol, T.untyped]])
298
+ )
299
+ @oci_token_cache ||= {}
300
+ end
301
+ private_class_method :oci_token_cache
302
+
303
+ # Basic for username/password pairs (OCI Distribution v2), Bearer for
304
+ # token-only creds (existing OpenTofu HTTP registry behaviour).
305
+ sig { params(cred: Dependabot::Credential).returns(String) }
306
+ def self.oci_auth_header(cred)
307
+ if cred["username"] && cred["password"]
308
+ "Basic " + Base64.strict_encode64("#{cred['username']}:#{cred['password']}")
309
+ else
310
+ "Bearer #{cred['token']}"
311
+ end
312
+ end
313
+ private_class_method :oci_auth_header
314
+
315
+ # Parses RFC 5988 `Link: <…>; rel="next"` from /tags/list responses.
316
+ sig { params(link_header: T.nilable(String), base: String).returns(T.nilable(String)) }
317
+ def self.oci_next_page_url(link_header, base:)
318
+ return nil if link_header.nil? || link_header.empty?
319
+
320
+ link_header.split(",").each do |part|
321
+ match = part.strip.match(/\A<([^>]+)>\s*;\s*rel="?next"?\z/)
322
+ next unless match
323
+
324
+ target = T.must(match[1])
325
+ return target.start_with?("http") ? target : "#{base}#{target}"
326
+ end
327
+ nil
328
+ end
329
+ private_class_method :oci_next_page_url
330
+
159
331
  private
160
332
 
161
333
  sig { returns(String) }
@@ -84,6 +84,7 @@ module Dependabot
84
84
  case req.dig(:source, :type)
85
85
  when "git" then update_git_requirement(req)
86
86
  when "registry", "provider" then update_registry_requirement(req)
87
+ when "oci" then update_oci_requirement(req)
87
88
  else req
88
89
  end
89
90
  end
@@ -108,6 +109,20 @@ module Dependabot
108
109
  req.merge(source: req[:source].merge(ref: tag_for_latest_version))
109
110
  end
110
111
 
112
+ sig { params(req: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
113
+ def update_oci_requirement(req)
114
+ return req unless defined?(@latest_version) && @latest_version
115
+ return req if req.dig(:source, :digest)
116
+ return req unless req.dig(:source, :tag)
117
+
118
+ new_tag = latest_version.to_s
119
+ return req if req.dig(:source, :tag) == new_tag
120
+
121
+ req.merge(
122
+ source: req[:source].merge(tag: new_tag, version: new_tag)
123
+ )
124
+ end
125
+
111
126
  sig { params(req: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
112
127
  def update_registry_requirement(req)
113
128
  return req if req.fetch(:requirement).nil?
@@ -19,7 +19,7 @@ module Dependabot
19
19
  require_relative "update_checker/latest_version_resolver"
20
20
 
21
21
  ELIGIBLE_SOURCE_TYPES = T.let(
22
- %w(git provider registry).freeze,
22
+ %w(git provider registry oci).freeze,
23
23
  T::Array[String]
24
24
  )
25
25
 
@@ -27,6 +27,7 @@ module Dependabot
27
27
  def latest_version
28
28
  return latest_version_for_git_dependency if git_dependency?
29
29
  return latest_version_for_registry_dependency if registry_dependency?
30
+ return latest_version_for_oci_dependency if oci_dependency?
30
31
 
31
32
  latest_version_for_provider_dependency if provider_dependency?
32
33
  # Other sources (mercurial, path dependencies) just return `nil`
@@ -213,6 +214,32 @@ module Dependabot
213
214
  dependency_source_details&.fetch(:type) == "provider"
214
215
  end
215
216
 
217
+ sig { returns(T::Boolean) }
218
+ def oci_dependency?
219
+ return false if dependency_source_details.nil?
220
+
221
+ dependency_source_details&.fetch(:type) == "oci"
222
+ end
223
+
224
+ sig { returns(T.nilable(Dependabot::Opentofu::Version)) }
225
+ def latest_version_for_oci_dependency
226
+ return unless oci_dependency?
227
+ # Digest pins are immutable; nothing to update to without a tag.
228
+ return if dependency_source_details&.fetch(:digest)
229
+
230
+ @latest_oci_version = T.let(@latest_oci_version, T.nilable(Dependabot::Opentofu::Version))
231
+ return @latest_oci_version if @latest_oci_version
232
+
233
+ identifier = T.must(dependency_source_details).fetch(:artifact_identifier)
234
+ versions = RegistryClient.all_oci_tags(
235
+ artifact_identifier: identifier,
236
+ credentials: credentials
237
+ )
238
+ versions.reject!(&:prerelease?) unless wants_prerelease?
239
+ versions.reject! { |v| ignore_requirements.any? { |r| r.satisfied_by?(v) } }
240
+ @latest_oci_version = versions.max
241
+ end
242
+
216
243
  sig { returns(T.nilable(T::Hash[T.any(String, Symbol), T.untyped])) }
217
244
  def dependency_source_details
218
245
  dependency.source_details(allowed_types: ELIGIBLE_SOURCE_TYPES)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dependabot-opentofu
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.373.0
4
+ version: 0.374.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dependabot
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - '='
17
17
  - !ruby/object:Gem::Version
18
- version: 0.373.0
18
+ version: 0.374.0
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - '='
24
24
  - !ruby/object:Gem::Version
25
- version: 0.373.0
25
+ version: 0.374.0
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: debug
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -262,7 +262,7 @@ licenses:
262
262
  - MIT
263
263
  metadata:
264
264
  bug_tracker_uri: https://github.com/dependabot/dependabot-core/issues
265
- changelog_uri: https://github.com/dependabot/dependabot-core/releases/tag/v0.373.0
265
+ changelog_uri: https://github.com/dependabot/dependabot-core/releases/tag/v0.374.0
266
266
  rdoc_options: []
267
267
  require_paths:
268
268
  - lib