Skip to content

Commit

Permalink
Add support for docker-compose.yml files
Browse files Browse the repository at this point in the history
  • Loading branch information
Pedro Pombeiro authored and robaiken committed Feb 5, 2025
1 parent c63362f commit 72940c0
Show file tree
Hide file tree
Showing 35 changed files with 2,540 additions and 3 deletions.
90 changes: 90 additions & 0 deletions docker/lib/dependabot/common/file_parser_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# frozen_string_literal: true

require "docker_registry2"

require "dependabot/dependency"
require "dependabot/errors"
require "dependabot/docker/utils/credentials_finder"

module Dependabot
module Docker
module FileParserHelper
private

def version_from(parsed_info)
return parsed_info.fetch("tag") if parsed_info.fetch("tag")

version_from_digest(
registry: parsed_info.fetch("registry"),
image: parsed_info.fetch("image"),
digest: parsed_info.fetch("digest")
)
end

def source_from(parsed_info)
source = {}

%w(registry tag digest).each do |part|
value = parsed_info.fetch(part)
source[part.to_sym] = value if value
end

source
end

def version_from_digest(registry:, image:, digest:)
return unless digest

repo = docker_repo_name(image, registry)
client = docker_registry_client(registry)
client.tags(repo, auto_paginate: true).fetch("tags").find do |tag|
digest == client.digest(repo, tag)
rescue DockerRegistry2::NotFound
# Shouldn't happen, but it does. Example of existing tag with
# no manifest is "library/python", "2-windowsservercore".
false
end
rescue DockerRegistry2::RegistryAuthenticationException,
RestClient::Forbidden
raise if standard_registry?(registry)

raise PrivateSourceAuthenticationFailure, registry
end

def docker_repo_name(image, registry)
return image unless standard_registry?(registry)
return image unless image.split("/").count < 2

"library/#{image}"
end

def docker_registry_client(registry)
if registry
credentials = registry_credentials(registry)

DockerRegistry2::Registry.new(
"https://#{registry}",
user: credentials&.fetch("username", nil),
password: credentials&.fetch("password", nil)
)
else
DockerRegistry2::Registry.new("https://registry.hub.docker.com")
end
end

def registry_credentials(registry_url)
credentials_finder.credentials_for_registry(registry_url)
end

def credentials_finder
@credentials_finder ||= Utils::CredentialsFinder.new(credentials)
end

def standard_registry?(registry)
return true if registry.nil?

registry == "registry.hub.docker.com"
end
end
end
end
92 changes: 92 additions & 0 deletions docker/lib/dependabot/common/file_updater_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# frozen_string_literal: true

require "dependabot/file_updaters"
require "dependabot/file_updaters/base"
require "dependabot/errors"

module Dependabot
module Docker
module FileUpdaterHelper
private

def update_digest_and_tag(file)
old_declaration_regex = digest_and_tag_regex(old_digest(file))

file.content.gsub(old_declaration_regex) do |old_dec|
old_dec.
gsub("@#{old_digest(file)}", "@#{new_digest(file)}").
gsub(":#{dependency.previous_version}",
":#{dependency.version}")
end
end

def update_tag(file)
return unless old_tag(file)

old_declaration =
if private_registry_url(file) then "#{private_registry_url(file)}/"
else ""
end
old_declaration += "#{dependency.name}:#{old_tag(file)}"

old_declaration_regex = tag_regex(old_declaration)

file.content.gsub(old_declaration_regex) do |old_dec|
old_dec.gsub(":#{old_tag(file)}", ":#{new_tag(file)}")
end
end

def fetch_file_source(file, reqs)
reqs.
find { |req| req[:file] == file.name }.
fetch(:source)
end

def fetch_property_in_file_source(file, reqs, property)
fetch_file_source(file, reqs).fetch(property)
end

def specified_with_digest?(file)
fetch_file_source(file, dependency.requirements)[:digest]
end

def new_digest(file)
return unless specified_with_digest?(file)

fetch_property_in_file_source(file, dependency.requirements, :digest)
end

def old_digest(file)
return unless specified_with_digest?(file)

fetch_property_in_file_source(
file,
dependency.previous_requirements,
:digest
)
end

def digest(file, reqs)
return unless specified_with_digest?(file)

fetch_property_in_file_source(file, reqs, :digest)
end

def new_tag(file)
fetch_property_in_file_source(file, dependency.requirements, :tag)
end

def old_tag(file)
fetch_property_in_file_source(
file,
dependency.previous_requirements,
:tag
)
end

def private_registry_url(file)
fetch_file_source(file, dependency.requirements)[:registry]
end
end
end
end
2 changes: 2 additions & 0 deletions docker/lib/dependabot/docker.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
require "dependabot/docker/requirement"
require "dependabot/docker/version"

require_relative "docker_compose"

require "dependabot/pull_request_creator/labeler"
Dependabot::PullRequestCreator::Labeler
.register_label_details("docker", name: "docker", colour: "21ceff")
Expand Down
21 changes: 21 additions & 0 deletions docker/lib/dependabot/docker_compose.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# frozen_string_literal: true

