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

Add devcontainers ecosystem #8445

Merged
merged 10 commits into from
Jan 22, 2024
72 changes: 71 additions & 1 deletion devcontainers/lib/dependabot/devcontainers/file_fetcher.rb
Original file line number Diff line number Diff line change
@@ -1,12 +1,82 @@
# typed: strong
# typed: true
# frozen_string_literal: true

require "dependabot/file_fetchers"
require "dependabot/file_fetchers/base"
require "dependabot/devcontainers/utils"

module Dependabot
module Devcontainers
class FileFetcher < Dependabot::FileFetchers::Base
def self.required_files_in?(filenames)
# There's several other places a devcontainer.json can be checked into
# See: https://containers.dev/implementors/spec/#devcontainerjson
filenames.any? { |f| f.end_with?("devcontainer.json") }
end

def self.required_files_message
"Repo must contain a dev container configuration file."
end

def fetch_files
fetched_files = []
fetched_files += root_files
fetched_files += scoped_files
fetched_files += custom_directory_files
return fetched_files if fetched_files.any?

raise Dependabot::DependencyFileNotFound.new(
nil,
"Neither .devcontainer.json nor .devcontainer/devcontainer.json nor " \
".devcontainer/<anything>/devcontainer.json found in #{directory}"
)
end

private

def root_files
fetch_config_and_lockfile_from(".")
end

def scoped_files
return [] unless devcontainer_directory

fetch_config_and_lockfile_from(".devcontainer")
end

def custom_directory_files
return [] unless devcontainer_directory

custom_directories.flat_map do |directory|
fetch_config_and_lockfile_from(directory.path)
end
end

def custom_directories
repo_contents(dir: ".devcontainer").select { |f| f.type == "dir" && f.name != ".devcontainer" }
end

def devcontainer_directory
return @devcontainer_directory if defined?(@devcontainer_directory)

@devcontainer_directory = repo_contents.find { |f| f.type == "dir" && f.name == ".devcontainer" }
end

def fetch_config_and_lockfile_from(directory)
files = []

config_name = Utils.expected_config_basename(directory)
config_file = fetch_file_if_present(File.join(directory, config_name))
return files unless config_file

files << config_file

lockfile_name = Utils.expected_lockfile_name(File.basename(config_file.name))
lockfile = fetch_support_file(File.join(directory, lockfile_name))
files << lockfile if lockfile

files
end
end
end
end
Expand Down
34 changes: 32 additions & 2 deletions devcontainers/lib/dependabot/devcontainers/file_parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,47 @@

require "dependabot/file_parsers"
require "dependabot/file_parsers/base"
require "dependabot/devcontainers/version"
require "dependabot/devcontainers/file_parser/feature_dependency_parser"

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

def parse
[]
dependency_set = DependencySet.new

config_dependency_files.each do |config_dependency_file|
parse_features(config_dependency_file).each do |dep|
dependency_set << dep
end
end

dependency_set.dependencies
end

private

def check_required_files; end
def check_required_files
return if config_dependency_files.any?

raise "No dev container configuration!"
end

def parse_features(config_dependency_file)
FeatureDependencyParser.new(
config_dependency_file: config_dependency_file,
repo_contents_path: repo_contents_path,
credentials: credentials
).parse
end

def config_dependency_files
@config_dependency_files ||= dependency_files.select do |f|
f.name.end_with?("devcontainer.json")
end
end
end
end
end
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# typed: true
# frozen_string_literal: true

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

module Dependabot
module Devcontainers
class FileParser < Dependabot::FileParsers::Base
class FeatureDependencyParser
def initialize(config_dependency_file:, repo_contents_path:, credentials:)
@config_dependency_file = config_dependency_file
@repo_contents_path = repo_contents_path
@credentials = credentials
end

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

def base_dir
File.dirname(config_dependency_file.path)
end

def config_name
File.basename(config_dependency_file.path)
end

def config_contents
config_dependency_file.content
end

# https://github.com/devcontainers/cli/blob/9444540283b236298c28f397dea879e7ec222ca1/src/spec-node/devContainersSpecCLI.ts#L1072
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"
Dependabot.logger.info("Running command: #{cmd}")

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

JSON.parse(json)
end

def parse_cli_json(json)
dependencies = []

features = json["features"]
features.each do |feature, versions_object|
name, requirement = feature.split(":")

