diff --git a/devcontainers/lib/dependabot/devcontainers/file_fetcher.rb b/devcontainers/lib/dependabot/devcontainers/file_fetcher.rb index 7e928863d0c..8b419744a81 100644 --- a/devcontainers/lib/dependabot/devcontainers/file_fetcher.rb +++ b/devcontainers/lib/dependabot/devcontainers/file_fetcher.rb @@ -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//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 diff --git a/devcontainers/lib/dependabot/devcontainers/file_parser.rb b/devcontainers/lib/dependabot/devcontainers/file_parser.rb index c532317761a..9d2fcc72049 100644 --- a/devcontainers/lib/dependabot/devcontainers/file_parser.rb +++ b/devcontainers/lib/dependabot/devcontainers/file_parser.rb @@ -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 diff --git a/devcontainers/lib/dependabot/devcontainers/file_parser/feature_dependency_parser.rb b/devcontainers/lib/dependabot/devcontainers/file_parser/feature_dependency_parser.rb new file mode 100644 index 00000000000..46dc04074d0 --- /dev/null +++ b/devcontainers/lib/dependabot/devcontainers/file_parser/feature_dependency_parser.rb @@ -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 diff --git a/devcontainers/lib/dependabot/devcontainers/file_updater.rb b/devcontainers/lib/dependabot/devcontainers/file_updater.rb index 28a5a22d199..30fb765cdf9 100644 --- a/devcontainers/lib/dependabot/devcontainers/file_updater.rb +++ b/devcontainers/lib/dependabot/devcontainers/file_updater.rb @@ -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 diff --git a/devcontainers/lib/dependabot/devcontainers/file_updater/config_updater.rb b/devcontainers/lib/dependabot/devcontainers/file_updater/config_updater.rb new file mode 100644 index 00000000000..f24f5e7d8b5 --- /dev/null +++ b/devcontainers/lib/dependabot/devcontainers/file_updater/config_updater.rb @@ -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 diff --git a/devcontainers/lib/dependabot/devcontainers/requirement.rb b/devcontainers/lib/dependabot/devcontainers/requirement.rb index 1f401a27532..9983d788cdc 100644 --- a/devcontainers/lib/dependabot/devcontainers/requirement.rb +++ b/devcontainers/lib/dependabot/devcontainers/requirement.rb @@ -19,10 +19,6 @@ def self.requirements_array(requirement_string) [new(requirement_string)] end - def satisfied_by?(version) - super(version.release_part) - end - # Patches Gem::Requirement to make it accept requirement strings like # "~> 4.2.5, >= 4.2.5.1" without first needing to split them. def initialize(*requirements) diff --git a/devcontainers/lib/dependabot/devcontainers/update_checker.rb b/devcontainers/lib/dependabot/devcontainers/update_checker.rb index ef0a88c13a9..08a1da621ef 100644 --- a/devcontainers/lib/dependabot/devcontainers/update_checker.rb +++ b/devcontainers/lib/dependabot/devcontainers/update_checker.rb @@ -1,12 +1,113 @@ -# typed: strong +# typed: true # frozen_string_literal: true require "dependabot/update_checkers" require "dependabot/update_checkers/base" +require "dependabot/devcontainers/version" +require "dependabot/update_checkers/version_filters" +require "dependabot/devcontainers/requirement" module Dependabot module Devcontainers class UpdateChecker < Dependabot::UpdateCheckers::Base + def latest_version + @latest_version ||= fetch_latest_version + end + + def latest_resolvable_version + latest_version # TODO + end + + def updated_requirements + dependency.requirements.map do |requirement| + required_version = version_class.new(requirement[:requirement]) + updated_requirement = remove_precision_changes(viable_candidates, required_version).last + + { + file: requirement[:file], + requirement: updated_requirement, + groups: requirement[:groups], + source: requirement[:source] + } + end + end + + def latest_resolvable_version_with_no_unlock + raise NotImplementedError + end + + private + + def viable_candidates + @viable_candidates ||= fetch_viable_candidates + end + + def fetch_viable_candidates + candidates = comparable_versions_from_registry + candidates = filter_ignored(candidates) + candidates.sort + end + + def fetch_latest_version + return current_version unless viable_candidates.any? + + viable_candidates.last + end + + def remove_precision_changes(versions, required_version) + versions.select do |version| + version.same_precision?(required_version) + end + end + + def filter_ignored(versions) + filtered = + versions.reject do |version| + ignore_requirements.any? { |r| version.satisfies?(r) } + end + + if @raise_on_ignored && + filter_lower_versions(filtered).empty? && + filter_lower_versions(versions).any? + raise AllVersionsIgnored + end + + filtered + end + + def comparable_versions_from_registry + tags_from_registry.filter_map do |tag| + version_class.correct?(tag) && version_class.new(tag) + end + end + + def tags_from_registry + @tags_from_registry ||= fetch_tags_from_registry + end + + def fetch_tags_from_registry + cmd = "devcontainer features info tags #{dependency.name} --output-format json" + + Dependabot.logger.info("Running command: `#{cmd}`") + + output = SharedHelpers.run_shell_command(cmd, stderr_to_stdout: false) + + JSON.parse(output).fetch("publishedTags") + end + + def filter_lower_versions(versions) + versions.select do |version| + version > current_version + end + end + + def latest_version_resolvable_with_full_unlock? + false # TODO + end + + def updated_dependencies_after_full_unlock + raise NotImplementedError + end end end end diff --git a/devcontainers/lib/dependabot/devcontainers/utils.rb b/devcontainers/lib/dependabot/devcontainers/utils.rb new file mode 100644 index 00000000000..d0b5e6a2138 --- /dev/null +++ b/devcontainers/lib/dependabot/devcontainers/utils.rb @@ -0,0 +1,24 @@ +# typed: true +# frozen_string_literal: true + +module Dependabot + module Devcontainers + module Utils + def self.expected_config_basename(directory) + root_directory?(directory) ? ".devcontainer.json" : "devcontainer.json" + end + + def self.root_directory?(directory) + Pathname.new(directory).cleanpath.to_path == Pathname.new(".").cleanpath.to_path + end + + def self.expected_lockfile_name(config_file_name) + if config_file_name.start_with?(".") + ".devcontainer-lock.json" + else + "devcontainer-lock.json" + end + end + end + end +end diff --git a/devcontainers/lib/dependabot/devcontainers/version.rb b/devcontainers/lib/dependabot/devcontainers/version.rb index 9d19a11c6e7..7077ef5bc46 100644 --- a/devcontainers/lib/dependabot/devcontainers/version.rb +++ b/devcontainers/lib/dependabot/devcontainers/version.rb @@ -1,4 +1,4 @@ -# typed: strong +# typed: true # frozen_string_literal: true require "dependabot/version" @@ -7,6 +7,27 @@ module Dependabot module Devcontainers class Version < Dependabot::Version + def same_precision?(other) + precision == other.precision + end + + def satisfies?(requirement) + requirement.satisfied_by?(self) + end + + def <=>(other) + if self == other + precision <=> other.precision + else + super + end + end + + protected + + def precision + segments.size + end end end end diff --git a/devcontainers/spec/dependabot/devcontainers/file_fetcher_spec.rb b/devcontainers/spec/dependabot/devcontainers/file_fetcher_spec.rb index f670cbee7b0..e0ffc278958 100644 --- a/devcontainers/spec/dependabot/devcontainers/file_fetcher_spec.rb +++ b/devcontainers/spec/dependabot/devcontainers/file_fetcher_spec.rb @@ -7,4 +7,71 @@ RSpec.describe Dependabot::Devcontainers::FileFetcher do it_behaves_like "a dependency file fetcher" + + let(:source) do + Dependabot::Source.new( + provider: "github", + repo: "mona/devcontainers-example", + directory: directory + ) + end + + let(:file_fetcher_instance) do + described_class.new(source: source, credentials: [], repo_contents_path: repo_contents_path) + end + + let(:repo_contents_path) { build_tmp_repo(project_name) } + + context "with a lone .devcontainer.json in repo root" do + let(:project_name) { "config_in_root" } + let(:directory) { "/" } + + it "fetches the correct files" do + expect(file_fetcher_instance.files.map(&:name)) + .to match_array(%w(.devcontainer.json)) + end + end + + context "with a .devcontainer folder" do + let(:project_name) { "config_in_dot_devcontainer_folder" } + let(:directory) { "/" } + + it "fetches the correct files" do + expect(file_fetcher_instance.files.map(&:name)) + .to match_array(%w(.devcontainer/devcontainer.json)) + end + end + + context "with repo that has multiple, valid dev container configs" do + let(:project_name) { "multiple_configs" } + let(:directory) { "/" } + it "fetches the correct files" do + expect(file_fetcher_instance.files.map(&:name)) + .to match_array(%w(.devcontainer.json .devcontainer/devcontainer.json)) + end + end + + context "with devcontainer.json files inside custom directories inside .devcontainer folder" do + let(:project_name) { "custom_configs" } + let(:directory) { "/" } + + it "fetches the correct files" do + expect(file_fetcher_instance.files.map(&:name)) + .to match_array(%w(.devcontainer/foo/devcontainer.json .devcontainer/bar/devcontainer.json)) + end + end + + context "with a directory that doesn't exist" do + let(:project_name) { "multiple_configs" } + let(:directory) { "/.devcontainer/nonexistent" } + + it "raises a helpful error" do + expect { file_fetcher_instance.files } + .to raise_error(Dependabot::DependencyFileNotFound) + .with_message( + "Neither .devcontainer.json nor .devcontainer/devcontainer.json nor " \ + ".devcontainer//devcontainer.json found in /.devcontainer/nonexistent" + ) + end + end end diff --git a/devcontainers/spec/dependabot/devcontainers/file_parser_spec.rb b/devcontainers/spec/dependabot/devcontainers/file_parser_spec.rb new file mode 100644 index 00000000000..c5ab9204318 --- /dev/null +++ b/devcontainers/spec/dependabot/devcontainers/file_parser_spec.rb @@ -0,0 +1,224 @@ +# typed: false +# frozen_string_literal: true + +require "spec_helper" +require "dependabot/dependency_file" +require "dependabot/source" +require "dependabot/devcontainers/file_parser" +require "dependabot/devcontainers/requirement" +require_common_spec "file_parsers/shared_examples_for_file_parsers" + +RSpec.describe Dependabot::Devcontainers::FileParser do + it_behaves_like "a dependency file parser" + + let(:parser) do + described_class.new(dependency_files: files, source: source, repo_contents_path: repo_contents_path) + end + + let(:source) do + Dependabot::Source.new( + provider: "github", + repo: "mona/Example", + directory: directory + ) + end + + let(:files) do + project_dependency_files(project_name, directory: directory) + end + + let(:repo_contents_path) { build_tmp_repo(project_name, path: "projects") } + + let(:dependencies) { parser.parse } + + shared_examples_for "parse" do + it "parses dependencies fine" do + expect(dependencies.size).to eq(expectations.size) + + expectations.each do |expected| + version = expected[:version] + name = expected[:name] + requirements = expected[:requirements] + metadata = expected[:metadata] + + dependency = dependencies.find { |dep| dep.name == name } + expect(dependency).to have_attributes( + name: name, + version: version, + requirements: requirements, + metadata: metadata + ) + end + end + end + + context "with a .devcontainer.json in repo root" do + let(:project_name) { "config_in_root" } + let(:directory) { "/" } + + let(:expectations) do + [ + { + name: "ghcr.io/codspace/versioning/foo", + version: "1.1.0", + requirements: [ + { + requirement: "1", + file: ".devcontainer.json", + groups: ["feature"], + source: nil + } + ], + metadata: {} + }, + { + name: "ghcr.io/codspace/versioning/bar", + version: "1.0.0", + requirements: [ + { + requirement: "1", + file: ".devcontainer.json", + groups: ["feature"], + source: nil + } + ], + metadata: {} + } + ].freeze + end + + it_behaves_like "parse" + end + + context "with a devcontainer.json in a .devcontainer folder" do + let(:project_name) { "config_in_dot_devcontainer_folder" } + let(:directory) { "/" } + + let(:expectations) do + [ + { + name: "ghcr.io/codspace/versioning/foo", + version: "1.1.0", + requirements: [ + { + requirement: "1", + file: ".devcontainer/devcontainer.json", + groups: ["feature"], + source: nil + } + ], + metadata: {} + }, + { + name: "ghcr.io/codspace/versioning/bar", + version: "1.0.0", + requirements: [ + { + requirement: "1", + file: ".devcontainer/devcontainer.json", + groups: ["feature"], + source: nil + } + ], + metadata: {} + }, + { + name: "ghcr.io/codspace/versioning/baz", + version: "1.0.0", + requirements: [ + { + requirement: "1.0", + file: ".devcontainer/devcontainer.json", + groups: ["feature"], + source: nil + } + ], + metadata: {} + } + ].freeze + end + + it_behaves_like "parse" + end + + context "with multiple, valid devcontainer.json config files in repo" do + let(:project_name) { "multiple_configs" } + let(:directory) { "/" } + + let(:expectations) do + [ + { + name: "ghcr.io/codspace/versioning/foo", + version: "1.1.0", + requirements: [ + { + requirement: "1", + file: ".devcontainer/devcontainer.json", + groups: ["feature"], + source: nil + }, + { + requirement: "1", + file: ".devcontainer.json", + groups: ["feature"], + source: nil + } + ], + metadata: {} + }, + { + name: "ghcr.io/codspace/versioning/bar", + version: "1.0.0", + requirements: [ + { + requirement: "1", + file: ".devcontainer/devcontainer.json", + groups: ["feature"], + source: nil + }, + { + requirement: "1", + file: ".devcontainer.json", + groups: ["feature"], + source: nil + } + ], + metadata: {} + }, + { + name: "ghcr.io/codspace/versioning/baz", + version: "1.0.0", + requirements: [ + { + requirement: "1.0", + file: ".devcontainer/devcontainer.json", + groups: ["feature"], + source: nil + } + ], + metadata: {} + } + ].freeze + end + + it_behaves_like "parse" + end + + context "with SHA-pinned features" do + let(:project_name) { "sha_pinned" } + let(:directory) { "/" } + + it "ignores them" do + expect(dependencies).to be_empty + end + end + + context "with deprecated features" do + let(:project_name) { "deprecated" } + let(:directory) { "/" } + + it "ignores them" do + expect(dependencies).to be_empty + end + end +end diff --git a/devcontainers/spec/dependabot/devcontainers/file_updater_spec.rb b/devcontainers/spec/dependabot/devcontainers/file_updater_spec.rb new file mode 100644 index 00000000000..30ae8d87d29 --- /dev/null +++ b/devcontainers/spec/dependabot/devcontainers/file_updater_spec.rb @@ -0,0 +1,226 @@ +# typed: false +# frozen_string_literal: true + +require "spec_helper" +require "dependabot/dependency" +require "dependabot/dependency_file" +require "dependabot/devcontainers/file_updater" +require "dependabot/devcontainers/requirement" +require_common_spec "file_updaters/shared_examples_for_file_updaters" + +RSpec.describe Dependabot::Devcontainers::FileUpdater do + it_behaves_like "a dependency file updater" + + subject(:updater) do + described_class.new( + dependency_files: files, + dependencies: dependencies, + credentials: credentials, + repo_contents_path: repo_contents_path + ) + end + + let(:repo_contents_path) { build_tmp_repo(project_name) } + + let(:files) { project_dependency_files(project_name, directory: directory) } + let(:directory) { "/" } + + let(:credentials) do + [{ "type" => "git_source", "host" => "github.com", "username" => "x-access-token", "password" => "token" }] + end + + describe "#updated_dependency_files" do + subject { updater.updated_dependency_files } + + let(:dependencies) do + [ + Dependabot::Dependency.new( + name: "ghcr.io/codspace/versioning/foo", + version: "2.11.1", + previous_version: "1.1.0", + requirements: [{ + requirement: "2", + groups: ["feature"], + file: ".devcontainer.json", + source: nil + }], + previous_requirements: [{ + requirement: "1", + groups: ["feature"], + file: ".devcontainer.json", + source: nil + }], + package_manager: "devcontainers" + ) + ] + end + + context "when there's only a devcontainer.json file" do + let(:project_name) { "config_in_root" } + + it "updates the version in .devcontainer.json" do + expect(subject.size).to eq(1) + + config = subject.first + expect(config.name).to eq(".devcontainer.json") + expect(config.content).to include("ghcr.io/codspace/versioning/foo:2\"") + end + end + + context "when there's both manifest and lockfile" do + let(:project_name) { "manifest_and_lockfile" } + + it "updates the version in both files" do + expect(subject.size).to eq(2) + + config = subject.find { |f| f.name == ".devcontainer.json" } + expect(config.content).to include("ghcr.io/codspace/versioning/foo:2\"") + + lockfile = subject.find { |f| f.name == ".devcontainer-lock.json" } + expect(lockfile.content).to include('"version": "2.11.1"') + end + end + + context "when there are multiple manifests, but only one needs updates" do + let(:project_name) { "multiple_configs" } + + let(:dependencies) do + [ + Dependabot::Dependency.new( + name: "ghcr.io/codspace/versioning/baz", + version: "2.0.0", + previous_version: "1.1.0", + requirements: [{ + requirement: "2.0", + groups: ["feature"], + file: ".devcontainer/devcontainer.json", + source: nil + }], + previous_requirements: [{ + requirement: "1.0", + groups: ["feature"], + file: ".devcontainer/devcontainer.json", + source: nil + }], + package_manager: "devcontainers" + ) + ] + end + + it "updates the version in both manifests" do + expect(subject.size).to eq(1) + + config = subject.first + expect(config.name).to eq(".devcontainer/devcontainer.json") + expect(config.content).to include("ghcr.io/codspace/versioning/baz:2.0\"") + end + end + + context "when there's both manifest and lockfile, but only the lockfile needs updates" do + let(:project_name) { "updated_manifest_outdated_lockfile" } + + let(:dependencies) do + [ + Dependabot::Dependency.new( + name: "ghcr.io/codspace/versioning/foo", + version: "2.11.1", + previous_version: "2.11.0", + requirements: [{ + requirement: "2", + groups: ["feature"], + file: ".devcontainer.json", + source: nil + }], + previous_requirements: [{ + requirement: "2", + groups: ["feature"], + file: ".devcontainer.json", + source: nil + }], + package_manager: "devcontainers" + ) + ] + end + + it "updates the version in lockfile" do + expect(subject.size).to eq(1) + + lockfile = subject.first + expect(lockfile.name).to eq(".devcontainer-lock.json") + expect(lockfile.content).to include('"version": "2.11.1"') + end + end + + context "when a custom directory is configured" do + let(:directory) { "src/go" } + let(:project_name) { "multiple_roots" } + + let(:dependencies) do + [ + Dependabot::Dependency.new( + name: "ghcr.io/devcontainers/features/common-utils", + version: "2.4.0", + previous_version: "2.3.2", + requirements: [{ + requirement: "2", + groups: ["feature"], + file: ".devcontainer/devcontainer.json", + source: nil + }], + previous_requirements: [{ + requirement: "2", + groups: ["feature"], + file: ".devcontainer/devcontainer.json", + source: nil + }], + package_manager: "devcontainers" + ) + ] + end + + it "updates the version in lockfile" do + expect(subject.size).to eq(1) + + config = subject.first + expect(config.name).to eq(".devcontainer/devcontainer-lock.json") + expect(config.content).to include("ghcr.io/devcontainers/features/common-utils:2") + expect(config.content).to include('"version": "2.4.0"') + end + end + + context "when target version is not the latest" do + let(:dependencies) do + [ + Dependabot::Dependency.new( + name: "ghcr.io/codspace/versioning/foo", + version: "2.10.0", + previous_version: "1.1.0", + requirements: [{ + requirement: "2", + groups: ["feature"], + file: ".devcontainer.json", + source: nil + }], + previous_requirements: [{ + requirement: "2", + groups: ["feature"], + file: ".devcontainer.json", + source: nil + }], + package_manager: "devcontainers" + ) + ] + end + + let(:project_name) { "updated_manifest_outdated_lockfile" } + + it "does not go past the target version in the lockfile" do + expect(subject.size).to eq(1) + + lockfile = subject.first + expect(lockfile.name).to eq(".devcontainer-lock.json") + expect(lockfile.content).to include('"version": "2.10.0"') + end + end + end +end diff --git a/devcontainers/spec/dependabot/devcontainers/update_checker_spec.rb b/devcontainers/spec/dependabot/devcontainers/update_checker_spec.rb new file mode 100644 index 00000000000..bf5b5d56be6 --- /dev/null +++ b/devcontainers/spec/dependabot/devcontainers/update_checker_spec.rb @@ -0,0 +1,122 @@ +# typed: false +# frozen_string_literal: true + +require "spec_helper" +require "dependabot/dependency" +require "dependabot/devcontainers/file_parser" +require "dependabot/devcontainers/update_checker" +require_common_spec "update_checkers/shared_examples_for_update_checkers" + +RSpec.describe Dependabot::Devcontainers::UpdateChecker do + it_behaves_like "an update checker" + + let(:checker) do + described_class.new( + dependency: dependency, + dependency_files: dependency_files, + repo_contents_path: repo_contents_path, + credentials: github_credentials, + security_advisories: security_advisories, + ignored_versions: ignored_versions, + raise_on_ignored: raise_on_ignored + ) + end + + let(:repo_contents_path) { build_tmp_repo(project_name, path: "projects") } + let(:dependency_files) { project_dependency_files(project_name, directory: directory) } + let(:security_advisories) { [] } + let(:ignored_versions) { [] } + let(:raise_on_ignored) { false } + + let(:dependencies) do + file_parser.parse + end + + let(:file_parser) do + Dependabot::Devcontainers::FileParser.new( + dependency_files: dependency_files, + repo_contents_path: repo_contents_path, + source: nil + ) + end + + let(:dependency) { dependencies.find { |dep| dep.name == name } } + + shared_context "in root" do + let(:project_name) { "config_in_root" } + let(:directory) { "/" } + end + + describe "#up_to_date?" do + subject { checker.up_to_date? } + + context "when feature is out-of-date" do + let(:name) { "ghcr.io/codspace/versioning/foo" } + + context "and config in root" do + include_context "in root" + + it { is_expected.to be_falsey } + end + + context "and config in .devcontainer folder " do + let(:project_name) { "config_in_dot_devcontainer_folder" } + let(:directory) { "/.devcontainer" } + + it { is_expected.to be_falsey } + end + end + + context "when feature is already up-to-date" do + let(:name) { "ghcr.io/codspace/versioning/bar" } + + context "and config in root" do + include_context "in root" + + it { is_expected.to be_truthy } + end + + context "and config in .devcontainer folder " do + let(:project_name) { "config_in_dot_devcontainer_folder" } + let(:directory) { "/.devcontainer" } + + it { is_expected.to be_truthy } + end + end + end + + describe "#latest_version" do + subject { checker.latest_version.to_s } + + let(:name) { "ghcr.io/codspace/versioning/foo" } + let(:current_version) { "1.1.0" } + + include_context "in root" + + context "when all later versions are being ignored" do + let(:ignored_versions) { ["> #{current_version}"] } + + it { is_expected.to eq(current_version) } + + context "raise_on_ignored" do + let(:raise_on_ignored) { true } + + it "raises an error" do + expect { subject }.to raise_error(Dependabot::AllVersionsIgnored) + end + end + end + + context "when some later versions are not ignored" do + let(:ignored_versions) { [">= 2.1.0"] } + + it { is_expected.to eq("2.0.0") } + + context "raise_on_ignored" do + let(:raise_on_ignored) { true } + + it { is_expected.to eq("2.0.0") } + end + end + end +end diff --git a/devcontainers/spec/fixtures/projects/config_in_dot_devcontainer_folder/.devcontainer/devcontainer.json b/devcontainers/spec/fixtures/projects/config_in_dot_devcontainer_folder/.devcontainer/devcontainer.json new file mode 100644 index 00000000000..a29477024e7 --- /dev/null +++ b/devcontainers/spec/fixtures/projects/config_in_dot_devcontainer_folder/.devcontainer/devcontainer.json @@ -0,0 +1,8 @@ +{ + "image": "mcr.microsoft.com/devcontainers/typescript-node:18", + "features": { + "ghcr.io/codspace/versioning/foo:1": {}, + "ghcr.io/codspace/versioning/bar:1": {}, + "ghcr.io/codspace/versioning/baz:1.0": {} + } +} \ No newline at end of file diff --git a/devcontainers/spec/fixtures/projects/config_in_root/.devcontainer.json b/devcontainers/spec/fixtures/projects/config_in_root/.devcontainer.json new file mode 100644 index 00000000000..79b80211423 --- /dev/null +++ b/devcontainers/spec/fixtures/projects/config_in_root/.devcontainer.json @@ -0,0 +1,7 @@ +{ + "image": "mcr.microsoft.com/devcontainers/typescript-node:18", + "features": { + "ghcr.io/codspace/versioning/foo:1": {}, + "ghcr.io/codspace/versioning/bar:1": {} + } +} \ No newline at end of file diff --git a/devcontainers/spec/fixtures/projects/custom_configs/.devcontainer/bar/devcontainer.json b/devcontainers/spec/fixtures/projects/custom_configs/.devcontainer/bar/devcontainer.json new file mode 100644 index 00000000000..433688a79d5 --- /dev/null +++ b/devcontainers/spec/fixtures/projects/custom_configs/.devcontainer/bar/devcontainer.json @@ -0,0 +1,6 @@ +{ + "image": "mcr.microsoft.com/devcontainers/typescript-node:18", + "features": { + "ghcr.io/codspace/versioning/bar:1": {} + } +} diff --git a/devcontainers/spec/fixtures/projects/custom_configs/.devcontainer/foo/devcontainer.json b/devcontainers/spec/fixtures/projects/custom_configs/.devcontainer/foo/devcontainer.json new file mode 100644 index 00000000000..8b20a0fc1c3 --- /dev/null +++ b/devcontainers/spec/fixtures/projects/custom_configs/.devcontainer/foo/devcontainer.json @@ -0,0 +1,6 @@ +{ + "image": "mcr.microsoft.com/devcontainers/typescript-node:18", + "features": { + "ghcr.io/codspace/versioning/foo:1": {} + } +} diff --git a/devcontainers/spec/fixtures/projects/deprecated/.devcontainer.json b/devcontainers/spec/fixtures/projects/deprecated/.devcontainer.json new file mode 100644 index 00000000000..4b6ebbb52be --- /dev/null +++ b/devcontainers/spec/fixtures/projects/deprecated/.devcontainer.json @@ -0,0 +1,6 @@ +{ + "name": "docs.github.com", + "features": { + "sshd": "latest" + } +} diff --git a/devcontainers/spec/fixtures/projects/manifest_and_lockfile/.devcontainer-lock.json b/devcontainers/spec/fixtures/projects/manifest_and_lockfile/.devcontainer-lock.json new file mode 100644 index 00000000000..f7dd4b4e749 --- /dev/null +++ b/devcontainers/spec/fixtures/projects/manifest_and_lockfile/.devcontainer-lock.json @@ -0,0 +1,14 @@ +{ + "features": { + "ghcr.io/codspace/versioning/bar:1": { + "version": "1.0.0", + "resolved": "ghcr.io/codspace/versioning/bar@sha256:0eb80a7a45ea6ac6d2057798608be4cacb3d3667d4818118e17acc5037d687d4", + "integrity": "sha256:0eb80a7a45ea6ac6d2057798608be4cacb3d3667d4818118e17acc5037d687d4" + }, + "ghcr.io/codspace/versioning/foo:1": { + "version": "1.1.0", + "resolved": "ghcr.io/codspace/versioning/foo@sha256:80d2d7b58afeaf907451c6f4e24de47b09a327a24a21a2d3323b7abf76d14be5", + "integrity": "sha256:80d2d7b58afeaf907451c6f4e24de47b09a327a24a21a2d3323b7abf76d14be5" + } + } +} \ No newline at end of file diff --git a/devcontainers/spec/fixtures/projects/manifest_and_lockfile/.devcontainer.json b/devcontainers/spec/fixtures/projects/manifest_and_lockfile/.devcontainer.json new file mode 100644 index 00000000000..79b80211423 --- /dev/null +++ b/devcontainers/spec/fixtures/projects/manifest_and_lockfile/.devcontainer.json @@ -0,0 +1,7 @@ +{ + "image": "mcr.microsoft.com/devcontainers/typescript-node:18", + "features": { + "ghcr.io/codspace/versioning/foo:1": {}, + "ghcr.io/codspace/versioning/bar:1": {} + } +} \ No newline at end of file diff --git a/devcontainers/spec/fixtures/projects/multiple_configs/.devcontainer.json b/devcontainers/spec/fixtures/projects/multiple_configs/.devcontainer.json new file mode 100644 index 00000000000..79b80211423 --- /dev/null +++ b/devcontainers/spec/fixtures/projects/multiple_configs/.devcontainer.json @@ -0,0 +1,7 @@ +{ + "image": "mcr.microsoft.com/devcontainers/typescript-node:18", + "features": { + "ghcr.io/codspace/versioning/foo:1": {}, + "ghcr.io/codspace/versioning/bar:1": {} + } +} \ No newline at end of file diff --git a/devcontainers/spec/fixtures/projects/multiple_configs/.devcontainer/devcontainer.json b/devcontainers/spec/fixtures/projects/multiple_configs/.devcontainer/devcontainer.json new file mode 100644 index 00000000000..a29477024e7 --- /dev/null +++ b/devcontainers/spec/fixtures/projects/multiple_configs/.devcontainer/devcontainer.json @@ -0,0 +1,8 @@ +{ + "image": "mcr.microsoft.com/devcontainers/typescript-node:18", + "features": { + "ghcr.io/codspace/versioning/foo:1": {}, + "ghcr.io/codspace/versioning/bar:1": {}, + "ghcr.io/codspace/versioning/baz:1.0": {} + } +} \ No newline at end of file diff --git a/devcontainers/spec/fixtures/projects/multiple_roots/.devcontainer/devcontainer.json b/devcontainers/spec/fixtures/projects/multiple_roots/.devcontainer/devcontainer.json new file mode 100644 index 00000000000..ff4d459b63d --- /dev/null +++ b/devcontainers/spec/fixtures/projects/multiple_roots/.devcontainer/devcontainer.json @@ -0,0 +1,7 @@ +{ + "image": "mcr.microsoft.com/devcontainers/javascript-node:0-18", + "features": { + "ghcr.io/devcontainers/features/docker-in-docker:2": {}, + "ghcr.io/devcontainers/features/azure-cli:1": {} + } +} diff --git a/devcontainers/spec/fixtures/projects/multiple_roots/src/go/.devcontainer/devcontainer-lock.json b/devcontainers/spec/fixtures/projects/multiple_roots/src/go/.devcontainer/devcontainer-lock.json new file mode 100644 index 00000000000..fc241c2fc2a --- /dev/null +++ b/devcontainers/spec/fixtures/projects/multiple_roots/src/go/.devcontainer/devcontainer-lock.json @@ -0,0 +1,9 @@ +{ + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "version": "2.4.0", + "resolved": "ghcr.io/devcontainers/features/common-utils@sha256:cd9c4413255c3b71fb716e63ee2df245c81c7262b858cf77406a68c80d09f12e", + "integrity": "sha256:cd9c4413255c3b71fb716e63ee2df245c81c7262b858cf77406a68c80d09f12e" + } + } +} diff --git a/devcontainers/spec/fixtures/projects/multiple_roots/src/go/.devcontainer/devcontainer.json b/devcontainers/spec/fixtures/projects/multiple_roots/src/go/.devcontainer/devcontainer.json new file mode 100644 index 00000000000..619cffd1db3 --- /dev/null +++ b/devcontainers/spec/fixtures/projects/multiple_roots/src/go/.devcontainer/devcontainer.json @@ -0,0 +1,15 @@ +{ + "build": { + "dockerfile": "./Dockerfile", + "context": "." + }, + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "installZsh": "true", + "username": "vscode", + "userUid": "1000", + "userGid": "1000", + "upgradePackages": "true" + } + } +} diff --git a/devcontainers/spec/fixtures/projects/sha_pinned/.devcontainer.json b/devcontainers/spec/fixtures/projects/sha_pinned/.devcontainer.json new file mode 100644 index 00000000000..859f5fd69c6 --- /dev/null +++ b/devcontainers/spec/fixtures/projects/sha_pinned/.devcontainer.json @@ -0,0 +1,6 @@ +{ + "image": "mcr.microsoft.com/devcontainers/base:jammy", + "features": { + "ghcr.io/devcontainers/features/aws-cli@sha256:4b67518ec3733df53110c3caf7b9d6007363d1b5aaae2f1fa27f18ad0cc9b2b9": {}, + } +} diff --git a/devcontainers/spec/fixtures/projects/updated_manifest_outdated_lockfile/.devcontainer-lock.json b/devcontainers/spec/fixtures/projects/updated_manifest_outdated_lockfile/.devcontainer-lock.json new file mode 100644 index 00000000000..0520cfd43bc --- /dev/null +++ b/devcontainers/spec/fixtures/projects/updated_manifest_outdated_lockfile/.devcontainer-lock.json @@ -0,0 +1,14 @@ +{ + "features": { + "ghcr.io/codspace/versioning/bar:1": { + "version": "1.0.0", + "resolved": "ghcr.io/codspace/versioning/bar@sha256:0eb80a7a45ea6ac6d2057798608be4cacb3d3667d4818118e17acc5037d687d4", + "integrity": "sha256:0eb80a7a45ea6ac6d2057798608be4cacb3d3667d4818118e17acc5037d687d4" + }, + "ghcr.io/codspace/versioning/foo:2": { + "version": "2.11.0", + "resolved": "ghcr.io/codspace/versioning/foo@sha256:9b5b5f165f7bff54d1b70d7228d63cb8d8a6b9f20fee6db772b520c3391beaa2", + "integrity": "sha256:9b5b5f165f7bff54d1b70d7228d63cb8d8a6b9f20fee6db772b520c3391beaa2" + } + } +} \ No newline at end of file diff --git a/devcontainers/spec/fixtures/projects/updated_manifest_outdated_lockfile/.devcontainer.json b/devcontainers/spec/fixtures/projects/updated_manifest_outdated_lockfile/.devcontainer.json new file mode 100644 index 00000000000..22b38fb4335 --- /dev/null +++ b/devcontainers/spec/fixtures/projects/updated_manifest_outdated_lockfile/.devcontainer.json @@ -0,0 +1,7 @@ +{ + "image": "mcr.microsoft.com/devcontainers/typescript-node:18", + "features": { + "ghcr.io/codspace/versioning/foo:2": {}, + "ghcr.io/codspace/versioning/bar:1": {} + } +}