Skip to content

Commit

Permalink
Merge pull request #8445 from joshspicer/joshspicer/devcontainers
Browse files Browse the repository at this point in the history
Add `devcontainers` ecosystem
  • Loading branch information
deivid-rodriguez committed Jan 22, 2024
2 parents fbd2204 + ae771dc commit 529a800
Show file tree
Hide file tree
Showing 28 changed files with 1,264 additions and 10 deletions.
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

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

0 comments on commit 529a800

Please sign in to comment.