# Skip sha pinned tags for now. Ideally the devcontainers CLI would give us updated SHA info
next if name.end_with?("@sha256")

# Skip deprecated features until `devcontainer features info tag`
# and `devcontainer upgrade` work with them. See https://github.com/devcontainers/cli/issues/712
next unless name.include?("/")

current = versions_object["current"]

dep = Dependency.new(
name: name,
version: current,
package_manager: "devcontainers",
requirements: [
{
requirement: requirement,
file: config_dependency_file.name,
groups: ["feature"],
source: nil
}
]
)

dependencies << dep
end
dependencies
end

attr_reader :config_dependency_file, :repo_contents_path, :credentials
end
end
end
end
73 changes: 72 additions & 1 deletion devcontainers/lib/dependabot/devcontainers/file_updater.rb
Original file line number Diff line number Diff line change
@@ -1,12 +1,83 @@
# typed: strong
# typed: true
# frozen_string_literal: true

require "dependabot/file_updaters"
require "dependabot/file_updaters/base"
require "dependabot/devcontainers/file_updater/config_updater"

module Dependabot
module Devcontainers
class FileUpdater < Dependabot::FileUpdaters::Base
def self.updated_files_regex
[
/^\.?devcontainer\.json$/,
/^\.?devcontainer-lock\.json$/
]
end

def updated_dependency_files
updated_files = []

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

config_contents, lockfile_contents = update(manifest, requirement)

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

lockfile = lockfile_for(manifest)

updated_files << updated_file(file: lockfile, content: lockfile_contents) if lockfile && lockfile_contents
end

updated_files
end

private

def dependency
# TODO: Handle one dependency at a time
dependencies.first
end

def check_required_files
return if dependency_files.any?

raise "No dev container configuration!"
end

def manifests
@manifests ||= dependency_files.select do |f|
f.name.end_with?("devcontainer.json")
end
end

def lockfile_for(manifest)
lockfile_name = lockfile_name_for(manifest)

dependency_files.find do |f|
f.name == lockfile_name
end
end

def lockfile_name_for(manifest)
basename = File.basename(manifest.name)
lockfile_name = Utils.expected_lockfile_name(basename)

manifest.name.delete_suffix(basename).concat(lockfile_name)
end

def update(manifest, requirement)
ConfigUpdater.new(
feature: dependency.name,
requirement: requirement[:requirement],
version: dependency.version,
manifest: manifest,
repo_contents_path: repo_contents_path,
credentials: credentials
).update
end
end
end
end
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# typed: true
# frozen_string_literal: true

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

module Dependabot
module Devcontainers
class FileUpdater < Dependabot::FileUpdaters::Base
class ConfigUpdater
def initialize(feature:, requirement:, version:, manifest:, repo_contents_path:, credentials:)
@feature = feature
@requirement = requirement
@version = version
@manifest = manifest
@repo_contents_path = repo_contents_path
@credentials = credentials
end

def update
SharedHelpers.in_a_temporary_repo_directory(base_dir, repo_contents_path) do
SharedHelpers.with_git_configured(credentials: credentials) do
update_manifests(
target_requirement: requirement,
target_version: version
)

[File.read(manifest_name), File.read(lockfile_name)].compact
end
end
end

private

def base_dir
File.dirname(manifest.path)
end

def manifest_name
File.basename(manifest.path)
end

def lockfile_name
Utils.expected_lockfile_name(manifest_name)
end

def update_manifests(target_requirement:, target_version:)
# First force target version to upgrade lockfile.
run_devcontainer_upgrade(target_version)

# Now replace specific version back with target requirement
force_target_requirement(manifest_name, from: target_version, to: target_requirement)
force_target_requirement(lockfile_name, from: target_version, to: target_requirement)
end

def force_target_requirement(file_name, from:, to:)
File.write(file_name, File.read(file_name).gsub("#{feature}:#{from}", "#{feature}:#{to}"))
end

def run_devcontainer_upgrade(target_version)
cmd = "devcontainer upgrade " \
"--workspace-folder . " \
"--feature #{feature} " \
"--config #{manifest_name} " \
"--target-version #{target_version}"

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

SharedHelpers.run_shell_command(cmd, stderr_to_stdout: false)
end
joshspicer marked this conversation as resolved.
Show resolved Hide resolved

attr_reader :feature, :requirement, :version, :manifest, :repo_contents_path, :credentials
end
end
end
end
Loading