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鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

Devcontainers integration for stale images #9271

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
13 changes: 13 additions & 0 deletions devcontainers/lib/dependabot/devcontainers/file_parser.rb
Expand Up @@ -7,6 +7,7 @@
require "dependabot/file_parsers/base"
require "dependabot/devcontainers/version"
require "dependabot/devcontainers/file_parser/feature_dependency_parser"
require "dependabot/devcontainers/file_parser/image_dependency_parser"

module Dependabot
module Devcontainers
Expand All @@ -23,6 +24,9 @@ def parse
parse_features(config_dependency_file).each do |dep|
dependency_set << dep
end
parse_images(config_dependency_file).each do |dep|
dependency_set << dep
end
end

dependency_set.dependencies
Expand All @@ -46,6 +50,15 @@ def parse_features(config_dependency_file)
).parse
end

sig { params(config_dependency_file: Dependabot::DependencyFile).returns(T::Array[Dependabot::Dependency]) }
def parse_images(config_dependency_file)
ImageDependencyParser.new(
config_dependency_file: config_dependency_file,
repo_contents_path: repo_contents_path,
credentials: credentials
).parse
end

sig { returns(T::Array[Dependabot::DependencyFile]) }
def config_dependency_files
@config_dependency_files ||= T.let(
Expand Down
Expand Up @@ -60,7 +60,7 @@ def config_contents
def evaluate_with_cli
raise "config_name must be a string" unless config_name.is_a?(String) && !config_name.empty?

cmd = "devcontainer outdated --workspace-folder . --config #{config_name} --output-format json"
cmd = "devcontainer outdated --workspace-folder . --only-features --config #{config_name} --output-format json"
Dependabot.logger.info("Running command: #{cmd}")

json = SharedHelpers.run_shell_command(
Expand Down
@@ -0,0 +1,104 @@
# typed: strict
# frozen_string_literal: true

require "dependabot/devcontainers/requirement"
require "dependabot/file_parsers/base"
require "dependabot/shared_helpers"
require "dependabot/dependency"
require "json"
require "sorbet-runtime"
require "uri"

module Dependabot
module Devcontainers
class FileParser < Dependabot::FileParsers::Base
class ImageDependencyParser
extend T::Sig

sig do
params(
config_dependency_file: Dependabot::DependencyFile,
repo_contents_path: T.nilable(String),
credentials: T::Array[Dependabot::Credential]
)
.void
end
def initialize(config_dependency_file:, repo_contents_path:, credentials:)
@config_dependency_file = config_dependency_file
@repo_contents_path = repo_contents_path
@credentials = credentials
end

sig { returns(T::Array[Dependabot::Dependency]) }
def parse
SharedHelpers.in_a_temporary_repo_directory(base_dir, repo_contents_path) do
SharedHelpers.with_git_configured(credentials: credentials) do
parse_cli_json(evaluate_with_cli)
end
end
end

private

sig { returns(String) }
def base_dir
File.dirname(config_dependency_file.path)
end

sig { returns(String) }
def config_name
File.basename(config_dependency_file.path)
end

sig { returns(T::Hash[String, T.untyped]) }
def evaluate_with_cli
raise "config_name must be a string" unless config_name.is_a?(String) && !config_name.empty?

cmd = "devcontainer outdated --workspace-folder . --only-images --config #{config_name} --output-format json"
Dependabot.logger.info("Running command: #{cmd}")

json = SharedHelpers.run_shell_command(
cmd,
stderr_to_stdout: false
)

JSON.parse(json)
end

sig { params(json: T::Hash[String, T.untyped]).returns(T::Array[Dependabot::Dependency]) }
def parse_cli_json(json)
dependencies = []

images = json["images"]
images.each do |image, image_object|
dep = Dependency.new(
name: image,
version: image_object["current"],
package_manager: "devcontainers",
requirements: [
{
requirement: image_object["latest"],
file: image_object["path"],
groups: ["image"],
source: nil
}
]
)

dependencies << dep
end
dependencies
end

sig { returns(Dependabot::DependencyFile) }
attr_reader :config_dependency_file

sig { returns(T.nilable(String)) }
attr_reader :repo_contents_path

sig { returns(T::Array[Dependabot::Credential]) }
attr_reader :credentials
end
end
end
end
50 changes: 43 additions & 7 deletions devcontainers/lib/dependabot/devcontainers/file_updater.rb
Expand Up @@ -6,29 +6,33 @@
require "dependabot/file_updaters"
require "dependabot/file_updaters/base"
require "dependabot/devcontainers/file_updater/config_updater"
require "dependabot/devcontainers/file_updater/image_config_updater"

module Dependabot
module Devcontainers
class FileUpdater < Dependabot::FileUpdaters::Base
extend T::Sig

DOCKER_REGEXP = /dockerfile/i
sig { override.returns(T::Array[Regexp]) }
def self.updated_files_regex
[
/^\.?devcontainer\.json$/,
/^\.?devcontainer-lock\.json$/
/^\.?devcontainer-lock\.json$/,
DOCKER_REGEXP,
/^\.?docker-compose\.yml$/

]
end

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

manifests.each do |manifest|
feature_manifests.each do |manifest|
requirement = dependency.requirements.find { |req| req[:file] == manifest.name }
next unless requirement

config_contents, lockfile_contents = update(manifest, requirement)
config_contents, lockfile_contents = update_features(manifest, requirement)

updated_files << updated_file(file: manifest, content: T.must(config_contents)) if file_changed?(manifest)

Expand All @@ -37,6 +41,17 @@ def updated_dependency_files
updated_files << updated_file(file: lockfile, content: lockfile_contents) if lockfile && lockfile_contents
end

image_manifests.each do |image|
file_name = image.requirements.first[:file]
manifest = Dependabot::DependencyFile.new(
content: File.read(file_name),
name: file_name
)

config_contents = update_images(manifest)
updated_files << updated_file(file: manifest, content: T.must(config_contents)) if file_changed?(manifest)
end

updated_files
end

Expand All @@ -56,15 +71,25 @@ def check_required_files
end

sig { returns(T::Array[Dependabot::DependencyFile]) }
def manifests
@manifests ||= T.let(
def feature_manifests
@feature_manifests ||= T.let(
dependency_files.select do |f|
f.name.end_with?("devcontainer.json")
end,
T.nilable(T::Array[Dependabot::DependencyFile])
)
end

sig { returns(T::Array[Dependabot::DependencyFile]) }
def image_manifests
@image_manifests ||= T.let(
dependencies.select do |i|
i.requirements.any? { |req| req[:groups].include?("image") }
end,
T.nilable(T::Array[Dependabot::DependencyFile])
)
end

sig { params(manifest: Dependabot::DependencyFile).returns(T.nilable(Dependabot::DependencyFile)) }
def lockfile_for(manifest)
lockfile_name = lockfile_name_for(manifest)
Expand All @@ -89,7 +114,7 @@ def lockfile_name_for(manifest)
)
.returns(T::Array[String])
end
def update(manifest, requirement)
def update_features(manifest, requirement)
ConfigUpdater.new(
feature: dependency.name,
requirement: requirement[:requirement],
Expand All @@ -99,6 +124,17 @@ def update(manifest, requirement)
credentials: credentials
).update
end

def update_images(manifest)
ImageConfigUpdater.new(
image: dependency.name,
version: dependency.previous_version,
requirement: dependency.version,
manifest: manifest,
repo_contents_path: T.must(repo_contents_path),
credentials: credentials
).update
end
end
end
end
Expand Down
@@ -0,0 +1,70 @@
# typed: strong
# frozen_string_literal: true

require "sorbet-runtime"

require "dependabot/file_updaters/base"
require "dependabot/shared_helpers"
require "dependabot/logger"
require "dependabot/devcontainers/utils"
require "dependabot/devcontainers/version"

module Dependabot
module Devcontainers
class FileUpdater < Dependabot::FileUpdaters::Base
class ImageConfigUpdater
extend T::Sig

sig do
params(
image: String,
version: String,
requirement: String,
manifest: Dependabot::DependencyFile,
repo_contents_path: String,
credentials: T::Array[Dependabot::Credential]
)
.void
end
def initialize(image:, version:, requirement:, manifest:, repo_contents_path:, credentials:)
@image = image
@version = version
@requirement = requirement
@manifest = manifest
@repo_contents_path = repo_contents_path
@credentials = credentials
end

sig { returns(T::Array[String]) }
def update
SharedHelpers.with_git_configured(credentials: credentials) do
force_image_target_requirement(manifest.path)
[File.read(manifest.path)].compact
end
end

private

sig { params(file_name: String).void }
def force_image_target_requirement(file_name)
File.write(file_name, File.read(file_name).gsub("#{@image}", "#{@image.gsub(@version, @requirement)}")
end

sig { returns(String) }
attr_reader :image

sig { returns(String) }
attr_reader :requirement

sig { returns(Dependabot::DependencyFile) }
attr_reader :manifest

sig { returns(String) }
attr_reader :repo_contents_path

sig { returns(T::Array[Dependabot::Credential]) }
attr_reader :credentials
end
end
end
end
Expand Up @@ -63,6 +63,8 @@ def fetch_viable_candidates

sig { returns(Dependabot::Devcontainers::Version) }
def fetch_latest_version
image = dependency.requirements.find { |req| req[:groups].include?("image") }
return Dependabot::Devcontainers::Version.new(image[:requirement]) if image
return T.cast(current_version, Dependabot::Devcontainers::Version) unless viable_candidates.any?

T.must(viable_candidates.last)
Expand Down Expand Up @@ -104,7 +106,7 @@ def filter_ignored(versions)

sig { returns(T::Array[Dependabot::Devcontainers::Version]) }
def comparable_versions_from_registry
tags_from_registry.filter_map do |tag|
Array(tags_from_registry).filter_map do |tag|
version_class.correct?(tag) && T.cast(version_class.new(tag), Dependabot::Devcontainers::Version)
end
end
Expand All @@ -116,6 +118,9 @@ def tags_from_registry

sig { returns(T::Array[String]) }
def fetch_tags_from_registry
feature = dependency.requirements.find { |req| req[:groups].include?("feature") }
return unless feature

cmd = "devcontainer features info tags #{dependency.name} --output-format json"

Dependabot.logger.info("Running command: `#{cmd}`")
Expand Down