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

Automate Terraform Platform Detection for Lockfile Hashes #4905

Merged
merged 12 commits into from
Apr 19, 2022
106 changes: 99 additions & 7 deletions terraform/lib/dependabot/terraform/file_updater.rb
Original file line number Diff line number Diff line change
Expand Up @@ -94,25 +94,110 @@ def update_registry_declaration(new_req, old_req, updated_content)
end
end

def update_lockfile_declaration(updated_manifest_files) # rubocop:disable Metrics/AbcSize
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:/) }
end

def lockfile_details(new_req)
content = lock_file.content.dup
provider_source = new_req[:source][:registry_hostname] + "/" + new_req[:source][:module_identifier]
declaration_regex = lockfile_declaration_regex(provider_source)

[content, provider_source, declaration_regex]
end

def lookup_hash_architecture # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whew that's a big one 😄 are there maybe any methods that we can extract out of here to make this a little easier to grok? Happy to take a stab at it (tomorrow) also

new_req = dependency.requirements.first

# NOTE: Only providers are inlcuded in the lockfile, modules are not
return unless new_req[:source][:type] == "provider"

architectures = []
content, provider_source, declaration_regex = lockfile_details(new_req)
hashes = extract_provider_h1_hashes(content, declaration_regex)

# These are ordered in assumed popularity
possible_architectures = %w(
linux_amd64
darwin_amd64
windows_amd64
darwin_arm64
linux_arm64
)

base_dir = dependency_files.first.directory
lockfile_hash_removed = content.sub(hashes_object_regex, "")

# This runs in the same directory as the actual lockfile update so
# the platform must be determined before the updated manifest files
# are written to disk
SharedHelpers.in_a_temporary_repo_directory(base_dir, repo_contents_path) do
possible_architectures.each do |arch|
# Exit early if we have detected all of the architectures present
break if architectures.count == hashes.count

# Terraform will update the lockfile in place so we use a fresh lockfile for each lookup
File.write(".terraform.lock.hcl", lockfile_hash_removed)

SharedHelpers.run_shell_command("terraform providers lock -platform=#{arch} #{provider_source} -no-color")

updated_lockfile = File.read(".terraform.lock.hcl")
updated_hashes = extract_provider_h1_hashes(updated_lockfile, declaration_regex)
next if updated_hashes.nil?

# Check if the architecture is present in the original lockfile
hashes.each do |hash|
updated_hashes.select { |h| h.match?(/^h1:/) }.each do |updated_hash|
architectures.append(arch.to_sym) if hash == updated_hash
end
end

File.delete(".terraform.lock.hcl")
end
rescue SharedHelpers::HelperSubprocessFailed => e
if @retrying_lock && e.message.match?(MODULE_NOT_INSTALLED_ERROR)
mod = e.message.match(MODULE_NOT_INSTALLED_ERROR).named_captures.fetch("mod")
raise Dependabot::DependencyFileNotResolvable, "Attempt to install module #{mod} failed"
end
raise if @retrying_lock || !e.message.include?("terraform init")

# NOTE: Modules need to be installed before terraform can update the lockfile
@retrying_lock = true
run_terraform_init
retry
end

architectures.to_a
end

def architecture_type
@architecture_type ||= lookup_hash_architecture.empty? ? [:linux_amd64] : lookup_hash_architecture
end

def update_lockfile_declaration(updated_manifest_files) # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity
return if lock_file.nil?

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

content = lock_file.content.dup
provider_source = new_req[:source][:registry_hostname] + "/" + new_req[:source][:module_identifier]
declaration_regex = lockfile_declaration_regex(provider_source)
content, provider_source, declaration_regex = lockfile_details(new_req)
lockfile_dependency_removed = content.sub(declaration_regex, "")

base_dir = dependency_files.first.directory
SharedHelpers.in_a_temporary_repo_directory(base_dir, repo_contents_path) do
# Determine the provider using the original manifest files
platforms = architecture_type.map { |arch| "-platform=#{arch}" }.join(" ")

# Update the provider requirements in case the previous requirement doesn't allow the new version
updated_manifest_files.each { |f| File.write(f.name, f.content) }

File.write(".terraform.lock.hcl", lockfile_dependency_removed)
SharedHelpers.run_shell_command("terraform providers lock #{provider_source} -no-color")

SharedHelpers.run_shell_command("terraform providers lock #{platforms} #{provider_source}")

updated_lockfile = File.read(".terraform.lock.hcl")
updated_dependency = updated_lockfile.scan(declaration_regex).first
Expand All @@ -130,8 +215,7 @@ def update_lockfile_declaration(updated_manifest_files) # rubocop:disable Metric
end
raise if @retrying_lock || !e.message.include?("terraform init")

# NOTE: Modules need to be installed before terraform can update the
# lockfile
# NOTE: Modules need to be installed before terraform can update the lockfile
@retrying_lock = true
run_terraform_init
retry
Expand Down Expand Up @@ -178,6 +262,14 @@ def check_required_files
raise "No Terraform configuration file!"
end

