Class: Dependabot::Opentofu::RegistryClient

Inherits:
Object
  • Object
show all
Extended by:
T::Sig
Defined in:
lib/dependabot/opentofu/registry_client.rb

Overview

Opentofu::RegistryClient is a basic API client to interact with a OpenTofu registry: api.opentofu.org/

Constant Summary collapse

ARCHIVE_EXTENSIONS =

Archive extensions supported by OpenTofu for HTTP URLs opentofu.org/docs/language/modules/sources/#http-urls

T.let(
  %w(.zip .bz2 .tar.bz2 .tar.tbz2 .tbz2 .gz .tar.gz .tgz .xz .tar.xz .txz).freeze,
  T::Array[String]
)
PUBLIC_HOSTNAME =
"registry.opentofu.org"
API_BASE_URL =
"api.opentofu.org"

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(hostname: PUBLIC_HOSTNAME, credentials: []) ⇒ RegistryClient

Returns a new instance of RegistryClient.



30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# File 'lib/dependabot/opentofu/registry_client.rb', line 30

def initialize(hostname: PUBLIC_HOSTNAME, credentials: [])
  @hostname = hostname
  @api_base_url = T.let(API_BASE_URL, String)
  @tokens = T.let(
    credentials.each_with_object({}) do |item, memo|
      # Only Bearer-token shaped creds belong here; OCI-only entries
      # (username/password) would otherwise store a nil token and
      # trigger a malformed `Authorization: Bearer ` header.
      next unless item["type"] == "opentofu_registry" && item["token"]

      memo[item["host"]] = item["token"]
    end,
    T::Hash[String, String]
  )
end

Class Method Details

.all_oci_tags(artifact_identifier:, credentials: []) ⇒ Object



93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
# File 'lib/dependabot/opentofu/registry_client.rb', line 93

def self.all_oci_tags(artifact_identifier:, credentials: [])
  host, _, repo = artifact_identifier.partition("/")
  if host.empty? || repo.empty?
    raise Dependabot::DependabotError, "Invalid OCI artifact: '#{artifact_identifier}'"
  end

  scheme = oci_scheme_for(host)
  next_url = T.let("#{scheme}://#{host}/v2/#{repo}/tags/list", T.nilable(String))
  tags = T.let([], T::Array[String])

  while next_url
    response = oci_get(next_url, host: host, credentials: credentials)
    case response.status
    when 200
      body = JSON.parse(response.body)
      tags.concat(Array(body["tags"]))
    when 401, 403
      raise Dependabot::PrivateSourceAuthenticationFailure, host
    when 404
      raise Dependabot::DependabotError,
            "OCI repository '#{repo}' not found on registry '#{host}'"
    else
      raise Dependabot::DependabotError,
            "OCI registry '#{host}' returned HTTP #{response.status} listing tags for '#{repo}'"
    end
    next_url = oci_next_page_url(response.headers["Link"], base: "#{scheme}://#{host}")
  end

  tags.filter_map { |t| Version.new(t) if Version.correct?(t) }
rescue Excon::Error::Socket, Excon::Error::Timeout
  raise Dependabot::PrivateSourceBadResponse, host
end

.get_proxied_source(raw_source) ⇒ Object



52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
# File 'lib/dependabot/opentofu/registry_client.rb', line 52

def self.get_proxied_source(raw_source)
  return raw_source unless raw_source.start_with?("http")

  uri = URI.parse(T.must(raw_source.split(%r{(?<!:)//}).first))
  return raw_source if ARCHIVE_EXTENSIONS.any? { |ext| uri.path&.end_with?(ext) }
  return raw_source if URI.parse(raw_source).query&.include?("archive=")

  url = T.must(raw_source.split(%r{(?<!:)//}).first) + "?opentofu-get=1"
  host = URI.parse(raw_source).host

  response = Dependabot::RegistryClient.get(url: url)
  raise PrivateSourceAuthenticationFailure, host if response.status == 401

  return T.must(response.headers["X-OpenTofu-Get"]) if response.headers["X-OpenTofu-Get"]

  doc = Nokogiri::XML(response.body)
  doc.css("meta").find do |tag|
    tag.attributes&.fetch("name", nil)&.value == "opentofu-get"
  end&.attributes&.fetch("content", nil)&.value
rescue Excon::Error::Socket, Excon::Error::Timeout => e
  raise PrivateSourceAuthenticationFailure, host if e.message.include?("no address for")

  raw_source
end

Instance Method Details

#all_module_versions(identifier:) ⇒ Object



153
154
155
156
157
158
159
160
# File 'lib/dependabot/opentofu/registry_client.rb', line 153

def all_module_versions(identifier:)
  base_url = service_url_for_registry("modules.v1")
  response = http_get!(URI.join(base_url, "#{identifier}/versions"))

  JSON.parse(response.body)
      .fetch("modules").first.fetch("versions")
      .map { |release| version_class.new(release.fetch("version")) }
end

#all_provider_versions(identifier:) ⇒ Object



134
135
136
137
138
139
140
141
142
143
# File 'lib/dependabot/opentofu/registry_client.rb', line 134

def all_provider_versions(identifier:)
  base_url = service_url_for_registry("providers.v1")
  response = http_get!(URI.join(base_url, "#{identifier}/versions"))

  JSON.parse(response.body)
      .fetch("versions")
      .map { |release| version_class.new(release.fetch("version")) }
rescue Excon::Error
  raise error("Could not fetch provider versions")
end

#service_url_for_registry(service_key) ⇒ Object



204
205
206
207
208
209
210
211
# File 'lib/dependabot/opentofu/registry_client.rb', line 204

def service_url_for_registry(service_key)
  url_for_registry(services.fetch(service_key))
rescue KeyError
  available = services.keys.empty? ? "none" : services.keys.join(", ")
  raise DependabotError,
        "Registry at #{hostname} does not support required service '#{service_key}'. " \
        "Available services: #{available}"
end

#source(dependency:) ⇒ Object



171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
# File 'lib/dependabot/opentofu/registry_client.rb', line 171

def source(dependency:)
  type = T.must(dependency.requirements.first)[:source][:type]
  base_url = url_for_api("/registry/docs/")
  case type
  when "module", "modules", "registry"
    download_url = URI.join(base_url, "modules/#{dependency.name}/#{dependency.version}/download")
    response = http_get(download_url)
    return nil unless response.status == 204

    source_url = response.headers.fetch("X-OpenTofu-Get")
    source_url = URI.join(download_url, source_url) if
      source_url.start_with?("/", "./", "../")
    source_url = RegistryClient.get_proxied_source(source_url) if source_url
  when "provider", "providers"
    url = URI.join(base_url, "providers/#{dependency.name}/v#{dependency.version}/index.json")
    response = http_get(url)
    return nil unless response.status == 200

    source_url = JSON.parse(response.body).dig("docs", "index", "edit_link")
  end

  Source.from_url(source_url) if source_url
rescue JSON::ParserError, Excon::Error::Timeout
  nil
end