diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..afccc9a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +.git +target +test-results diff --git a/.github/workflows/container-integration-test.yml b/.github/workflows/container-integration-test.yml new file mode 100644 index 0000000..a1e568b --- /dev/null +++ b/.github/workflows/container-integration-test.yml @@ -0,0 +1,66 @@ +name: Container integration test + +on: + workflow_dispatch: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: container-integration-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + clone-index-and-query: + name: Clone, index and query repository + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Check out GitDatabase + uses: actions/checkout@v4 + + - name: Prepare report directory + run: mkdir -p test-results + + - name: Build containers and run integration test + run: >- + docker compose + -f docker/docker-compose.integration-test.yml + up + --build + --abort-on-container-exit + --exit-code-from integration-test + integration-test + + - name: Publish integration test summary + if: always() + shell: bash + run: | + report="test-results/container-integration-test.md" + if [[ -f "${report}" ]]; then + cat "${report}" | tee -a "${GITHUB_STEP_SUMMARY}" + else + message="Integration test did not produce a report. Inspect the Compose logs above." + printf '## Container integration test\n\n%s\n' "${message}" | tee -a "${GITHUB_STEP_SUMMARY}" + fi + + - name: Upload integration test report + if: always() + uses: actions/upload-artifact@v4 + with: + name: container-integration-test-report + path: test-results/container-integration-test.md + if-no-files-found: warn + + - name: Show container logs on failure + if: failure() + run: docker compose -f docker/docker-compose.integration-test.yml logs --no-color + + - name: Stop integration infrastructure + if: always() + run: docker compose -f docker/docker-compose.integration-test.yml down --volumes --remove-orphans diff --git a/.gitignore b/.gitignore index ad67955..c251d70 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ target # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +# Container integration test output +/test-results/ diff --git a/README.md b/README.md index 826b47c..88bcbd0 100644 --- a/README.md +++ b/README.md @@ -225,6 +225,29 @@ LIMIT 1; --- +## Teste de integração em containers + +O teste de integração sobe um PostgreSQL isolado, clona +`DevComputaria/DevComputaria.Prompting`, executa `sync`, `hydrate-blobs` e +`search-index`, e valida o resultado com consultas SQL. + +```bash +mkdir -p test-results +docker compose -f docker/docker-compose.integration-test.yml up \ + --build \ + --abort-on-container-exit \ + --exit-code-from integration-test \ + integration-test +docker compose -f docker/docker-compose.integration-test.yml down --volumes +``` + +O resultado detalhado é exibido no console e gravado em +`test-results/container-integration-test.md`. O workflow +`Container integration test` também publica esse relatório no Job Summary e +como artefato da execução. + +--- + ## Documentação (MkDocs) Este repositório possui documentação versionada em `docs/` com publicação automática para `gh-pages` via GitHub Actions. diff --git a/docker/Dockerfile.integration-test b/docker/Dockerfile.integration-test new file mode 100644 index 0000000..dfa985c --- /dev/null +++ b/docker/Dockerfile.integration-test @@ -0,0 +1,36 @@ +# syntax=docker/dockerfile:1 + +# Cargo.lock includes crates that require Rust 1.89 (for example, pgwire 0.38.3). +FROM rust:1.89-bookworm AS builder + +WORKDIR /app + +COPY Cargo.toml Cargo.lock ./ +COPY crates ./crates +COPY migrations ./migrations + +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/usr/local/cargo/git \ + --mount=type=tmpfs,target=/app/target,size=4294967296 \ + --mount=type=secret,id=ca_bundle,required=false \ + if [ -f /run/secrets/ca_bundle ]; then \ + CARGO_HTTP_CAINFO=/run/secrets/ca_bundle \ + cargo build --locked --release -p gitbase-cli; \ + else \ + cargo build --locked --release -p gitbase-cli; \ + fi \ + && cp /app/target/release/gitbase /tmp/gitbase + +FROM debian:bookworm-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + git \ + postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /tmp/gitbase /usr/local/bin/gitbase +COPY docker/run-container-integration-test.sh /usr/local/bin/run-container-integration-test + +ENTRYPOINT ["/usr/local/bin/run-container-integration-test"] diff --git a/docker/docker-compose.integration-test.yml b/docker/docker-compose.integration-test.yml new file mode 100644 index 0000000..eeabf7f --- /dev/null +++ b/docker/docker-compose.integration-test.yml @@ -0,0 +1,35 @@ +name: gitdatabase-integration-test + +services: + postgres: + image: postgres:16-bookworm + environment: + POSTGRES_DB: gitbase + POSTGRES_USER: gitbase + POSTGRES_PASSWORD: gitbase + healthcheck: + test: ["CMD-SHELL", "pg_isready -U gitbase -d gitbase"] + interval: 2s + timeout: 3s + retries: 30 + tmpfs: + - /var/lib/postgresql/data + + integration-test: + image: gitdatabase-integration-test:local + build: + context: .. + dockerfile: docker/Dockerfile.integration-test + environment: + DATABASE_URL: postgres://gitbase:gitbase@postgres:5432/gitbase + EXPECTED_REPOSITORY_NAME: DevComputaria.Prompting + REPORT_FILE: /reports/container-integration-test.md + REPOSITORY_URL: https://github.com/DevComputaria/DevComputaria.Prompting.git + RUST_LOG: info + depends_on: + postgres: + condition: service_healthy + volumes: + - type: bind + source: ${GITDATABASE_REPORT_DIR:-../test-results} + target: /reports diff --git a/docker/run-container-integration-test.sh b/docker/run-container-integration-test.sh new file mode 100755 index 0000000..b4ef805 --- /dev/null +++ b/docker/run-container-integration-test.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +readonly REPOSITORY_URL="${REPOSITORY_URL:-https://github.com/DevComputaria/DevComputaria.Prompting.git}" +readonly EXPECTED_REPOSITORY_NAME="${EXPECTED_REPOSITORY_NAME:-DevComputaria.Prompting}" +readonly REPOSITORY_ROOT="${REPOSITORY_ROOT:-/tmp/repositories}" +readonly REPOSITORY_PATH="${REPOSITORY_ROOT}/${EXPECTED_REPOSITORY_NAME}" +readonly REPORT_FILE="${REPORT_FILE:-/reports/container-integration-test.md}" +readonly DATABASE_URL="${DATABASE_URL:?DATABASE_URL is required}" + +CURRENT_STAGE="initialization" +ASSERTIONS_PASSED=0 +REPORT_FINALIZED=0 + +mkdir -p "$(dirname "${REPORT_FILE}")" "${REPOSITORY_ROOT}" + +cat >"${REPORT_FILE}" <>"${REPORT_FILE}" + printf '[%s] %s (observed: %s)\n' "${result}" "${name}" "${value}" +} + +finish() { + local exit_code=$? + trap - EXIT + + if (( REPORT_FINALIZED == 0 )); then + if (( exit_code == 0 )); then + printf '\n**Result: PASS — %d SQL assertions passed.**\n' "${ASSERTIONS_PASSED}" >>"${REPORT_FILE}" + else + report_row "Integration pipeline" "FAIL" "stage=${CURRENT_STAGE}, exit=${exit_code}" + printf '\n**Result: FAIL — stopped during `%s`.**\n' "${CURRENT_STAGE}" >>"${REPORT_FILE}" + fi + printf '\n- Finished at: `%s`\n' "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >>"${REPORT_FILE}" + REPORT_FINALIZED=1 + fi + + printf '\n========== Integration test report ==========\n' + cat "${REPORT_FILE}" + exit "${exit_code}" +} +trap finish EXIT + +run_stage() { + CURRENT_STAGE="$1" + shift + printf '\n========== %s ==========\n' "${CURRENT_STAGE}" + "$@" +} + +sql_scalar() { + local query="$1" + printf '%s\n' "${query}" | psql "${DATABASE_URL}" \ + --no-psqlrc \ + --set ON_ERROR_STOP=1 \ + --set "repo_name=${EXPECTED_REPOSITORY_NAME}" \ + --tuples-only \ + --no-align | tr -d '[:space:]' +} + +assert_greater_than_zero() { + local name="$1" + local query="$2" + local value + value="$(sql_scalar "${query}")" + + if [[ "${value}" =~ ^[0-9]+$ ]] && (( value > 0 )); then + report_row "${name}" "PASS" "${value}" + ASSERTIONS_PASSED=$((ASSERTIONS_PASSED + 1)) + else + report_row "${name}" "FAIL" "${value:-empty}" + return 1 + fi +} + +assert_equals() { + local name="$1" + local expected="$2" + local query="$3" + local value + value="$(sql_scalar "${query}")" + + if [[ "${value}" == "${expected}" ]]; then + report_row "${name}" "PASS" "${value}" + ASSERTIONS_PASSED=$((ASSERTIONS_PASSED + 1)) + else + report_row "${name}" "FAIL" "${value:-empty} (expected ${expected})" + return 1 + fi +} + +run_stage "clone repository" \ + git clone --no-tags "${REPOSITORY_URL}" "${REPOSITORY_PATH}" + +CLONED_COMMIT="$(git -C "${REPOSITORY_PATH}" rev-parse HEAD)" +report_row "Repository cloned" "PASS" "${CLONED_COMMIT}" + +run_stage "database health and migrations" \ + gitbase health --database-url "${DATABASE_URL}" + +run_stage "repository metadata indexing" \ + gitbase sync --database-url "${DATABASE_URL}" --repo-roots "${REPOSITORY_ROOT}" + +run_stage "blob hydration" \ + gitbase hydrate-blobs --database-url "${DATABASE_URL}" --repo-roots "${REPOSITORY_ROOT}" + +run_stage "code search indexing" \ + gitbase search-index --database-url "${DATABASE_URL}" + +CURRENT_STAGE="SQL assertions" +printf '\n========== %s ==========\n' "${CURRENT_STAGE}" + +assert_equals "Target repository indexed exactly once" "1" \ + "SELECT count(*) FROM gitbase.repositories WHERE name = :'repo_name';" + +assert_greater_than_zero "Git refs indexed" \ + "SELECT count(*) FROM gitbase.refs r JOIN gitbase.repositories repo ON repo.id = r.repository_id WHERE repo.name = :'repo_name';" + +assert_greater_than_zero "Git commits indexed" \ + "SELECT count(*) FROM gitbase.commits c JOIN gitbase.repositories repo ON repo.id = c.repository_id WHERE repo.name = :'repo_name';" + +assert_greater_than_zero "Repository files indexed" \ + "SELECT count(*) FROM gitbase.files f JOIN gitbase.repositories repo ON repo.id = f.repository_id WHERE repo.name = :'repo_name';" + +assert_greater_than_zero "Text blobs hydrated" \ + "SELECT count(DISTINCT b.hash) FROM gitbase.blobs b JOIN gitbase.files f ON f.blob_hash = b.hash JOIN gitbase.repositories repo ON repo.id = f.repository_id WHERE repo.name = :'repo_name' AND b.content IS NOT NULL;" + +assert_greater_than_zero "Code-search documents indexed" \ + "SELECT count(DISTINCT ci.blob_hash) FROM gitbase.code_index ci JOIN gitbase.files f ON f.blob_hash = ci.blob_hash JOIN gitbase.repositories repo ON repo.id = f.repository_id WHERE repo.name = :'repo_name';" + +assert_greater_than_zero "README.md query returns data" \ + "SELECT count(*) FROM gitbase.files f JOIN gitbase.repositories repo ON repo.id = f.repository_id WHERE repo.name = :'repo_name' AND f.path = 'README.md';" + +assert_greater_than_zero "search_code('prompt') returns matches" \ + "SELECT count(*) FROM gitbase.search_code('prompt') result JOIN gitbase.repositories repo ON repo.id = result.repository_id WHERE repo.name = :'repo_name';" + +assert_equals "No indexed file references a missing repository" "0" \ + "SELECT count(*) FROM gitbase.files f LEFT JOIN gitbase.repositories repo ON repo.id = f.repository_id WHERE repo.id IS NULL;" + +REPORT_FINALIZED=1 +printf '\n**Result: PASS — %d SQL assertions passed.**\n' "${ASSERTIONS_PASSED}" >>"${REPORT_FILE}" +printf '\n- Finished at: `%s`\n' "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >>"${REPORT_FILE}"