diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..8359743 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,30 @@ +name: Test + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + ruby-version: ['3.4.8', '4.0.1'] + steps: + - uses: actions/checkout@v6 + - name: Set Ruby version + run: echo "${{ matrix.ruby-version }}" > .ruby-version + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Build test image + uses: docker/build-push-action@v6 + with: + context: . + load: true + tags: drcheckr-test + cache-from: type=gha,scope=test-ruby-${{ matrix.ruby-version }} + cache-to: type=gha,mode=max,scope=test-ruby-${{ matrix.ruby-version }} + - name: Run tests + run: docker run --rm drcheckr-test diff --git a/.gitignore b/.gitignore index 0b0f947..122b36b 100644 --- a/.gitignore +++ b/.gitignore @@ -11,7 +11,6 @@ tmp *.gem .bundle -Gemfile.lock vendor/bundle external @@ -21,4 +20,5 @@ node_modules .idea/ .yardoc/ +.claude/settings.local.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..445d5bd --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,50 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What is drcheckr + +A Ruby gem that tracks dependencies across Docker images, checks for version updates, locks versions, and generates Dockerfiles from ERB templates. Configuration is YAML-driven (`drcheckr.yml` + `drcheckr-lock.yml`). + +## Common Commands + +```bash +# Run tests (preferred: via Docker) +docker build -t drcheckr-test . && docker run --rm drcheckr-test + +# Run tests directly (requires Ruby 3.2+) +bundle install +bundle exec rake test + +# Run a single test file +bundle exec ruby -Ilib test/bin/drcheckr_test.rb + +# CLI usage +bin/drcheckr-cli check # Check if deps are up to date (exit 122 if outdated) +bin/drcheckr-cli update # Update versions in lock file +bin/drcheckr-cli gen # Generate Dockerfiles from templates +``` + +## Architecture + +**Entry points:** `bin/drcheckr` (shell wrapper handling Ruby version managers) → `bin/drcheckr-cli` (Ruby entry point) → `Drcheckr::Cli` + +**Core flow:** CLI parses args → `Checkrfile` loads YAML config/lock files → Commands execute: + +- `Commands::UpdateChecker` — queries version APIs (GitHub releases/Atom, Chrome Version History, EndOfLife.date, fixed), compares with locked versions, updates lock file +- `Commands::GenDockerfile` — builds template context via `ContextData`/`VariableLoader`, renders ERB templates through `Generator::ContainerFileGenerator` (uses Tilt) +- `Commands::ExpressionEvaluator` — evaluates standalone ERB expressions with dependency context + +**Key classes:** +- `Checkrfile` — loads and caches `drcheckr.yml` and `drcheckr-lock.yml` +- `ContextData` — assembles template binding from dependencies + variables (exposes `{dep}_version` as `VersionNumber` objects) +- `VersionNumber` — semantic version parser with `major`/`minor`/`patch` accessors, handles `v` prefix + +## Conventions + +- All files use `# frozen_string_literal: true` +- All classes nested under `Drcheckr` module; commands under `Drcheckr::Commands` +- Exit code 122 means "outdated dependencies" +- `CHECKRFILE` env var overrides default config file path +- No linter configured; no `.rubocop.yml` +- CI tests against Ruby 3.4 and 4.0 (`.github/workflows/test.yml`) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..dff2796 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,9 @@ +# Contributing + +## Running tests + +Tests run inside Docker. Build the image and run the suite with: + +```sh +docker build -t drcheckr-test . && docker run --rm drcheckr-test +``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..34e5b9b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +FROM debian:trixie-slim + +RUN apt-get update && apt-get install -y \ + git curl build-essential libssl-dev libreadline-dev \ + zlib1g-dev libyaml-dev libffi-dev autoconf && \ + rm -rf /var/lib/apt/lists/* + +# Install rbenv + ruby-build +RUN git clone https://github.com/rbenv/rbenv.git /root/.rbenv && \ + git clone https://github.com/rbenv/ruby-build.git /root/.rbenv/plugins/ruby-build +ENV PATH="/root/.rbenv/shims:/root/.rbenv/bin:$PATH" + +# Install Ruby version from .ruby-version +COPY .ruby-version /app/.ruby-version +WORKDIR /app +RUN rbenv install "$(cat .ruby-version)" && rbenv global "$(cat .ruby-version)" + +# Install gems (layered for caching) +RUN gem install bundler +COPY Gemfile Gemfile.lock drcheckr.gemspec /app/ +COPY lib/drcheckr/version.rb /app/lib/drcheckr/version.rb +RUN bundle install + +# Copy full project +COPY . /app/ + +CMD ["bundle", "exec", "rake", "test"] diff --git a/Gemfile b/Gemfile index 97f23f4..931f689 100644 --- a/Gemfile +++ b/Gemfile @@ -6,4 +6,9 @@ gemspec gem 'nokogiri' gem 'excon' +gem 'logger' +gem 'ostruct' gem 'debug' +gem 'minitest' +gem 'rake' +gem 'cgi' diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..cf63ef1 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,72 @@ +PATH + remote: . + specs: + drcheckr (0.1.0) + excon (~> 0.111) + tilt (~> 2.4) + +GEM + remote: https://rubygems.org/ + specs: + cgi (0.5.1) + date (3.5.1) + debug (1.11.1) + irb (~> 1.10) + reline (>= 0.3.8) + erb (6.0.1) + excon (0.112.0) + io-console (0.8.2) + irb (1.17.0) + pp (>= 0.6.0) + prism (>= 1.3.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + logger (1.7.0) + minitest (6.0.1) + prism (~> 1.5) + nokogiri (1.19.0-arm64-darwin) + racc (~> 1.4) + nokogiri (1.19.0-x86_64-darwin) + racc (~> 1.4) + nokogiri (1.19.0-x86_64-linux-gnu) + racc (~> 1.4) + ostruct (0.6.3) + pp (0.6.3) + prettyprint + prettyprint (0.2.0) + prism (1.9.0) + psych (5.3.1) + date + stringio + racc (1.8.1) + rake (13.3.1) + rdoc (7.2.0) + erb + psych (>= 4.0.0) + tsort + reline (0.6.3) + io-console (~> 0.5) + stringio (3.2.0) + tilt (2.7.0) + tsort (0.2.0) + +PLATFORMS + arm64-darwin-23 + arm64-darwin-24 + arm64-darwin-25 + x86_64-darwin-22 + x86_64-linux + +DEPENDENCIES + cgi + debug + drcheckr! + excon + logger + minitest + nokogiri + ostruct + rake + +BUNDLED WITH + 4.0.6 diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..5b4dfcc --- /dev/null +++ b/Rakefile @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require 'rake/testtask' + +Rake::TestTask.new(:test) do |t| + t.libs << 'test' + t.test_files = FileList['test/**/*_test.rb'] +end + +task default: :test diff --git a/bin/drcheckr b/bin/drcheckr index 2fe704f..b0debd9 100755 --- a/bin/drcheckr +++ b/bin/drcheckr @@ -1,11 +1,54 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true +#!/usr/bin/env sh +set -e -begin - lib_dir = File.expand_path("#{__dir__}/../lib") - $:.unshift(lib_dir) -end +# Resolve the real directory of this script, following symlinks. +SCRIPT="$0" +while [ -L "$SCRIPT" ]; do + SCRIPT_DIR="$(cd "$(dirname "$SCRIPT")" && pwd)" + SCRIPT="$(readlink "$SCRIPT")" + case "$SCRIPT" in + /*) ;; + *) SCRIPT="$SCRIPT_DIR/$SCRIPT" ;; + esac +done +SCRIPT_DIR="$(cd "$(dirname "$SCRIPT")" && pwd)" -require 'drcheckr' +# Locate a Ruby version manager and activate it so we get the right Ruby. +setup_ruby() { + # Try mise first (on PATH, then default install location). + if command -v mise >/dev/null 2>&1; then + eval "$(mise activate sh 2>/dev/null)" || true + eval "$(mise hook-env -s sh 2>/dev/null)" || true + return 0 + elif [ -x "$HOME/.local/bin/mise" ]; then + eval "$("$HOME/.local/bin/mise" activate sh 2>/dev/null)" || true + eval "$("$HOME/.local/bin/mise" hook-env -s sh 2>/dev/null)" || true + return 0 + fi -Drcheckr::Cli.start(ARGV) + # Try rbenv (on PATH, then default install location). + if command -v rbenv >/dev/null 2>&1; then + eval "$(rbenv init - sh 2>/dev/null)" || true + return 0 + elif [ -x "$HOME/.rbenv/bin/rbenv" ]; then + export PATH="$HOME/.rbenv/bin:$PATH" + eval "$(rbenv init - sh 2>/dev/null)" || true + return 0 + fi + + return 1 +} + +if ! setup_ruby; then + echo "drcheckr: error: no Ruby version manager found (tried mise, rbenv)." >&2 + echo "Install mise (https://mise.jdx.dev) or rbenv (https://github.com/rbenv/rbenv) to manage Ruby." >&2 + exit 1 +fi + +# Verify ruby is now available. +if ! command -v ruby >/dev/null 2>&1; then + echo "drcheckr: error: ruby not found after activating version manager. Ensure Ruby is installed." >&2 + exit 1 +fi + +exec ruby "$SCRIPT_DIR/drcheckr-cli" "$@" diff --git a/bin/drcheckr-cli b/bin/drcheckr-cli new file mode 100755 index 0000000..2fe704f --- /dev/null +++ b/bin/drcheckr-cli @@ -0,0 +1,11 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +begin + lib_dir = File.expand_path("#{__dir__}/../lib") + $:.unshift(lib_dir) +end + +require 'drcheckr' + +Drcheckr::Cli.start(ARGV) diff --git a/test/bin/drcheckr_test.rb b/test/bin/drcheckr_test.rb new file mode 100644 index 0000000..4518675 --- /dev/null +++ b/test/bin/drcheckr_test.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'test_helper' + +class DrcheckrBinTest < Minitest::Test + def test_binary_runs_successfully + output = `#{bin_path} --help 2>&1` + + assert_equal 0, $?.exitstatus, "Expected bin/drcheckr to exit 0, got #{$?.exitstatus}.\nOutput:\n#{output}" + end + + private + + def bin_path + File.expand_path('../../bin/drcheckr', __dir__) + end +end diff --git a/test/commands/gen_dockerfile_test.rb b/test/commands/gen_dockerfile_test.rb new file mode 100644 index 0000000..5a1a45c --- /dev/null +++ b/test/commands/gen_dockerfile_test.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'test_helper' +require 'drcheckr' +require 'tmpdir' +require 'fileutils' + +class GenDockerfileTest < Minitest::Test + FIXTURE_DIR = File.expand_path('../fixtures/gen_dockerfile', __dir__) + + def test_generates_dockerfile_from_template + Dir.mktmpdir do |tmpdir| + FileUtils.cp(File.join(FIXTURE_DIR, 'drcheckr.yml'), tmpdir) + FileUtils.cp(File.join(FIXTURE_DIR, 'drcheckr-lock.yml'), tmpdir) + FileUtils.mkdir_p(File.join(tmpdir, 'my_image')) + FileUtils.cp(File.join(FIXTURE_DIR, 'my_image', 'Dockerfile.erb'), File.join(tmpdir, 'my_image')) + + Dir.chdir(tmpdir) do + checkrfile = Drcheckr::Checkrfile.new('drcheckr.yml') + cmd = Drcheckr::Commands::GenDockerfile.new(checkrfile, additional_vars: {}) + cmd.run! + + generated = File.read(File.join(tmpdir, 'my_image', 'Dockerfile')) + expected = File.read(File.join(FIXTURE_DIR, 'my_image', 'Dockerfile')) + + assert_equal expected, generated + end + end + end +end diff --git a/test/commands/update_checker_test.rb b/test/commands/update_checker_test.rb new file mode 100644 index 0000000..79b099d --- /dev/null +++ b/test/commands/update_checker_test.rb @@ -0,0 +1,204 @@ +# frozen_string_literal: true + +require 'test_helper' +require 'drcheckr' +require 'tmpdir' +require 'fileutils' +require 'json' + +class UpdateCheckerTest < Minitest::Test + private + + def write_config(dir, dependencies:) + config = { 'dependencies' => dependencies } + File.write(File.join(dir, 'drcheckr.yml'), YAML.dump(config)) + end + + def write_lockfile(dir, locked_versions:) + lock = { 'dependencies' => locked_versions } + File.write(File.join(dir, 'drcheckr-lock.yml'), YAML.dump(lock)) + end + + def build_checker(dir) + checkrfile = Drcheckr::Checkrfile.new(File.join(dir, 'drcheckr.yml')) + Drcheckr::Commands::UpdateChecker.new(checkrfile) + end + + public + + def test_check_exits_0_when_all_up_to_date + Dir.mktmpdir do |dir| + write_config(dir, dependencies: [ + { 'name' => 'myapp', 'fixed_version' => '1.0.0' } + ]) + write_lockfile(dir, locked_versions: { 'myapp' => '1.0.0' }) + + checker = build_checker(dir) + err = assert_raises(SystemExit) { checker.check! } + assert_equal 0, err.status + end + end + + def test_check_exits_122_when_outdated + Dir.mktmpdir do |dir| + write_config(dir, dependencies: [ + { 'name' => 'myapp', 'fixed_version' => '2.0.0' } + ]) + write_lockfile(dir, locked_versions: { 'myapp' => '1.0.0' }) + + checker = build_checker(dir) + err = assert_raises(SystemExit) { checker.check! } + assert_equal 122, err.status + end + end + + def test_check_exits_1_when_no_lockfile + Dir.mktmpdir do |dir| + write_config(dir, dependencies: [ + { 'name' => 'myapp', 'fixed_version' => '1.0.0' } + ]) + # no lock file written + + checker = build_checker(dir) + err = assert_raises(SystemExit) { checker.check! } + assert_equal 1, err.status + end + end + + def test_check_exits_122_when_dep_not_in_lockfile + Dir.mktmpdir do |dir| + write_config(dir, dependencies: [ + { 'name' => 'myapp', 'fixed_version' => '1.0.0' } + ]) + write_lockfile(dir, locked_versions: {}) + + checker = build_checker(dir) + err = assert_raises(SystemExit) { checker.check! } + assert_equal 122, err.status + end + end + + def test_check_multiple_deps_all_up_to_date + Dir.mktmpdir do |dir| + write_config(dir, dependencies: [ + { 'name' => 'ruby', 'fixed_version' => '3.2.2' }, + { 'name' => 'node', 'fixed_version' => '20.0.0' } + ]) + write_lockfile(dir, locked_versions: { + 'ruby' => '3.2.2', + 'node' => '20.0.0' + }) + + checker = build_checker(dir) + err = assert_raises(SystemExit) { checker.check! } + assert_equal 0, err.status + end + end + + def test_check_multiple_deps_one_outdated + Dir.mktmpdir do |dir| + write_config(dir, dependencies: [ + { 'name' => 'ruby', 'fixed_version' => '3.2.2' }, + { 'name' => 'node', 'fixed_version' => '21.0.0' } + ]) + write_lockfile(dir, locked_versions: { + 'ruby' => '3.2.2', + 'node' => '20.0.0' + }) + + checker = build_checker(dir) + err = assert_raises(SystemExit) { checker.check! } + assert_equal 122, err.status + end + end + + def with_excon_stub(response_body) + Excon.defaults[:mock] = true + Excon.stub({}, { body: response_body, status: 200 }) + yield + ensure + Excon.stubs.clear + Excon.defaults[:mock] = false + end + + def test_check_with_eol_source + Dir.mktmpdir do |dir| + write_config(dir, dependencies: [ + { 'name' => 'ruby', 'eol' => 'ruby', 'cycle' => '3.2' } + ]) + write_lockfile(dir, locked_versions: { 'ruby' => '3.2.2' }) + + body = JSON.generate([ + { 'cycle' => '3.2', 'latest' => '3.2.2' }, + { 'cycle' => '3.1', 'latest' => '3.1.4' } + ]) + + with_excon_stub(body) do + checker = build_checker(dir) + err = assert_raises(SystemExit) { checker.check! } + assert_equal 0, err.status + end + end + end + + def test_check_with_github_source + Dir.mktmpdir do |dir| + write_config(dir, dependencies: [ + { 'name' => 'tini', 'github' => 'krallin/tini' } + ]) + write_lockfile(dir, locked_versions: { 'tini' => 'v0.19.0' }) + + atom_xml = <<~XML + + + + tag:github.com,2008:Repository/12345/v0.19.0 + + + tag:github.com,2008:Repository/12345/v0.18.0 + + + XML + + with_excon_stub(atom_xml) do + checker = build_checker(dir) + err = assert_raises(SystemExit) { checker.check! } + assert_equal 0, err.status + end + end + end + + def test_check_with_google_chrome_source + Dir.mktmpdir do |dir| + write_config(dir, dependencies: [ + { 'name' => 'chrome', 'google_chrome' => 'linux/stable' } + ]) + write_lockfile(dir, locked_versions: { 'chrome' => '117.0.5938.132' }) + + body = JSON.generate({ + 'versions' => [ + { 'version' => '117.0.5938.132' }, + { 'version' => '117.0.5938.92' } + ] + }) + + with_excon_stub(body) do + checker = build_checker(dir) + err = assert_raises(SystemExit) { checker.check! } + assert_equal 0, err.status + end + end + end + + def test_raises_on_unknown_source_type + Dir.mktmpdir do |dir| + write_config(dir, dependencies: [ + { 'name' => 'mystery' } + ]) + write_lockfile(dir, locked_versions: { 'mystery' => '1.0' }) + + checker = build_checker(dir) + assert_raises(RuntimeError) { checker.check! } + end + end +end diff --git a/test/fixtures/gen_dockerfile/drcheckr-lock.yml b/test/fixtures/gen_dockerfile/drcheckr-lock.yml new file mode 100644 index 0000000..e4dfba9 --- /dev/null +++ b/test/fixtures/gen_dockerfile/drcheckr-lock.yml @@ -0,0 +1,7 @@ +--- +dependencies: + ruby: 3.2.2 + chrome: 117.0.5938.132 + +variables: + version: '3.14159' diff --git a/test/fixtures/gen_dockerfile/drcheckr.yml b/test/fixtures/gen_dockerfile/drcheckr.yml new file mode 100644 index 0000000..79ed8c1 --- /dev/null +++ b/test/fixtures/gen_dockerfile/drcheckr.yml @@ -0,0 +1,13 @@ +--- +images: +- name: my_image + +variables: +- name: version + +dependencies: +- name: ruby + eol: ruby + cycle: '3.2' +- name: chrome + google_chrome: linux/stable diff --git a/test/fixtures/gen_dockerfile/my_image/Dockerfile b/test/fixtures/gen_dockerfile/my_image/Dockerfile new file mode 100644 index 0000000..4519f0f --- /dev/null +++ b/test/fixtures/gen_dockerfile/my_image/Dockerfile @@ -0,0 +1,11 @@ +# This is image version 3.14159 +FROM ruby:3.2.2-bookworm + +# Install google chrome +ENV GOOGLE_CHROME_VERSION=117.0.5938.132-1 +RUN set -eux ; \ + apt-get update -y \ + && curl -Lo /tmp/google-chrome.deb "https://dl.google.com/linux/chrome/deb/pool/main/g/google-chrome-stable/google-chrome-stable_${GOOGLE_CHROME_VERSION}_amd64.deb" \ + && apt install /tmp/google-chrome.deb -y --no-install-recommends \ + && rm /tmp/google-chrome.deb \ + && rm -rf /var/lib/apt/lists/* diff --git a/test/fixtures/gen_dockerfile/my_image/Dockerfile.erb b/test/fixtures/gen_dockerfile/my_image/Dockerfile.erb new file mode 100644 index 0000000..b953319 --- /dev/null +++ b/test/fixtures/gen_dockerfile/my_image/Dockerfile.erb @@ -0,0 +1,11 @@ +# This is image version <%= version %> +FROM ruby:<%= ruby_version %>-bookworm + +# Install google chrome +ENV GOOGLE_CHROME_VERSION=<%= chrome_version %>-1 +RUN set -eux ; \ + apt-get update -y \ + && curl -Lo /tmp/google-chrome.deb "https://dl.google.com/linux/chrome/deb/pool/main/g/google-chrome-stable/google-chrome-stable_${GOOGLE_CHROME_VERSION}_amd64.deb" \ + && apt install /tmp/google-chrome.deb -y --no-install-recommends \ + && rm /tmp/google-chrome.deb \ + && rm -rf /var/lib/apt/lists/* diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000..92fef10 --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +require 'minitest/autorun'