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,144 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "time"
6
+ require "cgi"
7
+ require "excon"
8
+ require "sorbet-runtime"
9
+ require "dependabot/swift"
10
+
11
+ module Dependabot
12
+ module Opentofu
13
+ module Package
14
+ class PackageDetailsFetcher
15
+ extend T::Sig
16
+
17
+ RELEASES_URL_GIT = "https://api.github.com/repos/"
18
+ RELEASE_URL_FOR_PROVIDER = "https://api.opentofu.org/registry/docs/providers/"
19
+ RELEASE_URL_FOR_MODULE = "https://api.opentofu.org/registry/docs/modules/"
20
+ APPLICATION_JSON = "JSON"
21
+ # https://api.opentofu.org/registry/docs/providers/hashicorp/aws/index.json
22
+ # https://api.opentofu.org/registry/docs/modules/hashicorp/consul/aws/index.json
23
+
24
+ ELIGIBLE_SOURCE_TYPES = T.let(
25
+ %w(git provider registry).freeze,
26
+ T::Array[String]
27
+ )
28
+
29
+ sig do
30
+ params(
31
+ dependency: Dependency,
32
+ credentials: T::Array[Dependabot::Credential],
33
+ git_commit_checker: Dependabot::GitCommitChecker
34
+ ).void
35
+ end
36
+ def initialize(dependency:, credentials:, git_commit_checker:)
37
+ @dependency = dependency
38
+ @credentials = credentials
39
+ @git_commit_checker = git_commit_checker
40
+ end
41
+
42
+ sig { returns(Dependabot::GitCommitChecker) }
43
+ attr_reader :git_commit_checker
44
+
45
+ sig { returns(T::Array[Dependabot::Credential]) }
46
+ attr_reader :credentials
47
+
48
+ sig { returns(T::Array[GitTagWithDetail]) }
49
+ def fetch_tag_and_release_date
50
+ truncate_github_url = @dependency.name.gsub("github.com/", "")
51
+ url = RELEASES_URL_GIT + "#{truncate_github_url}/releases"
52
+ result_lines = T.let([], T::Array[GitTagWithDetail])
53
+ # Fetch the releases from the GitHub API
54
+ response = Excon.get(
55
+ url,
56
+ headers: { "User-Agent" => "Dependabot (dependabot.com)",
57
+ "Accept" => "application/vnd.github.v3+json" }
58
+ )
59
+ Dependabot.logger.error("Failed call details: #{response.body}") unless response.status == 200
60
+ return result_lines unless response.status == 200
61
+
62
+ # Parse the JSON response
63
+ releases = JSON.parse(response.body)
64
+
65
+ # Extract version names and release dates into a hash
66
+ releases.map do |release|
67
+ result_lines << GitTagWithDetail.new(
68
+ tag: release["tag_name"],
69
+ release_date: release["published_at"]
70
+ )
71
+ end
72
+
73
+ # sort the result lines by tag in descending order
74
+ result_lines = result_lines.sort_by(&:tag).reverse
75
+ # Log the extracted details for debugging
76
+ Dependabot.logger.info("Extracted release details: #{result_lines}")
77
+ result_lines
78
+ end
79
+
80
+ sig { returns(T::Array[GitTagWithDetail]) }
81
+ def fetch_tag_and_release_date_from_provider
82
+ return [] unless dependency_source_details
83
+
84
+ url = RELEASE_URL_FOR_PROVIDER + dependency_source_details&.fetch(:module_identifier) + "/index.json"
85
+ Dependabot.logger.info("Fetching provider release details from URL: #{url}")
86
+ result_lines = T.let([], T::Array[GitTagWithDetail])
87
+ # Fetch the releases from the provider API
88
+ response = Excon.get(url, headers: { "Accept" => "application/vnd.github.v3+json" })
89
+ Dependabot.logger.error("Failed call details: #{response.body}") unless response.status == 200
90
+ return result_lines unless response.status == 200
91
+
92
+ # Parse the JSON response
93
+ releases = JSON.parse(response.body).fetch("versions", [])
94
+ # Check if releases is an array and not empty
95
+ return result_lines unless releases.is_a?(Array) && !releases.empty?
96
+
97
+ # Extract version names and release dates into result_lines
98
+ releases.each do |release|
99
+ result_lines << GitTagWithDetail.new(
100
+ tag: release["id"],
101
+ release_date: release["published"]
102
+ )
103
+ end
104
+ # Sort the result lines by tag in descending order
105
+ result_lines.sort_by(&:tag).reverse
106
+ end
107
+ # RuboCop:enable Metrics/AbcSize, Metrics/MethodLength
108
+
109
+ sig { returns(T::Array[GitTagWithDetail]) }
110
+ def fetch_tag_and_release_date_from_module
111
+ return [] unless dependency_source_details
112
+
113
+ url = RELEASE_URL_FOR_MODULE + dependency_source_details&.fetch(:module_identifier) + "/index.json"
114
+ Dependabot.logger.info("Fetching provider release details from URL: #{url}")
115
+ result_lines = T.let([], T::Array[GitTagWithDetail])
116
+ # Fetch the releases from the provider API
117
+ response = Excon.get(url, headers: { "Accept" => "application/vnd.github.v3+json" })
118
+ Dependabot.logger.error("Failed call details: #{response.body}") unless response.status == 200
119
+ return result_lines unless response.status == 200
120
+
121
+ # Parse the JSON response
122
+ releases = JSON.parse(response.body).fetch("versions", [])
123
+
124
+ # Extract version names and release dates into result_lines
125
+ releases.each do |release|
126
+ result_lines << GitTagWithDetail.new(
127
+ tag: release["id"],
128
+ release_date: release["published"]
129
+ )
130
+ end
131
+ # Sort the result lines by tag in descending order
132
+ result_lines.sort_by(&:tag).reverse
133
+ end
134
+
135
+ sig { returns(T.nilable(T::Hash[T.any(String, Symbol), T.untyped])) }
136
+ def dependency_source_details
137
+ return nil unless @dependency.source_details
138
+
139
+ @dependency.source_details(allowed_types: ELIGIBLE_SOURCE_TYPES)
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,41 @@
1
+ # typed: strong
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+ require "dependabot/ecosystem"
6
+ require "dependabot/opentofu/version"
7
+
8
+ module Dependabot
9
+ module Opentofu
10
+ ECOSYSTEM = "opentofu"
11
+ PACKAGE_MANAGER = "opentofu"
12
+ SUPPORTED_OPENTOFU_VERSIONS = T.let([].freeze, T::Array[Dependabot::Version])
13
+
14
+ # When a version is going to be unsupported, it will be added here
15
+ DEPRECATED_OPENTOFU_VERSIONS = T.let([].freeze, T::Array[Dependabot::Version])
16
+
17
+ class PackageManager < Dependabot::Ecosystem::VersionManager
18
+ extend T::Sig
19
+
20
+ sig { params(raw_version: String).void }
21
+ def initialize(raw_version)
22
+ super(
23
+ name: PACKAGE_MANAGER,
24
+ version: Version.new(raw_version),
25
+ deprecated_versions: DEPRECATED_OPENTOFU_VERSIONS,
26
+ supported_versions: SUPPORTED_OPENTOFU_VERSIONS
27
+ )
28
+ end
29
+
30
+ sig { returns(T::Boolean) }
31
+ def deprecated?
32
+ false
33
+ end
34
+
35
+ sig { returns(T::Boolean) }
36
+ def unsupported?
37
+ false
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,246 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "dependabot/dependency"
5
+ require "dependabot/errors"
6
+ require "dependabot/registry_client"
7
+ require "dependabot/source"
8
+ require "dependabot/opentofu/version"
9
+
10
+ module Dependabot
11
+ module Opentofu
12
+ # Opentofu::RegistryClient is a basic API client to interact with a
13
+ # OpenTofu registry: https://api.opentofu.org/
14
+ class RegistryClient
15
+ extend T::Sig
16
+
17
+ # Archive extensions supported by OpenTofu for HTTP URLs
18
+ # https://opentofu.org/docs/language/modules/sources/#http-urls
19
+ ARCHIVE_EXTENSIONS = T.let(
20
+ %w(.zip .bz2 .tar.bz2 .tar.tbz2 .tbz2 .gz .tar.gz .tgz .xz .tar.xz .txz).freeze,
21
+ T::Array[String]
22
+ )
23
+ PUBLIC_HOSTNAME = "registry.opentofu.org"
24
+ API_BASE_URL = "api.opentofu.org"
25
+
26
+ sig { params(hostname: String, credentials: T::Array[Dependabot::Credential]).void }
27
+ def initialize(hostname: PUBLIC_HOSTNAME, credentials: [])
28
+ @hostname = hostname
29
+ @api_base_url = T.let(API_BASE_URL, String)
30
+ @tokens = T.let(
31
+ credentials.each_with_object({}) do |item, memo|
32
+ memo[item["host"]] = item["token"] if item["type"] == "opentofu_registry"
33
+ end,
34
+ T::Hash[String, String]
35
+ )
36
+ end
37
+
38
+ # rubocop:disable Metrics/PerceivedComplexity
39
+ # rubocop:disable Metrics/AbcSize
40
+ # rubocop:disable Metrics/CyclomaticComplexity
41
+ # See https://opentofu.org/docs/language/modules/sources/#http-urls for
42
+ # details of how OpenTofu handle HTTP(S) sources for modules
43
+ sig { params(raw_source: String).returns(String) }
44
+ def self.get_proxied_source(raw_source)
45
+ return raw_source unless raw_source.start_with?("http")
46
+
47
+ uri = URI.parse(T.must(raw_source.split(%r{(?<!:)//}).first))
48
+ return raw_source if ARCHIVE_EXTENSIONS.any? { |ext| uri.path&.end_with?(ext) }
49
+ return raw_source if URI.parse(raw_source).query&.include?("archive=")
50
+
51
+ url = T.must(raw_source.split(%r{(?<!:)//}).first) + "?opentofu-get=1"
52
+ host = URI.parse(raw_source).host
53
+
54
+ response = Dependabot::RegistryClient.get(url: url)
55
+ raise PrivateSourceAuthenticationFailure, host if response.status == 401
56
+
57
+ return T.must(response.headers["X-OpenTofu-Get"]) if response.headers["X-OpenTofu-Get"]
58
+
59
+ doc = Nokogiri::XML(response.body)
60
+ doc.css("meta").find do |tag|
61
+ tag.attributes&.fetch("name", nil)&.value == "opentofu-get"
62
+ end&.attributes&.fetch("content", nil)&.value
63
+ rescue Excon::Error::Socket, Excon::Error::Timeout => e
64
+ raise PrivateSourceAuthenticationFailure, host if e.message.include?("no address for")
65
+
66
+ raw_source
67
+ end
68
+ # rubocop:enable Metrics/CyclomaticComplexity
69
+ # rubocop:enable Metrics/AbcSize
70
+ # rubocop:enable Metrics/PerceivedComplexity
71
+
72
+ # Fetch all the versions of a provider, and return a Version
73
+ # representation of them.
74
+ #
75
+ # @param identifier [String] the identifier for the dependency, i.e:
76
+ # "hashicorp/aws"
77
+ # @return [Array<Dependabot::Opentofu::Version>]
78
+ # @raise [Dependabot::DependabotError] when the versions cannot be retrieved
79
+ sig { params(identifier: String).returns(T::Array[Dependabot::Opentofu::Version]) }
80
+ def all_provider_versions(identifier:)
81
+ base_url = service_url_for_registry("providers.v1")
82
+ response = http_get!(URI.join(base_url, "#{identifier}/versions"))
83
+
84
+ JSON.parse(response.body)
85
+ .fetch("versions")
86
+ .map { |release| version_class.new(release.fetch("version")) }
87
+ rescue Excon::Error
88
+ raise error("Could not fetch provider versions")
89
+ end
90
+
91
+ # Fetch all the versions of a module, and return a Version
92
+ # representation of them.
93
+ #
94
+ # @param identifier [String] the identifier for the dependency, i.e:
95
+ # "hashicorp/consul/aws"
96
+ # @return [Array<Dependabot::Opentofu::Version>]
97
+ # @raise [Dependabot::DependabotError] when the versions cannot be retrieved
98
+ sig { params(identifier: String).returns(T::Array[Dependabot::Opentofu::Version]) }
99
+ def all_module_versions(identifier:)
100
+ base_url = service_url_for_registry("modules.v1")
101
+ response = http_get!(URI.join(base_url, "#{identifier}/versions"))
102
+
103
+ JSON.parse(response.body)
104
+ .fetch("modules").first.fetch("versions")
105
+ .map { |release| version_class.new(release.fetch("version")) }
106
+ end
107
+
108
+ # Fetch the "source" for a module or provider. We use the API to fetch
109
+ # the source for a dependency, this typically points to a source code
110
+ # repository, and then instantiate a Dependabot::Source object that we
111
+ # can use to fetch Metadata about a specific version of the dependency.
112
+ #
113
+ # @param dependency [Dependabot::Dependency] the dependency who's source
114
+ # we're attempting to find
115
+ # @return [nil, Dependabot::Source]
116
+ sig { params(dependency: Dependabot::Dependency).returns(T.nilable(Dependabot::Source)) }
117
+ def source(dependency:)
118
+ type = T.must(dependency.requirements.first)[:source][:type]
119
+ base_url = url_for_api("/registry/docs/")
120
+ case type
121
+ when "module", "modules", "registry"
122
+ download_url = URI.join(base_url, "modules/#{dependency.name}/#{dependency.version}/download")
123
+ response = http_get(download_url)
124
+ return nil unless response.status == 204
125
+
126
+ source_url = response.headers.fetch("X-OpenTofu-Get")
127
+ source_url = URI.join(download_url, source_url) if
128
+ source_url.start_with?("/", "./", "../")
129
+ source_url = RegistryClient.get_proxied_source(source_url) if source_url
130
+ when "provider", "providers"
131
+ url = URI.join(base_url, "providers/#{dependency.name}/v#{dependency.version}/index.json")
132
+ response = http_get(url)
133
+ return nil unless response.status == 200
134
+
135
+ source_url = JSON.parse(response.body).dig("docs", "index", "edit_link")
136
+ end
137
+
138
+ Source.from_url(source_url) if source_url
139
+ rescue JSON::ParserError, Excon::Error::Timeout
140
+ nil
141
+ end
142
+
143
+ # Perform service discovery and return the absolute URL for
144
+ # the requested service.
145
+ #
146
+ # @param service_key [String] the service type
147
+ # @param return String
148
+ # @raise [Dependabot::PrivateSourceAuthenticationFailure] when the service is not available
149
+ sig { params(service_key: String).returns(String) }
150
+ def service_url_for_registry(service_key)
151
+ url_for_registry(services.fetch(service_key))
152
+ rescue KeyError
153
+ raise Dependabot::PrivateSourceAuthenticationFailure, "Host does not support required OpenTofu-native service"
154
+ end
155
+
156
+ private
157
+
158
+ sig { returns(String) }
159
+ attr_reader :hostname, :api_base_url
160
+
161
+ sig { returns(T::Hash[String, String]) }
162
+ attr_reader :tokens
163
+
164
+ sig { returns(T.class_of(Dependabot::Opentofu::Version)) }
165
+ def version_class
166
+ Version
167
+ end
168
+
169
+ sig { params(hostname: String).returns(T::Hash[String, String]) }
170
+ def headers_for(hostname)
171
+ token = tokens[hostname]
172
+ token ? { "Authorization" => "Bearer #{token}" } : {}
173
+ end
174
+
175
+ sig { returns(T::Hash[String, String]) }
176
+ def services
177
+ @services ||= T.let(
178
+ begin
179
+ response = http_get(url_for_registry("/.well-known/terraform.json"))
180
+ response.status == 200 ? JSON.parse(response.body) : {}
181
+ end,
182
+ T.nilable(T::Hash[String, String])
183
+ )
184
+ end
185
+
186
+ sig { params(type: String).returns(String) }
187
+ def service_key_for(type)
188
+ case type
189
+ when "module", "modules", "registry"
190
+ "modules.v1"
191
+ when "provider", "providers"
192
+ "providers.v1"
193
+ else
194
+ raise error("Invalid source type")
195
+ end
196
+ end
197
+
198
+ sig { params(url: T.any(String, URI::Generic)).returns(Excon::Response) }
199
+ def http_get(url)
200
+ Dependabot::RegistryClient.get(
201
+ url: url.to_s,
202
+ headers: headers_for(hostname)
203
+ )
204
+ rescue Excon::Error::Socket, Excon::Error::Timeout
205
+ raise PrivateSourceBadResponse, hostname
206
+ end
207
+
208
+ sig { params(url: URI::Generic).returns(Excon::Response) }
209
+ def http_get!(url)
210
+ response = http_get(url)
211
+
212
+ raise Dependabot::PrivateSourceAuthenticationFailure, hostname if response.status == 401
213
+ raise error("Response from registry was #{response.status}") unless response.status == 200
214
+
215
+ response
216
+ end
217
+
218
+ sig { params(path: String).returns(String) }
219
+ def url_for_registry(path)
220
+ uri = URI.parse(path)
221
+ return uri.to_s if uri.scheme == "https"
222
+ raise error("Unsupported scheme provided") if uri.host && uri.scheme
223
+
224
+ uri.host = hostname
225
+ uri.scheme = "https"
226
+ uri.to_s
227
+ end
228
+
229
+ sig { params(path: String).returns(String) }
230
+ def url_for_api(path)
231
+ uri = URI.parse(path)
232
+ return uri.to_s if uri.scheme == "https"
233
+ raise error("Unsupported scheme provided") if uri.host && uri.scheme
234
+
235
+ uri.host = api_base_url
236
+ uri.scheme = "https"
237
+ uri.to_s
238
+ end
239
+
240
+ sig { params(message: String).returns(Dependabot::DependabotError) }
241
+ def error(message)
242
+ Dependabot::DependabotError.new(message)
243
+ end
244
+ end
245
+ end
246
+ end
@@ -0,0 +1,59 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "dependabot/requirement"
7
+ require "dependabot/utils"
8
+ require "dependabot/opentofu/version"
9
+
10
+ # Just ensures that OpenTofu requirements use OpenTofu versions
11
+ module Dependabot
12
+ module Opentofu
13
+ class Requirement < Dependabot::Requirement
14
+ extend T::Sig
15
+
16
+ # Override regex PATTERN from Gem::Requirement to add support for the
17
+ # optional 'v' prefix to release tag names, which OpenTofu supports.
18
+ OPERATORS = T.let(OPS.keys.map { |key| Regexp.quote(key) }.join("|").freeze, String)
19
+ PATTERN_RAW = T.let("\\s*(#{OPERATORS})?\\s*v?(#{Gem::Version::VERSION_PATTERN})\\s*".freeze, String)
20
+ PATTERN = /\A#{PATTERN_RAW}\z/
21
+
22
+ sig { params(obj: T.any(String, Gem::Version)).returns(T::Array[T.any(String, Version)]) }
23
+ def self.parse(obj)
24
+ return ["=", Version.new(obj.to_s)] if obj.is_a?(Gem::Version)
25
+
26
+ unless (matches = PATTERN.match(obj.to_s))
27
+ msg = "Illformed requirement [#{obj.inspect}]"
28
+ raise BadRequirementError, msg
29
+ end
30
+
31
+ return DefaultRequirement if matches[1] == ">=" && matches[2] == "0"
32
+
33
+ [matches[1] || "=", Opentofu::Version.new(matches[2])]
34
+ end
35
+
36
+ # For consistency with other languages, we define a requirements array.
37
+ # OpenTofu doesn't have an `OR` separator for requirements, so it
38
+ # always contains a single element.
39
+ sig { override.params(requirement_string: T.nilable(String)).returns(T::Array[Requirement]) }
40
+ def self.requirements_array(requirement_string)
41
+ [new(requirement_string.to_s)]
42
+ end
43
+
44
+ # Patches Gem::Requirement to make it accept requirement strings like
45
+ # "~> 4.2.5, >= 4.2.5.1" without first needing to split them.
46
+ sig { params(requirements: T.any(String, T::Array[String])).void }
47
+ def initialize(*requirements)
48
+ requirements = requirements.flatten.flat_map do |req_string|
49
+ req_string.split(",").map(&:strip)
50
+ end
51
+
52
+ super(requirements)
53
+ end
54
+ end
55
+ end
56
+ end
57
+
58
+ Dependabot::Utils
59
+ .register_requirement_class("opentofu", Dependabot::Opentofu::Requirement)