def hashes_object_regex
/hashes\s*=\s*.*\]/m
end

def hashes_string_regex
/(?<=\").*(?=\")/
end

def provider_declaration_regex
name = Regexp.escape(dependency.name)
%r{
Expand Down
97 changes: 97 additions & 0 deletions terraform/spec/dependabot/terraform/file_updater_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -868,6 +868,102 @@ module "consul" {
end
end

context "using versions.tf with a lockfile with multiple platforms present" do
let(:project_name) { "lockfile_multiple_platforms" }
let(:dependencies) do
[
Dependabot::Dependency.new(
name: "hashicorp/aws",
version: "3.42.0",
previous_version: "3.37.0",
requirements: [{
requirement: "3.42.0",
groups: [],
file: "versions.tf",
source: {
type: "provider",
registry_hostname: "registry.terraform.io",
module_identifier: "hashicorp/aws"
}
}],
previous_requirements: [{
requirement: "3.37.0",
groups: [],
file: "versions.tf",
source: {
type: "provider",
registry_hostname: "registry.terraform.io",
module_identifier: "hashicorp/aws"
}
}],
package_manager: "terraform"
)
]
end

it "does not update requirements in the `versions.tf` file" do
updated_file = files.find { |file| file.name == "versions.tf" }

expect(updated_file.content).to include(
<<~DEP
terraform {
required_providers {
random = {
source = "hashicorp/random"
version = "3.0.0"
}

aws = {
source = "hashicorp/aws"
version = ">= 3.37.0, < 3.46.0"
}
}
}
DEP
)
end

it "updates the aws requirement in the lockfile" do
actual_lockfile = subject.find { |file| file.name == ".terraform.lock.hcl" }

expect(actual_lockfile.content).to include(
<<~DEP
provider "registry.terraform.io/hashicorp/aws" {
version = "3.45.0"
constraints = ">= 3.42.0, < 3.46.0"
DEP
)
end

it "does not update the http requirement in the lockfile" do
actual_lockfile = subject.find { |file| file.name == ".terraform.lock.hcl" }

expect(actual_lockfile.content).to include(
<<~DEP
provider "registry.terraform.io/hashicorp/random" {
version = "3.0.0"
constraints = "3.0.0"
hashes = [
"h1:+JUEdzBH7Od9JKdMMAIJlX9v6P8jfbMR7V4/FKXLAgY=",
"h1:grDzxfnOdFXi90FRIIwP/ZrCzirJ/SfsGBe6cE0Shg4=",
"h1:yhHJpb4IfQQfuio7qjUXuUFTU/s+ensuEpm23A+VWz0=",
"zh:0fcb00ff8b87dcac1b0ee10831e47e0203a6c46aafd76cb140ba2bab81f02c6b",
"zh:123c984c0e04bad910c421028d18aa2ca4af25a153264aef747521f4e7c36a17",
"zh:287443bc6fd7fa9a4341dec235589293cbcc6e467a042ae225fd5d161e4e68dc",
"zh:2c1be5596dd3cca4859466885eaedf0345c8e7628503872610629e275d71b0d2",
"zh:684a2ef6f415287944a3d966c4c8cee82c20e393e096e2f7cdcb4b2528407f6b",
"zh:7625ccbc6ff17c2d5360ff2af7f9261c3f213765642dcd84e84ae02a3768fd51",
"zh:9a60811ab9e6a5bfa6352fbb943bb530acb6198282a49373283a8fa3aa2b43fc",
"zh:c73e0eaeea6c65b1cf5098b101d51a2789b054201ce7986a6d206a9e2dacaefd",
"zh:e8f9ed41ac83dbe407de9f0206ef1148204a0d51ba240318af801ffb3ee5f578",
"zh:fbdd0684e62563d3ac33425b0ac9439d543a3942465f4b26582bcfabcb149515",
]
}
DEP
)
end
end

context "when using a lockfile that requires access to an unreachable module" do
let(:project_name) { "lockfile_unreachable_module" }
let(:dependencies) do
Expand Down Expand Up @@ -1410,6 +1506,7 @@ module "caf" {
provider_files.each do |file|
expect(file.content).to include("version = \"0.0.10\"")
end

expect(lockfile.content).to include(
<<~DEP
provider "registry.terraform.io/mongey/confluentcloud" {
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
provider "aws" {
region = "us-west-2"
}

resource "random_pet" "petname" {
length = 5
separator = "-"
}

resource "aws_s3_bucket" "sample" {
bucket = random_pet.petname.id
acl = "public-read"

region = "us-west-2"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
terraform {
required_providers {
random = {
source = "hashicorp/random"
version = "3.0.0"
}

aws = {
source = "hashicorp/aws"
version = ">= 3.37.0, < 3.46.0"
}
}
}