diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..d777e57 --- /dev/null +++ b/.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.25" + + - name: Set up ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.5 + bundler-cache: true + cache-version: 5 + working-directory: builder + + - name: Run make refresh_test_fixtures + run: make refresh_test_fixtures + working-directory: builder + + # 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 + working-directory: builder + + - name: Set up Git + run: | + git config --global user.email "you@example.com" + git config --global user.name "Your Name" + + - name: Run tests + run: cp .env.example .env && make test + working-directory: builder diff --git a/Dockerfile b/Dockerfile index e8a21ac..aaefeba 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,15 @@ ENV PATH="/root/.depot/bin:$PATH" # Ensure depot is installed and working RUN depot --version -# Download test runner CLI +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 \ No newline at end of file +RUN --mount=type=secret,id=test_runner_downloader_token,env=GITHUB_TOKEN /tmp/download_test_runner.sh + +# 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/builder/.env.example b/builder/.env.example new file mode 100644 index 0000000..e2e61cb --- /dev/null +++ b/builder/.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/builder/.gitignore b/builder/.gitignore new file mode 100644 index 0000000..ef6e102 --- /dev/null +++ b/builder/.gitignore @@ -0,0 +1,5 @@ +.vagrant/ + +tests/fixtures/ +dist +.env \ No newline at end of file diff --git a/builder/.ruby-version b/builder/.ruby-version new file mode 100644 index 0000000..0163af7 --- /dev/null +++ b/builder/.ruby-version @@ -0,0 +1 @@ +3.3.5 \ No newline at end of file diff --git a/builder/Gemfile b/builder/Gemfile new file mode 100644 index 0000000..a428928 --- /dev/null +++ b/builder/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/builder/Gemfile.lock b/builder/Gemfile.lock new file mode 100644 index 0000000..4832b26 --- /dev/null +++ b/builder/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/builder/Makefile b/builder/Makefile new file mode 100644 index 0000000..7b26481 --- /dev/null +++ b/builder/Makefile @@ -0,0 +1,62 @@ +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_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_build_image: build + bundle exec ruby tests/build_image_test.rb --verbose --fail-fast + diff --git a/builder/README.md b/builder/README.md new file mode 100644 index 0000000..57251f1 --- /dev/null +++ b/builder/README.md @@ -0,0 +1,34 @@ +# Test Runner + +This is the Go code invoked from the [test-runner-builder](https://github.com/codecrafters-io/test-runner-builder) Docker image. + +Responsibilities: + +- Builds the docker image that needs to executed to run tests for a challenge +- Also 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-builder/releases) page. +- Ask Paul to deploy the release (this part isn't automated yet) diff --git a/builder/docker-compose.yml b/builder/docker-compose.yml new file mode 100644 index 0000000..da39a44 --- /dev/null +++ b/builder/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/builder/go.mod b/builder/go.mod new file mode 100644 index 0000000..63b8312 --- /dev/null +++ b/builder/go.mod @@ -0,0 +1,36 @@ +module github.com/codecrafters-io/test-runner-builder + +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/builder/go.sum b/builder/go.sum new file mode 100644 index 0000000..770639a --- /dev/null +++ b/builder/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/builder/internal/backend/client.go b/builder/internal/backend/client.go new file mode 100644 index 0000000..9f23b88 --- /dev/null +++ b/builder/internal/backend/client.go @@ -0,0 +1,63 @@ +package backend + +import ( + "bytes" + "fmt" + "net/http" + "time" + + "github.com/codecrafters-io/test-runner-builder/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/builder/internal/backend/finalize_build.go b/builder/internal/backend/finalize_build.go new file mode 100644 index 0000000..693b65e --- /dev/null +++ b/builder/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/builder/internal/build_image.go b/builder/internal/build_image.go new file mode 100644 index 0000000..d29df13 --- /dev/null +++ b/builder/internal/build_image.go @@ -0,0 +1,109 @@ +package internal + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + + "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" +) + +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/builder/internal/build_logs_processor.go b/builder/internal/build_logs_processor.go new file mode 100644 index 0000000..4349951 --- /dev/null +++ b/builder/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/builder/internal/build_logs_processor_test.go b/builder/internal/build_logs_processor_test.go new file mode 100644 index 0000000..31fe67b --- /dev/null +++ b/builder/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/builder/internal/color/color.go b/builder/internal/color/color.go new file mode 100644 index 0000000..f9d4bac --- /dev/null +++ b/builder/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/builder/internal/commands/build_image_command.go b/builder/internal/commands/build_image_command.go new file mode 100644 index 0000000..5a96276 --- /dev/null +++ b/builder/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-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. +// 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[1:]) // Use 1: to avoid the first command (the program name) + + 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/builder/internal/friendly_error.go b/builder/internal/friendly_error.go new file mode 100644 index 0000000..6e81601 --- /dev/null +++ b/builder/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/builder/internal/get_buildpack.go b/builder/internal/get_buildpack.go new file mode 100644 index 0000000..93ae0d2 --- /dev/null +++ b/builder/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/builder/internal/get_head_commit_sha.go b/builder/internal/get_head_commit_sha.go new file mode 100644 index 0000000..7b5ac6e --- /dev/null +++ b/builder/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/builder/internal/globals/codecrafters_server_url.go b/builder/internal/globals/codecrafters_server_url.go new file mode 100644 index 0000000..954a227 --- /dev/null +++ b/builder/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/builder/internal/switch_to_commit.go b/builder/internal/switch_to_commit.go new file mode 100644 index 0000000..bbd53df --- /dev/null +++ b/builder/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/builder/internal/test_runner_build.go b/builder/internal/test_runner_build.go new file mode 100644 index 0000000..06ed57d --- /dev/null +++ b/builder/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/builder/internal/utils/sentry.go b/builder/internal/utils/sentry.go new file mode 100644 index 0000000..88b8e3e --- /dev/null +++ b/builder/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/builder/main.go b/builder/main.go new file mode 100644 index 0000000..6e9daee --- /dev/null +++ b/builder/main.go @@ -0,0 +1,30 @@ +package main + +import ( + "fmt" + "os" + + "github.com/codecrafters-io/test-runner-builder/internal/commands" + "github.com/codecrafters-io/test-runner-builder/internal/utils" + "github.com/getsentry/sentry-go" +) + +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() { + defer sentry.Recover() // Ensure panics are captured and sent to Sentry + exitCode = commands.BuildImageCommand() + }() + + os.Exit(exitCode) +} diff --git a/builder/tests/build_image_test.rb b/builder/tests/build_image_test.rb new file mode 100644 index 0000000..109dcb5 --- /dev/null +++ b/builder/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/builder/tests/lib/build_image_command_runner.rb b/builder/tests/lib/build_image_command_runner.rb new file mode 100644 index 0000000..1e3e35a --- /dev/null +++ b/builder/tests/lib/build_image_command_runner.rb @@ -0,0 +1,63 @@ +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__) + +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) + + # This isn't actually being used! We just need a binary present there to test. + test_runner_dir = Dir.mktmpdir + `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 = [ + BUILDER_EXECUTABLE_PATH, + "--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/builder/tests/lib/buildpack_dockerfile_processor.rb b/builder/tests/lib/buildpack_dockerfile_processor.rb new file mode 100644 index 0000000..bebaf56 --- /dev/null +++ b/builder/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/builder/tests/lib/code_fixtures.rb b/builder/tests/lib/code_fixtures.rb new file mode 100644 index 0000000..4468b8f --- /dev/null +++ b/builder/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/builder/tests/lib/database_helper.rb b/builder/tests/lib/database_helper.rb new file mode 100644 index 0000000..83be65b --- /dev/null +++ b/builder/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/builder/tests/lib/db/models/application_record.rb b/builder/tests/lib/db/models/application_record.rb new file mode 100644 index 0000000..6962fb6 --- /dev/null +++ b/builder/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/builder/tests/lib/db/models/buildpack.rb b/builder/tests/lib/db/models/buildpack.rb new file mode 100644 index 0000000..edf58de --- /dev/null +++ b/builder/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/builder/tests/lib/db/models/repository.rb b/builder/tests/lib/db/models/repository.rb new file mode 100644 index 0000000..b4055e3 --- /dev/null +++ b/builder/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/builder/tests/lib/db/models/submission.rb b/builder/tests/lib/db/models/submission.rb new file mode 100644 index 0000000..ba70b0e --- /dev/null +++ b/builder/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/builder/tests/lib/db/models/test_run.rb b/builder/tests/lib/db/models/test_run.rb new file mode 100644 index 0000000..cf826f1 --- /dev/null +++ b/builder/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/builder/tests/lib/db/models/test_run_result.rb b/builder/tests/lib/db/models/test_run_result.rb new file mode 100644 index 0000000..d535e6c --- /dev/null +++ b/builder/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/builder/tests/lib/db/models/test_runner.rb b/builder/tests/lib/db/models/test_runner.rb new file mode 100644 index 0000000..6483da1 --- /dev/null +++ b/builder/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/builder/tests/lib/db/models/test_runner_build.rb b/builder/tests/lib/db/models/test_runner_build.rb new file mode 100644 index 0000000..0cae8fb --- /dev/null +++ b/builder/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/builder/tests/lib/db/schema.rb b/builder/tests/lib/db/schema.rb new file mode 100644 index 0000000..7a1abe4 --- /dev/null +++ b/builder/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/builder/tests/lib/fake_git_repository.rb b/builder/tests/lib/fake_git_repository.rb new file mode 100644 index 0000000..bdfb56e --- /dev/null +++ b/builder/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/builder/tests/lib/fake_server.rb b/builder/tests/lib/fake_server.rb new file mode 100644 index 0000000..41c30b9 --- /dev/null +++ b/builder/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/builder/tests/lib/git_api_gateway.rb b/builder/tests/lib/git_api_gateway.rb new file mode 100644 index 0000000..2e50fa0 --- /dev/null +++ b/builder/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/builder/tests/lib/submission_test_runner_actions_builder.rb b/builder/tests/lib/submission_test_runner_actions_builder.rb new file mode 100644 index 0000000..5308693 --- /dev/null +++ b/builder/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/builder/tests/lib/test_runner_actions/await_terminal_build_status.rb b/builder/tests/lib/test_runner_actions/await_terminal_build_status.rb new file mode 100644 index 0000000..92cfc2b --- /dev/null +++ b/builder/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/builder/tests/lib/test_runner_actions/await_terminal_submission_status.rb b/builder/tests/lib/test_runner_actions/await_terminal_submission_status.rb new file mode 100644 index 0000000..d3095ea --- /dev/null +++ b/builder/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/builder/tests/lib/test_runner_actions/base.rb b/builder/tests/lib/test_runner_actions/base.rb new file mode 100644 index 0000000..0af0063 --- /dev/null +++ b/builder/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/builder/tests/lib/test_runner_actions/print_message.rb b/builder/tests/lib/test_runner_actions/print_message.rb new file mode 100644 index 0000000..f9bcddb --- /dev/null +++ b/builder/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/builder/tests/lib/test_runner_actions/sleep.rb b/builder/tests/lib/test_runner_actions/sleep.rb new file mode 100644 index 0000000..ce70c5f --- /dev/null +++ b/builder/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/builder/tests/lib/test_runner_actions/stream_logs.rb b/builder/tests/lib/test_runner_actions/stream_logs.rb new file mode 100644 index 0000000..ee3c099 --- /dev/null +++ b/builder/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/builder/tests/lib/test_runner_actions/terminate.rb b/builder/tests/lib/test_runner_actions/terminate.rb new file mode 100644 index 0000000..3b9a61e --- /dev/null +++ b/builder/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/builder/tests/lib/tester_downloader.rb b/builder/tests/lib/tester_downloader.rb new file mode 100644 index 0000000..0f7fcaa --- /dev/null +++ b/builder/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/builder/tests/sample_data/docker_logs.txt b/builder/tests/sample_data/docker_logs.txt new file mode 100644 index 0000000..9615c65 --- /dev/null +++ b/builder/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/builder/tests/test_helper.rb b/builder/tests/test_helper.rb new file mode 100644 index 0000000..01462b4 --- /dev/null +++ b/builder/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__) 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