From 8cf8aaab3056fa087b2432bc625ae14d750380e9 Mon Sep 17 00:00:00 2001 From: Paul Kuruvilla Date: Sun, 16 Nov 2025 19:03:48 -0800 Subject: [PATCH 01/13] update --- download_test_runner.sh | 36 --- test-runner/.env.example | 6 + test-runner/.github/workflows/publish.yml | 35 +++ test-runner/.github/workflows/tag.yml | 29 ++ test-runner/.github/workflows/test.yml | 65 +++++ test-runner/.gitignore | 5 + test-runner/.ruby-version | 1 + test-runner/Gemfile | 48 ++++ test-runner/Gemfile.lock | 160 +++++++++++ test-runner/Makefile | 65 +++++ test-runner/README.md | 40 +++ test-runner/docker-compose.yml | 39 +++ test-runner/go.mod | 36 +++ test-runner/go.sum | 83 ++++++ test-runner/internal/backend/client.go | 63 +++++ .../backend/create_test_run_result.go | 41 +++ .../internal/backend/finalize_build.go | 39 +++ .../internal/backend/report_shutdown.go | 37 +++ .../internal/backend/reserve_test_run.go | 62 +++++ .../backend/trigger_infrastructure_rebuild.go | 49 ++++ test-runner/internal/build_image.go | 109 ++++++++ test-runner/internal/build_logs_processor.go | 57 ++++ .../internal/build_logs_processor_test.go | 68 +++++ test-runner/internal/color/color.go | 23 ++ .../internal/commands/build_image_command.go | 260 ++++++++++++++++++ test-runner/internal/friendly_error.go | 11 + test-runner/internal/get_buildpack.go | 42 +++ test-runner/internal/get_head_commit_sha.go | 18 ++ .../globals/codecrafters_server_url.go | 15 + .../move_app_cached_files_if_needed.go | 24 ++ .../internal/run_precompilation_if_needed.go | 71 +++++ test-runner/internal/run_tester.go | 33 +++ test-runner/internal/switch_to_commit.go | 61 ++++ test-runner/internal/test_run.go | 78 ++++++ test-runner/internal/test_runner_build.go | 98 +++++++ test-runner/internal/utils/sentry.go | 28 ++ test-runner/main.go | 23 ++ test-runner/main.goreleaser.yml | 11 + test-runner/tests/build_image_test.rb | 168 +++++++++++ .../tests/lib/build_image_command_runner.rb | 63 +++++ .../lib/buildpack_dockerfile_processor.rb | 88 ++++++ test-runner/tests/lib/code_fixtures.rb | 38 +++ test-runner/tests/lib/database_helper.rb | 10 + .../tests/lib/db/models/application_record.rb | 7 + test-runner/tests/lib/db/models/buildpack.rb | 15 + test-runner/tests/lib/db/models/repository.rb | 29 ++ test-runner/tests/lib/db/models/submission.rb | 10 + test-runner/tests/lib/db/models/test_run.rb | 28 ++ .../tests/lib/db/models/test_run_result.rb | 16 ++ .../tests/lib/db/models/test_runner.rb | 15 + .../tests/lib/db/models/test_runner_build.rb | 32 +++ test-runner/tests/lib/db/schema.rb | 56 ++++ test-runner/tests/lib/fake_git_repository.rb | 90 ++++++ test-runner/tests/lib/fake_server.rb | 196 +++++++++++++ test-runner/tests/lib/git_api_gateway.rb | 43 +++ .../tests/lib/run_tests_command_runner.rb | 53 ++++ .../submission_test_runner_actions_builder.rb | 62 +++++ .../await_terminal_build_status.rb | 17 ++ .../await_terminal_submission_status.rb | 17 ++ .../tests/lib/test_runner_actions/base.rb | 29 ++ .../lib/test_runner_actions/print_message.rb | 33 +++ .../tests/lib/test_runner_actions/sleep.rb | 18 ++ .../lib/test_runner_actions/stream_logs.rb | 13 + .../lib/test_runner_actions/terminate.rb | 13 + test-runner/tests/lib/tester_downloader.rb | 54 ++++ test-runner/tests/run_tests_test.rb | 198 +++++++++++++ test-runner/tests/sample_data/docker_logs.txt | 155 +++++++++++ test-runner/tests/test_helper.rb | 25 ++ 68 files changed, 3524 insertions(+), 36 deletions(-) delete mode 100755 download_test_runner.sh create mode 100644 test-runner/.env.example create mode 100644 test-runner/.github/workflows/publish.yml create mode 100644 test-runner/.github/workflows/tag.yml create mode 100644 test-runner/.github/workflows/test.yml create mode 100644 test-runner/.gitignore create mode 100644 test-runner/.ruby-version create mode 100644 test-runner/Gemfile create mode 100644 test-runner/Gemfile.lock create mode 100644 test-runner/Makefile create mode 100644 test-runner/README.md create mode 100644 test-runner/docker-compose.yml create mode 100644 test-runner/go.mod create mode 100644 test-runner/go.sum create mode 100644 test-runner/internal/backend/client.go create mode 100644 test-runner/internal/backend/create_test_run_result.go create mode 100644 test-runner/internal/backend/finalize_build.go create mode 100644 test-runner/internal/backend/report_shutdown.go create mode 100644 test-runner/internal/backend/reserve_test_run.go create mode 100644 test-runner/internal/backend/trigger_infrastructure_rebuild.go create mode 100644 test-runner/internal/build_image.go create mode 100644 test-runner/internal/build_logs_processor.go create mode 100644 test-runner/internal/build_logs_processor_test.go create mode 100644 test-runner/internal/color/color.go create mode 100644 test-runner/internal/commands/build_image_command.go create mode 100644 test-runner/internal/friendly_error.go create mode 100644 test-runner/internal/get_buildpack.go create mode 100644 test-runner/internal/get_head_commit_sha.go create mode 100644 test-runner/internal/globals/codecrafters_server_url.go create mode 100644 test-runner/internal/move_app_cached_files_if_needed.go create mode 100644 test-runner/internal/run_precompilation_if_needed.go create mode 100644 test-runner/internal/run_tester.go create mode 100644 test-runner/internal/switch_to_commit.go create mode 100644 test-runner/internal/test_run.go create mode 100644 test-runner/internal/test_runner_build.go create mode 100644 test-runner/internal/utils/sentry.go create mode 100644 test-runner/main.go create mode 100644 test-runner/main.goreleaser.yml create mode 100644 test-runner/tests/build_image_test.rb create mode 100644 test-runner/tests/lib/build_image_command_runner.rb create mode 100644 test-runner/tests/lib/buildpack_dockerfile_processor.rb create mode 100644 test-runner/tests/lib/code_fixtures.rb create mode 100644 test-runner/tests/lib/database_helper.rb create mode 100644 test-runner/tests/lib/db/models/application_record.rb create mode 100644 test-runner/tests/lib/db/models/buildpack.rb create mode 100644 test-runner/tests/lib/db/models/repository.rb create mode 100644 test-runner/tests/lib/db/models/submission.rb create mode 100644 test-runner/tests/lib/db/models/test_run.rb create mode 100644 test-runner/tests/lib/db/models/test_run_result.rb create mode 100644 test-runner/tests/lib/db/models/test_runner.rb create mode 100644 test-runner/tests/lib/db/models/test_runner_build.rb create mode 100644 test-runner/tests/lib/db/schema.rb create mode 100644 test-runner/tests/lib/fake_git_repository.rb create mode 100644 test-runner/tests/lib/fake_server.rb create mode 100644 test-runner/tests/lib/git_api_gateway.rb create mode 100644 test-runner/tests/lib/run_tests_command_runner.rb create mode 100644 test-runner/tests/lib/submission_test_runner_actions_builder.rb create mode 100644 test-runner/tests/lib/test_runner_actions/await_terminal_build_status.rb create mode 100644 test-runner/tests/lib/test_runner_actions/await_terminal_submission_status.rb create mode 100644 test-runner/tests/lib/test_runner_actions/base.rb create mode 100644 test-runner/tests/lib/test_runner_actions/print_message.rb create mode 100644 test-runner/tests/lib/test_runner_actions/sleep.rb create mode 100644 test-runner/tests/lib/test_runner_actions/stream_logs.rb create mode 100644 test-runner/tests/lib/test_runner_actions/terminate.rb create mode 100644 test-runner/tests/lib/tester_downloader.rb create mode 100644 test-runner/tests/run_tests_test.rb create mode 100644 test-runner/tests/sample_data/docker_logs.txt create mode 100644 test-runner/tests/test_helper.rb diff --git a/download_test_runner.sh b/download_test_runner.sh deleted file mode 100755 index 3c7a51c..0000000 --- a/download_test_runner.sh +++ /dev/null @@ -1,36 +0,0 @@ -set -e -set -x - -destinationDir="/var/opt/test-runner" # Destination directory - -repoOwner="codecrafters-io" # GitHub repository owner -repoName="test-runner" # GitHub repository name -releaseTag="v0.3.67" # Release tag to download -assetName="${releaseTag}_linux_amd64.tar.gz" # Asset name -downloadedTarPath="$(mktemp)" # Path to downloaded tar file - -# Get the asset ID -assetId=$(curl -fsSL --header "Authorization: Bearer ${GITHUB_TOKEN}" "https://api.github.com/repos/$repoOwner/$repoName/releases/tags/$releaseTag" | jq -r --arg assetName "$assetName" '.assets[] | select(.name==$assetName) | .id') -echo "Asset ID: $assetId" - -downloadUrl="https://api.github.com/repos/$repoOwner/$repoName/releases/assets/$assetId" -echo "Download URL: $downloadUrl" - -wget --header="Authorization: Bearer ${GITHUB_TOKEN}" --header="Accept: application/octet-stream" -O $downloadedTarPath $downloadUrl -mkdir -p $destinationDir -tar xz -C $destinationDir -f $downloadedTarPath - -rm -rf $downloadedTarPath - -# TODO: Replace this by adding a `--version` flag! -set +e -output=$(/var/opt/test-runner/test-runner --help 2>&1) -exit_code=$? -echo "$output" -echo "$output" | grep -qi "unknown command" -grep_exit_code=$? -if [ $exit_code -ne 1 ] || [ $grep_exit_code -ne 0 ]; then - echo "Test failed: exit code was $exit_code, output was: $output" - exit 1 -fi -set -e diff --git a/test-runner/.env.example b/test-runner/.env.example new file mode 100644 index 0000000..e2e61cb --- /dev/null +++ b/test-runner/.env.example @@ -0,0 +1,6 @@ +# An access token with access to the image we're pushing to +FLY_ACCESS_TOKEN="FlyV1 fm2_lJPECAAAAAAAAFu1xBDrE74JtpQtL/bs+u0CJiAlwrVodHRwczovL2FwaS5mbHkuaW8vdjGWAJLNzSMfC5O5aHR0cHM6Ly9hcGkuZmx5LmlvL2FhYS92McQ8WBLmZVkPM+bp+MYa59roq4p6RFGZOPyD4t7nrtfytR5QBg98z5akYSJcTjdQTua4QzBDT++kXlJi2W27xEWjm09GHI3BdcjBvMxe2c8jiT8KYneORr3tDZU+4lSvtJPDPaOnMVgNGb7NhJBhbP5sbY3oC93TYOco5L6xYEStTEKq4xUNkpQDkYHOABtQrB8FkYKnYnVpbGRlch+id2cfAcQgU/LVB8aWvqkvnRbRKd42i//Yvd5T73HVF0WxfsZ1Xbk=,fm2_lJPERaObT0YcjcF1yMG8zF7ZzyOJPwpid45Gve0NlT7iVK+0k8M9o6cxWA0Zvs2EkGFs/mxtjegL3dNg5yjkvrFgRK1MQqrjFcQQKZ82IUHac0JAIRLvYvaGEcO5aHR0cHM6Ly9hcGkuZmx5LmlvL2FhYS92MZYEks5lqHCMzwAAAAEhoI6qCpHNTwQMxBDpPM5My6ihVldeuGRWbT4vxCCF+j8afdZU4B5W/rcGBV34Ky0zOuVErci6LlBvm+xgsA==" + +# A depot project & token +DEPOT_PROJECT="jlmt2zdb5m" # test-runner-ci +DEPOT_TOKEN="depot_project_e252ea64749beae1c983d2f95c952c892cb211c3a0e4aff1fe9ceb5bd14fc0a5" diff --git a/test-runner/.github/workflows/publish.yml b/test-runner/.github/workflows/publish.yml new file mode 100644 index 0000000..9285566 --- /dev/null +++ b/test-runner/.github/workflows/publish.yml @@ -0,0 +1,35 @@ +name: Publish + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + publish_tester: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Unshallow + run: git fetch --prune --unshallow + + - name: Set up Go + uses: actions/setup-go@v1 + with: + go-version: 1.21.x + + # Is this needed? + # - run: "sudo apt-get install gcc-multilib g++-multilib" + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v5 + with: + version: v1.21.2 + args: release -f main.goreleaser.yml --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/test-runner/.github/workflows/tag.yml b/test-runner/.github/workflows/tag.yml new file mode 100644 index 0000000..91d6a47 --- /dev/null +++ b/test-runner/.github/workflows/tag.yml @@ -0,0 +1,29 @@ +name: Tag + +on: + push: + branches: [main] + +jobs: + create-tag: + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 # Ensure that all tags are available + + - name: "Compute next version tag" + run: |- + nextVersionTag=$(make print_next_version_tag) + echo "next: $nextVersionTag" + echo "::set-output name=next_version_tag::$nextVersionTag" + id: compute_next_version_tag + + - name: Bump version and push tag + uses: mathieudutour/github-tag-action@v6.2 + with: + github_token: ${{ secrets.CODECRAFTERS_BOT_GITHUB_TOKEN }} + custom_tag: ${{ steps.compute_next_version_tag.outputs.next_version_tag }} + tag_prefix: "" diff --git a/test-runner/.github/workflows/test.yml b/test-runner/.github/workflows/test.yml new file mode 100644 index 0000000..76d4e58 --- /dev/null +++ b/test-runner/.github/workflows/test.yml @@ -0,0 +1,65 @@ +name: Test + +on: + pull_request: + push: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest-4-cores + timeout-minutes: 15 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - uses: depot/setup-action@v1 + + - name: Install logstream + run: | + curl -L https://github.com/codecrafters-io/logstream/releases/download/v0.2.2/v0.2.2_linux_amd64 -o /usr/local/bin/logstream + chmod +x /usr/local/bin/logstream + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: "1.21" + + - name: Set up ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.5 + bundler-cache: true + cache-version: 5 + + - name: Run make refresh_test_fixtures + run: make refresh_test_fixtures + + # Required for pulling git-api and git-daemon + - name: Authenticate with ghcr.io + run: echo '${{ secrets.CI_DOCKER_AUTH_PAT }}' | docker login ghcr.io -u codecrafters-bot --password-stdin + + # Required by build_image + - name: Authenticate with registry.fly.io + run: echo '${{ secrets.FLY_ACCESS_TOKEN }}' | docker login registry.fly.io -u x --password-stdin + + - name: Set up Docker + run: docker compose up -d + + - name: Set up Git + run: | + git config --global user.email "you@example.com" + git config --global user.name "Your Name" + + - name: Run tests + run: make test + env: + DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }} + DEPOT_PROJECT: ${{ secrets.DEPOT_PROJECT }} + FLY_ACCESS_TOKEN: ${{ secrets.FLY_ACCESS_TOKEN }} diff --git a/test-runner/.gitignore b/test-runner/.gitignore new file mode 100644 index 0000000..ef6e102 --- /dev/null +++ b/test-runner/.gitignore @@ -0,0 +1,5 @@ +.vagrant/ + +tests/fixtures/ +dist +.env \ No newline at end of file diff --git a/test-runner/.ruby-version b/test-runner/.ruby-version new file mode 100644 index 0000000..0163af7 --- /dev/null +++ b/test-runner/.ruby-version @@ -0,0 +1 @@ +3.3.5 \ No newline at end of file diff --git a/test-runner/Gemfile b/test-runner/Gemfile new file mode 100644 index 0000000..a428928 --- /dev/null +++ b/test-runner/Gemfile @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } + +# Test Runner +gem "minitest" + +# GitHub SDK +gem "octokit" + +# Debugging +gem "pry" + +# Parallel processing +gem "pmap" + +# Formatter +gem "standard" + +# Docker SDK +gem "docker-api" + +# HTTParty +gem "httparty" +gem "faraday-retry" + +# Web Server +gem "sinatra", require: false +gem "puma" + +# Database +gem "sqlite3" +gem "activerecord" + +# Spawn processes +gem "childprocess" +gem "tty-command" + +# .env files +gem "dotenv" + +# Autoloading +gem "zeitwerk" + +# Ending minitest tests early +gem "minitest-fail-fast" diff --git a/test-runner/Gemfile.lock b/test-runner/Gemfile.lock new file mode 100644 index 0000000..4832b26 --- /dev/null +++ b/test-runner/Gemfile.lock @@ -0,0 +1,160 @@ +GEM + remote: https://rubygems.org/ + specs: + activemodel (7.1.1) + activesupport (= 7.1.1) + activerecord (7.1.1) + activemodel (= 7.1.1) + activesupport (= 7.1.1) + timeout (>= 0.4.0) + activesupport (7.1.1) + base64 + bigdecimal + concurrent-ruby (~> 1.0, >= 1.0.2) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + minitest (>= 5.1) + mutex_m + tzinfo (~> 2.0) + addressable (2.8.5) + public_suffix (>= 2.0.2, < 6.0) + ast (2.4.2) + base64 (0.1.1) + bigdecimal (3.1.4) + childprocess (4.1.0) + coderay (1.1.3) + concurrent-ruby (1.2.2) + connection_pool (2.4.1) + docker-api (2.2.0) + excon (>= 0.47.0) + multi_json + dotenv (2.8.1) + drb (2.1.1) + ruby2_keywords + excon (0.104.0) + faraday (2.7.11) + base64 + faraday-net_http (>= 2.0, < 3.1) + ruby2_keywords (>= 0.0.4) + faraday-net_http (3.0.2) + faraday-retry (2.2.0) + faraday (~> 2.0) + httparty (0.21.0) + mini_mime (>= 1.0.0) + multi_xml (>= 0.5.2) + i18n (1.14.1) + concurrent-ruby (~> 1.0) + json (2.6.3) + language_server-protocol (3.17.0.3) + lint_roller (1.1.0) + method_source (1.0.0) + mini_mime (1.1.2) + mini_portile2 (2.8.4) + minitest (5.20.0) + minitest-fail-fast (0.1.0) + minitest (~> 5) + multi_json (1.15.0) + multi_xml (0.6.0) + mustermann (3.0.0) + ruby2_keywords (~> 0.0.1) + mutex_m (0.1.2) + nio4r (2.5.9) + octokit (7.2.0) + faraday (>= 1, < 3) + sawyer (~> 0.9) + parallel (1.23.0) + parser (3.2.2.4) + ast (~> 2.4.1) + racc + pastel (0.8.0) + tty-color (~> 0.5) + pmap (1.1.1) + pry (0.14.2) + coderay (~> 1.1) + method_source (~> 1.0) + public_suffix (5.0.3) + puma (6.4.0) + nio4r (~> 2.0) + racc (1.7.1) + rack (2.2.7) + rack-protection (3.0.6) + rack + rainbow (3.1.1) + regexp_parser (2.8.2) + rexml (3.2.6) + rubocop (1.56.4) + base64 (~> 0.1.1) + json (~> 2.3) + language_server-protocol (>= 3.17.0) + parallel (~> 1.10) + parser (>= 3.2.2.3) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 1.8, < 3.0) + rexml (>= 3.2.5, < 4.0) + rubocop-ast (>= 1.28.1, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 3.0) + rubocop-ast (1.29.0) + parser (>= 3.2.1.0) + rubocop-performance (1.19.1) + rubocop (>= 1.7.0, < 2.0) + rubocop-ast (>= 0.4.0) + ruby-progressbar (1.13.0) + ruby2_keywords (0.0.5) + sawyer (0.9.2) + addressable (>= 2.3.5) + faraday (>= 0.17.3, < 3) + sinatra (3.0.6) + mustermann (~> 3.0) + rack (~> 2.2, >= 2.2.4) + rack-protection (= 3.0.6) + tilt (~> 2.0) + sqlite3 (1.6.7) + mini_portile2 (~> 2.8.0) + standard (1.31.2) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.56.4) + standard-custom (~> 1.0.0) + standard-performance (~> 1.2) + standard-custom (1.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.50) + standard-performance (1.2.1) + lint_roller (~> 1.1) + rubocop-performance (~> 1.19.1) + tilt (2.2.0) + timeout (0.4.0) + tty-color (0.6.0) + tty-command (0.10.1) + pastel (~> 0.8) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unicode-display_width (2.5.0) + zeitwerk (2.6.12) + +PLATFORMS + ruby + +DEPENDENCIES + activerecord + childprocess + docker-api + dotenv + faraday-retry + httparty + minitest + minitest-fail-fast + octokit + pmap + pry + puma + sinatra + sqlite3 + standard + tty-command + zeitwerk + +BUNDLED WITH + 2.4.20 diff --git a/test-runner/Makefile b/test-runner/Makefile new file mode 100644 index 0000000..fa58118 --- /dev/null +++ b/test-runner/Makefile @@ -0,0 +1,65 @@ +current_patch_number := $(shell git tag --list "v0.3.*" | sort -V | tail -n 1 | cut -c 6-) +next_patch_number := $(shell echo $$(($(current_patch_number)+1))) +next_version_tag := v0.3.$(next_patch_number) + +# Releases are automatically created on pushes to `main`, but this can be used to manually create a release. +release: + git tag $(next_version_tag) + git push origin main $(next_version_tag) + +build_linux: + # -mod=mod is required to ensure that go isn't confused by Ruby's vendor/bundle directory + # CGO_ENABLED=0 ensures that the binary is statically linked and doesn't depend on any shared libraries + GOOS=linux CGO_ENABLED=0 go build -mod=mod -o dist/main-linux.out ./ + +build_native: + # -mod=mod is required to ensure that go isn't confused by Ruby's vendor/bundle directory + # CGO_ENABLED=0 ensures that the binary is statically linked and doesn't depend on any shared libraries + CGO_ENABLED=0 go build -mod=mod -o dist/main.out ./ + +build: build_linux build_native + +print_next_version_tag: + @echo $(next_version_tag) | tr -d '\n' + +refresh_test_fixtures: + rm -rf ./tests/fixtures + mkdir -p ./tests/fixtures + mkdir -p ./tests/fixtures/dockerfiles + + rm -rf /tmp/byo-redis + git clone https://github.com/codecrafters-io/build-your-own-redis /tmp/byo-redis + cd /tmp/byo-redis && git checkout 5ce64229f59108851de28c4f083f3459d58eff34 + + rm -rf /tmp/byo-sqlite + git clone https://github.com/codecrafters-io/build-your-own-sqlite /tmp/byo-sqlite + cd /tmp/byo-sqlite && git checkout 8f419979fb7b3eb95cd2d0547f084032cf9edf39 + + rm -rf ./tests/fixtures/redis-ruby-pass-stage-1 + cp -R /tmp/byo-redis/solutions/ruby/01-jm1/code ./tests/fixtures/redis-ruby-pass-stage-1 + cp /tmp/byo-redis/dockerfiles/ruby-3.3.Dockerfile ./tests/fixtures/dockerfiles/redis-ruby-3.3.Dockerfile + + rm -rf ./tests/fixtures/redis-rust-pass-stage-1 + cp -R /tmp/byo-redis/solutions/rust/01-jm1/code ./tests/fixtures/redis-rust-pass-stage-1 + cp /tmp/byo-redis/dockerfiles/rust-1.88.Dockerfile ./tests/fixtures/dockerfiles/redis-rust-1.88.Dockerfile + + rm -rf ./tests/fixtures/redis-go-pass-stage-1 + cp -R /tmp/byo-redis/solutions/go/01-jm1/code ./tests/fixtures/redis-go-pass-stage-1 + cp /tmp/byo-redis/dockerfiles/go-1.24.Dockerfile ./tests/fixtures/dockerfiles/redis-go-1.24.Dockerfile + + rm -rf ./tests/fixtures/sqlite-python-pass-stage-1 + cp -R /tmp/byo-sqlite/solutions/python/01-dr6/code ./tests/fixtures/sqlite-python-pass-stage-1 + cp /tmp/byo-sqlite/dockerfiles/python-3.13.Dockerfile ./tests/fixtures/dockerfiles/sqlite-python-3.13.Dockerfile + +test: test_go test_run_tests test_build_image + +test_go: + # -mod=mod is required to ensure that go isn't confused by Ruby's vendor/bundle directory + go test -mod=mod ./... + +test_run_tests: build + bundle exec ruby tests/run_tests_test.rb --verbose --fail-fast + +test_build_image: build + bundle exec ruby tests/build_image_test.rb --verbose --fail-fast + diff --git a/test-runner/README.md b/test-runner/README.md new file mode 100644 index 0000000..f5eb571 --- /dev/null +++ b/test-runner/README.md @@ -0,0 +1,40 @@ +# Test Runner + +This is the component responsible for running tests for CodeCrafters challenges. + +### Functionality + +It supports two commands: + +- `build_image` + - Builds the docker image that needs to executed to run tests for a challenge + - Also streams logs back to a logstream URL +- `run_tests` + - Runs the tests for a challenge (this is the entrypoint of the docker image) + - Streams logs back to a logstream URL + +### Testing Locally + +This repository includes a fake server that can be used to test the test runner program locally. + +- Install Ruby & Go +- `bundle install` +- `make refresh_test_fixtures` (downloads sample repositories) +- Ensure you're logged into the GitHub container registry by running this command: `docker login ghcr.io`. + - The username is your github username. + - The password is a GitHub access token with `read:packages` permissions (do **NOT** use your GitHub password here) +- Use `flyctl auth docker` to authenticate with Fly's Docker registry +- `docker compose up -d` +- `make test` + +To run a specific test: + +- `bundle exec ruby tests/run_tests_test.rb --verbose -n /when_/` + +### Deploys + +- Releases are automatically created on pushes to `main` + - This happens via [./github/workflows/{tag,release}.yml](.github/workflows/tag.yml) + - It takes 2-3 minutes for a release to be created after a push to `main`. + - You can confirm that a release has been created by checking the [Releases](https://github.com/codecrafters-io/test-runner/releases) page. +- Ask Paul to deploy the release (this part isn't automated yet) diff --git a/test-runner/docker-compose.yml b/test-runner/docker-compose.yml new file mode 100644 index 0000000..da39a44 --- /dev/null +++ b/test-runner/docker-compose.yml @@ -0,0 +1,39 @@ +version: '3' + +services: + # Used to serve Git repositories for cloning in tests + git_daemon: + image: ghcr.io/codecrafters-io/git-daemon/git-daemon:v59 + # comment this out to build locally + # build: + # context: ../git-daemon + environment: + CODECRAFTERS_SERVER_URL: "http://host.docker.internal:6331" + volumes: + - git_data:/usr/git + ports: + - "6332:9500" + extra_hosts: + - "host.docker.internal:host-gateway" # Required for CI, works by default on macOS + + # Used to programmatically create git repositories + git_api: + image: ghcr.io/codecrafters-io/git-api/web:v122 + # comment out to build locally + # build: + # context: ../git-api + environment: + GIT_ROOT_PATH: /usr/git + SENTRY_ENV: "local" + volumes: + - git_data:/usr/git + ports: + - "6333:5000" + + logstream_redis: + image: redis:alpine + ports: + - "6334:6379" + +volumes: + git_data: diff --git a/test-runner/go.mod b/test-runner/go.mod new file mode 100644 index 0000000..b409f39 --- /dev/null +++ b/test-runner/go.mod @@ -0,0 +1,36 @@ +module github.com/codecrafters-io/test-runner + +go 1.23 + +toolchain go1.24.2 + +require ( + github.com/codecrafters-io/logstream v0.2.3 + github.com/egym-playground/go-prefix-writer v0.0.0-20180609083313-7326ea162eca + github.com/fatih/color v1.16.0 + github.com/getsentry/sentry-go v0.28.0 + github.com/google/uuid v1.6.0 + github.com/hashicorp/go-retryablehttp v0.7.8 + github.com/otiai10/copy v0.0.0-20240925044834-49b0b590f1e1 + github.com/stretchr/testify v1.9.0 + gopkg.in/yaml.v2 v2.4.0 + gvisor.dev/gvisor v0.0.0-20231013233859-1a5aee553938 +) + +require ( + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/go-redis/redis/v8 v8.11.5 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/otiai10/mint v1.6.3 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rohitpaulk/asyncwriter v0.0.2 // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.14.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/test-runner/go.sum b/test-runner/go.sum new file mode 100644 index 0000000..770639a --- /dev/null +++ b/test-runner/go.sum @@ -0,0 +1,83 @@ +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/codecrafters-io/logstream v0.2.3 h1:eHVmn3J2o7rTx9e73ikLsUbYOG0yZc/I1iXbs9jk5QI= +github.com/codecrafters-io/logstream v0.2.3/go.mod h1:6iRBMIRZqSy7s4xKWJRnbDXvv7EDmlD/TtacC++h7q0= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/egym-playground/go-prefix-writer v0.0.0-20180609083313-7326ea162eca h1:sWNMfkKG8GW1pGUyNlbsWq6f04pFgcsomY+Fly8XdB4= +github.com/egym-playground/go-prefix-writer v0.0.0-20180609083313-7326ea162eca/go.mod h1:Ar+qogA+fkjeUR18xJfFzrMSjfs/sCPO+yjVvhXpyEg= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/getsentry/sentry-go v0.28.0 h1:7Rqx9M3ythTKy2J6uZLHmc8Sz9OGgIlseuO1iBX/s0M= +github.com/getsentry/sentry-go v0.28.0/go.mod h1:1fQZ+7l7eeJ3wYi82q5Hg8GqAPgefRq+FP/QhafYVgg= +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= +github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= +github.com/go-redis/redismock/v8 v8.11.5 h1:RJFIiua58hrBrSpXhnGX3on79AU3S271H4ZhRI1wyVo= +github.com/go-redis/redismock/v8 v8.11.5/go.mod h1:UaAU9dEe1C+eGr+FHV5prCWIt0hafyPWbGMEWE0UWdA= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= +github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE= +github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs= +github.com/otiai10/copy v0.0.0-20240925044834-49b0b590f1e1 h1:3gzBjrC8qp1EhwZV/x12sz5oUORNj9pqeZe1cU9kLYU= +github.com/otiai10/copy v0.0.0-20240925044834-49b0b590f1e1/go.mod h1:oQwrEDDOci3IM8dJF0d8+jnbfPDllW6vUjNc3DoZm9I= +github.com/otiai10/mint v1.6.3 h1:87qsV/aw1F5as1eH1zS/yqHY85ANKVMgkDrf9rcxbQs= +github.com/otiai10/mint v1.6.3/go.mod h1:MJm72SBthJjz8qhefc4z1PYEieWmy8Bku7CjcAqyUSM= +github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= +github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rohitpaulk/asyncwriter v0.0.2 h1:jaFb0vQIzauXkhC6rjhCatvjSd2Es6VW0CHF+GZCqbo= +github.com/rohitpaulk/asyncwriter v0.0.2/go.mod h1:y3Ja8mupuzm26lOnNtZ2TpELfHsGVr1igRlSYd7Ob0E= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gvisor.dev/gvisor v0.0.0-20231013233859-1a5aee553938 h1:vRPyvNYWBKDUL+pqHMCuzDj6gipARDWpttH3pxrVILA= +gvisor.dev/gvisor v0.0.0-20231013233859-1a5aee553938/go.mod h1:8hmigyCdYtw5xJGfQDJzSH5Ju8XEIDBnpyi8+O6GRt8= diff --git a/test-runner/internal/backend/client.go b/test-runner/internal/backend/client.go new file mode 100644 index 0000000..8461fdb --- /dev/null +++ b/test-runner/internal/backend/client.go @@ -0,0 +1,63 @@ +package backend + +import ( + "bytes" + "fmt" + "net/http" + "time" + + "github.com/codecrafters-io/test-runner/internal/globals" + "github.com/google/uuid" + "github.com/hashicorp/go-retryablehttp" +) + +var globalClient = retryablehttp.NewClient() +var loggingEnabled = true + +func init() { + globalClient.HTTPClient.Timeout = 10 * time.Second + globalClient.RetryWaitMin = 1 * time.Second + globalClient.RetryWaitMax = 5 * time.Second + globalClient.RetryMax = 5 +} + +func DisableLogging() { + loggingEnabled = false +} + +func EnableLogging() { + loggingEnabled = true +} + +func PerformRequest(method, path string, body []byte) (*http.Response, error) { + req := newRequest(method, path, body) + + if loggingEnabled { + fmt.Printf("%s %s (request_id: \"%s\")\n", req.Method, req.URL.String(), req.Header.Get("X-Request-Id")) + } + + retryableReq, err := retryablehttp.FromRequest(req) + if err != nil { + panic("CodeCrafters Internal Error: Failed to create retryable HTTP request: " + err.Error()) + } + + return globalClient.Do(retryableReq) +} + +func newRequest(method, path string, body []byte) *http.Request { + if path[0] != '/' { + panic("path must start with a slash") + } + + url := fmt.Sprintf("%s%s", globals.GetCodecraftersServerURL(), path) + req, err := http.NewRequest(method, url, bytes.NewBuffer(body)) + if err != nil { + panic("CodeCrafters Internal Error: Failed to create HTTP request: " + err.Error()) + } + + req.Header.Add("Accept", "application/json") + req.Header.Add("Content-type", "application/json") + req.Header.Add("X-Request-Id", uuid.New().String()) + + return req +} diff --git a/test-runner/internal/backend/create_test_run_result.go b/test-runner/internal/backend/create_test_run_result.go new file mode 100644 index 0000000..be51ce6 --- /dev/null +++ b/test-runner/internal/backend/create_test_run_result.go @@ -0,0 +1,41 @@ +package backend + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" +) + +func CreateTestRunResult(testRunId string, testRunnerId string, logs []byte, status string) error { + body := map[string]string{ + "test_run_id": testRunId, + "test_runner_id": testRunnerId, + "status": status, + "logs_base64": base64.StdEncoding.EncodeToString(logs), + } + + bodyJson, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + + resp, err := PerformRequest("POST", "/services/test_runner/create_test_run_result", bodyJson) + if err != nil { + return fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + fmt.Printf("Response: %d\n", resp.StatusCode) + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + if resp.StatusCode != 200 { + return fmt.Errorf("unexpected HTTP status %d. Body: %s", resp.StatusCode, string(bodyBytes)) + } + + return nil +} diff --git a/test-runner/internal/backend/finalize_build.go b/test-runner/internal/backend/finalize_build.go new file mode 100644 index 0000000..693b65e --- /dev/null +++ b/test-runner/internal/backend/finalize_build.go @@ -0,0 +1,39 @@ +package backend + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" +) + +func FinalizeBuild(buildId string, status string, logs []byte) error { + body := map[string]string{ + "test_runner_build_id": buildId, + "status": status, + "logs_base64": base64.StdEncoding.EncodeToString(logs), + } + + bodyJson, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + + resp, err := PerformRequest("POST", "/services/test_runner/finalize_test_runner_build", bodyJson) + if err != nil { + return fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == 200 { + return nil + } else { + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("unexpected HTTP status %d. Body read error: %w", resp.StatusCode, err) + } + + return fmt.Errorf("unexpected HTTP status %d. Body: %s", resp.StatusCode, string(bodyBytes)) + } +} diff --git a/test-runner/internal/backend/report_shutdown.go b/test-runner/internal/backend/report_shutdown.go new file mode 100644 index 0000000..a4f8942 --- /dev/null +++ b/test-runner/internal/backend/report_shutdown.go @@ -0,0 +1,37 @@ +package backend + +import ( + "encoding/json" + "fmt" + "io" +) + +func ReportShutdown(testRunnerId string) error { + body := map[string]string{ + "test_runner_id": testRunnerId, + } + + bodyJson, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + + resp, err := PerformRequest("POST", "/services/test_runner/report_shutdown", bodyJson) + if err != nil { + return fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + fmt.Printf("Response: %d\n", resp.StatusCode) + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + if resp.StatusCode == 200 { + return nil + } else { + return fmt.Errorf("unexpected HTTP status %d. Body: %s", resp.StatusCode, string(bodyBytes)) + } +} diff --git a/test-runner/internal/backend/reserve_test_run.go b/test-runner/internal/backend/reserve_test_run.go new file mode 100644 index 0000000..a87f75d --- /dev/null +++ b/test-runner/internal/backend/reserve_test_run.go @@ -0,0 +1,62 @@ +package backend + +import ( + "encoding/json" + "fmt" + "io" +) + +type HTTPStatusError struct { + StatusCode int +} + +func (e *HTTPStatusError) Error() string { + return fmt.Sprintf("HTTP status %d", e.StatusCode) +} + +type ReservedTestRun struct { + ID string `json:"id"` + CommitSHA string `json:"commit_sha"` + TestCasesJSON string `json:"test_cases_json"` + LogstreamURL string `json:"logstream_url"` +} + +type ReserveTestRunResponse struct { + TestRun *ReservedTestRun `json:"test_run"` + ShouldRetry bool `json:"should_retry"` +} + +func ReserveTestRun(testRunnerId string) (ReserveTestRunResponse, error) { + path := fmt.Sprintf("/services/test_runner/reserve_test_run?test_runner_id=%s", testRunnerId) + + resp, err := PerformRequest("POST", path, nil) + if err != nil { + return ReserveTestRunResponse{}, fmt.Errorf("HTTP request failed: %w", err) + } + + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return ReserveTestRunResponse{}, fmt.Errorf("read response failed: %w", err) + } + + fmt.Printf("Response (%d): %s\n", resp.StatusCode, string(body)) + + // Handle legacy response (TODO: Remove this once we've migrated all clients) + if resp.StatusCode == 202 { + return ReserveTestRunResponse{ShouldRetry: false}, nil + } + + if resp.StatusCode == 200 { + var reserveTestRunResponse ReserveTestRunResponse + + if err = json.Unmarshal(body, &reserveTestRunResponse); err != nil { + return ReserveTestRunResponse{}, fmt.Errorf("unable to parse response: %w", err) + } + + return reserveTestRunResponse, nil + } else { + return ReserveTestRunResponse{}, &HTTPStatusError{StatusCode: resp.StatusCode} + } +} diff --git a/test-runner/internal/backend/trigger_infrastructure_rebuild.go b/test-runner/internal/backend/trigger_infrastructure_rebuild.go new file mode 100644 index 0000000..54fdac9 --- /dev/null +++ b/test-runner/internal/backend/trigger_infrastructure_rebuild.go @@ -0,0 +1,49 @@ +package backend + +import ( + "encoding/json" + "fmt" + "io" +) + +type TriggerInfrastructureRebuildResponse struct { + ID string `json:"id"` + CommitSHA string `json:"commit_sha"` + LogstreamURL string `json:"logstream_url"` +} + +func TriggerInfrastructureRebuild(testRunId string, testRunnerId string) (TriggerInfrastructureRebuildResponse, error) { + body := map[string]string{ + "test_run_id": testRunId, + "test_runner_id": testRunnerId, + } + + bodyJson, err := json.Marshal(body) + if err != nil { + return TriggerInfrastructureRebuildResponse{}, fmt.Errorf("failed to marshal JSON: %w", err) + } + + resp, err := PerformRequest("POST", "/services/test_runner/rebuild_for_test_run", bodyJson) + if err != nil { + return TriggerInfrastructureRebuildResponse{}, fmt.Errorf("HTTP request failed: %w", err) + } + + defer resp.Body.Close() + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return TriggerInfrastructureRebuildResponse{}, fmt.Errorf("failed to read response body: %w", err) + } + + if resp.StatusCode != 200 { + return TriggerInfrastructureRebuildResponse{}, fmt.Errorf("unexpected HTTP status %d. Body: %s", resp.StatusCode, string(bodyBytes)) + } + + buildResponse := &TriggerInfrastructureRebuildResponse{} + err = json.Unmarshal(bodyBytes, buildResponse) + + if err != nil { + return TriggerInfrastructureRebuildResponse{}, fmt.Errorf("failed to parse response into submission: %w", err) + } + + return *buildResponse, nil +} diff --git a/test-runner/internal/build_image.go b/test-runner/internal/build_image.go new file mode 100644 index 0000000..49a9ae5 --- /dev/null +++ b/test-runner/internal/build_image.go @@ -0,0 +1,109 @@ +package internal + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/codecrafters-io/test-runner/internal/globals" + "github.com/egym-playground/go-prefix-writer/prefixer" + cp "github.com/otiai10/copy" + "gvisor.dev/gvisor/pkg/linewriter" +) + +type RemoteDockerImageConfiguration struct { + ImageName string + ImageTag string + RegistryDomain string + RegistryUsername string + RegistryPassword string + DepotToken string + DepotProject string +} + +func (c RemoteDockerImageConfiguration) FullTag() string { + return fmt.Sprintf("%s/%s:%s", c.RegistryDomain, c.ImageName, c.ImageTag) +} + +func BuildImage(build *TestRunnerBuild, dockerImageConfiguration RemoteDockerImageConfiguration, repositoryDir string, dockerfilePath string, testerDir string, testRunnerDir string, repositoryId string) (bool, error) { + newTesterDir := filepath.Join(repositoryDir, "tester") + newTestRunnerDir := filepath.Join(repositoryDir, "test-runner") + + for _, dir := range []string{newTesterDir, newTestRunnerDir} { + if _, err := os.Stat(dir); !os.IsNotExist(err) { + removeErr := os.RemoveAll(dir) + if removeErr != nil { + return false, fmt.Errorf("failed to remove directory: %w", removeErr) + } + } + } + + if err := cp.Copy(testerDir, newTesterDir); err != nil { + return false, fmt.Errorf("failed to copy tester directory: %w", err) + } + + if err := cp.Copy(testRunnerDir, newTestRunnerDir); err != nil { + return false, fmt.Errorf("failed to copy test-runner directory: %w", err) + } + + commandParts := []string{ + "depot", + "build", + "--provenance", "false", + "--token", dockerImageConfiguration.DepotToken, + "--project", dockerImageConfiguration.DepotProject, + "--tag", dockerImageConfiguration.FullTag(), + "--file", dockerfilePath, + "--build-arg", fmt.Sprintf("REPOSITORY_ID=%s", repositoryId), + "--build-arg", fmt.Sprintf("CODECRAFTERS_SERVER_URL=%s", globals.GetCodecraftersServerURL()), + "--push", + repositoryDir, + } + + cmd := exec.Command(commandParts[0], commandParts[1:]...) + + userLogsSink := build + prefixedWriter := prefixer.New(userLogsSink, func() string { return "\033[33m[build]\033[0m " }) + buildLogsProcessor := NewBuildLogsProcessor(prefixedWriter) + lineWriter := linewriter.NewWriter(func(p []byte) { buildLogsProcessor.OnLogLine(p) }) + + cmd.Stdout = lineWriter // Stdout isn't relevant for builds + cmd.Stderr = lineWriter // Let's send both raw and prefixed logs to stderr + + prefixedWriter.Write([]byte(fmt.Sprintf("\033[34m%s\033[0m\n", "Starting build..."))) + prefixedWriter.Write([]byte(fmt.Sprintf("\033[34m%s\033[0m\n", "If you don't see logs for 60s+, please contact us at hello@codecrafters.io"))) + + fmt.Printf("Running command: %s\n", cmd.String()) + + // TODO: More prefixedWriter logs? + + err := cmd.Run() // Also prints logs, since we set cmd.Stdout and cmd.Stderr + + if exitError, ok := err.(*exec.ExitError); ok { + prefixedWriter.Write([]byte("\033[31mBuild failed. Check the logs above for the reason.\033[0m\n")) + prefixedWriter.Write([]byte("\033[31mIf you think this is a CodeCrafters error, please contact us at hello@codecrafters.io.\033[0m\n")) + userLogsSink.Write([]byte("\n")) + return exitError.ExitCode() == 0, nil + } + + if err != nil { + prefixedWriter.Write([]byte("\033[31mBuild failed. Check the logs above for the reason.\033[0m\n")) + prefixedWriter.Write([]byte("\033[31mIf you think this is a CodeCrafters error, please contact us at hello@codecrafters.io.\033[0m\n")) + userLogsSink.Write([]byte("\n")) + return false, nil // If the build command itself fails, for now we'll assume it was a user error. + } + + prefixedWriter.Write([]byte("\033[32mBuild successful.\033[0m\n")) + userLogsSink.Write([]byte("\n")) + + // Ensure we don't leave any unflushed logs + if build.testRunLogstreamWriter != nil { + if err := build.testRunLogstreamWriter.Flush(); err != nil { + // Don't report a user error for now + fmt.Printf("CodeCrafters Internal Error: failed to flush logs: %v\n", err) + } + } + + return true, nil +} diff --git a/test-runner/internal/build_logs_processor.go b/test-runner/internal/build_logs_processor.go new file mode 100644 index 0000000..4349951 --- /dev/null +++ b/test-runner/internal/build_logs_processor.go @@ -0,0 +1,57 @@ +package internal + +import ( + "fmt" + "io" + "regexp" +) + +type buildLogsProcessor struct { + // lastLine []byte + currentBuildStepLine []byte + writer io.Writer +} + +var buildStepLineRegex = regexp.MustCompile(`^#(\d+) \[[^\]]+\] (.*)$`) +var buildStepDoneRegex = regexp.MustCompile(`^#(\d+) DONE .*$`) +var buildStepCachedRegex = regexp.MustCompile(`^#(\d+) CACHED.*$`) +var buildProgressLineRegex = regexp.MustCompile(`^#(\d+) [\d.]+ (.*)$`) +var buildErrorLineRegex = regexp.MustCompile(`^#(\d+) ERROR: (.*)$`) + +// func (p *buildLogsProcessor) currentBuildStepNumber() string { +// return string(buildStepLineRegex.FindSubmatch(p.currentBuildStepLine)[1]) +// } + +func (p *buildLogsProcessor) OnLogLine(line []byte) { + // p.writer.Write(append([]byte("[Verbose Logger] "), line...)) + if buildStepLineRegex.Match(line) { + // Ignore the line + p.currentBuildStepLine = line + } else if buildStepDoneRegex.Match(line) { + stepNumber := buildStepDoneRegex.FindSubmatch(line)[1] + p.writer.Write([]byte(fmt.Sprintf("Step %s complete.\n", stepNumber))) + p.currentBuildStepLine = nil + } else if buildStepCachedRegex.Match(line) { + stepNumber := buildStepCachedRegex.FindSubmatch(line)[1] + p.writer.Write([]byte(fmt.Sprintf("Step %s complete.\n", stepNumber))) + p.currentBuildStepLine = nil + } else if buildErrorLineRegex.Match(line) { + error := string(buildErrorLineRegex.FindSubmatch(line)[2]) + p.writer.Write([]byte(fmt.Sprintf("Error: %s.\n", error))) + } else { + if p.currentBuildStepLine == nil { + // Ignore the line + } else { + if buildProgressLineRegex.Match(line) { + progressText := string(buildProgressLineRegex.FindSubmatch(line)[2]) + p.writer.Write([]byte(fmt.Sprintf("> %s\n", progressText))) + } + } + } +} + +func NewBuildLogsProcessor(writer io.Writer) *buildLogsProcessor { + return &buildLogsProcessor{ + writer: writer, + } +} diff --git a/test-runner/internal/build_logs_processor_test.go b/test-runner/internal/build_logs_processor_test.go new file mode 100644 index 0000000..31fe67b --- /dev/null +++ b/test-runner/internal/build_logs_processor_test.go @@ -0,0 +1,68 @@ +package internal + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBuildLogsProcessorSimple(t *testing.T) { + bytesBuffer := bytes.NewBuffer([]byte{}) + p := NewBuildLogsProcessor(bytesBuffer) + + p.OnLogLine([]byte("#1 [depot] build: xxx")) + p.OnLogLine([]byte("#1 DONE 0.0s")) + p.OnLogLine([]byte("")) + p.OnLogLine([]byte("#2 [depot] launching amd64 machine")) + p.OnLogLine([]byte("#2 DONE 0.3s")) + + assert.Equal(t, "Step 1 complete.\nStep 2 complete.\n", bytesBuffer.String()) +} + +func TestBuildLogsProcessorCached(t *testing.T) { + bytesBuffer := bytes.NewBuffer([]byte{}) + p := NewBuildLogsProcessor(bytesBuffer) + + logs := ` +#26 [19/21] WORKDIR /app +#26 CACHED + +#27 [20/21] COPY . /app +#27 DONE 0.2s +` + + logLines := bytes.Split([]byte(logs), []byte("\n")) + + for _, line := range logLines { + p.OnLogLine(line) + } + + assert.Equal(t, "Step 26 complete.\nStep 27 complete.\n", bytesBuffer.String()) +} + +func TestBuildLogsProcessorRunCommandProgress(t *testing.T) { + bytesBuffer := bytes.NewBuffer([]byte{}) + p := NewBuildLogsProcessor(bytesBuffer) + + logs := ` +#13 [ 6/21] WORKDIR /app +#13 DONE 0.0s + +#14 [ 7/21] RUN cargo build --release --target-dir=/tmp/codecrafters-redis-target +#14 0.600 Updating crates.io index +#14 0.678 Downloading crates... +#14 DONE 8.3s + +#15 [ 8/21] RUN rm /tmp/codecrafters-redis-target/release/redis-starter-rust +#15 DONE 0.2s +` + + logLines := bytes.Split([]byte(logs), []byte("\n")) + + for _, line := range logLines { + p.OnLogLine(line) + } + + assert.Equal(t, "Step 13 complete.\n> Updating crates.io index\n> Downloading crates...\nStep 14 complete.\nStep 15 complete.\n", bytesBuffer.String()) +} diff --git a/test-runner/internal/color/color.go b/test-runner/internal/color/color.go new file mode 100644 index 0000000..f9d4bac --- /dev/null +++ b/test-runner/internal/color/color.go @@ -0,0 +1,23 @@ +package color + +import fatihColor "github.com/fatih/color" + +// Red colors the given text in red +func Red(text string) string { + colorer := fatihColor.New(fatihColor.FgRed) + colorer.EnableColor() + return colorer.SprintFunc()(text) +} + +// Blue colors the given text in blue +func Blue(text string) string { + colorer := fatihColor.New(fatihColor.FgBlue) + colorer.EnableColor() + return colorer.SprintFunc()(text) +} + +func Green(text string) string { + colorer := fatihColor.New(fatihColor.FgGreen) + colorer.EnableColor() + return colorer.SprintFunc()(text) +} diff --git a/test-runner/internal/commands/build_image_command.go b/test-runner/internal/commands/build_image_command.go new file mode 100644 index 0000000..702848d --- /dev/null +++ b/test-runner/internal/commands/build_image_command.go @@ -0,0 +1,260 @@ +package commands + +import ( + "flag" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/codecrafters-io/test-runner/internal" + "github.com/codecrafters-io/test-runner/internal/backend" + "github.com/codecrafters-io/test-runner/internal/color" + "github.com/codecrafters-io/test-runner/internal/globals" +) + +// If err is FriendlyError, it's a user error. Otherwise, it's an internal error. +// TODO: Handle write errors if InitLogging actually failed? +func handleErrorDuringBuild(build *internal.TestRunnerBuild, logPrefix string, err error) int { + if logPrefix != "" { + fmt.Printf("%s: %s\n", logPrefix, err) + } else { + fmt.Printf("%s\n", err) + } + + if build != nil { + if friendlyErr, ok := err.(*internal.FriendlyError); ok { + if !friendlyErr.IsLogged { + lines := strings.Split(friendlyErr.UserError, "\n") + for _, line := range lines { + build.Write([]byte(fmt.Sprintf("%s\n", color.Red(line)))) + } + } + + build.Write([]byte(fmt.Sprintf("%s\n", color.Red("If you think this is a CodeCrafters bug, please contact us at hello@codecrafters.io.")))) + + if err = flushLogsAndFinalizeBuild(build, "failure"); err != nil { + fmt.Printf("Failed to create build result: %s\n", err) + return 1 // Failed to report + } else { + return 0 // User error + } + } else { + build.Write([]byte(fmt.Sprintf("%s\n", color.Red(fmt.Sprintf("CodeCrafters internal error: %s", err))))) + build.Write([]byte(fmt.Sprintf("%s\n", color.Red("Try again? Contact us at hello@codecrafters.io if this persists.")))) + + // Best effort, we're still going to return 1 since the error is an internal error + if err = flushLogsAndFinalizeBuild(build, "error"); err != nil { + fmt.Printf("Failed to create build result: %s\n", err) + } + + return 1 + } + } + return 1 +} + +func flushLogsAndFinalizeBuild(build *internal.TestRunnerBuild, status string) error { + if status != "success" && status != "failure" && status != "error" { + panic(fmt.Errorf("invalid status: %s", status)) + } + + logs, err := build.ReadLogsFromFile() + if err != nil { + return err + } + + build.Close() // Ensure we close the logstream writer + build.CleanupLogging() + + return backend.FinalizeBuild(build.ID, status, logs) +} + +func BuildImageCommand() int { + var buildCommitSha string + var buildId string + var buildLogstreamUrl string + var buildTestRunLogstreamUrl string + var buildpackDockerfilePath string + var buildpackSlug string + var codecraftersServerUrl string + var courseSlug string + var depotToken string + var depotProject string + var dockerImageName string + var dockerImageTag string + var dockerRegistryDomain string + var dockerRegistryPassword string + var dockerRegistryUsername string + var repositoryDir string + var repositoryId string + var testRunnerDir string + var testerDir string + + flag.StringVar(&buildCommitSha, "build-commit-sha", "", "The SHA of the commit to use for building") + flag.StringVar(&buildId, "build-id", "", "The ID of the build") + flag.StringVar(&buildLogstreamUrl, "build-logstream-url", "", "The Logstream URL of the build") + flag.StringVar(&buildTestRunLogstreamUrl, "build-test-run-logstream-url", "", "The Logstream URL of a test run that's waiting on this build") + flag.StringVar(&buildpackDockerfilePath, "buildpack-dockerfile-path", "", "The path to the buildpack Dockerfile") + flag.StringVar(&buildpackSlug, "buildpack-slug", "", "The slug of the buildpack") + flag.StringVar(&codecraftersServerUrl, "codecrafters-server-url", "", "The server URL of Codecrafters") + flag.StringVar(&courseSlug, "course-slug", "", "The slug of the course") + flag.StringVar(&depotToken, "depot-token", "", "The Depot token to use") + flag.StringVar(&depotProject, "depot-project", "", "The Depot project to use") + flag.StringVar(&dockerImageName, "docker-image-name", "", "The name of the Docker image") + flag.StringVar(&dockerImageTag, "docker-image-tag", "", "The tag of the Docker image") + flag.StringVar(&dockerRegistryDomain, "docker-registry-domain", "", "The domain of the Docker registry") + flag.StringVar(&dockerRegistryPassword, "docker-registry-password", "", "The password of the Docker registry") + flag.StringVar(&dockerRegistryUsername, "docker-registry-username", "", "The username of the Docker registry") + flag.StringVar(&repositoryId, "repository-id", "", "The ID of the repository") + flag.StringVar(&repositoryDir, "repository-dir", "", "The directory of the repository") + flag.StringVar(&testRunnerDir, "test-runner-dir", "", "The directory of the test runner") + flag.StringVar(&testerDir, "tester-dir", "", "The directory of the tester") + + flag.CommandLine.Parse(os.Args[2:]) // Use 2: to avoid the first command + + if codecraftersServerUrl == "" { + fmt.Println("codecrafters-server-url must be provided") + os.Exit(1) + } + + globals.SetCodecraftersServerURL(codecraftersServerUrl) + + fmt.Printf("buildId: %q, buildLogstreamUrl: %q\n", buildId, buildLogstreamUrl) + + build := &internal.TestRunnerBuild{ + CommitSHA: buildCommitSha, + ID: buildId, + LogstreamURL: buildLogstreamUrl, + TestRunLogstreamURL: buildTestRunLogstreamUrl, + } + + if err := build.InitLogging(); err != nil { + return handleErrorDuringBuild(build, "Failed to initialize logging", err) + } + defer build.CleanupLogging() + + // Just making sure we don't use these again, and use values on build instead. + buildId = "" + buildLogstreamUrl = "" + buildCommitSha = "" + buildTestRunLogstreamUrl = "" + + flags := map[string]string{ + "buildpack-dockerfile-path": buildpackDockerfilePath, + "buildpack-slug": buildpackSlug, + "course-slug": courseSlug, + "depot-token": depotToken, + "depot-project": depotProject, + "repository-id": repositoryId, + "repository-dir": repositoryDir, + "tester-dir": testerDir, + "test-runner-dir": testRunnerDir, + "docker-image-name": dockerImageName, + "docker-image-tag": dockerImageTag, + "docker-registry-domain": dockerRegistryDomain, + "docker-registry-username": dockerRegistryUsername, + "docker-registry-password": dockerRegistryPassword, + } + + for flagName, flagValue := range flags { + fmt.Printf("%-30s: %q\n", flagName, flagValue) + if flagValue == "" { + return handleErrorDuringBuild(build, "", fmt.Errorf("%s must be provided", flagName)) + } + } + + if _, err := os.Stat(testerDir); os.IsNotExist(err) { + return handleErrorDuringBuild(build, "", fmt.Errorf("tester dir (%s) does not exist", testerDir)) + } + + if _, err := os.Stat(testRunnerDir); os.IsNotExist(err) { + return handleErrorDuringBuild(build, "", fmt.Errorf("test runner dir (%s) does not exist", testRunnerDir)) + } + + if _, err := os.Stat(repositoryDir); os.IsNotExist(err) { + return handleErrorDuringBuild(build, "", fmt.Errorf("repository dir (%s) does not exist", repositoryDir)) + } + + currentCommitSha, err := internal.GetHeadCommitSha(repositoryDir) + if err != nil { + return handleErrorDuringBuild(build, "Failed to get HEAD commit SHA", err) + } + + if build.CommitSHA == "" { + build.CommitSHA = currentCommitSha + } + + if build.CommitSHA != currentCommitSha { + fmt.Printf("Checking out commit %s\n", build.CommitSHA) + if err := internal.SwitchToCommit(repositoryDir, build.CommitSHA); err != nil { + return handleErrorDuringBuild(build, fmt.Sprintf("failed to checkout commit %s", build.CommitSHA), err) + } + } else { + fmt.Println("HEAD commit SHA is same as expected") + } + + buildpackSlugInRepo, friendlyErr := internal.GetBuildpack(repositoryDir) + if friendlyErr != nil { + return handleErrorDuringBuild(build, "Failed to get buildpack", friendlyErr) + } + + if buildpackSlugInRepo != buildpackSlug { + return handleErrorDuringBuild(build, "", &internal.FriendlyError{ + UserError: fmt.Sprintf("Detected changes to buildpack (old: %s, new: %s).\nThis isn't supported. If you're trying to upgrade to a new language version, please run `codecrafters update-buildpack`.", buildpackSlug, buildpackSlugInRepo), + }) + } + + fmt.Printf("Buildpack Slug: %s\n", buildpackSlugInRepo) + fmt.Printf("Buildpack Dockerfile: %s\n", buildpackDockerfilePath) + + if _, err := os.Stat(buildpackDockerfilePath); os.IsNotExist(err) { + return handleErrorDuringBuild(build, "", fmt.Errorf("dockerfile (%s) does not exist", buildpackDockerfilePath)) + } + + dockerignorePath := filepath.Join(repositoryDir, ".dockerignore") + if _, err := os.Stat(dockerignorePath); err == nil { + if err := os.RemoveAll(dockerignorePath); err != nil { + return handleErrorDuringBuild(build, "Failed to remove .dockerignore", err) + } + } + + buildWasSuccessful, err := internal.BuildImage( + build, + internal.RemoteDockerImageConfiguration{ + ImageName: dockerImageName, + ImageTag: dockerImageTag, + RegistryDomain: dockerRegistryDomain, + RegistryUsername: dockerRegistryUsername, + RegistryPassword: dockerRegistryPassword, + DepotToken: depotToken, + DepotProject: depotProject, + }, + repositoryDir, + buildpackDockerfilePath, + testerDir, + testRunnerDir, + repositoryId, + ) + if err != nil { + return handleErrorDuringBuild(build, "Failed to run tester", err) + } + + if buildWasSuccessful { + fmt.Println("Build was successful") + + err = flushLogsAndFinalizeBuild(build, "success") + if err != nil { + return handleErrorDuringBuild(build, "Failed to finalize build", err) + } + } else { + fmt.Println("Build was not successful") + + err = flushLogsAndFinalizeBuild(build, "failure") + if err != nil { + return handleErrorDuringBuild(build, "Failed to finalize build", err) + } + } + + return 0 +} diff --git a/test-runner/internal/friendly_error.go b/test-runner/internal/friendly_error.go new file mode 100644 index 0000000..6e81601 --- /dev/null +++ b/test-runner/internal/friendly_error.go @@ -0,0 +1,11 @@ +package internal + +type FriendlyError struct { + UserError string + InternalError string + IsLogged bool // Helps us avoid logging the same error twice +} + +func (e *FriendlyError) Error() string { + return e.UserError +} diff --git a/test-runner/internal/get_buildpack.go b/test-runner/internal/get_buildpack.go new file mode 100644 index 0000000..93ae0d2 --- /dev/null +++ b/test-runner/internal/get_buildpack.go @@ -0,0 +1,42 @@ +package internal + +import ( + "fmt" + "os" + "strings" + + "gopkg.in/yaml.v2" +) + +func GetBuildpack(repositoryDir string) (string, error) { + file, err := os.Open(fmt.Sprintf("%s/codecrafters.yml", repositoryDir)) + if err != nil { + return "", &FriendlyError{ + UserError: "Didn't find a codecrafters.yml file in your repository. This is required to run tests.", + InternalError: fmt.Sprintf("failed to open codecrafters.yml. err=%s", err), + } + } + defer file.Close() + + decoder := yaml.NewDecoder(file) + var data map[string]interface{} + err = decoder.Decode(&data) + if err != nil { + return "", &FriendlyError{ + UserError: "Your codecrafters.yml file is not valid YAML. Please fix it and try again.", + InternalError: fmt.Sprintf("failed to decode codecrafters.yml. err=%s", err), + } + } + + // Try buildpack first (new), then fall back to language_pack (old) for backwards compatibility + if buildpack, ok := data["buildpack"].(string); ok { + return strings.TrimSpace(buildpack), nil + } else if languagePack, ok := data["language_pack"].(string); ok { + return strings.TrimSpace(languagePack), nil + } else { + return "", &FriendlyError{ + UserError: "Could not find buildpack in codecrafters.yml. Please make sure codecrafters.yml is a valid YAML file with a buildpack key.", + InternalError: "codecrafters.yml is missing buildpack field", + } + } +} diff --git a/test-runner/internal/get_head_commit_sha.go b/test-runner/internal/get_head_commit_sha.go new file mode 100644 index 0000000..7b5ac6e --- /dev/null +++ b/test-runner/internal/get_head_commit_sha.go @@ -0,0 +1,18 @@ +package internal + +import ( + "fmt" + "os/exec" + "strings" +) + +func GetHeadCommitSha(repositoryDir string) (string, error) { + cmd := exec.Command("git", "-C", repositoryDir, "rev-parse", "HEAD") + + output, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("failed to get HEAD commit SHA: %w. Output: %v", err, string(output)) + } + + return strings.TrimSpace(string(output)), nil +} diff --git a/test-runner/internal/globals/codecrafters_server_url.go b/test-runner/internal/globals/codecrafters_server_url.go new file mode 100644 index 0000000..954a227 --- /dev/null +++ b/test-runner/internal/globals/codecrafters_server_url.go @@ -0,0 +1,15 @@ +package globals + +var codecraftersServerURL string + +func SetCodecraftersServerURL(url string) { + codecraftersServerURL = url +} + +func GetCodecraftersServerURL() string { + if codecraftersServerURL == "" { + panic("CodecraftersServerURL is not set") + } + + return codecraftersServerURL +} diff --git a/test-runner/internal/move_app_cached_files_if_needed.go b/test-runner/internal/move_app_cached_files_if_needed.go new file mode 100644 index 0000000..32c85e1 --- /dev/null +++ b/test-runner/internal/move_app_cached_files_if_needed.go @@ -0,0 +1,24 @@ +package internal + +import ( + "fmt" + "os" + + cp "github.com/otiai10/copy" +) + +func MoveAppCachedFilesIfNeeded(repositoryDir string) error { + if _, err := os.Stat("/app-cached"); !os.IsNotExist(err) { + fmt.Printf("Moving /app-cached to %s\n", repositoryDir) + + err := cp.Copy("/app-cached", repositoryDir, cp.Options{ + PreserveTimes: true, // Some caching strategies rely on file timestamps + PreserveOwner: true, // Some caching strategies rely on file ownership + }) + if err != nil { + return fmt.Errorf("failed to copy dir: %s", err) + } + } + + return nil +} diff --git a/test-runner/internal/run_precompilation_if_needed.go b/test-runner/internal/run_precompilation_if_needed.go new file mode 100644 index 0000000..17921bd --- /dev/null +++ b/test-runner/internal/run_precompilation_if_needed.go @@ -0,0 +1,71 @@ +package internal + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path" + + "github.com/egym-playground/go-prefix-writer/prefixer" +) + +func RunPrecompilationIfNeeded(testRun *TestRun, repositoryDir string) error { + logWriter := testRun + logWithPrefixWriter := prefixer.New(logWriter, func() string { return "\033[33m[compile]\033[0m " }) + + newPrecompileFilePath := path.Join(repositoryDir, ".codecrafters/compile.sh") + legacyPrecompileFilePath := "/codecrafters-precompile.sh" + + var precompileFilePath string + _, newPrecompileFilePathErr := os.Stat(newPrecompileFilePath) + _, legacyPrecompileFilePathErr := os.Stat(legacyPrecompileFilePath) + + if newPrecompileFilePathErr == nil { + precompileFilePath = newPrecompileFilePath + } else if legacyPrecompileFilePathErr == nil { + precompileFilePath = legacyPrecompileFilePath + } else if !errors.Is(newPrecompileFilePathErr, os.ErrNotExist) { // new script exists but failed to stat + return &FriendlyError{ + UserError: "CodeCrafters internal error: failed to stat precompile script: " + newPrecompileFilePathErr.Error(), + InternalError: "failed to stat precompile script: " + newPrecompileFilePathErr.Error(), + } + } else if !errors.Is(legacyPrecompileFilePathErr, os.ErrNotExist) { // legacy script exists but failed to stat + return &FriendlyError{ + UserError: "CodeCrafters internal error: failed to stat precompile script: " + legacyPrecompileFilePathErr.Error(), + InternalError: "failed to stat precompile script: " + legacyPrecompileFilePathErr.Error(), + } + } + + // No precompile script found + if precompileFilePath == "" { + return nil + } + + cmd := exec.Command("sh", precompileFilePath) + cmd.Env = os.Environ() + cmd.Env = append(cmd.Env, fmt.Sprintf("CODECRAFTERS_SUBMISSION_DIR=%s", repositoryDir)) // TODO: Legacy, remove + cmd.Env = append(cmd.Env, fmt.Sprintf("CODECRAFTERS_REPOSITORY_DIR=%s", repositoryDir)) + cmd.Stdout = logWithPrefixWriter + cmd.Stderr = logWithPrefixWriter + + err := cmd.Run() + if _, ok := err.(*exec.ExitError); ok { + logWithPrefixWriter.Write([]byte("\033[31mLooks like your code failed to compile.\033[0m\n")) + logWithPrefixWriter.Write([]byte("\033[31mIf you think this is a CodeCrafters error, please let us know at hello@codecrafters.io.\033[0m\n")) + logWriter.Write([]byte("\n")) + + return &FriendlyError{ + UserError: "Compilation failed (user error!)", + InternalError: "Compilation failed (internal error!)", + IsLogged: true, + } + } else if err != nil { + return &FriendlyError{ + UserError: "CodeCrafters internal error: failed to run precompile script: " + err.Error(), + InternalError: "failed to run precompile script: " + err.Error(), + } + } + + return nil +} diff --git a/test-runner/internal/run_tester.go b/test-runner/internal/run_tester.go new file mode 100644 index 0000000..bbedf76 --- /dev/null +++ b/test-runner/internal/run_tester.go @@ -0,0 +1,33 @@ +package internal + +import ( + "fmt" + "os" + "os/exec" + "path" +) + +func RunTester(testRun *TestRun, repositoryDir string, testerDir string) (bool, error) { + cmd := exec.Command(path.Join(testerDir, "test.sh")) + cmd.Env = os.Environ() + cmd.Env = append(cmd.Env, "PYTHONUNBUFFERED=true") // TODO: Move this to dockerfile? + cmd.Env = append(cmd.Env, fmt.Sprintf("CODECRAFTERS_SUBMISSION_DIR=%s", repositoryDir)) // TODO: Legacy, remove + cmd.Env = append(cmd.Env, fmt.Sprintf("CODECRAFTERS_REPOSITORY_DIR=%s", repositoryDir)) + cmd.Env = append(cmd.Env, fmt.Sprintf("TESTER_DIR=%s", testerDir)) + cmd.Env = append(cmd.Env, fmt.Sprintf("CODECRAFTERS_TEST_CASES_JSON=%s", testRun.TestCasesJSON)) + cmd.Stdout = testRun + cmd.Stderr = testRun + + err := cmd.Run() + if exitError, ok := err.(*exec.ExitError); ok { + if exitError.ExitCode() != 0 && exitError.ExitCode() != 1 { + return false, err + } + + return exitError.ExitCode() == 0, nil + } else if err != nil { + return false, err + } + + return true, nil +} diff --git a/test-runner/internal/switch_to_commit.go b/test-runner/internal/switch_to_commit.go new file mode 100644 index 0000000..bbd53df --- /dev/null +++ b/test-runner/internal/switch_to_commit.go @@ -0,0 +1,61 @@ +package internal + +import ( + "context" + "fmt" + "os" + "os/exec" + "time" +) + +func SwitchToCommit(repositoryDir string, newCommitSHA string) error { + commands := []struct { + name string + args []string + errMsg string + timeout time.Duration + }{ + // Clean up any untracked files, (such as dummy ones created in the Dockerfile) + { + name: "git", + args: []string{"-C", repositoryDir, "clean", "-fd"}, + errMsg: "failed to clean repository", + timeout: 2 * time.Second, + }, + { + name: "git", + args: []string{"-C", repositoryDir, "fetch", "origin", newCommitSHA}, // This commit might not be on the default branch, so we need to fetch it specifically + errMsg: "failed to fetch commit from origin", + timeout: 10 * time.Second, + }, + { + name: "git", + args: []string{"-C", repositoryDir, "checkout", "-f", newCommitSHA}, + errMsg: "failed to checkout commit", + timeout: 10 * time.Second, + }, + } + + for _, command := range commands { + // Create a context with timeout + ctx, cancel := context.WithTimeout(context.Background(), command.timeout) + defer cancel() + + // Start command with context + cmd := exec.CommandContext(ctx, command.name, command.args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + err := cmd.Run() + if err != nil { + // Check if the error is due to timeout + if ctx.Err() == context.DeadlineExceeded { + return fmt.Errorf("%s, timeout after %d seconds", command.errMsg, int(command.timeout.Seconds())) + } + + return fmt.Errorf("%s, %w", command.errMsg, err) + } + } + + return nil +} diff --git a/test-runner/internal/test_run.go b/test-runner/internal/test_run.go new file mode 100644 index 0000000..ad460be --- /dev/null +++ b/test-runner/internal/test_run.go @@ -0,0 +1,78 @@ +package internal + +import ( + "fmt" + "io" + "os" + + "github.com/codecrafters-io/logstream/redis" +) + +type TestRun struct { + ID string + CommitSHA string + TestCasesJSON string + LogstreamURL string + + logstreamWriter *redis.Producer + logsFile *os.File +} + +func (t *TestRun) InitLogging() error { + logstreamWriter, err := redis.NewProducer(t.LogstreamURL) + if err != nil { + return fmt.Errorf("failed to create logstream writer: %w", err) + } + + logsFile, err := os.CreateTemp("", "test-run") + if err != nil { + return fmt.Errorf("failed to create temporary file: %w", err) + } + + t.logstreamWriter = logstreamWriter + t.logsFile = logsFile + + return nil +} + +func (t *TestRun) CleanupLogging() { + if t.logsFile != nil { + err := os.Remove(t.logsFile.Name()) + if err != nil { + fmt.Printf("CodeCrafters Internal Error: unable to delete logs file: %v\n", err) + } + + t.logsFile = nil + } +} + +func (t *TestRun) ReadLogsFromFile() ([]byte, error) { + t.logsFile.Seek(0, 0) + logs, err := io.ReadAll(t.logsFile) + if err != nil { + return nil, fmt.Errorf("failed to read logs from file: %w", err) + } + + return logs, nil +} + +func (t *TestRun) Write(p []byte) (n int, err error) { + _, logStreamError := t.logstreamWriter.Write(p) + _, fileError := t.logsFile.Write(p) + _, _ = os.Stdout.Write(p) + + if logStreamError != nil { + return 0, fmt.Errorf("failed to write to logstream: %w", logStreamError) + } + + if fileError != nil { + return 0, fmt.Errorf("failed to write to file: %w", fileError) + } + + return len(p), nil +} + +func (t *TestRun) Close() { + t.logstreamWriter.Close() + t.logsFile.Close() +} diff --git a/test-runner/internal/test_runner_build.go b/test-runner/internal/test_runner_build.go new file mode 100644 index 0000000..06ed57d --- /dev/null +++ b/test-runner/internal/test_runner_build.go @@ -0,0 +1,98 @@ +package internal + +import ( + "fmt" + "io" + "os" + + "github.com/codecrafters-io/logstream/redis" +) + +type TestRunnerBuild struct { + ID string + CommitSHA string + LogstreamURL string + TestRunLogstreamURL string // Logstream URL for a test run that's waiting on this build. + + logstreamWriter *redis.Producer + logsFile *os.File + testRunLogstreamWriter *redis.Producer +} + +func (b *TestRunnerBuild) InitLogging() error { + logstreamWriter, err := redis.NewProducer(b.LogstreamURL) + if err != nil { + return fmt.Errorf("failed to create logstream writer: %w", err) + } + + b.logstreamWriter = logstreamWriter + + logsFile, err := os.CreateTemp("", "test-runner-build") + if err != nil { + return fmt.Errorf("failed to create temporary file: %w", err) + } + + b.logsFile = logsFile + + if b.TestRunLogstreamURL != "" { + testRunLogstreamWriter, err := redis.NewProducer(b.TestRunLogstreamURL) + if err != nil { + return fmt.Errorf("failed to create test run logstream writer: %w", err) + } + + b.testRunLogstreamWriter = testRunLogstreamWriter + } + + return nil +} + +func (b *TestRunnerBuild) CleanupLogging() { + if b.logsFile != nil { + err := os.Remove(b.logsFile.Name()) + if err != nil { + fmt.Printf("CodeCrafters Internal Error: unable to delete logs file: %v\n", err) + } + + b.logsFile = nil + } +} + +func (b *TestRunnerBuild) ReadLogsFromFile() ([]byte, error) { + b.logsFile.Seek(0, 0) + logs, err := io.ReadAll(b.logsFile) + if err != nil { + return nil, fmt.Errorf("failed to read logs from file: %w", err) + } + + return logs, nil +} + +func (b *TestRunnerBuild) Write(p []byte) (n int, err error) { + _, logStreamError := b.logstreamWriter.Write(p) + _, fileError := b.logsFile.Write(p) + os.Stdout.Write(p) + + if logStreamError != nil { + return 0, fmt.Errorf("failed to write to logstream: %w", logStreamError) + } + + if fileError != nil { + return 0, fmt.Errorf("failed to write to file: %w", fileError) + } + + if b.testRunLogstreamWriter != nil { + _, testRunLogStreamError := b.testRunLogstreamWriter.Write(p) + + if testRunLogStreamError != nil { + return 0, fmt.Errorf("failed to write to test run logstream: %w", testRunLogStreamError) + } + } + + return len(p), nil +} + +// We intentionally don't close b.testRunLogstreamWriter here because that'll need to receive test run logs later. +func (b *TestRunnerBuild) Close() { + b.logstreamWriter.Close() + b.logsFile.Close() +} diff --git a/test-runner/internal/utils/sentry.go b/test-runner/internal/utils/sentry.go new file mode 100644 index 0000000..88b8e3e --- /dev/null +++ b/test-runner/internal/utils/sentry.go @@ -0,0 +1,28 @@ +package utils + +import ( + "os" + "time" + + "github.com/getsentry/sentry-go" +) + +var defaultSentryDSN = "https://cf3aff40fb414dcc9311747852872623@o294739.ingest.us.sentry.io/5859980" + +func InitSentry() { + dsn, ok := os.LookupEnv("SENTRY_DSN") + if !ok { + dsn = defaultSentryDSN + } + + err := sentry.Init(sentry.ClientOptions{ + Dsn: dsn, + Debug: os.Getenv("SENTRY_DEBUG") == "1", + TracesSampleRate: 1.0, + }) + _ = err // ignore +} + +func TeardownSentry() { + sentry.Flush(time.Second) +} diff --git a/test-runner/main.go b/test-runner/main.go new file mode 100644 index 0000000..bb02e66 --- /dev/null +++ b/test-runner/main.go @@ -0,0 +1,23 @@ +package main + +import ( + "os" + + "github.com/codecrafters-io/test-runner/internal/commands" + "github.com/codecrafters-io/test-runner/internal/utils" + "github.com/getsentry/sentry-go" +) + +func main() { + utils.InitSentry() + defer utils.TeardownSentry() + + exitCode := 0 + + func() { + defer sentry.Recover() // Ensure panics are captured and sent to Sentry + exitCode = commands.BuildImageCommand() + }() + + os.Exit(exitCode) +} diff --git a/test-runner/main.goreleaser.yml b/test-runner/main.goreleaser.yml new file mode 100644 index 0000000..6f26c50 --- /dev/null +++ b/test-runner/main.goreleaser.yml @@ -0,0 +1,11 @@ +builds: + - main: ./ + binary: test-runner + env: + - CGO_ENABLED=0 + goarch: [amd64, arm64] + goos: [linux, darwin] + +archives: + - name_template: "{{ .Tag }}_{{ .Os }}_{{ .Arch }}" + format: tar.gz diff --git a/test-runner/tests/build_image_test.rb b/test-runner/tests/build_image_test.rb new file mode 100644 index 0000000..109dcb5 --- /dev/null +++ b/test-runner/tests/build_image_test.rb @@ -0,0 +1,168 @@ +require_relative "test_helper" + +require "timeout" + +class BuildImageTest < Minitest::Test + def setup + DatabaseHelper.clean + + @repository = nil + @git_repository = nil + @build = nil + + @fake_server_thread ||= Thread.new do + FakeServer.run! + end + end + + def test_pushes_image + create_and_push_git_repository!("redis-ruby") + + create_build! + run_script! + + output = Timeout.timeout(5) { `logstream -url #{@build.local_logstream_url} follow` } + + assert_match(/Build successful/, output) + assert_equal "success", @build.reload.status + assert_match(/Build successful/, @build.reload.parsed_logs) + end + + def test_streams_logs_to_test_run_too + create_and_push_git_repository!("redis-ruby") + + create_build! + test_run = TestRun.create!(repository_id: @repository.id, id: SecureRandom.uuid, commit_sha: @build.commit_sha) + + run_script!(test_run: test_run) + + output = Timeout.timeout(5) { `logstream -url #{@build.local_logstream_url} follow` } + Timeout.timeout(5) { `logstream -url #{test_run.local_logstream_url} run echo ended` } + test_run_output = Timeout.timeout(5) { `logstream -url #{test_run.local_logstream_url} follow` } + + assert_match(/Build successful/, output) + assert_match(/Build successful/, test_run_output) + assert_equal "success", @build.reload.status + assert_match(/Build successful/, @build.reload.parsed_logs) + end + + def test_handles_missing_buildpack + create_and_push_git_repository!("redis-ruby") + + @git_repository.commit_changes_to_branch!(branch: "master") do |working_dir| + FileUtils.rm("#{working_dir}/codecrafters.yml") + end + + create_build! + run_script! + + assert_equal "failure", @build.reload.status + + output = Timeout.timeout(5) { `logstream -url #{@build.local_logstream_url} follow` } + assert_match(/Didn't find a codecrafters.yml file in your repository/, output) + assert_match(/Didn't find a codecrafters.yml file in your repository/, @build.reload.parsed_logs) + end + + def test_build_failures + create_and_push_git_repository!("redis-rust") + + @git_repository.commit_changes_to_branch!(branch: "master") do |working_dir| + `echo "abcd" > #{working_dir}/Cargo.toml` + end + + create_build! + run_script! + + assert_equal "failure", @build.reload.status + + output = Timeout.timeout(5) { `logstream -url #{@build.local_logstream_url} follow` } + + assert_match(/Build failed/, output) + assert_match(/Build failed/, @build.reload.parsed_logs) + end + + def test_build_failures_go + create_and_push_git_repository!("redis-go") + + @git_repository.commit_changes_to_branch!(branch: "master") do |working_dir| + # Add an invalid module path to go.mod that will cause a build failure + `echo "module invalid_#" > #{working_dir}/go.mod` + end + + create_build! + run_script! + + assert_equal "failure", @build.reload.status + + output = Timeout.timeout(5) { `logstream -url #{@build.local_logstream_url} follow` } + + assert_match(/Build failed/, output) + assert_match(/Build failed/, @build.reload.parsed_logs) + assert_match(/go: malformed module path "invalid_#"/, output) + assert_match(/go: malformed module path "invalid_#"/, @build.reload.parsed_logs) + end + + def test_builds_for_specific_commit_sha + create_and_push_git_repository!("redis-rust") + + old_commit_sha = @git_repository.head_commit_sha + + # Should cause a build failure + new_commit_sha = @git_repository.commit_changes_to_branch!(branch: "testing") do |working_dir| + `echo "abcd" > #{working_dir}/Cargo.toml` + end + + refute_equal old_commit_sha, new_commit_sha # make sure they're different + + create_build!(commit_sha: new_commit_sha) + + script_result = run_script! + assert_match(/Checking out commit #{new_commit_sha}/, script_result.out) + + assert_equal "failure", @build.reload.status + + output = Timeout.timeout(5) { `logstream -url #{@build.local_logstream_url} follow` } + + assert_match(/Build failed/, output) + assert_match(/Build failed/, @build.reload.parsed_logs) + end + + def test_removes_dockerignore_file + create_and_push_git_repository!("redis-ruby") + + @git_repository.commit_changes_to_branch!(branch: "master") do |working_dir| + `touch #{working_dir}/.dockerignore` + `touch #{working_dir}/control.txt` + end + + create_build! + run_script! + stream_output = Timeout.timeout(5) { `logstream -url #{@build.local_logstream_url} follow` } + + assert_match(/Build successful/, stream_output) + assert_equal "success", @build.reload.status + + ls_output = `docker run --rm registry.fly.io/test-depot-push:test-runner-tests ls -la /app` + refute_match(/.dockerignore/, ls_output, ".dockerignore should not exist in the built image") + assert_match(/control.txt/, ls_output, "control.txt should still exist in the built image") + end + + def create_build!(commit_sha: nil) + commit_sha ||= @git_repository.head_commit_sha + @build = TestRunnerBuild.create!(test_runner: @repository.test_runners.first, id: SecureRandom.uuid, commit_sha: commit_sha) + end + + def create_and_push_git_repository!(code_fixture_key) + @code_fixture = CodeFixtures.get(code_fixture_key) + buildpack = Buildpack.upsert_from_code_fixture!(@code_fixture) + @repository = Repository.create!(id: SecureRandom.uuid, course_slug: @code_fixture.fetch("course_slug"), language_slug: @code_fixture.fetch("language_slug"), buildpack: buildpack) + @git_repository = FakeGitRepository.new(@code_fixture.fetch("code_dir"), @repository) + @git_repository.push_to_git_daemon! + end + + def run_script!(test_run: nil) + BuildImageCommandRunner + .new(git_repository: @git_repository, repository: @repository) + .run(build: @build, test_run: test_run) + end +end diff --git a/test-runner/tests/lib/build_image_command_runner.rb b/test-runner/tests/lib/build_image_command_runner.rb new file mode 100644 index 0000000..def1886 --- /dev/null +++ b/test-runner/tests/lib/build_image_command_runner.rb @@ -0,0 +1,63 @@ +TEST_RUNNER_EXECUTABLE_PATH = File.expand_path("../../dist/main.out", __dir__) +TEST_RUNNER_SOURCE_DIR = File.expand_path("../../", __dir__) +TESTERS_DIR = File.expand_path("../fixtures/testers", __dir__) + +class BuildImageCommandRunner + attr_accessor :git_repository + attr_accessor :repository + + def initialize(git_repository:, repository:) + self.git_repository = git_repository + self.repository = repository + end + + def run(build:, test_run: nil) + tmp_dir = Dir.mktmpdir + tester_dir = TesterDownloader.new(course: OpenStruct.new(slug: repository.course_slug), testers_root_dir: TESTERS_DIR).download_if_needed + + FileUtils.rm_rf(tmp_dir) + FileUtils.cp_r(git_repository.tmp_dir, tmp_dir) + + test_runner_dir = Dir.mktmpdir + `cp #{TEST_RUNNER_EXECUTABLE_PATH} #{test_runner_dir}/test-runner` + + dockerfile_handle = Tempfile.new + dockerfile_handle.write(repository.buildpack.processed_dockerfile_contents) + dockerfile_handle.close + + command_parts = [ + TEST_RUNNER_EXECUTABLE_PATH, + "build_image", + "--buildpack-slug='#{repository.buildpack.slug}'", + "--buildpack-dockerfile-path='#{dockerfile_handle.path}'", + "--build-id='#{build.id}'", + "--build-logstream-url='#{build.local_logstream_url}'", + "--codecrafters-server-url='http://localhost:6331'", + "--course-slug='#{repository.course_slug}'", + "--depot-token='#{ENV.fetch("DEPOT_TOKEN")}'", + "--depot-project='#{ENV.fetch("DEPOT_PROJECT")}'", + "--docker-image-name='test-depot-push'", + "--docker-image-tag='test-runner-tests'", + "--docker-registry-domain='registry.fly.io'", + "--docker-registry-password='#{ENV.fetch("FLY_ACCESS_TOKEN")}'", + "--docker-registry-username='x'", + "--repository-id='dummy-repository-id'", + "--repository-dir='#{tmp_dir}'", + "--test-runner-dir='#{test_runner_dir}'", + "--tester-dir='#{tester_dir}'" + ] + + if build.commit_sha + command_parts << "-build-commit-sha #{build.commit_sha}" + end + + if test_run + command_parts << "-build-test-run-logstream-url #{test_run.local_logstream_url}" + end + + result = TTY::Command.new.run(command_parts.join(" ")) + raise "Build image script failed. Stdout: #{result.out}. Stderr: #{result.err}" unless result.status == 0 + + result + end +end diff --git a/test-runner/tests/lib/buildpack_dockerfile_processor.rb b/test-runner/tests/lib/buildpack_dockerfile_processor.rb new file mode 100644 index 0000000..bebaf56 --- /dev/null +++ b/test-runner/tests/lib/buildpack_dockerfile_processor.rb @@ -0,0 +1,88 @@ +# Mirrors what is available in core +class BuildpackDockerfileProcessor + def self.append_line(contents, line) + lines = contents.lines + lines.push("\n") + lines.push(line) + + lines.join + end + + def self.build_postlude_lines + [ + "# BEGIN GENERATED SECTION", + "WORKDIR /app", + "ARG CODECRAFTERS_SERVER_URL", + "ARG REPOSITORY_ID", + "ENV CODECRAFTERS_SERVER_URL=$CODECRAFTERS_SERVER_URL", + "ENV REPOSITORY_ID=$REPOSITORY_ID", + "COPY . /app", + grouped_run( + "mkdir -p /var/opt", + # Copy test runner binary + "mv /app/test-runner/test-runner /var/opt/test-runner", + "chmod +x /var/opt/test-runner", + "rm -rf /app/test-runner", + # Copy tester binary + "mv /app/tester /var/opt/tester", + # Ensure all files are readable by everyone (some languages have files only readable by user) + "chmod -R +rw /app", + "sh -c '(test -d /app-cached && chmod -R ugo+rwx /app-cached) || true'" + ), + "# END GENERATED SECTION" + ] + end + + def self.build_prelude_lines(from_line) + [ + "# BEGIN GENERATED SECTION", + + # Git is required to pull repository changes. I don't think we need curl anymore, not sure... + case from_line + when /alpine/ + "RUN apk add --update-cache --upgrade git curl" + when /buster/, /slim/, /focal/, /bullseye/, /bookworm/ + "RUN apt-get update && apt-get install -y git curl" + when /dart/ + "RUN apt-get update && apt-get install -y git curl" # Dart doesn't have OS type in its FROM line + else + raise "Unknown from_line: #{from_line}" + end, + + "CMD [\"/var/opt/test-runner\", \"run_tests\"]", + "ENV TESTER_DIR=/var/opt/tester", + "# END GENERATED SECTION" + ] + end + + def self.grouped_run(*commands) + ("RUN " + commands.join(" && ")) + end + + def self.insert_line(contents, index, line) + lines = contents.lines + lines.insert(index, line + "\n") + lines.join + end + + def self.process(dockerfile_contents) + lines = dockerfile_contents.lines.map(&:strip) + from_line_index = lines.index { |line| line.start_with?("FROM") } + + raise "No FROM line found. Dockerfile: #{dockerfile_contents}" unless from_line_index.present? + + from_line = lines[from_line_index] + + first_user_line_index = lines.index { |line| line.start_with?("USER") } + + # If there's a USER line, we'll insert the prelude after the first USER line. Else, we'll insert it after the FROM line. + pivot_index = first_user_line_index || from_line_index + + [ + lines[0..pivot_index].join("\n"), + build_prelude_lines(from_line).join("\n"), + lines[(pivot_index + 1)..].join("\n"), + build_postlude_lines.join("\n") + ].join("\n\n").strip.gsub(/\n\n\n+/, "\n\n") + "\n" + end +end diff --git a/test-runner/tests/lib/code_fixtures.rb b/test-runner/tests/lib/code_fixtures.rb new file mode 100644 index 0000000..4468b8f --- /dev/null +++ b/test-runner/tests/lib/code_fixtures.rb @@ -0,0 +1,38 @@ +module CodeFixtures + repository_root = File.expand_path("../../", __dir__) + + DB = { + "redis-ruby" => { + "dockerfile_contents" => File.read(File.join(repository_root, "tests/fixtures/dockerfiles/redis-ruby-3.3.Dockerfile")), + "buildpack_slug" => "ruby-3.3", + "code_dir" => File.join(repository_root, "tests/fixtures/redis-ruby-pass-stage-1"), + "course_slug" => "redis", + "language_slug" => "ruby" + }, + "redis-rust" => { + "dockerfile_contents" => File.read(File.join(repository_root, "tests/fixtures/dockerfiles/redis-rust-1.88.Dockerfile")), + "buildpack_slug" => "rust-1.88", + "code_dir" => File.join(repository_root, "tests/fixtures/redis-rust-pass-stage-1"), + "course_slug" => "redis", + "language_slug" => "rust" + }, + "redis-go" => { + "dockerfile_contents" => File.read(File.join(repository_root, "tests/fixtures/dockerfiles/redis-go-1.24.Dockerfile")), + "buildpack_slug" => "go-1.24", + "code_dir" => File.join(repository_root, "tests/fixtures/redis-go-pass-stage-1"), + "course_slug" => "redis", + "language_slug" => "go" + }, + "sqlite-python" => { + "dockerfile_contents" => File.read(File.join(repository_root, "tests/fixtures/dockerfiles/sqlite-python-3.13.Dockerfile")), + "buildpack_slug" => "python-3.13", + "code_dir" => File.join(repository_root, "tests/fixtures/sqlite-python-pass-stage-1"), + "course_slug" => "sqlite", + "language_slug" => "python" + } + } + + def self.get(code_config_key) + DB.fetch(code_config_key) + end +end diff --git a/test-runner/tests/lib/database_helper.rb b/test-runner/tests/lib/database_helper.rb new file mode 100644 index 0000000..83be65b --- /dev/null +++ b/test-runner/tests/lib/database_helper.rb @@ -0,0 +1,10 @@ +module DatabaseHelper + def self.clean + TestRunResult.delete_all + TestRun.delete_all + TestRunnerBuild.delete_all + Submission.delete_all + Repository.delete_all + # TestRunnerInfrastructure + end +end diff --git a/test-runner/tests/lib/db/models/application_record.rb b/test-runner/tests/lib/db/models/application_record.rb new file mode 100644 index 0000000..6962fb6 --- /dev/null +++ b/test-runner/tests/lib/db/models/application_record.rb @@ -0,0 +1,7 @@ +class ApplicationRecord < ActiveRecord::Base + self.abstract_class = true + + before_validation do + self.id ||= SecureRandom.uuid + end +end diff --git a/test-runner/tests/lib/db/models/buildpack.rb b/test-runner/tests/lib/db/models/buildpack.rb new file mode 100644 index 0000000..edf58de --- /dev/null +++ b/test-runner/tests/lib/db/models/buildpack.rb @@ -0,0 +1,15 @@ +class Buildpack < ApplicationRecord + validates_presence_of :course_slug + validates_presence_of :language_slug + validates_presence_of :slug + + def self.upsert_from_code_fixture!(code_fixture) + buildpack = find_or_initialize_by(slug: code_fixture.fetch("buildpack_slug")) + buildpack.course_slug = code_fixture.fetch("course_slug") + buildpack.language_slug = code_fixture.fetch("language_slug") + buildpack.processed_dockerfile_contents = BuildpackDockerfileProcessor.process(code_fixture.fetch("dockerfile_contents")) + buildpack.save! + + buildpack + end +end diff --git a/test-runner/tests/lib/db/models/repository.rb b/test-runner/tests/lib/db/models/repository.rb new file mode 100644 index 0000000..b4055e3 --- /dev/null +++ b/test-runner/tests/lib/db/models/repository.rb @@ -0,0 +1,29 @@ +class Repository < ActiveRecord::Base + belongs_to :buildpack + + has_many :test_runs + has_many :test_runners + + validates_presence_of :course_slug + validates_presence_of :language_slug + + after_create do + test_runners.create! + end + + def clone_url + "http://host.docker.internal:6332/#{id}" + end + + def local_clone_url + "http://localhost:6332/#{id}" + end + + def secret_id + id # temp + end + + def test_runner + @test_runner ||= test_runners.first # Caching this so that we can mutate ID + end +end diff --git a/test-runner/tests/lib/db/models/submission.rb b/test-runner/tests/lib/db/models/submission.rb new file mode 100644 index 0000000..ba70b0e --- /dev/null +++ b/test-runner/tests/lib/db/models/submission.rb @@ -0,0 +1,10 @@ +class Submission < ApplicationRecord + belongs_to :repository + has_many :test_runs + + validates_presence_of :commit_sha + + def logstream_url + test_runs.first.logstream_url + end +end diff --git a/test-runner/tests/lib/db/models/test_run.rb b/test-runner/tests/lib/db/models/test_run.rb new file mode 100644 index 0000000..cf826f1 --- /dev/null +++ b/test-runner/tests/lib/db/models/test_run.rb @@ -0,0 +1,28 @@ +class TestRun < ApplicationRecord + ALL_STATUSES = %w[ + pending + success + failure + error + cancelled + ] + + enum status: ALL_STATUSES.zip(ALL_STATUSES).to_h + + belongs_to :repository + belongs_to :submission, optional: true + + has_many :results, class_name: "TestRunResult" + + before_validation do + self.status ||= "pending" + end + + def logstream_url + "redis://host.docker.internal:6334/test-run-#{id}" + end + + def local_logstream_url + "redis://localhost:6334/test-run-#{id}" + end +end diff --git a/test-runner/tests/lib/db/models/test_run_result.rb b/test-runner/tests/lib/db/models/test_run_result.rb new file mode 100644 index 0000000..d535e6c --- /dev/null +++ b/test-runner/tests/lib/db/models/test_run_result.rb @@ -0,0 +1,16 @@ +class TestRunResult < ActiveRecord::Base + ALL_STATUSES = %w[ + success + failure + error + cancelled + ] + + enum :status, ALL_STATUSES.zip(ALL_STATUSES).to_h + + belongs_to :test_run + + def parsed_logs + Base64.decode64(logs_base64) + end +end diff --git a/test-runner/tests/lib/db/models/test_runner.rb b/test-runner/tests/lib/db/models/test_runner.rb new file mode 100644 index 0000000..6483da1 --- /dev/null +++ b/test-runner/tests/lib/db/models/test_runner.rb @@ -0,0 +1,15 @@ +class TestRunner < ApplicationRecord + ALL_MACHINE_STATUSES = [ + "stopped", + "starting", + "running" + ] + + enum machine_status: ALL_MACHINE_STATUSES.zip(ALL_MACHINE_STATUSES).to_h + + belongs_to :repository + + before_validation do + self.machine_status ||= "starting" + end +end diff --git a/test-runner/tests/lib/db/models/test_runner_build.rb b/test-runner/tests/lib/db/models/test_runner_build.rb new file mode 100644 index 0000000..0cae8fb --- /dev/null +++ b/test-runner/tests/lib/db/models/test_runner_build.rb @@ -0,0 +1,32 @@ +class TestRunnerBuild < ApplicationRecord + ALL_STATUSES = %w[ + not_started + in_progress + success + failure + error + ] + + enum status: ALL_STATUSES.zip(ALL_STATUSES).to_h + + belongs_to :test_runner + + validates_presence_of :commit_sha + validates_presence_of :status + + before_validation do + self.status ||= "not_started" + end + + def logstream_url + "redis://host.docker.internal:6334/test-runner-build-#{id}" + end + + def local_logstream_url + "redis://localhost:6334/test-runner-build-#{id}" + end + + def parsed_logs + Base64.strict_decode64(logs_base64) + end +end diff --git a/test-runner/tests/lib/db/schema.rb b/test-runner/tests/lib/db/schema.rb new file mode 100644 index 0000000..7a1abe4 --- /dev/null +++ b/test-runner/tests/lib/db/schema.rb @@ -0,0 +1,56 @@ +ActiveRecord::Schema.define do + create_table "buildpacks", id: :string, force: :cascade do |t| + t.string "course_slug", null: false + t.string "language_slug", null: false + t.string "slug", null: false + t.string "processed_dockerfile_contents", null: false + end + + create_table "repositories", id: :string, force: :cascade do |t| + t.string "course_slug", null: false + t.string "language_slug", null: false + t.string "buildpack_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + create_table "test_runs", id: :string, force: :cascade do |t| + t.string "repository_id", null: false + t.string "submission_id" + t.string "commit_sha", null: false + t.string "status", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + create_table "test_run_results", id: :string, force: :cascade do |t| + t.string "test_run_id", null: false + t.string "logs_base64" + t.string "status" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + create_table "test_runner_builds", id: :string, force: :cascade do |t| + t.string "test_runner_id", null: false + t.string "commit_sha" # can be null at the start? + t.string "status", null: false + t.string "logs_base64" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + create_table "test_runners", id: :string, force: :cascade do |t| + t.string "repository_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "machine_status", null: false + end + + create_table "submissions", id: :string, force: :cascade do |t| + t.string "repository_id", null: false + t.string "commit_sha", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end +end diff --git a/test-runner/tests/lib/fake_git_repository.rb b/test-runner/tests/lib/fake_git_repository.rb new file mode 100644 index 0000000..bdfb56e --- /dev/null +++ b/test-runner/tests/lib/fake_git_repository.rb @@ -0,0 +1,90 @@ +class FakeGitRepository + attr_accessor :tmp_dir + attr_accessor :repository + + def initialize(copy_from_dir, repository) + @tmp_dir = Dir.mktmpdir + @repository = repository + + FileUtils.rm_rf(tmp_dir) + FileUtils.cp_r(copy_from_dir, tmp_dir) + + commands = [ + "git -C #{tmp_dir} init ", + "git -C #{tmp_dir} checkout -b master", + "git -C #{tmp_dir} add .", + "git -C #{tmp_dir} commit -m \"testing [skip ci]\"", + "git -C #{tmp_dir} remote add origin #{repository.local_clone_url}" + ] + + commands.each do |command| + output = `#{command} 2>&1` + raise "Git command failed: #{command}. Output: #{output}" unless $?.success? + end + end + + def commit_changes_to_branch!(branch: "testing", &block) + block.call(tmp_dir) + + commands = [ + "git -C #{tmp_dir} checkout -b #{branch} || git -C #{tmp_dir} checkout #{branch}", + "git -C #{tmp_dir} add .", + "git -C #{tmp_dir} commit -m \"changes [skip ci]\"" + ] + + commands.each do |command| + output = `#{command} 2>&1` + raise "Git command failed: #{command}. Output: #{output}" unless $?.success? + end + + commit_sha = head_commit_sha + + push_to_git_daemon!(branch: branch) + + commands = [ + "git -C #{tmp_dir} checkout -f master" + ] + + commands.each do |command| + output = `#{command} 2>&1` + raise "Git command failed: #{command}. Output: #{output}" unless $?.success? + end + + commit_sha + end + + def head_commit_sha + `git -C #{tmp_dir} rev-parse HEAD`.strip + end + + def push_to_git_daemon!(branch: "master") + GitApiGateway.new.create_repository(repository.id) + + result = TTY::Command.new.run("git -C #{tmp_dir} push -f -u origin #{branch}") + raise "Git command failed: #{command}. Stdout: #{result.out}, Stderr: #{result.err}" unless result.status == 0 + + result + end + + def push_changes_to_master!(&block) + block.call(tmp_dir) + + commands = [ + "git -C #{tmp_dir} add .", + "git -C #{tmp_dir} commit --allow-empty -m \"changes\"" + ] + + commands.each do |command| + output = `#{command} 2>&1` + raise "Git command failed: #{command}. Output: #{output}" unless $?.success? + end + + push_to_git_daemon!(branch: "master") + end + + def set_remote_url(remote_url) + command = "git -C #{tmp_dir} remote set-url origin #{remote_url}" + result = TTY::Command.new.run(command) + raise "Git command failed: #{command}. Stdout: #{result.out}, Stderr: #{result.err}" unless result.status == 0 + end +end diff --git a/test-runner/tests/lib/fake_server.rb b/test-runner/tests/lib/fake_server.rb new file mode 100644 index 0000000..41c30b9 --- /dev/null +++ b/test-runner/tests/lib/fake_server.rb @@ -0,0 +1,196 @@ +require "sinatra/base" + +class FakeServer < Sinatra::Base + set :port, 6331 + set :bind, "0.0.0.0" + + error do + content_type :json + status 500 + + { + error: env["sinatra.error"].message, + backtrace: env["sinatra.error"].backtrace.join("\n") + }.to_json + end + + get "/" do + "hello" + end + + # --- Public Interface Starts --- + + post "/services/test_runner/create_submission" do + halt 400, {error: "invalid content type"}.to_json unless request.content_type == "application/json" + json = JSON.parse(request.body.read) + + repository_id = json.fetch("repository_id") + repository = Repository.find_by(id: repository_id) # should actually use secret_id + + if !repository + halt 400, "repository not found" + end + + if repository.id == "no-paid-access" + halt 200, {is_error: true, error_message: "This stage requires a CodeCrafters membership"}.to_json + end + + submission = Submission.create!(commit_sha: json.fetch("commit_sha"), repository: repository) + test_run = submission.test_runs.create!(commit_sha: json.fetch("commit_sha"), repository: repository) + + Timeout.timeout(5) { `logstream -url #{test_run.local_logstream_url} run echo test-run-success` } + + build = TestRunnerBuild.where(test_runner: repository.test_runners, status: "in_progress").first + + halt 200, { + id: submission.id, + actions: SubmissionTestRunnerActionsBuilder.build(submission: submission, test_run: test_run, pending_build: build).map(&:as_json_for_test_runner) + }.to_json + end + + get "/services/test_runner/fetch_submission" do + _submission = Submission.find(params.fetch("submission_id")) + + halt 200, {status: "success"}.to_json + end + + get "/services/test_runner/fetch_test_runner_build" do + test_runner_build = TestRunnerBuild.find_by_id(params.fetch("test_runner_build_id")) + + if !test_runner_build + puts "build not found, id: #{params.fetch("id")}" + halt 404, {error: "build not found"}.to_json + end + + halt 200, {status: test_runner_build.status}.to_json + end + + post "/services/test_runner/finalize_test_runner_build" do + halt 400, {error: "invalid content type"}.to_json unless request.content_type == "application/json" + + json = JSON.parse(request.body.read) + + build = TestRunnerBuild.find_by(id: json.fetch("test_runner_build_id")) + halt 404, {error: "build not found"}.to_json if build.nil? + + build.update!(status: json.fetch("status"), logs_base64: json.fetch("logs_base64")) + end + + post "/services/test_runner/reserve_test_run" do + test_runner_id = params.fetch("test_runner_id") + halt 400, {error: "test_runner_id is required"}.to_json if test_runner_id.strip.empty? + + test_runner = TestRunner.find_by(id: test_runner_id) + halt 400, {error: "test runner not found"}.to_json unless test_runner + + test_runner.update!(machine_status: "running") + + test_run = test_runner.repository.test_runs.pending.order(created_at: :asc).first + + if test_run && test_run.repository.course_slug.eql?("redis") + { + test_run: { + id: test_run.id, + commit_sha: test_run.commit_sha, + test_cases_json: [{slug: "jm1", tester_log_prefix: "tester::#JM1", title: "Stage #JM1 Bind to a port"}].to_json, + logstream_url: test_run.logstream_url + } + }.to_json + elsif test_run && test_run.repository.course_slug.eql?("sqlite") + { + test_run: { + id: test_run.id, + commit_sha: test_run.commit_sha, + test_cases_json: [{slug: "dr6", tester_log_prefix: "tester::#DR6", title: "Stage #DR6 Print page size"}].to_json, + logstream_url: test_run.logstream_url + } + }.to_json + else + halt 200, {should_retry: false}.to_json + end + end + + post "/services/test_runner/create_test_run_result" do + halt 400, {error: "invalid content type"}.to_json unless request.content_type == "application/json" + + json = JSON.parse(request.body.read) + + test_run = TestRun.find_by(id: json.fetch("test_run_id")) + test_runner = TestRunner.find_by(id: json.fetch("test_runner_id")) + + halt 404, {error: "test run not found"}.to_json if test_run.nil? + halt 404, {error: "test runner not found"}.to_json if test_runner.nil? + halt 400, {error: "repository mismatch"}.to_json unless test_run.repository.eql?(test_runner.repository) + + # This is a sanity check, production is actually forgiving of this case + halt 400, {error: "test runner not running"}.to_json unless test_runner.machine_status.eql?("running") + + TestRun.transaction do + result = test_run.results.create!( + id: SecureRandom.uuid, + status: json.fetch("status"), + logs_base64: json.fetch("logs_base64") + ) + + test_run.update!(status: result.status) + end + end + + post "/services/test_runner/rebuild_for_test_run" do + halt 400, {error: "invalid content type"}.to_json unless request.content_type == "application/json" + + json = JSON.parse(request.body.read) + + test_run = TestRun.find_by(id: json.fetch("test_run_id")) + test_runner = TestRunner.find_by(id: json.fetch("test_runner_id")) + + halt 404, {error: "test run not found"}.to_json if test_run.nil? + halt 404, {error: "test runner not found"}.to_json if test_runner.nil? + halt 400, {error: "repository mismatch"}.to_json unless test_run.repository.eql?(test_runner.repository) + + # This is a sanity check, production is actually forgiving of this case + halt 400, {error: "test runner not running"}.to_json unless test_runner.machine_status.eql?("running") + + test_runner.update!(machine_status: "starting") # Reprovisioning would set the start to "starting" + + build = TestRunnerBuild.create!(test_runner: test_run.repository.test_runner, status: "not_started", commit_sha: test_run.commit_sha) + + Thread.new do + Timeout.timeout(5) { `logstream -url #{build.local_logstream_url} run echo build-success` } + sleep 1 + build.update!(status: "success", logs_base64: Base64.strict_encode64("build-success")) + end + + { + id: build.id, + commit_sha: build.commit_sha, + logstream_url: build.logstream_url + }.to_json + end + + post "/services/test_runner/report_shutdown" do + halt 400, {error: "invalid content type"}.to_json unless request.content_type == "application/json" + + json = JSON.parse(request.body.read) + + test_runner = TestRunner.find_by(id: json.fetch("test_runner_id")) + halt 404, {error: "test runner not found"}.to_json if test_runner.nil? + + # This is a sanity check, production is actually forgiving of this case + halt 400, {error: "test runner not running"}.to_json unless test_runner.machine_status.eql?("running") + + test_runner.update!(machine_status: "stopped") + + "success" + end + + # --- Public Interface Ends --- + + post "/_debug/create_test_run" do + repository_id = params.fetch("repository_id") + test_run_id = params.fetch("test_run_id") + TEST_RUNS[repository_id] = test_run_id + + "created test run #{test_run_id} for repository #{repository_id}" + end +end diff --git a/test-runner/tests/lib/git_api_gateway.rb b/test-runner/tests/lib/git_api_gateway.rb new file mode 100644 index 0000000..2e50fa0 --- /dev/null +++ b/test-runner/tests/lib/git_api_gateway.rb @@ -0,0 +1,43 @@ +# Copied from core +class GitApiGateway + def create_repository(repository_name) + response = HTTParty.post( + "http://localhost:6333/api/v1/repositories", + body: { + name: repository_name + }.to_json, + headers: {"Content-Type" => "application/json"} + ) + + if response.code.eql?(200) + response.parsed_response + else + raise "Error creating repository. Status: #{response.code}, Body: #{response.body}" + end + end + + def delete_repository(repository_name) + response = HTTParty.delete("http://localhost:6333/api/v1/repositories/#{repository_name}") + + if response.code.eql?(200) + response.parsed_response + elsif response.code.eql?(404) + Sentry.capture_message("Repository not found when deleting", extra: {repository_name: repository_name}) + nil # Idempotency + else + raise "Error deleting repository. Status: #{response.code}, Body: #{response.body}" + end + end + + def fetch_commit(repository_id, commit_sha) + response = HTTParty.get("http://localhost:6333/api/v1/repositories/#{repository_id}/commits/#{commit_sha}") + + if response.code.eql?(200) + response.parsed_response + elsif response.code.eql?(404) + nil + else + raise "Error fetching commit. Status: #{response.code}, Body: #{response.body}" + end + end +end diff --git a/test-runner/tests/lib/run_tests_command_runner.rb b/test-runner/tests/lib/run_tests_command_runner.rb new file mode 100644 index 0000000..88067fa --- /dev/null +++ b/test-runner/tests/lib/run_tests_command_runner.rb @@ -0,0 +1,53 @@ +TEST_RUNNER_LINUX_EXECUTABLE_PATH = File.expand_path("../../dist/main-linux.out", __dir__) +TESTERS_DIR = File.expand_path("../fixtures/testers", __dir__) + +class RunTestsCommandRunner + attr_accessor :git_repository + attr_accessor :repository + + def initialize(git_repository:, repository:) + self.git_repository = git_repository + self.repository = repository + end + + def dockerfile_path + repository.dockerfile_path(git_repository) + end + + def run + tmp_dir = Dir.mktmpdir + tester_dir = TesterDownloader.new(course: OpenStruct.new(slug: repository.course_slug), testers_root_dir: TESTERS_DIR).download_if_needed + + FileUtils.rm_rf(tmp_dir) + FileUtils.cp_r(git_repository.tmp_dir, tmp_dir) + + dockerfile_handle = Tempfile.new + dockerfile_handle.write(repository.buildpack.processed_dockerfile_contents) + dockerfile_handle.close + + puts File.read(dockerfile_handle.path) + + # Required for build + FileUtils.mkdir_p("#{tmp_dir}/test-runner") + FileUtils.cp(TEST_RUNNER_LINUX_EXECUTABLE_PATH, "#{tmp_dir}/test-runner/test-runner") + FileUtils.cp_r(tester_dir, "#{tmp_dir}/tester") + + result = TTY::Command.new.run("docker build --quiet -t test_runner_test_image -f #{dockerfile_handle.path} #{tmp_dir}") + raise "Docker build failed. Stdout: #{result.out}. Stderr: #{result.err}" unless result.status == 0 + + command_parts = [ + "docker run", + "-e CODECRAFTERS_SERVER_URL=http://host.docker.internal:6331", + "-e CODECRAFTERS_TEST_RUNNER_ID=#{repository.test_runner.id}", + "-e REPOSITORY_ID=#{repository.id}", + "--add-host=host.docker.internal:host-gateway", # Required for CI, works by default on macOS + "--rm ", + "test_runner_test_image" + ] + + result = TTY::Command.new.run(command_parts.join(" ")) + raise "Test runner script failed. Stdout: #{result.out}. Stderr: #{result.err}" unless result.status == 0 + + result + end +end diff --git a/test-runner/tests/lib/submission_test_runner_actions_builder.rb b/test-runner/tests/lib/submission_test_runner_actions_builder.rb new file mode 100644 index 0000000..5308693 --- /dev/null +++ b/test-runner/tests/lib/submission_test_runner_actions_builder.rb @@ -0,0 +1,62 @@ +# This is a simplified version of the SubmissionTestRunnerActionsBuilder in core +class SubmissionTestRunnerActionsBuilder + def self.build(submission:, test_run:, pending_build:) + actions = [] + + if pending_build.present? + actions << TestRunnerActions::PrintMessage.green("Welcome back! Note that this might take a while...") + end + + actions << TestRunnerActions::PrintMessage.plain("") + + # If there's a panding build present, stream build logs first + if pending_build + actions << TestRunnerActions::PrintMessage.plain("Streaming build logs...") + actions << TestRunnerActions::PrintMessage.plain("") + actions << TestRunnerActions::StreamLogs.from_url(pending_build.local_logstream_url) + + on_success_actions = [ + TestRunnerActions::Sleep.milliseconds(1000) + ] + + on_failure_actions = [ + TestRunnerActions::PrintMessage.red(""), + TestRunnerActions::PrintMessage.red("Looks like your codebase failed to build."), + TestRunnerActions::PrintMessage.red("If you think this is a CodeCrafters error, please let us know at hello@codecrafters.io."), + TestRunnerActions::PrintMessage.red(""), + TestRunnerActions::Terminate.with_exit_code(0) + ] + + actions << TestRunnerActions::AwaitTerminalBuildStatus.for_build_id( + pending_build.id, + on_success_actions: on_success_actions, + on_failure_actions: on_failure_actions + ) + end + + actions << TestRunnerActions::PrintMessage.plain("") + actions << TestRunnerActions::PrintMessage.plain("Running tests on your code. Logs should appear shortly...") + actions << TestRunnerActions::PrintMessage.plain("") + + # Small delay, perceive logs as streamed + actions << TestRunnerActions::Sleep.milliseconds(200) + actions << TestRunnerActions::StreamLogs.from_url(test_run.local_logstream_url) + actions << TestRunnerActions::PrintMessage.plain("") + + actions << TestRunnerActions::AwaitTerminalSubmissionStatus.for_submission_id( + submission.id, + on_success_actions: [ + TestRunnerActions::PrintMessage.plain(""), + TestRunnerActions::PrintMessage.plain("Test passed. Congrats!"), + TestRunnerActions::PrintMessage.plain("") + ], + on_failure_actions: [ + TestRunnerActions::PrintMessage.plain(""), + TestRunnerActions::PrintMessage.plain("Tests failed! Oops."), + TestRunnerActions::PrintMessage.plain("") + ] + ) + + actions + end +end diff --git a/test-runner/tests/lib/test_runner_actions/await_terminal_build_status.rb b/test-runner/tests/lib/test_runner_actions/await_terminal_build_status.rb new file mode 100644 index 0000000..92cfc2b --- /dev/null +++ b/test-runner/tests/lib/test_runner_actions/await_terminal_build_status.rb @@ -0,0 +1,17 @@ +class TestRunnerActions::AwaitTerminalBuildStatus < TestRunnerActions::Base + attr_accessor :build_id + attr_accessor :on_success_actions + attr_accessor :on_failure_actions + + validates_presence_of :build_id + validates_presence_of :on_success_actions + validates_presence_of :on_failure_actions + + def args_for_test_runner + [:build_id, :on_success_actions, :on_failure_actions] + end + + def self.for_build_id(build_id, on_success_actions:, on_failure_actions:) + new(build_id: build_id, on_success_actions: on_success_actions, on_failure_actions: on_failure_actions) + end +end diff --git a/test-runner/tests/lib/test_runner_actions/await_terminal_submission_status.rb b/test-runner/tests/lib/test_runner_actions/await_terminal_submission_status.rb new file mode 100644 index 0000000..d3095ea --- /dev/null +++ b/test-runner/tests/lib/test_runner_actions/await_terminal_submission_status.rb @@ -0,0 +1,17 @@ +class TestRunnerActions::AwaitTerminalSubmissionStatus < TestRunnerActions::Base + attr_accessor :submission_id + attr_accessor :on_success_actions + attr_accessor :on_failure_actions + + validates_presence_of :submission_id + validates_presence_of :on_success_actions + validates_presence_of :on_failure_actions + + def args_for_test_runner + [:submission_id, :on_success_actions, :on_failure_actions] + end + + def self.for_submission_id(submission_id, on_success_actions:, on_failure_actions:) + new(submission_id: submission_id, on_success_actions: on_success_actions, on_failure_actions: on_failure_actions) + end +end diff --git a/test-runner/tests/lib/test_runner_actions/base.rb b/test-runner/tests/lib/test_runner_actions/base.rb new file mode 100644 index 0000000..0af0063 --- /dev/null +++ b/test-runner/tests/lib/test_runner_actions/base.rb @@ -0,0 +1,29 @@ +class TestRunnerActions::Base + include ActiveModel::Model + + def as_json_for_test_runner + { + type: self.class.name.demodulize.underscore, + args: args_for_test_runner.map { |arg| + value = send(arg) + + [ + arg, + if value.is_a?(TestRunnerActions::Base) + value.as_json_for_test_runner + elsif value.is_a?(Array) && value.all? { |v| v.is_a?(TestRunnerActions::Base) } + value.map { |v| v.as_json_for_test_runner } + else + value + end + ] + }.to_h + } + end + + def initialize(**attributes) + super(attributes) + + validate! # Ensure we don't construct an invalid action + end +end diff --git a/test-runner/tests/lib/test_runner_actions/print_message.rb b/test-runner/tests/lib/test_runner_actions/print_message.rb new file mode 100644 index 0000000..f9bcddb --- /dev/null +++ b/test-runner/tests/lib/test_runner_actions/print_message.rb @@ -0,0 +1,33 @@ +class TestRunnerActions::PrintMessage < TestRunnerActions::Base + attr_accessor :color + attr_accessor :text + + validates_presence_of :color + validates_presence_of :text, allow_blank: true + + validates_inclusion_of :color, in: %w[green red yellow blue plain] + + def args_for_test_runner + [:color, :text] + end + + def self.blue(message) + new(color: "blue", text: message) + end + + def self.green(message) + new(color: "green", text: message) + end + + def self.plain(message) + new(color: "plain", text: message) + end + + def self.red(message) + new(color: "red", text: message) + end + + def self.yellow(message) + new(color: "yellow", text: message) + end +end diff --git a/test-runner/tests/lib/test_runner_actions/sleep.rb b/test-runner/tests/lib/test_runner_actions/sleep.rb new file mode 100644 index 0000000..ce70c5f --- /dev/null +++ b/test-runner/tests/lib/test_runner_actions/sleep.rb @@ -0,0 +1,18 @@ +class TestRunnerActions::Sleep < TestRunnerActions::Base + attr_accessor :duration_in_milliseconds + + validates_presence_of :duration_in_milliseconds + validates_numericality_of :duration_in_milliseconds, greater_than: 0 + + def args_for_test_runner + [:duration_in_milliseconds] + end + + def self.milliseconds(duration_in_milliseconds) + new(duration_in_milliseconds: duration_in_milliseconds) + end + + def self.seconds(duration_in_seconds) + new(duration_in_milliseconds: duration_in_seconds * 1000) + end +end diff --git a/test-runner/tests/lib/test_runner_actions/stream_logs.rb b/test-runner/tests/lib/test_runner_actions/stream_logs.rb new file mode 100644 index 0000000..ee3c099 --- /dev/null +++ b/test-runner/tests/lib/test_runner_actions/stream_logs.rb @@ -0,0 +1,13 @@ +class TestRunnerActions::StreamLogs < TestRunnerActions::Base + attr_accessor :logstream_url + + validates_presence_of :logstream_url + + def args_for_test_runner + [:logstream_url] + end + + def self.from_url(logstream_url) + new(logstream_url: logstream_url) + end +end diff --git a/test-runner/tests/lib/test_runner_actions/terminate.rb b/test-runner/tests/lib/test_runner_actions/terminate.rb new file mode 100644 index 0000000..3b9a61e --- /dev/null +++ b/test-runner/tests/lib/test_runner_actions/terminate.rb @@ -0,0 +1,13 @@ +class TestRunnerActions::Terminate < TestRunnerActions::Base + attr_accessor :exit_code + + validates_presence_of :exit_code + + def args_for_test_runner + [:exit_code] + end + + def self.with_exit_code(exit_code) + new(exit_code: exit_code) + end +end diff --git a/test-runner/tests/lib/tester_downloader.rb b/test-runner/tests/lib/tester_downloader.rb new file mode 100644 index 0000000..0f7fcaa --- /dev/null +++ b/test-runner/tests/lib/tester_downloader.rb @@ -0,0 +1,54 @@ +class TesterDownloader + def initialize(course:, testers_root_dir:) + @course = course + @testers_root_dir = testers_root_dir + end + + def download_if_needed + return tester_dir if File.exist?(tester_dir) + + compressed_file_path = File.join(@testers_root_dir, "#{@course.slug}.tar.gz") + + FileUtils.mkdir_p(@testers_root_dir) + + File.open(compressed_file_path, "wb") do |file| + artifact_url = "https://github.com/#{tester_repository_name}/releases/download/#{latest_tester_version}/#{latest_tester_version}_linux_amd64.tar.gz" + puts "Downloading #{artifact_url}" + + HTTParty.get(artifact_url, stream_body: true, follow_redirects: true) do |fragment| + file.write(fragment) + end + end + + FileUtils.mkdir_p(tester_dir) + `tar xf #{compressed_file_path} -C #{tester_dir}` + unless $?.to_i.eql?(0) + puts File.read(compressed_file_path)[0..100] + raise "failed to extract archive" + end + + File.delete(compressed_file_path) + + tester_dir + end + + def latest_tester_version + @latest_tester_version ||= begin + latest_release = if ENV["GITHUB_TOKEN"].nil? + HTTParty.get("https://api.github.com/repos/#{tester_repository_name}/releases/latest") + else + HTTParty.get("https://api.github.com/repos/#{tester_repository_name}/releases/latest", headers: {"Authorization" => "Bearer #{ENV["GITHUB_TOKEN"]}"}) + end + + latest_release["tag_name"] + end + end + + def tester_dir + File.join(@testers_root_dir, @course.slug) + end + + def tester_repository_name + "codecrafters-io/#{@course.slug}-tester" + end +end diff --git a/test-runner/tests/run_tests_test.rb b/test-runner/tests/run_tests_test.rb new file mode 100644 index 0000000..49d2473 --- /dev/null +++ b/test-runner/tests/run_tests_test.rb @@ -0,0 +1,198 @@ +require_relative "test_helper" + +class RunTestsTest < Minitest::Test + def setup + DatabaseHelper.clean + + @repository = nil + @git_repository = nil + @test_run = nil + + @fake_server_thread ||= Thread.new do + FakeServer.run! + end + end + + def test_complains_when_test_runner_id_is_invalid + create_and_push_git_repository!("redis-ruby") + + error = assert_raises(RuntimeError) do + @repository.test_runner.id = "inexistent-test-runner-id" + + RunTestsCommandRunner + .new(git_repository: @git_repository, repository: @repository) + .run + end + + assert_match "test runner not found", error.message + assert_equal 0, TestRunResult.count + end + + def test_works_when_no_test_runs_are_found + create_and_push_git_repository!("redis-ruby") + script_result = run_script! + + assert_match "\"should_retry\":false", script_result.stdout + assert_equal 0, TestRunResult.count + + assert_equal "stopped", @repository.test_runner.reload.machine_status + end + + def test_works_when_test_runs_are_found + create_and_push_git_repository!("redis-ruby") + create_test_run! + + run_script! + + assert_equal 1, @test_run.results.count + assert_equal "success", @test_run.results.first.status, @test_run.results.first.parsed_logs + assert_equal "stopped", @repository.test_runner.reload.machine_status + end + + def test_works_when_multiple_test_runs_are_present + create_and_push_git_repository!("redis-ruby") + test_run_1 = create_test_run! + test_run_2 = create_test_run! + + run_script! + + assert_equal 1, test_run_1.results.count + assert_equal "success", test_run_1.results.first.status, test_run_1.results.first.parsed_logs + assert_equal 1, test_run_2.results.count + assert_equal "success", test_run_2.results.first.status, test_run_2.results.first.parsed_logs + + assert_equal "stopped", @repository.test_runner.reload.machine_status + end + + def test_works_when_codecrafters_yml_is_missing + create_and_push_git_repository!("redis-ruby") + + commit_sha = @git_repository.commit_changes_to_branch! do |working_dir| + FileUtils.rm("#{working_dir}/codecrafters.yml") + end + + create_test_run!(commit_sha: commit_sha) + run_script! + + assert_equal 1, @test_run.results.count + assert_equal "failure", @test_run.results.first.status, @test_run.results.first.parsed_logs + + assert_match "Didn't find a codecrafters.yml file", @test_run.results.first.parsed_logs + assert_equal "stopped", @repository.test_runner.reload.machine_status + end + + def test_works_when_buildpack_has_changed + create_and_push_git_repository!("redis-ruby") + + commit_sha = @git_repository.commit_changes_to_branch! do |working_dir| + codecrafters_yml_path = "#{working_dir}/codecrafters.yml" + File.write(codecrafters_yml_path, File.read(codecrafters_yml_path).gsub(/ruby-3.3/, "ruby-3.5")) + end + + create_test_run!(commit_sha: commit_sha) + run_script! + + assert_equal 1, @test_run.results.count + assert_equal "failure", @test_run.results.first.status, @test_run.results.first.parsed_logs + + assert_match "Detected changes to buildpack (old: ruby-3.3, new: ruby-3.5)", @test_run.results.first.parsed_logs + assert_equal "stopped", @repository.test_runner.reload.machine_status + end + + def test_works_when_precompilation_fails + create_and_push_git_repository!("redis-rust") + + commit_sha = @git_repository.commit_changes_to_branch! do |working_dir| + `echo "abcd" > #{working_dir}/src/main.rs` + end + + create_test_run!(commit_sha: commit_sha) + run_script! + + assert_equal 1, @test_run.results.count + assert_equal "failure", @test_run.results.first.status, @test_run.results.first.parsed_logs + + assert_match "code failed to compile", @test_run.results.first.parsed_logs + assert_equal "stopped", @repository.test_runner.reload.machine_status + end + + def test_works_when_precompilation_succeeds + create_and_push_git_repository!("redis-rust") + + create_test_run! + run_script! + + assert_equal 1, @test_run.results.count + assert_equal "success", @test_run.results.first.status, @test_run.results.first.parsed_logs + assert_equal "stopped", @repository.test_runner.reload.machine_status + + assert_match "[compile]", @test_run.results.first.parsed_logs + end + + def test_detects_dependency_file_changes + create_and_push_git_repository!("redis-rust") + + commit_sha = @git_repository.commit_changes_to_branch! do |working_dir| + `echo "" > #{working_dir}/Cargo.toml` + end + + create_test_run!(commit_sha: commit_sha) + result = run_script! + + assert_equal 0, @test_run.results.count + assert_match "Detected changes to Cargo.toml", result.out + assert_equal "starting", @repository.test_runner.reload.machine_status + end + + def test_moves_app_cached + create_and_push_git_repository!("sqlite-python") + + create_test_run! + run_script! + + assert_equal 1, @test_run.results.count + assert_equal "success", @test_run.results.first.status, @test_run.results.first.parsed_logs + assert_equal "stopped", @repository.test_runner.reload.machine_status + + # If /app-cached is not moved, the following line will be present in the logs + refute_match "Creating a virtualenv", @test_run.results.first.parsed_logs + end + + def test_works_when_run_sh_is_not_present + create_and_push_git_repository!("redis-ruby") + + commit_sha = @git_repository.commit_changes_to_branch! do |working_dir| + FileUtils.mkdir_p("#{working_dir}/.codecrafters") + FileUtils.cp("#{working_dir}/.codecrafters/run.sh", "#{working_dir}/spawn_redis_server.sh") + FileUtils.rm("#{working_dir}/.codecrafters/run.sh") + end + + create_test_run!(commit_sha: commit_sha) + run_script! + + assert_equal 1, @test_run.results.count + assert_equal "success", @test_run.results.first.status, @test_run.results.first.parsed_logs + assert_equal "stopped", @repository.test_runner.reload.machine_status + end + + def create_test_run!(commit_sha: nil) + commit_sha ||= @git_repository.head_commit_sha + @test_run = TestRun.create!(repository_id: @repository.id, id: SecureRandom.uuid, commit_sha: commit_sha) + end + + def create_and_push_git_repository!(code_fixture_key) + @code_fixture = CodeFixtures.get(code_fixture_key) + buildpack = Buildpack.upsert_from_code_fixture!(@code_fixture) + @repository = Repository.create!(id: SecureRandom.uuid, course_slug: @code_fixture.fetch("course_slug"), language_slug: @code_fixture.fetch("language_slug"), buildpack: buildpack) + @git_repository = FakeGitRepository.new(@code_fixture.fetch("code_dir"), @repository) + @git_repository.push_to_git_daemon! + end + + def run_script! + @git_repository.set_remote_url(@repository.clone_url) + + RunTestsCommandRunner + .new(git_repository: @git_repository, repository: @repository) + .run + end +end diff --git a/test-runner/tests/sample_data/docker_logs.txt b/test-runner/tests/sample_data/docker_logs.txt new file mode 100644 index 0000000..9615c65 --- /dev/null +++ b/test-runner/tests/sample_data/docker_logs.txt @@ -0,0 +1,155 @@ +#14 [ 7/21] RUN cargo build --release --target-dir=/tmp/codecrafters-redis-target +#14 0.600 Updating crates.io index +#14 0.678 Downloading crates ... +#14 0.714 Downloaded tokio-macros v1.8.2 +#14 0.717 Downloaded thiserror-impl v1.0.32 +#14 0.719 Downloaded lock_api v0.4.9 +#14 0.720 Downloaded socket2 v0.4.7 +#14 0.722 Downloaded smallvec v1.10.0 +#14 0.724 Downloaded signal-hook-registry v1.2.2 +#14 0.725 Downloaded pin-project-lite v0.2.9 +#14 0.729 Downloaded quote v1.0.7 +#14 0.731 Downloaded memchr v2.3.4 +#14 0.733 Downloaded unicode-xid v0.2.1 +#14 0.734 Downloaded mio v0.8.5 +#14 0.739 Downloaded bytes v1.3.0 +#14 0.742 Downloaded proc-macro2 v1.0.24 +#14 0.744 Downloaded num_cpus v1.13.0 +#14 0.746 Downloaded syn v1.0.67 +#14 0.755 Downloaded log v0.4.11 +#14 0.757 Downloaded tokio v1.23.0 +#14 0.786 Downloaded libc v0.2.138 +#14 0.809 Downloaded parking_lot v0.12.1 +#14 0.810 Downloaded cfg-if v1.0.0 +#14 0.811 Downloaded autocfg v1.1.0 +#14 0.812 Downloaded anyhow v1.0.59 +#14 0.815 Downloaded cfg-if v0.1.10 +#14 0.816 Downloaded thiserror v1.0.32 +#14 0.819 Downloaded parking_lot_core v0.9.5 +#14 0.821 Downloaded scopeguard v1.1.0 +#14 0.864 Compiling libc v0.2.138 +#14 0.864 Compiling proc-macro2 v1.0.24 +#14 0.864 Compiling unicode-xid v0.2.1 +#14 0.864 Compiling autocfg v1.1.0 +#14 0.864 Compiling syn v1.0.67 +#14 0.864 Compiling parking_lot_core v0.9.5 +#14 0.866 Compiling log v0.4.11 +#14 0.866 Compiling scopeguard v1.1.0 +#14 0.867 Compiling cfg-if v1.0.0 +#14 0.868 Compiling cfg-if v0.1.10 +#14 0.868 Compiling smallvec v1.10.0 +#14 0.869 Compiling memchr v2.3.4 +#14 0.870 Compiling anyhow v1.0.59 +#14 0.871 Compiling bytes v1.3.0 +#14 0.872 Compiling pin-project-lite v0.2.9 +#14 1.265 Compiling lock_api v0.4.9 +#14 1.265 Compiling tokio v1.23.0 +#14 1.744 Compiling quote v1.0.7 +#14 1.854 Compiling num_cpus v1.13.0 +#14 1.854 Compiling signal-hook-registry v1.2.2 +#14 1.854 Compiling socket2 v0.4.7 +#14 1.854 Compiling mio v0.8.5 +#14 2.015 Compiling parking_lot v0.12.1 +#14 3.769 Compiling tokio-macros v1.8.2 +#14 3.769 Compiling thiserror-impl v1.0.32 +#14 4.469 Compiling thiserror v1.0.32 +#14 8.128 Compiling redis-starter-rust v0.1.0 (/app) +#14 8.272 Finished release [optimized] target(s) in 8.01s +#14 DONE 8.3s + +#15 [ 8/21] RUN rm /tmp/codecrafters-redis-target/release/redis-starter-rust +#15 DONE 0.2s + +#16 [ 9/21] RUN rm /tmp/codecrafters-redis-target/release/redis-starter-rust.d +#16 DONE 0.1s + +#17 [10/21] RUN find /tmp/codecrafters-redis-target/release -type f -maxdepth 1 -delete +#17 DONE 0.2s + +#18 [11/21] RUN rm -f /tmp/codecrafters-redis-target/release/deps/*redis_starter_rust* +#18 DONE 0.2s + +#19 [12/21] RUN rm -f /tmp/codecrafters-redis-target/release/deps/redis_starter_rust* +#19 DONE 0.3s + +#20 [13/21] RUN rm -f /tmp/codecrafters-redis-target/release/.fingerprint/*redis_starter_rust* +#20 DONE 0.2s + +#21 [14/21] RUN rm -f /tmp/codecrafters-redis-target/release/.fingerprint/redis_starter_rust* +#21 DONE 0.2s + +#22 [15/21] RUN rm -rf /app/src +#22 DONE 0.2s + +#23 [16/21] RUN echo "cd ${CODECRAFTERS_SUBMISSION_DIR} && cargo build --release --target-dir=/tmp/codecrafters-redis-target --manifest-path Cargo.toml" > /codecrafters-precompile.sh +#23 DONE 0.2s + +#24 [17/21] RUN chmod +x /codecrafters-precompile.sh +#24 DONE 0.3s + +#25 [18/21] RUN apt-get update && apt-get install -y git curl +#25 0.260 Get:1 http://deb.debian.org/debian buster InRelease [122 kB] +#25 0.271 Get:2 http://deb.debian.org/debian-security buster/updates InRelease [34.8 kB] +#25 0.274 Get:3 http://deb.debian.org/debian buster-updates InRelease [56.6 kB] +#25 0.379 Get:4 http://deb.debian.org/debian buster/main amd64 Packages [7909 kB] +#25 0.443 Get:5 http://deb.debian.org/debian-security buster/updates/main amd64 Packages [550 kB] +#25 0.510 Get:6 http://deb.debian.org/debian buster-updates/main amd64 Packages [8788 B] +#25 1.358 Fetched 8681 kB in 1s (7921 kB/s) +#25 1.358 Reading package lists... +#25 1.794 Reading package lists... +#25 2.213 Building dependency tree... +#25 2.280 Reading state information... +#25 2.353 git is already the newest version (1:2.20.1-2+deb10u8). +#25 2.353 The following additional packages will be installed: +#25 2.353 libcurl4 libcurl4-openssl-dev +#25 2.354 Suggested packages: +#25 2.354 libcurl4-doc libidn11-dev libldap2-dev librtmp-dev libssh2-1-dev +#25 2.365 The following packages will be upgraded: +#25 2.365 curl libcurl4 libcurl4-openssl-dev +#25 2.392 3 upgraded, 0 newly installed, 0 to remove and 42 not upgraded. +#25 2.392 Need to get 1024 kB of archives. +#25 2.392 After this operation, 1024 B disk space will be freed. +#25 2.392 Get:1 http://deb.debian.org/debian-security buster/updates/main amd64 libcurl4-openssl-dev amd64 7.64.0-4+deb10u7 [423 kB] +#25 2.399 Get:2 http://deb.debian.org/debian-security buster/updates/main amd64 curl amd64 7.64.0-4+deb10u7 [266 kB] +#25 2.401 Get:3 http://deb.debian.org/debian-security buster/updates/main amd64 libcurl4 amd64 7.64.0-4+deb10u7 [335 kB] +#25 2.580 debconf: delaying package configuration, since apt-utils is not installed +#25 2.603 Fetched 1024 kB in 0s (32.7 MB/s) +#25 2.635 (Reading database ... +(Reading database ... 5% +(Reading database ... 10% +(Reading database ... 15% +(Reading database ... 20% +(Reading database ... 25% +(Reading database ... 30% +(Reading database ... 35% +(Reading database ... 40% +(Reading database ... 45% +(Reading database ... 50% +(Reading database ... 55% +(Reading database ... 60% +(Reading database ... 65% +(Reading database ... 70% +(Reading database ... 75% +(Reading database ... 80% +(Reading database ... 85% +(Reading database ... 90% +(Reading database ... 95% +(Reading database ... 100% +(Reading database ... 23989 files and directories currently installed.) +#25 2.805 Preparing to unpack .../libcurl4-openssl-dev_7.64.0-4+deb10u7_amd64.deb ... +#25 2.821 Unpacking libcurl4-openssl-dev:amd64 (7.64.0-4+deb10u7) over (7.64.0-4+deb10u6) ... +#25 2.964 Preparing to unpack .../curl_7.64.0-4+deb10u7_amd64.deb ... +#25 2.977 Unpacking curl (7.64.0-4+deb10u7) over (7.64.0-4+deb10u6) ... +#25 3.052 Preparing to unpack .../libcurl4_7.64.0-4+deb10u7_amd64.deb ... +#25 3.072 Unpacking libcurl4:amd64 (7.64.0-4+deb10u7) over (7.64.0-4+deb10u6) ... +#25 3.150 Setting up libcurl4:amd64 (7.64.0-4+deb10u7) ... +#25 3.163 Setting up curl (7.64.0-4+deb10u7) ... +#25 3.175 Setting up libcurl4-openssl-dev:amd64 (7.64.0-4+deb10u7) ... +#25 3.188 Processing triggers for libc-bin (2.28-10+deb10u2) ... +#25 DONE 3.5s + +#26 [19/21] WORKDIR /app +#26 DONE 0.0s + +#27 [20/21] ADD . /app +#27 DONE 0.1s \ No newline at end of file diff --git a/test-runner/tests/test_helper.rb b/test-runner/tests/test_helper.rb new file mode 100644 index 0000000..01462b4 --- /dev/null +++ b/test-runner/tests/test_helper.rb @@ -0,0 +1,25 @@ +require "bundler/setup" +Bundler.require(:default, :test) + +require "zeitwerk" +loader = Zeitwerk::Loader.new +loader.push_dir("#{__dir__}/lib") +loader.push_dir("#{__dir__}/lib/db/models") +loader.setup + +require "dotenv/load" + +require "active_record" + +require "fileutils" +require "minitest/autorun" +require "securerandom" + +ActiveRecord::Base.establish_connection( + adapter: "sqlite3", + database: "/tmp/test.db" +) + +require_relative "lib/db/schema" + +ENV["TEST_RUNNER_REPOSITORY_ROOT"] = File.expand_path("../", __dir__) From 2d1bb3da043692ba62247c31b05d7d74ed6e53ba Mon Sep 17 00:00:00 2001 From: Paul Kuruvilla Date: Sun, 16 Nov 2025 19:10:34 -0800 Subject: [PATCH 02/13] Refactor GitHub Actions workflows and remove unused files from test-runner. --- .../.github => .github}/workflows/test.yml | 4 + test-runner/.github/workflows/publish.yml | 35 ---- test-runner/.github/workflows/tag.yml | 29 --- test-runner/Makefile | 5 +- test-runner/README.md | 14 +- .../backend/create_test_run_result.go | 41 ---- .../internal/backend/report_shutdown.go | 37 ---- .../internal/backend/reserve_test_run.go | 62 ------ .../backend/trigger_infrastructure_rebuild.go | 49 ----- .../move_app_cached_files_if_needed.go | 24 --- .../internal/run_precompilation_if_needed.go | 71 ------- test-runner/internal/run_tester.go | 33 --- test-runner/internal/test_run.go | 78 ------- .../tests/lib/run_tests_command_runner.rb | 53 ----- test-runner/tests/run_tests_test.rb | 198 ------------------ 15 files changed, 9 insertions(+), 724 deletions(-) rename {test-runner/.github => .github}/workflows/test.yml (91%) delete mode 100644 test-runner/.github/workflows/publish.yml delete mode 100644 test-runner/.github/workflows/tag.yml delete mode 100644 test-runner/internal/backend/create_test_run_result.go delete mode 100644 test-runner/internal/backend/report_shutdown.go delete mode 100644 test-runner/internal/backend/reserve_test_run.go delete mode 100644 test-runner/internal/backend/trigger_infrastructure_rebuild.go delete mode 100644 test-runner/internal/move_app_cached_files_if_needed.go delete mode 100644 test-runner/internal/run_precompilation_if_needed.go delete mode 100644 test-runner/internal/run_tester.go delete mode 100644 test-runner/internal/test_run.go delete mode 100644 test-runner/tests/lib/run_tests_command_runner.rb delete mode 100644 test-runner/tests/run_tests_test.rb diff --git a/test-runner/.github/workflows/test.yml b/.github/workflows/test.yml similarity index 91% rename from test-runner/.github/workflows/test.yml rename to .github/workflows/test.yml index 76d4e58..5f97d74 100644 --- a/test-runner/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -37,9 +37,11 @@ jobs: ruby-version: 3.3.5 bundler-cache: true cache-version: 5 + working-directory: test-runner - name: Run make refresh_test_fixtures run: make refresh_test_fixtures + working-directory: test-runner # Required for pulling git-api and git-daemon - name: Authenticate with ghcr.io @@ -51,6 +53,7 @@ jobs: - name: Set up Docker run: docker compose up -d + working-directory: test-runner - name: Set up Git run: | @@ -59,6 +62,7 @@ jobs: - name: Run tests run: make test + working-directory: test-runner env: DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }} DEPOT_PROJECT: ${{ secrets.DEPOT_PROJECT }} diff --git a/test-runner/.github/workflows/publish.yml b/test-runner/.github/workflows/publish.yml deleted file mode 100644 index 9285566..0000000 --- a/test-runner/.github/workflows/publish.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Publish - -on: - push: - tags: - - "v*" - -permissions: - contents: write - -jobs: - publish_tester: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v2 - - - name: Unshallow - run: git fetch --prune --unshallow - - - name: Set up Go - uses: actions/setup-go@v1 - with: - go-version: 1.21.x - - # Is this needed? - # - run: "sudo apt-get install gcc-multilib g++-multilib" - - - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v5 - with: - version: v1.21.2 - args: release -f main.goreleaser.yml --clean - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/test-runner/.github/workflows/tag.yml b/test-runner/.github/workflows/tag.yml deleted file mode 100644 index 91d6a47..0000000 --- a/test-runner/.github/workflows/tag.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Tag - -on: - push: - branches: [main] - -jobs: - create-tag: - runs-on: ubuntu-latest - timeout-minutes: 10 - - steps: - - uses: actions/checkout@v2 - with: - fetch-depth: 0 # Ensure that all tags are available - - - name: "Compute next version tag" - run: |- - nextVersionTag=$(make print_next_version_tag) - echo "next: $nextVersionTag" - echo "::set-output name=next_version_tag::$nextVersionTag" - id: compute_next_version_tag - - - name: Bump version and push tag - uses: mathieudutour/github-tag-action@v6.2 - with: - github_token: ${{ secrets.CODECRAFTERS_BOT_GITHUB_TOKEN }} - custom_tag: ${{ steps.compute_next_version_tag.outputs.next_version_tag }} - tag_prefix: "" diff --git a/test-runner/Makefile b/test-runner/Makefile index fa58118..7b26481 100644 --- a/test-runner/Makefile +++ b/test-runner/Makefile @@ -51,15 +51,12 @@ refresh_test_fixtures: cp -R /tmp/byo-sqlite/solutions/python/01-dr6/code ./tests/fixtures/sqlite-python-pass-stage-1 cp /tmp/byo-sqlite/dockerfiles/python-3.13.Dockerfile ./tests/fixtures/dockerfiles/sqlite-python-3.13.Dockerfile -test: test_go test_run_tests test_build_image +test: test_go test_build_image test_go: # -mod=mod is required to ensure that go isn't confused by Ruby's vendor/bundle directory go test -mod=mod ./... -test_run_tests: build - bundle exec ruby tests/run_tests_test.rb --verbose --fail-fast - test_build_image: build bundle exec ruby tests/build_image_test.rb --verbose --fail-fast diff --git a/test-runner/README.md b/test-runner/README.md index f5eb571..9af4208 100644 --- a/test-runner/README.md +++ b/test-runner/README.md @@ -1,17 +1,11 @@ # Test Runner -This is the component responsible for running tests for CodeCrafters challenges. +This is the Go code invoked from the [test-runner-builder](https://github.com/codecrafters-io/test-runner-builder) Docker image. -### Functionality +Responsibilities: -It supports two commands: - -- `build_image` - - Builds the docker image that needs to executed to run tests for a challenge - - Also streams logs back to a logstream URL -- `run_tests` - - Runs the tests for a challenge (this is the entrypoint of the docker image) - - Streams logs back to a logstream URL +- Builds the docker image that needs to executed to run tests for a challenge +- Also streams logs back to a logstream URL ### Testing Locally diff --git a/test-runner/internal/backend/create_test_run_result.go b/test-runner/internal/backend/create_test_run_result.go deleted file mode 100644 index be51ce6..0000000 --- a/test-runner/internal/backend/create_test_run_result.go +++ /dev/null @@ -1,41 +0,0 @@ -package backend - -import ( - "encoding/base64" - "encoding/json" - "fmt" - "io" -) - -func CreateTestRunResult(testRunId string, testRunnerId string, logs []byte, status string) error { - body := map[string]string{ - "test_run_id": testRunId, - "test_runner_id": testRunnerId, - "status": status, - "logs_base64": base64.StdEncoding.EncodeToString(logs), - } - - bodyJson, err := json.Marshal(body) - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - - resp, err := PerformRequest("POST", "/services/test_runner/create_test_run_result", bodyJson) - if err != nil { - return fmt.Errorf("HTTP request failed: %w", err) - } - defer resp.Body.Close() - - fmt.Printf("Response: %d\n", resp.StatusCode) - - bodyBytes, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("failed to read response body: %w", err) - } - - if resp.StatusCode != 200 { - return fmt.Errorf("unexpected HTTP status %d. Body: %s", resp.StatusCode, string(bodyBytes)) - } - - return nil -} diff --git a/test-runner/internal/backend/report_shutdown.go b/test-runner/internal/backend/report_shutdown.go deleted file mode 100644 index a4f8942..0000000 --- a/test-runner/internal/backend/report_shutdown.go +++ /dev/null @@ -1,37 +0,0 @@ -package backend - -import ( - "encoding/json" - "fmt" - "io" -) - -func ReportShutdown(testRunnerId string) error { - body := map[string]string{ - "test_runner_id": testRunnerId, - } - - bodyJson, err := json.Marshal(body) - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - - resp, err := PerformRequest("POST", "/services/test_runner/report_shutdown", bodyJson) - if err != nil { - return fmt.Errorf("HTTP request failed: %w", err) - } - defer resp.Body.Close() - - fmt.Printf("Response: %d\n", resp.StatusCode) - - bodyBytes, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("failed to read response body: %w", err) - } - - if resp.StatusCode == 200 { - return nil - } else { - return fmt.Errorf("unexpected HTTP status %d. Body: %s", resp.StatusCode, string(bodyBytes)) - } -} diff --git a/test-runner/internal/backend/reserve_test_run.go b/test-runner/internal/backend/reserve_test_run.go deleted file mode 100644 index a87f75d..0000000 --- a/test-runner/internal/backend/reserve_test_run.go +++ /dev/null @@ -1,62 +0,0 @@ -package backend - -import ( - "encoding/json" - "fmt" - "io" -) - -type HTTPStatusError struct { - StatusCode int -} - -func (e *HTTPStatusError) Error() string { - return fmt.Sprintf("HTTP status %d", e.StatusCode) -} - -type ReservedTestRun struct { - ID string `json:"id"` - CommitSHA string `json:"commit_sha"` - TestCasesJSON string `json:"test_cases_json"` - LogstreamURL string `json:"logstream_url"` -} - -type ReserveTestRunResponse struct { - TestRun *ReservedTestRun `json:"test_run"` - ShouldRetry bool `json:"should_retry"` -} - -func ReserveTestRun(testRunnerId string) (ReserveTestRunResponse, error) { - path := fmt.Sprintf("/services/test_runner/reserve_test_run?test_runner_id=%s", testRunnerId) - - resp, err := PerformRequest("POST", path, nil) - if err != nil { - return ReserveTestRunResponse{}, fmt.Errorf("HTTP request failed: %w", err) - } - - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return ReserveTestRunResponse{}, fmt.Errorf("read response failed: %w", err) - } - - fmt.Printf("Response (%d): %s\n", resp.StatusCode, string(body)) - - // Handle legacy response (TODO: Remove this once we've migrated all clients) - if resp.StatusCode == 202 { - return ReserveTestRunResponse{ShouldRetry: false}, nil - } - - if resp.StatusCode == 200 { - var reserveTestRunResponse ReserveTestRunResponse - - if err = json.Unmarshal(body, &reserveTestRunResponse); err != nil { - return ReserveTestRunResponse{}, fmt.Errorf("unable to parse response: %w", err) - } - - return reserveTestRunResponse, nil - } else { - return ReserveTestRunResponse{}, &HTTPStatusError{StatusCode: resp.StatusCode} - } -} diff --git a/test-runner/internal/backend/trigger_infrastructure_rebuild.go b/test-runner/internal/backend/trigger_infrastructure_rebuild.go deleted file mode 100644 index 54fdac9..0000000 --- a/test-runner/internal/backend/trigger_infrastructure_rebuild.go +++ /dev/null @@ -1,49 +0,0 @@ -package backend - -import ( - "encoding/json" - "fmt" - "io" -) - -type TriggerInfrastructureRebuildResponse struct { - ID string `json:"id"` - CommitSHA string `json:"commit_sha"` - LogstreamURL string `json:"logstream_url"` -} - -func TriggerInfrastructureRebuild(testRunId string, testRunnerId string) (TriggerInfrastructureRebuildResponse, error) { - body := map[string]string{ - "test_run_id": testRunId, - "test_runner_id": testRunnerId, - } - - bodyJson, err := json.Marshal(body) - if err != nil { - return TriggerInfrastructureRebuildResponse{}, fmt.Errorf("failed to marshal JSON: %w", err) - } - - resp, err := PerformRequest("POST", "/services/test_runner/rebuild_for_test_run", bodyJson) - if err != nil { - return TriggerInfrastructureRebuildResponse{}, fmt.Errorf("HTTP request failed: %w", err) - } - - defer resp.Body.Close() - bodyBytes, err := io.ReadAll(resp.Body) - if err != nil { - return TriggerInfrastructureRebuildResponse{}, fmt.Errorf("failed to read response body: %w", err) - } - - if resp.StatusCode != 200 { - return TriggerInfrastructureRebuildResponse{}, fmt.Errorf("unexpected HTTP status %d. Body: %s", resp.StatusCode, string(bodyBytes)) - } - - buildResponse := &TriggerInfrastructureRebuildResponse{} - err = json.Unmarshal(bodyBytes, buildResponse) - - if err != nil { - return TriggerInfrastructureRebuildResponse{}, fmt.Errorf("failed to parse response into submission: %w", err) - } - - return *buildResponse, nil -} diff --git a/test-runner/internal/move_app_cached_files_if_needed.go b/test-runner/internal/move_app_cached_files_if_needed.go deleted file mode 100644 index 32c85e1..0000000 --- a/test-runner/internal/move_app_cached_files_if_needed.go +++ /dev/null @@ -1,24 +0,0 @@ -package internal - -import ( - "fmt" - "os" - - cp "github.com/otiai10/copy" -) - -func MoveAppCachedFilesIfNeeded(repositoryDir string) error { - if _, err := os.Stat("/app-cached"); !os.IsNotExist(err) { - fmt.Printf("Moving /app-cached to %s\n", repositoryDir) - - err := cp.Copy("/app-cached", repositoryDir, cp.Options{ - PreserveTimes: true, // Some caching strategies rely on file timestamps - PreserveOwner: true, // Some caching strategies rely on file ownership - }) - if err != nil { - return fmt.Errorf("failed to copy dir: %s", err) - } - } - - return nil -} diff --git a/test-runner/internal/run_precompilation_if_needed.go b/test-runner/internal/run_precompilation_if_needed.go deleted file mode 100644 index 17921bd..0000000 --- a/test-runner/internal/run_precompilation_if_needed.go +++ /dev/null @@ -1,71 +0,0 @@ -package internal - -import ( - "errors" - "fmt" - "os" - "os/exec" - "path" - - "github.com/egym-playground/go-prefix-writer/prefixer" -) - -func RunPrecompilationIfNeeded(testRun *TestRun, repositoryDir string) error { - logWriter := testRun - logWithPrefixWriter := prefixer.New(logWriter, func() string { return "\033[33m[compile]\033[0m " }) - - newPrecompileFilePath := path.Join(repositoryDir, ".codecrafters/compile.sh") - legacyPrecompileFilePath := "/codecrafters-precompile.sh" - - var precompileFilePath string - _, newPrecompileFilePathErr := os.Stat(newPrecompileFilePath) - _, legacyPrecompileFilePathErr := os.Stat(legacyPrecompileFilePath) - - if newPrecompileFilePathErr == nil { - precompileFilePath = newPrecompileFilePath - } else if legacyPrecompileFilePathErr == nil { - precompileFilePath = legacyPrecompileFilePath - } else if !errors.Is(newPrecompileFilePathErr, os.ErrNotExist) { // new script exists but failed to stat - return &FriendlyError{ - UserError: "CodeCrafters internal error: failed to stat precompile script: " + newPrecompileFilePathErr.Error(), - InternalError: "failed to stat precompile script: " + newPrecompileFilePathErr.Error(), - } - } else if !errors.Is(legacyPrecompileFilePathErr, os.ErrNotExist) { // legacy script exists but failed to stat - return &FriendlyError{ - UserError: "CodeCrafters internal error: failed to stat precompile script: " + legacyPrecompileFilePathErr.Error(), - InternalError: "failed to stat precompile script: " + legacyPrecompileFilePathErr.Error(), - } - } - - // No precompile script found - if precompileFilePath == "" { - return nil - } - - cmd := exec.Command("sh", precompileFilePath) - cmd.Env = os.Environ() - cmd.Env = append(cmd.Env, fmt.Sprintf("CODECRAFTERS_SUBMISSION_DIR=%s", repositoryDir)) // TODO: Legacy, remove - cmd.Env = append(cmd.Env, fmt.Sprintf("CODECRAFTERS_REPOSITORY_DIR=%s", repositoryDir)) - cmd.Stdout = logWithPrefixWriter - cmd.Stderr = logWithPrefixWriter - - err := cmd.Run() - if _, ok := err.(*exec.ExitError); ok { - logWithPrefixWriter.Write([]byte("\033[31mLooks like your code failed to compile.\033[0m\n")) - logWithPrefixWriter.Write([]byte("\033[31mIf you think this is a CodeCrafters error, please let us know at hello@codecrafters.io.\033[0m\n")) - logWriter.Write([]byte("\n")) - - return &FriendlyError{ - UserError: "Compilation failed (user error!)", - InternalError: "Compilation failed (internal error!)", - IsLogged: true, - } - } else if err != nil { - return &FriendlyError{ - UserError: "CodeCrafters internal error: failed to run precompile script: " + err.Error(), - InternalError: "failed to run precompile script: " + err.Error(), - } - } - - return nil -} diff --git a/test-runner/internal/run_tester.go b/test-runner/internal/run_tester.go deleted file mode 100644 index bbedf76..0000000 --- a/test-runner/internal/run_tester.go +++ /dev/null @@ -1,33 +0,0 @@ -package internal - -import ( - "fmt" - "os" - "os/exec" - "path" -) - -func RunTester(testRun *TestRun, repositoryDir string, testerDir string) (bool, error) { - cmd := exec.Command(path.Join(testerDir, "test.sh")) - cmd.Env = os.Environ() - cmd.Env = append(cmd.Env, "PYTHONUNBUFFERED=true") // TODO: Move this to dockerfile? - cmd.Env = append(cmd.Env, fmt.Sprintf("CODECRAFTERS_SUBMISSION_DIR=%s", repositoryDir)) // TODO: Legacy, remove - cmd.Env = append(cmd.Env, fmt.Sprintf("CODECRAFTERS_REPOSITORY_DIR=%s", repositoryDir)) - cmd.Env = append(cmd.Env, fmt.Sprintf("TESTER_DIR=%s", testerDir)) - cmd.Env = append(cmd.Env, fmt.Sprintf("CODECRAFTERS_TEST_CASES_JSON=%s", testRun.TestCasesJSON)) - cmd.Stdout = testRun - cmd.Stderr = testRun - - err := cmd.Run() - if exitError, ok := err.(*exec.ExitError); ok { - if exitError.ExitCode() != 0 && exitError.ExitCode() != 1 { - return false, err - } - - return exitError.ExitCode() == 0, nil - } else if err != nil { - return false, err - } - - return true, nil -} diff --git a/test-runner/internal/test_run.go b/test-runner/internal/test_run.go deleted file mode 100644 index ad460be..0000000 --- a/test-runner/internal/test_run.go +++ /dev/null @@ -1,78 +0,0 @@ -package internal - -import ( - "fmt" - "io" - "os" - - "github.com/codecrafters-io/logstream/redis" -) - -type TestRun struct { - ID string - CommitSHA string - TestCasesJSON string - LogstreamURL string - - logstreamWriter *redis.Producer - logsFile *os.File -} - -func (t *TestRun) InitLogging() error { - logstreamWriter, err := redis.NewProducer(t.LogstreamURL) - if err != nil { - return fmt.Errorf("failed to create logstream writer: %w", err) - } - - logsFile, err := os.CreateTemp("", "test-run") - if err != nil { - return fmt.Errorf("failed to create temporary file: %w", err) - } - - t.logstreamWriter = logstreamWriter - t.logsFile = logsFile - - return nil -} - -func (t *TestRun) CleanupLogging() { - if t.logsFile != nil { - err := os.Remove(t.logsFile.Name()) - if err != nil { - fmt.Printf("CodeCrafters Internal Error: unable to delete logs file: %v\n", err) - } - - t.logsFile = nil - } -} - -func (t *TestRun) ReadLogsFromFile() ([]byte, error) { - t.logsFile.Seek(0, 0) - logs, err := io.ReadAll(t.logsFile) - if err != nil { - return nil, fmt.Errorf("failed to read logs from file: %w", err) - } - - return logs, nil -} - -func (t *TestRun) Write(p []byte) (n int, err error) { - _, logStreamError := t.logstreamWriter.Write(p) - _, fileError := t.logsFile.Write(p) - _, _ = os.Stdout.Write(p) - - if logStreamError != nil { - return 0, fmt.Errorf("failed to write to logstream: %w", logStreamError) - } - - if fileError != nil { - return 0, fmt.Errorf("failed to write to file: %w", fileError) - } - - return len(p), nil -} - -func (t *TestRun) Close() { - t.logstreamWriter.Close() - t.logsFile.Close() -} diff --git a/test-runner/tests/lib/run_tests_command_runner.rb b/test-runner/tests/lib/run_tests_command_runner.rb deleted file mode 100644 index 88067fa..0000000 --- a/test-runner/tests/lib/run_tests_command_runner.rb +++ /dev/null @@ -1,53 +0,0 @@ -TEST_RUNNER_LINUX_EXECUTABLE_PATH = File.expand_path("../../dist/main-linux.out", __dir__) -TESTERS_DIR = File.expand_path("../fixtures/testers", __dir__) - -class RunTestsCommandRunner - attr_accessor :git_repository - attr_accessor :repository - - def initialize(git_repository:, repository:) - self.git_repository = git_repository - self.repository = repository - end - - def dockerfile_path - repository.dockerfile_path(git_repository) - end - - def run - tmp_dir = Dir.mktmpdir - tester_dir = TesterDownloader.new(course: OpenStruct.new(slug: repository.course_slug), testers_root_dir: TESTERS_DIR).download_if_needed - - FileUtils.rm_rf(tmp_dir) - FileUtils.cp_r(git_repository.tmp_dir, tmp_dir) - - dockerfile_handle = Tempfile.new - dockerfile_handle.write(repository.buildpack.processed_dockerfile_contents) - dockerfile_handle.close - - puts File.read(dockerfile_handle.path) - - # Required for build - FileUtils.mkdir_p("#{tmp_dir}/test-runner") - FileUtils.cp(TEST_RUNNER_LINUX_EXECUTABLE_PATH, "#{tmp_dir}/test-runner/test-runner") - FileUtils.cp_r(tester_dir, "#{tmp_dir}/tester") - - result = TTY::Command.new.run("docker build --quiet -t test_runner_test_image -f #{dockerfile_handle.path} #{tmp_dir}") - raise "Docker build failed. Stdout: #{result.out}. Stderr: #{result.err}" unless result.status == 0 - - command_parts = [ - "docker run", - "-e CODECRAFTERS_SERVER_URL=http://host.docker.internal:6331", - "-e CODECRAFTERS_TEST_RUNNER_ID=#{repository.test_runner.id}", - "-e REPOSITORY_ID=#{repository.id}", - "--add-host=host.docker.internal:host-gateway", # Required for CI, works by default on macOS - "--rm ", - "test_runner_test_image" - ] - - result = TTY::Command.new.run(command_parts.join(" ")) - raise "Test runner script failed. Stdout: #{result.out}. Stderr: #{result.err}" unless result.status == 0 - - result - end -end diff --git a/test-runner/tests/run_tests_test.rb b/test-runner/tests/run_tests_test.rb deleted file mode 100644 index 49d2473..0000000 --- a/test-runner/tests/run_tests_test.rb +++ /dev/null @@ -1,198 +0,0 @@ -require_relative "test_helper" - -class RunTestsTest < Minitest::Test - def setup - DatabaseHelper.clean - - @repository = nil - @git_repository = nil - @test_run = nil - - @fake_server_thread ||= Thread.new do - FakeServer.run! - end - end - - def test_complains_when_test_runner_id_is_invalid - create_and_push_git_repository!("redis-ruby") - - error = assert_raises(RuntimeError) do - @repository.test_runner.id = "inexistent-test-runner-id" - - RunTestsCommandRunner - .new(git_repository: @git_repository, repository: @repository) - .run - end - - assert_match "test runner not found", error.message - assert_equal 0, TestRunResult.count - end - - def test_works_when_no_test_runs_are_found - create_and_push_git_repository!("redis-ruby") - script_result = run_script! - - assert_match "\"should_retry\":false", script_result.stdout - assert_equal 0, TestRunResult.count - - assert_equal "stopped", @repository.test_runner.reload.machine_status - end - - def test_works_when_test_runs_are_found - create_and_push_git_repository!("redis-ruby") - create_test_run! - - run_script! - - assert_equal 1, @test_run.results.count - assert_equal "success", @test_run.results.first.status, @test_run.results.first.parsed_logs - assert_equal "stopped", @repository.test_runner.reload.machine_status - end - - def test_works_when_multiple_test_runs_are_present - create_and_push_git_repository!("redis-ruby") - test_run_1 = create_test_run! - test_run_2 = create_test_run! - - run_script! - - assert_equal 1, test_run_1.results.count - assert_equal "success", test_run_1.results.first.status, test_run_1.results.first.parsed_logs - assert_equal 1, test_run_2.results.count - assert_equal "success", test_run_2.results.first.status, test_run_2.results.first.parsed_logs - - assert_equal "stopped", @repository.test_runner.reload.machine_status - end - - def test_works_when_codecrafters_yml_is_missing - create_and_push_git_repository!("redis-ruby") - - commit_sha = @git_repository.commit_changes_to_branch! do |working_dir| - FileUtils.rm("#{working_dir}/codecrafters.yml") - end - - create_test_run!(commit_sha: commit_sha) - run_script! - - assert_equal 1, @test_run.results.count - assert_equal "failure", @test_run.results.first.status, @test_run.results.first.parsed_logs - - assert_match "Didn't find a codecrafters.yml file", @test_run.results.first.parsed_logs - assert_equal "stopped", @repository.test_runner.reload.machine_status - end - - def test_works_when_buildpack_has_changed - create_and_push_git_repository!("redis-ruby") - - commit_sha = @git_repository.commit_changes_to_branch! do |working_dir| - codecrafters_yml_path = "#{working_dir}/codecrafters.yml" - File.write(codecrafters_yml_path, File.read(codecrafters_yml_path).gsub(/ruby-3.3/, "ruby-3.5")) - end - - create_test_run!(commit_sha: commit_sha) - run_script! - - assert_equal 1, @test_run.results.count - assert_equal "failure", @test_run.results.first.status, @test_run.results.first.parsed_logs - - assert_match "Detected changes to buildpack (old: ruby-3.3, new: ruby-3.5)", @test_run.results.first.parsed_logs - assert_equal "stopped", @repository.test_runner.reload.machine_status - end - - def test_works_when_precompilation_fails - create_and_push_git_repository!("redis-rust") - - commit_sha = @git_repository.commit_changes_to_branch! do |working_dir| - `echo "abcd" > #{working_dir}/src/main.rs` - end - - create_test_run!(commit_sha: commit_sha) - run_script! - - assert_equal 1, @test_run.results.count - assert_equal "failure", @test_run.results.first.status, @test_run.results.first.parsed_logs - - assert_match "code failed to compile", @test_run.results.first.parsed_logs - assert_equal "stopped", @repository.test_runner.reload.machine_status - end - - def test_works_when_precompilation_succeeds - create_and_push_git_repository!("redis-rust") - - create_test_run! - run_script! - - assert_equal 1, @test_run.results.count - assert_equal "success", @test_run.results.first.status, @test_run.results.first.parsed_logs - assert_equal "stopped", @repository.test_runner.reload.machine_status - - assert_match "[compile]", @test_run.results.first.parsed_logs - end - - def test_detects_dependency_file_changes - create_and_push_git_repository!("redis-rust") - - commit_sha = @git_repository.commit_changes_to_branch! do |working_dir| - `echo "" > #{working_dir}/Cargo.toml` - end - - create_test_run!(commit_sha: commit_sha) - result = run_script! - - assert_equal 0, @test_run.results.count - assert_match "Detected changes to Cargo.toml", result.out - assert_equal "starting", @repository.test_runner.reload.machine_status - end - - def test_moves_app_cached - create_and_push_git_repository!("sqlite-python") - - create_test_run! - run_script! - - assert_equal 1, @test_run.results.count - assert_equal "success", @test_run.results.first.status, @test_run.results.first.parsed_logs - assert_equal "stopped", @repository.test_runner.reload.machine_status - - # If /app-cached is not moved, the following line will be present in the logs - refute_match "Creating a virtualenv", @test_run.results.first.parsed_logs - end - - def test_works_when_run_sh_is_not_present - create_and_push_git_repository!("redis-ruby") - - commit_sha = @git_repository.commit_changes_to_branch! do |working_dir| - FileUtils.mkdir_p("#{working_dir}/.codecrafters") - FileUtils.cp("#{working_dir}/.codecrafters/run.sh", "#{working_dir}/spawn_redis_server.sh") - FileUtils.rm("#{working_dir}/.codecrafters/run.sh") - end - - create_test_run!(commit_sha: commit_sha) - run_script! - - assert_equal 1, @test_run.results.count - assert_equal "success", @test_run.results.first.status, @test_run.results.first.parsed_logs - assert_equal "stopped", @repository.test_runner.reload.machine_status - end - - def create_test_run!(commit_sha: nil) - commit_sha ||= @git_repository.head_commit_sha - @test_run = TestRun.create!(repository_id: @repository.id, id: SecureRandom.uuid, commit_sha: commit_sha) - end - - def create_and_push_git_repository!(code_fixture_key) - @code_fixture = CodeFixtures.get(code_fixture_key) - buildpack = Buildpack.upsert_from_code_fixture!(@code_fixture) - @repository = Repository.create!(id: SecureRandom.uuid, course_slug: @code_fixture.fetch("course_slug"), language_slug: @code_fixture.fetch("language_slug"), buildpack: buildpack) - @git_repository = FakeGitRepository.new(@code_fixture.fetch("code_dir"), @repository) - @git_repository.push_to_git_daemon! - end - - def run_script! - @git_repository.set_remote_url(@repository.clone_url) - - RunTestsCommandRunner - .new(git_repository: @git_repository, repository: @repository) - .run - end -end From a0ecd9c6511c0aa1f69acad7e543f1af17c27ee1 Mon Sep 17 00:00:00 2001 From: Paul Kuruvilla Date: Sun, 16 Nov 2025 19:18:16 -0800 Subject: [PATCH 03/13] Add Go installation and test runner build to Dockerfile; update main.go for version check. --- Dockerfile | 19 ++++++++++++++++--- test-runner/main.go | 7 +++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index e8a21ac..52faf72 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,6 +3,17 @@ FROM debian:bookworm-slim # Install deps RUN apt-get update && apt-get install -y curl wget jq tar git docker.io +# Install Go +RUN wget https://go.dev/dl/go1.25.0.linux-amd64.tar.gz -O /tmp/go.tar.gz && \ + rm -rf /usr/local/go && \ + tar -C /usr/local -xzf /tmp/go.tar.gz && \ + rm /tmp/go.tar.gz + +ENV PATH="/usr/local/go/bin:$PATH" + +# Ensure Go is installed and working +RUN go version + # Install depot RUN curl -fsSL https://depot.dev/install-cli.sh -o /tmp/install-cli.sh && \ sh /tmp/install-cli.sh 2.100.12 && \ @@ -14,6 +25,8 @@ ENV PATH="/root/.depot/bin:$PATH" # Ensure depot is installed and working RUN depot --version -# Download test runner CLI -COPY download_test_runner.sh /tmp/download_test_runner.sh -RUN --mount=type=secret,id=test_runner_downloader_token,env=GITHUB_TOKEN /tmp/download_test_runner.sh \ No newline at end of file +COPY test-runner/ /tmp/test-runner/ +RUN cd /tmp/test-runner && go build -o /var/opt/test-runner-builder main.go + +# Ensure the Go program is installed and working +RUN /var/opt/test-runner-builder --version \ No newline at end of file diff --git a/test-runner/main.go b/test-runner/main.go index bb02e66..4788055 100644 --- a/test-runner/main.go +++ b/test-runner/main.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "os" "github.com/codecrafters-io/test-runner/internal/commands" @@ -12,6 +13,12 @@ func main() { utils.InitSentry() defer utils.TeardownSentry() + // Used to check if the test runner is installed in the dockerfile + if len(os.Args) == 2 && os.Args[1] == "--version" { + fmt.Println("Test runner installed!") + os.Exit(0) + } + exitCode := 0 func() { From 813ecf50974e5b4374eb226dfe1e22d542f149b0 Mon Sep 17 00:00:00 2001 From: Paul Kuruvilla Date: Sun, 16 Nov 2025 19:19:18 -0800 Subject: [PATCH 04/13] update --- test-runner/internal/commands/build_image_command.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-runner/internal/commands/build_image_command.go b/test-runner/internal/commands/build_image_command.go index 702848d..64a9401 100644 --- a/test-runner/internal/commands/build_image_command.go +++ b/test-runner/internal/commands/build_image_command.go @@ -111,7 +111,7 @@ func BuildImageCommand() int { flag.StringVar(&testRunnerDir, "test-runner-dir", "", "The directory of the test runner") flag.StringVar(&testerDir, "tester-dir", "", "The directory of the tester") - flag.CommandLine.Parse(os.Args[2:]) // Use 2: to avoid the first command + flag.CommandLine.Parse(os.Args[1:]) // Use 1: to avoid the first command (the program name) if codecraftersServerUrl == "" { fmt.Println("codecrafters-server-url must be provided") From 41eae83a210046dfccf3a74015468ea619faa75b Mon Sep 17 00:00:00 2001 From: Paul Kuruvilla Date: Sun, 16 Nov 2025 20:04:12 -0800 Subject: [PATCH 05/13] update Go version from 1.21 to 1.25 in GitHub Actions workflow configuration --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5f97d74..95b1b87 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,7 +29,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v4 with: - go-version: "1.21" + go-version: "1.25" - name: Set up ruby uses: ruby/setup-ruby@v1 From cde29bca08fae3a2d90c80bd39c4dc7f12a1db1d Mon Sep 17 00:00:00 2001 From: Paul Kuruvilla Date: Sun, 16 Nov 2025 20:16:04 -0800 Subject: [PATCH 06/13] Remove redundant "build_image" command from BuildImageCommandRunner. --- test-runner/tests/lib/build_image_command_runner.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/test-runner/tests/lib/build_image_command_runner.rb b/test-runner/tests/lib/build_image_command_runner.rb index def1886..42938b6 100644 --- a/test-runner/tests/lib/build_image_command_runner.rb +++ b/test-runner/tests/lib/build_image_command_runner.rb @@ -27,7 +27,6 @@ def run(build:, test_run: nil) command_parts = [ TEST_RUNNER_EXECUTABLE_PATH, - "build_image", "--buildpack-slug='#{repository.buildpack.slug}'", "--buildpack-dockerfile-path='#{dockerfile_handle.path}'", "--build-id='#{build.id}'", From 1a45c549aacefbc48404ef0d7c9a6c93303ebe44 Mon Sep 17 00:00:00 2001 From: Paul Kuruvilla Date: Sun, 16 Nov 2025 20:20:15 -0800 Subject: [PATCH 07/13] Update Dockerfile and rename test-runner to builder with new download script. --- Dockerfile | 7 ++-- {test-runner => builder}/.env.example | 0 {test-runner => builder}/.gitignore | 0 {test-runner => builder}/.ruby-version | 0 {test-runner => builder}/Gemfile | 0 {test-runner => builder}/Gemfile.lock | 0 {test-runner => builder}/Makefile | 0 {test-runner => builder}/README.md | 2 +- {test-runner => builder}/docker-compose.yml | 0 {test-runner => builder}/go.mod | 2 +- {test-runner => builder}/go.sum | 0 .../internal/backend/client.go | 2 +- .../internal/backend/finalize_build.go | 0 .../internal/build_image.go | 2 +- .../internal/build_logs_processor.go | 0 .../internal/build_logs_processor_test.go | 0 .../internal/color/color.go | 0 .../internal/commands/build_image_command.go | 8 ++--- .../internal/friendly_error.go | 0 .../internal/get_buildpack.go | 0 .../internal/get_head_commit_sha.go | 0 .../globals/codecrafters_server_url.go | 0 .../internal/switch_to_commit.go | 0 .../internal/test_runner_build.go | 0 .../internal/utils/sentry.go | 0 {test-runner => builder}/main.go | 4 +-- {test-runner => builder}/main.goreleaser.yml | 0 .../tests/build_image_test.rb | 0 .../tests/lib/build_image_command_runner.rb | 0 .../lib/buildpack_dockerfile_processor.rb | 0 .../tests/lib/code_fixtures.rb | 0 .../tests/lib/database_helper.rb | 0 .../tests/lib/db/models/application_record.rb | 0 .../tests/lib/db/models/buildpack.rb | 0 .../tests/lib/db/models/repository.rb | 0 .../tests/lib/db/models/submission.rb | 0 .../tests/lib/db/models/test_run.rb | 0 .../tests/lib/db/models/test_run_result.rb | 0 .../tests/lib/db/models/test_runner.rb | 0 .../tests/lib/db/models/test_runner_build.rb | 0 .../tests/lib/db/schema.rb | 0 .../tests/lib/fake_git_repository.rb | 0 .../tests/lib/fake_server.rb | 0 .../tests/lib/git_api_gateway.rb | 0 .../submission_test_runner_actions_builder.rb | 0 .../await_terminal_build_status.rb | 0 .../await_terminal_submission_status.rb | 0 .../tests/lib/test_runner_actions/base.rb | 0 .../lib/test_runner_actions/print_message.rb | 0 .../tests/lib/test_runner_actions/sleep.rb | 0 .../lib/test_runner_actions/stream_logs.rb | 0 .../lib/test_runner_actions/terminate.rb | 0 .../tests/lib/tester_downloader.rb | 0 .../tests/sample_data/docker_logs.txt | 0 {test-runner => builder}/tests/test_helper.rb | 0 download_test_runner.sh | 36 +++++++++++++++++++ 56 files changed, 51 insertions(+), 12 deletions(-) rename {test-runner => builder}/.env.example (100%) rename {test-runner => builder}/.gitignore (100%) rename {test-runner => builder}/.ruby-version (100%) rename {test-runner => builder}/Gemfile (100%) rename {test-runner => builder}/Gemfile.lock (100%) rename {test-runner => builder}/Makefile (100%) rename {test-runner => builder}/README.md (93%) rename {test-runner => builder}/docker-compose.yml (100%) rename {test-runner => builder}/go.mod (95%) rename {test-runner => builder}/go.sum (100%) rename {test-runner => builder}/internal/backend/client.go (95%) rename {test-runner => builder}/internal/backend/finalize_build.go (100%) rename {test-runner => builder}/internal/build_image.go (98%) rename {test-runner => builder}/internal/build_logs_processor.go (100%) rename {test-runner => builder}/internal/build_logs_processor_test.go (100%) rename {test-runner => builder}/internal/color/color.go (100%) rename {test-runner => builder}/internal/commands/build_image_command.go (97%) rename {test-runner => builder}/internal/friendly_error.go (100%) rename {test-runner => builder}/internal/get_buildpack.go (100%) rename {test-runner => builder}/internal/get_head_commit_sha.go (100%) rename {test-runner => builder}/internal/globals/codecrafters_server_url.go (100%) rename {test-runner => builder}/internal/switch_to_commit.go (100%) rename {test-runner => builder}/internal/test_runner_build.go (100%) rename {test-runner => builder}/internal/utils/sentry.go (100%) rename {test-runner => builder}/main.go (78%) rename {test-runner => builder}/main.goreleaser.yml (100%) rename {test-runner => builder}/tests/build_image_test.rb (100%) rename {test-runner => builder}/tests/lib/build_image_command_runner.rb (100%) rename {test-runner => builder}/tests/lib/buildpack_dockerfile_processor.rb (100%) rename {test-runner => builder}/tests/lib/code_fixtures.rb (100%) rename {test-runner => builder}/tests/lib/database_helper.rb (100%) rename {test-runner => builder}/tests/lib/db/models/application_record.rb (100%) rename {test-runner => builder}/tests/lib/db/models/buildpack.rb (100%) rename {test-runner => builder}/tests/lib/db/models/repository.rb (100%) rename {test-runner => builder}/tests/lib/db/models/submission.rb (100%) rename {test-runner => builder}/tests/lib/db/models/test_run.rb (100%) rename {test-runner => builder}/tests/lib/db/models/test_run_result.rb (100%) rename {test-runner => builder}/tests/lib/db/models/test_runner.rb (100%) rename {test-runner => builder}/tests/lib/db/models/test_runner_build.rb (100%) rename {test-runner => builder}/tests/lib/db/schema.rb (100%) rename {test-runner => builder}/tests/lib/fake_git_repository.rb (100%) rename {test-runner => builder}/tests/lib/fake_server.rb (100%) rename {test-runner => builder}/tests/lib/git_api_gateway.rb (100%) rename {test-runner => builder}/tests/lib/submission_test_runner_actions_builder.rb (100%) rename {test-runner => builder}/tests/lib/test_runner_actions/await_terminal_build_status.rb (100%) rename {test-runner => builder}/tests/lib/test_runner_actions/await_terminal_submission_status.rb (100%) rename {test-runner => builder}/tests/lib/test_runner_actions/base.rb (100%) rename {test-runner => builder}/tests/lib/test_runner_actions/print_message.rb (100%) rename {test-runner => builder}/tests/lib/test_runner_actions/sleep.rb (100%) rename {test-runner => builder}/tests/lib/test_runner_actions/stream_logs.rb (100%) rename {test-runner => builder}/tests/lib/test_runner_actions/terminate.rb (100%) rename {test-runner => builder}/tests/lib/tester_downloader.rb (100%) rename {test-runner => builder}/tests/sample_data/docker_logs.txt (100%) rename {test-runner => builder}/tests/test_helper.rb (100%) create mode 100755 download_test_runner.sh diff --git a/Dockerfile b/Dockerfile index 52faf72..7e65ab1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,8 +25,11 @@ ENV PATH="/root/.depot/bin:$PATH" # Ensure depot is installed and working RUN depot --version -COPY test-runner/ /tmp/test-runner/ -RUN cd /tmp/test-runner && go build -o /var/opt/test-runner-builder main.go +COPY builder/ /tmp/builder/ +RUN cd /tmp/builder && go build -o /var/opt/test-runner-builder main.go + +COPY download_test_runner.sh /tmp/download_test_runner.sh +RUN --mount=type=secret,id=test_runner_downloader_token,env=GITHUB_TOKEN /tmp/download_test_runner.sh # Ensure the Go program is installed and working RUN /var/opt/test-runner-builder --version \ No newline at end of file diff --git a/test-runner/.env.example b/builder/.env.example similarity index 100% rename from test-runner/.env.example rename to builder/.env.example diff --git a/test-runner/.gitignore b/builder/.gitignore similarity index 100% rename from test-runner/.gitignore rename to builder/.gitignore diff --git a/test-runner/.ruby-version b/builder/.ruby-version similarity index 100% rename from test-runner/.ruby-version rename to builder/.ruby-version diff --git a/test-runner/Gemfile b/builder/Gemfile similarity index 100% rename from test-runner/Gemfile rename to builder/Gemfile diff --git a/test-runner/Gemfile.lock b/builder/Gemfile.lock similarity index 100% rename from test-runner/Gemfile.lock rename to builder/Gemfile.lock diff --git a/test-runner/Makefile b/builder/Makefile similarity index 100% rename from test-runner/Makefile rename to builder/Makefile diff --git a/test-runner/README.md b/builder/README.md similarity index 93% rename from test-runner/README.md rename to builder/README.md index 9af4208..57251f1 100644 --- a/test-runner/README.md +++ b/builder/README.md @@ -30,5 +30,5 @@ To run a specific test: - Releases are automatically created on pushes to `main` - This happens via [./github/workflows/{tag,release}.yml](.github/workflows/tag.yml) - It takes 2-3 minutes for a release to be created after a push to `main`. - - You can confirm that a release has been created by checking the [Releases](https://github.com/codecrafters-io/test-runner/releases) page. + - You can confirm that a release has been created by checking the [Releases](https://github.com/codecrafters-io/test-runner-builder/releases) page. - Ask Paul to deploy the release (this part isn't automated yet) diff --git a/test-runner/docker-compose.yml b/builder/docker-compose.yml similarity index 100% rename from test-runner/docker-compose.yml rename to builder/docker-compose.yml diff --git a/test-runner/go.mod b/builder/go.mod similarity index 95% rename from test-runner/go.mod rename to builder/go.mod index b409f39..63b8312 100644 --- a/test-runner/go.mod +++ b/builder/go.mod @@ -1,4 +1,4 @@ -module github.com/codecrafters-io/test-runner +module github.com/codecrafters-io/test-runner-builder go 1.23 diff --git a/test-runner/go.sum b/builder/go.sum similarity index 100% rename from test-runner/go.sum rename to builder/go.sum diff --git a/test-runner/internal/backend/client.go b/builder/internal/backend/client.go similarity index 95% rename from test-runner/internal/backend/client.go rename to builder/internal/backend/client.go index 8461fdb..9f23b88 100644 --- a/test-runner/internal/backend/client.go +++ b/builder/internal/backend/client.go @@ -6,7 +6,7 @@ import ( "net/http" "time" - "github.com/codecrafters-io/test-runner/internal/globals" + "github.com/codecrafters-io/test-runner-builder/internal/globals" "github.com/google/uuid" "github.com/hashicorp/go-retryablehttp" ) diff --git a/test-runner/internal/backend/finalize_build.go b/builder/internal/backend/finalize_build.go similarity index 100% rename from test-runner/internal/backend/finalize_build.go rename to builder/internal/backend/finalize_build.go diff --git a/test-runner/internal/build_image.go b/builder/internal/build_image.go similarity index 98% rename from test-runner/internal/build_image.go rename to builder/internal/build_image.go index 49a9ae5..d29df13 100644 --- a/test-runner/internal/build_image.go +++ b/builder/internal/build_image.go @@ -6,7 +6,7 @@ import ( "os/exec" "path/filepath" - "github.com/codecrafters-io/test-runner/internal/globals" + "github.com/codecrafters-io/test-runner-builder/internal/globals" "github.com/egym-playground/go-prefix-writer/prefixer" cp "github.com/otiai10/copy" "gvisor.dev/gvisor/pkg/linewriter" diff --git a/test-runner/internal/build_logs_processor.go b/builder/internal/build_logs_processor.go similarity index 100% rename from test-runner/internal/build_logs_processor.go rename to builder/internal/build_logs_processor.go diff --git a/test-runner/internal/build_logs_processor_test.go b/builder/internal/build_logs_processor_test.go similarity index 100% rename from test-runner/internal/build_logs_processor_test.go rename to builder/internal/build_logs_processor_test.go diff --git a/test-runner/internal/color/color.go b/builder/internal/color/color.go similarity index 100% rename from test-runner/internal/color/color.go rename to builder/internal/color/color.go diff --git a/test-runner/internal/commands/build_image_command.go b/builder/internal/commands/build_image_command.go similarity index 97% rename from test-runner/internal/commands/build_image_command.go rename to builder/internal/commands/build_image_command.go index 64a9401..5a96276 100644 --- a/test-runner/internal/commands/build_image_command.go +++ b/builder/internal/commands/build_image_command.go @@ -7,10 +7,10 @@ import ( "path/filepath" "strings" - "github.com/codecrafters-io/test-runner/internal" - "github.com/codecrafters-io/test-runner/internal/backend" - "github.com/codecrafters-io/test-runner/internal/color" - "github.com/codecrafters-io/test-runner/internal/globals" + "github.com/codecrafters-io/test-runner-builder/internal" + "github.com/codecrafters-io/test-runner-builder/internal/backend" + "github.com/codecrafters-io/test-runner-builder/internal/color" + "github.com/codecrafters-io/test-runner-builder/internal/globals" ) // If err is FriendlyError, it's a user error. Otherwise, it's an internal error. diff --git a/test-runner/internal/friendly_error.go b/builder/internal/friendly_error.go similarity index 100% rename from test-runner/internal/friendly_error.go rename to builder/internal/friendly_error.go diff --git a/test-runner/internal/get_buildpack.go b/builder/internal/get_buildpack.go similarity index 100% rename from test-runner/internal/get_buildpack.go rename to builder/internal/get_buildpack.go diff --git a/test-runner/internal/get_head_commit_sha.go b/builder/internal/get_head_commit_sha.go similarity index 100% rename from test-runner/internal/get_head_commit_sha.go rename to builder/internal/get_head_commit_sha.go diff --git a/test-runner/internal/globals/codecrafters_server_url.go b/builder/internal/globals/codecrafters_server_url.go similarity index 100% rename from test-runner/internal/globals/codecrafters_server_url.go rename to builder/internal/globals/codecrafters_server_url.go diff --git a/test-runner/internal/switch_to_commit.go b/builder/internal/switch_to_commit.go similarity index 100% rename from test-runner/internal/switch_to_commit.go rename to builder/internal/switch_to_commit.go diff --git a/test-runner/internal/test_runner_build.go b/builder/internal/test_runner_build.go similarity index 100% rename from test-runner/internal/test_runner_build.go rename to builder/internal/test_runner_build.go diff --git a/test-runner/internal/utils/sentry.go b/builder/internal/utils/sentry.go similarity index 100% rename from test-runner/internal/utils/sentry.go rename to builder/internal/utils/sentry.go diff --git a/test-runner/main.go b/builder/main.go similarity index 78% rename from test-runner/main.go rename to builder/main.go index 4788055..6e9daee 100644 --- a/test-runner/main.go +++ b/builder/main.go @@ -4,8 +4,8 @@ import ( "fmt" "os" - "github.com/codecrafters-io/test-runner/internal/commands" - "github.com/codecrafters-io/test-runner/internal/utils" + "github.com/codecrafters-io/test-runner-builder/internal/commands" + "github.com/codecrafters-io/test-runner-builder/internal/utils" "github.com/getsentry/sentry-go" ) diff --git a/test-runner/main.goreleaser.yml b/builder/main.goreleaser.yml similarity index 100% rename from test-runner/main.goreleaser.yml rename to builder/main.goreleaser.yml diff --git a/test-runner/tests/build_image_test.rb b/builder/tests/build_image_test.rb similarity index 100% rename from test-runner/tests/build_image_test.rb rename to builder/tests/build_image_test.rb diff --git a/test-runner/tests/lib/build_image_command_runner.rb b/builder/tests/lib/build_image_command_runner.rb similarity index 100% rename from test-runner/tests/lib/build_image_command_runner.rb rename to builder/tests/lib/build_image_command_runner.rb diff --git a/test-runner/tests/lib/buildpack_dockerfile_processor.rb b/builder/tests/lib/buildpack_dockerfile_processor.rb similarity index 100% rename from test-runner/tests/lib/buildpack_dockerfile_processor.rb rename to builder/tests/lib/buildpack_dockerfile_processor.rb diff --git a/test-runner/tests/lib/code_fixtures.rb b/builder/tests/lib/code_fixtures.rb similarity index 100% rename from test-runner/tests/lib/code_fixtures.rb rename to builder/tests/lib/code_fixtures.rb diff --git a/test-runner/tests/lib/database_helper.rb b/builder/tests/lib/database_helper.rb similarity index 100% rename from test-runner/tests/lib/database_helper.rb rename to builder/tests/lib/database_helper.rb diff --git a/test-runner/tests/lib/db/models/application_record.rb b/builder/tests/lib/db/models/application_record.rb similarity index 100% rename from test-runner/tests/lib/db/models/application_record.rb rename to builder/tests/lib/db/models/application_record.rb diff --git a/test-runner/tests/lib/db/models/buildpack.rb b/builder/tests/lib/db/models/buildpack.rb similarity index 100% rename from test-runner/tests/lib/db/models/buildpack.rb rename to builder/tests/lib/db/models/buildpack.rb diff --git a/test-runner/tests/lib/db/models/repository.rb b/builder/tests/lib/db/models/repository.rb similarity index 100% rename from test-runner/tests/lib/db/models/repository.rb rename to builder/tests/lib/db/models/repository.rb diff --git a/test-runner/tests/lib/db/models/submission.rb b/builder/tests/lib/db/models/submission.rb similarity index 100% rename from test-runner/tests/lib/db/models/submission.rb rename to builder/tests/lib/db/models/submission.rb diff --git a/test-runner/tests/lib/db/models/test_run.rb b/builder/tests/lib/db/models/test_run.rb similarity index 100% rename from test-runner/tests/lib/db/models/test_run.rb rename to builder/tests/lib/db/models/test_run.rb diff --git a/test-runner/tests/lib/db/models/test_run_result.rb b/builder/tests/lib/db/models/test_run_result.rb similarity index 100% rename from test-runner/tests/lib/db/models/test_run_result.rb rename to builder/tests/lib/db/models/test_run_result.rb diff --git a/test-runner/tests/lib/db/models/test_runner.rb b/builder/tests/lib/db/models/test_runner.rb similarity index 100% rename from test-runner/tests/lib/db/models/test_runner.rb rename to builder/tests/lib/db/models/test_runner.rb diff --git a/test-runner/tests/lib/db/models/test_runner_build.rb b/builder/tests/lib/db/models/test_runner_build.rb similarity index 100% rename from test-runner/tests/lib/db/models/test_runner_build.rb rename to builder/tests/lib/db/models/test_runner_build.rb diff --git a/test-runner/tests/lib/db/schema.rb b/builder/tests/lib/db/schema.rb similarity index 100% rename from test-runner/tests/lib/db/schema.rb rename to builder/tests/lib/db/schema.rb diff --git a/test-runner/tests/lib/fake_git_repository.rb b/builder/tests/lib/fake_git_repository.rb similarity index 100% rename from test-runner/tests/lib/fake_git_repository.rb rename to builder/tests/lib/fake_git_repository.rb diff --git a/test-runner/tests/lib/fake_server.rb b/builder/tests/lib/fake_server.rb similarity index 100% rename from test-runner/tests/lib/fake_server.rb rename to builder/tests/lib/fake_server.rb diff --git a/test-runner/tests/lib/git_api_gateway.rb b/builder/tests/lib/git_api_gateway.rb similarity index 100% rename from test-runner/tests/lib/git_api_gateway.rb rename to builder/tests/lib/git_api_gateway.rb diff --git a/test-runner/tests/lib/submission_test_runner_actions_builder.rb b/builder/tests/lib/submission_test_runner_actions_builder.rb similarity index 100% rename from test-runner/tests/lib/submission_test_runner_actions_builder.rb rename to builder/tests/lib/submission_test_runner_actions_builder.rb diff --git a/test-runner/tests/lib/test_runner_actions/await_terminal_build_status.rb b/builder/tests/lib/test_runner_actions/await_terminal_build_status.rb similarity index 100% rename from test-runner/tests/lib/test_runner_actions/await_terminal_build_status.rb rename to builder/tests/lib/test_runner_actions/await_terminal_build_status.rb diff --git a/test-runner/tests/lib/test_runner_actions/await_terminal_submission_status.rb b/builder/tests/lib/test_runner_actions/await_terminal_submission_status.rb similarity index 100% rename from test-runner/tests/lib/test_runner_actions/await_terminal_submission_status.rb rename to builder/tests/lib/test_runner_actions/await_terminal_submission_status.rb diff --git a/test-runner/tests/lib/test_runner_actions/base.rb b/builder/tests/lib/test_runner_actions/base.rb similarity index 100% rename from test-runner/tests/lib/test_runner_actions/base.rb rename to builder/tests/lib/test_runner_actions/base.rb diff --git a/test-runner/tests/lib/test_runner_actions/print_message.rb b/builder/tests/lib/test_runner_actions/print_message.rb similarity index 100% rename from test-runner/tests/lib/test_runner_actions/print_message.rb rename to builder/tests/lib/test_runner_actions/print_message.rb diff --git a/test-runner/tests/lib/test_runner_actions/sleep.rb b/builder/tests/lib/test_runner_actions/sleep.rb similarity index 100% rename from test-runner/tests/lib/test_runner_actions/sleep.rb rename to builder/tests/lib/test_runner_actions/sleep.rb diff --git a/test-runner/tests/lib/test_runner_actions/stream_logs.rb b/builder/tests/lib/test_runner_actions/stream_logs.rb similarity index 100% rename from test-runner/tests/lib/test_runner_actions/stream_logs.rb rename to builder/tests/lib/test_runner_actions/stream_logs.rb diff --git a/test-runner/tests/lib/test_runner_actions/terminate.rb b/builder/tests/lib/test_runner_actions/terminate.rb similarity index 100% rename from test-runner/tests/lib/test_runner_actions/terminate.rb rename to builder/tests/lib/test_runner_actions/terminate.rb diff --git a/test-runner/tests/lib/tester_downloader.rb b/builder/tests/lib/tester_downloader.rb similarity index 100% rename from test-runner/tests/lib/tester_downloader.rb rename to builder/tests/lib/tester_downloader.rb diff --git a/test-runner/tests/sample_data/docker_logs.txt b/builder/tests/sample_data/docker_logs.txt similarity index 100% rename from test-runner/tests/sample_data/docker_logs.txt rename to builder/tests/sample_data/docker_logs.txt diff --git a/test-runner/tests/test_helper.rb b/builder/tests/test_helper.rb similarity index 100% rename from test-runner/tests/test_helper.rb rename to builder/tests/test_helper.rb diff --git a/download_test_runner.sh b/download_test_runner.sh new file mode 100755 index 0000000..3c7a51c --- /dev/null +++ b/download_test_runner.sh @@ -0,0 +1,36 @@ +set -e +set -x + +destinationDir="/var/opt/test-runner" # Destination directory + +repoOwner="codecrafters-io" # GitHub repository owner +repoName="test-runner" # GitHub repository name +releaseTag="v0.3.67" # Release tag to download +assetName="${releaseTag}_linux_amd64.tar.gz" # Asset name +downloadedTarPath="$(mktemp)" # Path to downloaded tar file + +# Get the asset ID +assetId=$(curl -fsSL --header "Authorization: Bearer ${GITHUB_TOKEN}" "https://api.github.com/repos/$repoOwner/$repoName/releases/tags/$releaseTag" | jq -r --arg assetName "$assetName" '.assets[] | select(.name==$assetName) | .id') +echo "Asset ID: $assetId" + +downloadUrl="https://api.github.com/repos/$repoOwner/$repoName/releases/assets/$assetId" +echo "Download URL: $downloadUrl" + +wget --header="Authorization: Bearer ${GITHUB_TOKEN}" --header="Accept: application/octet-stream" -O $downloadedTarPath $downloadUrl +mkdir -p $destinationDir +tar xz -C $destinationDir -f $downloadedTarPath + +rm -rf $downloadedTarPath + +# TODO: Replace this by adding a `--version` flag! +set +e +output=$(/var/opt/test-runner/test-runner --help 2>&1) +exit_code=$? +echo "$output" +echo "$output" | grep -qi "unknown command" +grep_exit_code=$? +if [ $exit_code -ne 1 ] || [ $grep_exit_code -ne 0 ]; then + echo "Test failed: exit code was $exit_code, output was: $output" + exit 1 +fi +set -e From cc03069e8b1808a7ef8f52c39281280817e0cf12 Mon Sep 17 00:00:00 2001 From: Paul Kuruvilla Date: Sun, 16 Nov 2025 20:39:45 -0800 Subject: [PATCH 08/13] Update Dockerfile and download_test_runner.sh for test runner version 0.3.71 --- Dockerfile | 8 ++++++-- download_test_runner.sh | 15 +-------------- 2 files changed, 7 insertions(+), 16 deletions(-) diff --git a/Dockerfile b/Dockerfile index 7e65ab1..aaefeba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,8 +28,12 @@ RUN depot --version COPY builder/ /tmp/builder/ RUN cd /tmp/builder && go build -o /var/opt/test-runner-builder main.go +# Ensure the Go program is installed and working +RUN /var/opt/test-runner-builder --version + +# Download test runner to a directory (mounted during docker builds) COPY download_test_runner.sh /tmp/download_test_runner.sh RUN --mount=type=secret,id=test_runner_downloader_token,env=GITHUB_TOKEN /tmp/download_test_runner.sh -# Ensure the Go program is installed and working -RUN /var/opt/test-runner-builder --version \ No newline at end of file +# Ensure the test runner is installed and working +RUN /var/opt/test-runner/test-runner --version \ No newline at end of file diff --git a/download_test_runner.sh b/download_test_runner.sh index 3c7a51c..66dce81 100755 --- a/download_test_runner.sh +++ b/download_test_runner.sh @@ -5,7 +5,7 @@ destinationDir="/var/opt/test-runner" # Destination directory repoOwner="codecrafters-io" # GitHub repository owner repoName="test-runner" # GitHub repository name -releaseTag="v0.3.67" # Release tag to download +releaseTag="v0.3.71" # Release tag to download assetName="${releaseTag}_linux_amd64.tar.gz" # Asset name downloadedTarPath="$(mktemp)" # Path to downloaded tar file @@ -21,16 +21,3 @@ mkdir -p $destinationDir tar xz -C $destinationDir -f $downloadedTarPath rm -rf $downloadedTarPath - -# TODO: Replace this by adding a `--version` flag! -set +e -output=$(/var/opt/test-runner/test-runner --help 2>&1) -exit_code=$? -echo "$output" -echo "$output" | grep -qi "unknown command" -grep_exit_code=$? -if [ $exit_code -ne 1 ] || [ $grep_exit_code -ne 0 ]; then - echo "Test failed: exit code was $exit_code, output was: $output" - exit 1 -fi -set -e From 311aff34367d90167596e92646d7df2e0134e3b3 Mon Sep 17 00:00:00 2001 From: Paul Kuruvilla Date: Sun, 16 Nov 2025 20:43:41 -0800 Subject: [PATCH 09/13] Rename TEST_RUNNER_EXECUTABLE_PATH to BUILDER_EXECUTABLE_PATH in tests. --- builder/tests/lib/build_image_command_runner.rb | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/builder/tests/lib/build_image_command_runner.rb b/builder/tests/lib/build_image_command_runner.rb index 42938b6..1e3e35a 100644 --- a/builder/tests/lib/build_image_command_runner.rb +++ b/builder/tests/lib/build_image_command_runner.rb @@ -1,4 +1,4 @@ -TEST_RUNNER_EXECUTABLE_PATH = File.expand_path("../../dist/main.out", __dir__) +BUILDER_EXECUTABLE_PATH = File.expand_path("../../dist/main.out", __dir__) TEST_RUNNER_SOURCE_DIR = File.expand_path("../../", __dir__) TESTERS_DIR = File.expand_path("../fixtures/testers", __dir__) @@ -18,15 +18,16 @@ def run(build:, test_run: nil) FileUtils.rm_rf(tmp_dir) FileUtils.cp_r(git_repository.tmp_dir, tmp_dir) + # This isn't actually being used! We just need a binary present there to test. test_runner_dir = Dir.mktmpdir - `cp #{TEST_RUNNER_EXECUTABLE_PATH} #{test_runner_dir}/test-runner` + `cp #{BUILDER_EXECUTABLE_PATH} #{test_runner_dir}/test-runner` dockerfile_handle = Tempfile.new dockerfile_handle.write(repository.buildpack.processed_dockerfile_contents) dockerfile_handle.close command_parts = [ - TEST_RUNNER_EXECUTABLE_PATH, + BUILDER_EXECUTABLE_PATH, "--buildpack-slug='#{repository.buildpack.slug}'", "--buildpack-dockerfile-path='#{dockerfile_handle.path}'", "--build-id='#{build.id}'", From 38c10fa66eefe0009e26e05fcbae9cf2e1f497b6 Mon Sep 17 00:00:00 2001 From: Paul Kuruvilla Date: Sun, 16 Nov 2025 20:45:19 -0800 Subject: [PATCH 10/13] Update GitHub Actions workflow to use 'builder' directory and remove old config. --- .github/workflows/test.yml | 8 ++++---- builder/main.goreleaser.yml | 11 ----------- 2 files changed, 4 insertions(+), 15 deletions(-) delete mode 100644 builder/main.goreleaser.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 95b1b87..8744025 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -37,11 +37,11 @@ jobs: ruby-version: 3.3.5 bundler-cache: true cache-version: 5 - working-directory: test-runner + working-directory: build - name: Run make refresh_test_fixtures run: make refresh_test_fixtures - working-directory: test-runner + working-directory: builder # Required for pulling git-api and git-daemon - name: Authenticate with ghcr.io @@ -53,7 +53,7 @@ jobs: - name: Set up Docker run: docker compose up -d - working-directory: test-runner + working-directory: builder - name: Set up Git run: | @@ -62,7 +62,7 @@ jobs: - name: Run tests run: make test - working-directory: test-runner + working-directory: builder env: DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }} DEPOT_PROJECT: ${{ secrets.DEPOT_PROJECT }} diff --git a/builder/main.goreleaser.yml b/builder/main.goreleaser.yml deleted file mode 100644 index 6f26c50..0000000 --- a/builder/main.goreleaser.yml +++ /dev/null @@ -1,11 +0,0 @@ -builds: - - main: ./ - binary: test-runner - env: - - CGO_ENABLED=0 - goarch: [amd64, arm64] - goos: [linux, darwin] - -archives: - - name_template: "{{ .Tag }}_{{ .Os }}_{{ .Arch }}" - format: tar.gz From b79ffc0422d515bec5c97b37964f2e8ae0ca890e Mon Sep 17 00:00:00 2001 From: Paul Kuruvilla Date: Sun, 16 Nov 2025 20:46:45 -0800 Subject: [PATCH 11/13] Update working directory in GitHub Actions workflow from 'build' to 'builder' --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8744025..af72d41 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -37,7 +37,7 @@ jobs: ruby-version: 3.3.5 bundler-cache: true cache-version: 5 - working-directory: build + working-directory: builder - name: Run make refresh_test_fixtures run: make refresh_test_fixtures From eca8cebcab8930154245cd55ca38048ea1160f08 Mon Sep 17 00:00:00 2001 From: Paul Kuruvilla Date: Sun, 16 Nov 2025 20:53:04 -0800 Subject: [PATCH 12/13] Update test workflow to copy .env.example before running tests --- .github/workflows/test.yml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index af72d41..830964e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -61,9 +61,4 @@ jobs: git config --global user.name "Your Name" - name: Run tests - run: make test - working-directory: builder - env: - DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }} - DEPOT_PROJECT: ${{ secrets.DEPOT_PROJECT }} - FLY_ACCESS_TOKEN: ${{ secrets.FLY_ACCESS_TOKEN }} + run: cp .env.example .env && make test From d015d379e30770fc495b21d50f7c8a4590606f80 Mon Sep 17 00:00:00 2001 From: Paul Kuruvilla Date: Sun, 16 Nov 2025 20:54:52 -0800 Subject: [PATCH 13/13] Update test workflow to set working directory for test execution. --- .github/workflows/test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 830964e..d777e57 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -62,3 +62,4 @@ jobs: - name: Run tests run: cp .env.example .env && make test + working-directory: builder