Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Strict type Dependabot::Terraform::FileUpdater #10644

Merged
merged 1 commit into from
Oct 8, 2024
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 60 additions & 17 deletions terraform/lib/dependabot/terraform/file_updater.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# typed: true
# typed: strict
# frozen_string_literal: true

require "sorbet-runtime"
Expand All @@ -20,10 +20,12 @@ class FileUpdater < Dependabot::FileUpdaters::Base
MODULE_NOT_INSTALLED_ERROR = /Module not installed.*module\s*\"(?<mod>\S+)\"/m
GIT_HTTPS_PREFIX = %r{^git::https://}

sig { override.returns(T::Array[Regexp]) }
def self.updated_files_regex
[/\.tf$/, /\.hcl$/]
end

sig { override.returns(T::Array[Dependabot::DependencyFile]) }
def updated_dependency_files
updated_files = []

Expand Down Expand Up @@ -69,23 +71,25 @@ def updated_dependency_files
# (requirements - previous_requirements) | (previous_requirements - requirements)
# => [{requirement: "0.9.1"}]
# we can detect that change.
sig { params(file: Dependabot::DependencyFile, dependency: Dependabot::Dependency).returns(T::Boolean) }
def requirement_changed?(file, dependency)
changed_requirements =
(dependency.requirements - dependency.previous_requirements) |
(dependency.previous_requirements - dependency.requirements)
(dependency.requirements - T.must(dependency.previous_requirements)) |
(T.must(dependency.previous_requirements) - dependency.requirements)

changed_requirements.any? { |f| f[:file] == file.name }
end

sig { params(file: Dependabot::DependencyFile).returns(String) }
def updated_terraform_file_content(file)
content = file.content.dup
content = T.must(file.content.dup)

reqs = dependency.requirements.zip(dependency.previous_requirements)
reqs = dependency.requirements.zip(T.must(dependency.previous_requirements))
.reject { |new_req, old_req| new_req == old_req }

# Loop through each changed requirement and update the files and lockfile
reqs.each do |new_req, old_req|
raise "Bad req match" unless new_req[:file] == old_req[:file]
raise "Bad req match" unless new_req[:file] == old_req&.fetch(:file)
next unless new_req.fetch(:file) == file.name

case new_req[:source][:type]
Expand All @@ -102,20 +106,37 @@ def updated_terraform_file_content(file)
content
end

sig do
params(
new_req: T::Hash[Symbol, T.untyped],
old_req: T.nilable(T::Hash[Symbol, T.untyped]),
updated_content: String,
filename: String
)
.void
end
def update_git_declaration(new_req, old_req, updated_content, filename)
url = old_req.fetch(:source)[:url].gsub(%r{^https://}, "")
tag = old_req.fetch(:source)[:ref]
url = old_req&.dig(:source, :url)&.gsub(%r{^https://}, "")
tag = old_req&.dig(:source, :ref)
url_regex = /#{Regexp.quote(url)}.*ref=#{Regexp.quote(tag)}/

declaration_regex = git_declaration_regex(filename)

updated_content.sub!(declaration_regex) do |regex_match|
regex_match.sub(url_regex) do |url_match|
url_match.sub(old_req[:source][:ref], new_req[:source][:ref])
url_match.sub(old_req&.dig(:source, :ref), new_req[:source][:ref])
end
end
end

sig do
params(
new_req: T::Hash[Symbol, T.untyped],
old_req: T.nilable(T::Hash[Symbol, T.untyped]),
updated_content: String
)
.void
end
def update_registry_declaration(new_req, old_req, updated_content)
regex = if new_req[:source][:type] == "provider"
provider_declaration_regex(updated_content)
Expand All @@ -124,18 +145,20 @@ def update_registry_declaration(new_req, old_req, updated_content)
end
updated_content.gsub!(regex) do |regex_match|
regex_match.sub(/^\s*version\s*=.*/) do |req_line_match|
req_line_match.sub(old_req[:requirement], new_req[:requirement])
req_line_match.sub(old_req&.fetch(:requirement), new_req[:requirement])
end
end
end

sig { params(content: String, declaration_regex: Regexp).returns(T::Array[String]) }
def extract_provider_h1_hashes(content, declaration_regex)
content.match(declaration_regex).to_s
.match(hashes_object_regex).to_s
.split("\n").map { |hash| hash.match(hashes_string_regex).to_s }
.select { |h| h&.match?(/^h1:/) }
.select { |h| h.match?(/^h1:/) }
end

sig { params(content: String, declaration_regex: Regexp).returns(String) }
def remove_provider_h1_hashes(content, declaration_regex)
content.match(declaration_regex).to_s
.sub(hashes_object_regex, "")
Expand All @@ -155,8 +178,9 @@ def lockfile_details(new_req)
[T.must(content), provider_source, declaration_regex]
end

sig { returns(T.nilable(T::Array[Symbol])) }
def lookup_hash_architecture # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity
new_req = dependency.requirements.first
new_req = T.must(dependency.requirements.first)

# NOTE: Only providers are included in the lockfile, modules are not
return unless new_req[:source][:type] == "provider"
Expand Down Expand Up @@ -222,14 +246,23 @@ def lookup_hash_architecture # rubocop:disable Metrics/AbcSize, Metrics/MethodLe
architectures.to_a
end

sig { returns(T::Array[Symbol]) }
def architecture_type
@architecture_type ||= lookup_hash_architecture.empty? ? [:linux_amd64] : lookup_hash_architecture
@architecture_type ||= T.let(
if lookup_hash_architecture.nil? || lookup_hash_architecture&.empty?
[:linux_amd64]
else
T.must(lookup_hash_architecture)
end,
T.nilable(T::Array[Symbol])
)
end

sig { params(updated_manifest_files: T::Array[Dependabot::DependencyFile]).returns(T.nilable(String)) }
def update_lockfile_declaration(updated_manifest_files) # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity
return if lockfile.nil?

new_req = dependency.requirements.first
new_req = T.must(dependency.requirements.first)
# NOTE: Only providers are included in the lockfile, modules are not
return unless new_req[:source][:type] == "provider"

Expand Down Expand Up @@ -268,14 +301,15 @@ def update_lockfile_declaration(updated_manifest_files) # rubocop:disable Metric
raise if @retrying_lock || !e.message.include?("terraform init")

# NOTE: Modules need to be installed before terraform can update the lockfile
@retrying_lock = true
@retrying_lock = T.let(true, T.nilable(T::Boolean))
run_terraform_init
retry
end

content
end

sig { void }
def run_terraform_init
SharedHelpers.with_git_configured(credentials: credentials) do
# -backend=false option used to ignore any backend configuration, as these won't be accessible
Expand All @@ -298,30 +332,36 @@ def run_terraform_init
end
end

sig { returns(Dependabot::Dependency) }
def dependency
# Terraform updates will only ever be updating a single dependency
dependencies.first
T.must(dependencies.first)
end

sig { returns(T::Array[Dependabot::DependencyFile]) }
def files_with_requirement
filenames = dependency.requirements.map { |r| r[:file] }
dependency_files.select { |file| filenames.include?(file.name) }
end

sig { override.void }
def check_required_files
return if [*terraform_files, *terragrunt_files].any?

raise "No Terraform configuration file!"
end

sig { returns(Regexp) }
def hashes_object_regex
/hashes\s*=\s*[^\]]*\]/m
end

sig { returns(Regexp) }
def hashes_string_regex
/(?<=\").*(?=\")/
end

sig { params(updated_content: String).returns(Regexp) }
def provider_declaration_regex(updated_content)
name = Regexp.escape(dependency.name)
registry_host = Regexp.escape(registry_host_for(dependency))
Expand All @@ -341,6 +381,7 @@ def provider_declaration_regex(updated_content)
end
end

sig { returns(Regexp) }
def registry_declaration_regex
%r{
(?<=\{)
Expand All @@ -354,20 +395,22 @@ def registry_declaration_regex
}mx
end

sig { params(filename: String).returns(Regexp) }
def git_declaration_regex(filename)
# For terragrunt dependencies there's not a lot we can base the
# regex on. Just look for declarations within a `terraform` block
return /terraform\s*\{(?:(?!^\}).)*/m if terragrunt_file?(filename)

# For modules we can do better - filter for module blocks that use the
# name of the module
module_name = dependency.name.split("::").first
module_name = T.must(dependency.name.split("::").first)
/
module\s+["']#{Regexp.escape(module_name)}["']\s*\{
(?:(?!^\}).)*
/mx
end

sig { params(dependency: Dependabot::Dependency).returns(String) }
def registry_host_for(dependency)
source = dependency.requirements.filter_map { |r| r[:source] }.first
source[:registry_hostname] || source["registry_hostname"] || "registry.terraform.io"
Expand Down
Loading