# These all need to be required so the various classes can be registered in a
# lookup table of package manager names to concrete classes.
require "dependabot/docker_compose/file_fetcher"
require "dependabot/docker_compose/file_parser"
require "dependabot/docker_compose/update_checker"
require "dependabot/docker_compose/file_updater"
require "dependabot/docker_compose/metadata_finder"
require "dependabot/docker_compose/requirement"
require "dependabot/docker_compose/version"

require "dependabot/pull_request_creator/labeler"
Dependabot::PullRequestCreator::Labeler.
register_label_details("docker_compose", name: "docker", colour: "21ceff")

require "dependabot/dependency"
Dependabot::Dependency.register_production_check(
"docker_compose",
->(_) { true }
)
61 changes: 61 additions & 0 deletions docker/lib/dependabot/docker_compose/file_fetcher.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# frozen_string_literal: true

require "dependabot/file_fetchers"
require "dependabot/file_fetchers/base"

module Dependabot
module DockerCompose
class FileFetcher < Dependabot::FileFetchers::Base
FILENAME_REGEX = /docker-compose(?>\.override)?\.yml/i.freeze

def self.required_files_in?(filenames)
filenames.any? { |f| f.match?(FILENAME_REGEX) }
end

def self.required_files_message
"Repo must contain a docker-compose.yaml file."
end

private

def fetch_files
fetched_files = []
fetched_files += correctly_encoded_docker_compose_files

return fetched_files if fetched_files.any?

if incorrectly_encoded_docker_compose_files.none?
raise(
Dependabot::DependencyFileNotFound,
File.join(directory, "docker-compose.yml")
)
else
raise(
Dependabot::DependencyFileNotParseable,
incorrectly_encoded_docker_compose_files.first.path
)
end
end

def docker_compose_files
@docker_compose_files ||=
repo_contents(raise_errors: false).
select { |f| f.type == "file" && f.name.match?(FILENAME_REGEX) }.
map { |f| fetch_file_from_host(f.name) }
end

def correctly_encoded_docker_compose_files
docker_compose_files.select { |f| f.content.valid_encoding? }
end

def incorrectly_encoded_docker_compose_files
docker_compose_files.reject { |f| f.content.valid_encoding? }
end
end
end
end

Dependabot::FileFetchers.register(
"docker_compose",
Dependabot::DockerCompose::FileFetcher
)
85 changes: 85 additions & 0 deletions docker/lib/dependabot/docker_compose/file_parser.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# frozen_string_literal: true

require "yaml"

require "dependabot/file_parsers"
require "dependabot/file_parsers/base"
require "dependabot/common/file_parser_helper"

module Dependabot
module DockerCompose
class FileParser < Dependabot::FileParsers::Base
require "dependabot/file_parsers/base/dependency_set"

include Dependabot::Docker::FileParserHelper

# Details of Docker regular expressions is at
# https://github.com/docker/distribution/blob/master/reference/regexp.go
DOMAIN_COMPONENT =
/(?:[[:alnum:]]|[[:alnum:]][[[:alnum:]]-]*[[:alnum:]])/.freeze
DOMAIN = /(?:#{DOMAIN_COMPONENT}(?:\.#{DOMAIN_COMPONENT})+)/.freeze
REGISTRY = /(?<registry>#{DOMAIN}(?::\d+)?)/.freeze

NAME_COMPONENT = /(?:[a-z\d]+(?:(?:[._]|__|[-]*)[a-z\d]+)*)/.freeze
IMAGE = %r{(?<image>#{NAME_COMPONENT}(?:/#{NAME_COMPONENT})*)}.freeze

TAG = /:(?<tag>[\w][\w.-]{0,127})/.freeze
DIGEST = /@(?<digest>[^\s]+)/.freeze
NAME = /\s+AS\s+(?<name>[\w-]+)/.freeze
FROM_IMAGE =
%r{^(#{REGISTRY}/)?#{IMAGE}#{TAG}?#{DIGEST}?#{NAME}?}.freeze

def parse
dependency_set = DependencySet.new

composefiles.each do |composefile|
yaml = YAML.safe_load(composefile.content)
yaml["services"].each do |_, service|
parsed_from_image =
FROM_IMAGE.match(service["image"]).named_captures
if parsed_from_image["registry"] == "docker.io"
parsed_from_image["registry"] = nil
end

version = version_from(parsed_from_image)
next unless version

dependency_set << Dependency.new(
name: parsed_from_image["image"],
version: version,
package_manager: "docker_compose",
requirements: [
requirement: nil,
groups: [],
file: composefile.name,
source: source_from(parsed_from_image)
]
)
end
end

dependency_set.dependencies
end

private

def composefiles
# The DockerCompose file fetcher only fetches docker-compose.yml files,
# so no need to filter here
dependency_files
end

def check_required_files
# Just check if there are any files at all.
return if dependency_files.any?

raise "No docker-compose.yml file!"
end
end
end
end

Dependabot::FileParsers.register(
"docker_compose",
Dependabot::DockerCompose::FileParser
)
Loading

0 comments on commit 72940c0

Please sign in to comment.