diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 00000000..e67234cb --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,7 @@ +[alias] +cov = "llvm-cov" +cov-lcov = "llvm-cov --lcov --output-path=./.coverage/lcov.info" +cov-html = "llvm-cov --html" +time = "build --timings --all-targets" +e2e = "test --features e2e-tests" + diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..89d167c9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,22 @@ +/.env +/.env.local +/.git +/.git-blame-ignore +/.github +/.gitignore +/.vscode +/data_v2.db* +/data.db* +/bin/ +/config-idx-back.toml.local +/config-tracker.toml.local +/config.toml +/config.toml.local +/cspell.json +/data.db +/docker/ +/project-words.txt +/README.md +/rustfmt.toml +/storage/ +/target/ diff --git a/.env.local b/.env.local new file mode 100644 index 00000000..90b3e4b3 --- /dev/null +++ b/.env.local @@ -0,0 +1,6 @@ +DATABASE_URL=sqlite://storage/database/data.db?mode=rwc +TORRUST_IDX_BACK_CONFIG= +TORRUST_IDX_BACK_USER_UID=1000 +TORRUST_TRACKER_CONFIG= +TORRUST_TRACKER_USER_UID=1000 +TORRUST_TRACKER_API_TOKEN=MyAccessToken diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml index 1b27a4e7..dca7e7e1 100644 --- a/.github/workflows/develop.yml +++ b/.github/workflows/develop.yml @@ -1,6 +1,6 @@ name: Development Checks -on: [push,pull_request] +on: [push, pull_request] jobs: run: @@ -14,27 +14,16 @@ jobs: components: rustfmt, clippy - uses: Swatinem/rust-cache@v2 - name: Format - uses: ClementTsang/cargo-action@main - with: - command: fmt - args: --all --check + run: cargo fmt --all --check - name: Check - uses: ClementTsang/cargo-action@main - with: - command: check - args: --all-targets + run: cargo check --all-targets - name: Clippy - uses: ClementTsang/cargo-action@main - with: - command: clippy - args: --all-targets - - name: Build - uses: ClementTsang/cargo-action@main - with: - command: build - args: --all-targets - - name: Test - uses: ClementTsang/cargo-action@main - with: - command: test - args: --all-targets \ No newline at end of file + run: cargo clippy --all-targets + - name: Unit and integration tests + run: cargo test --all-targets + - uses: taiki-e/install-action@cargo-llvm-cov + - uses: taiki-e/install-action@nextest + - name: Test Coverage + run: cargo llvm-cov nextest + - name: E2E Tests + run: ./docker/bin/run-e2e-tests.sh diff --git a/.github/workflows/publish_docker_image.yml b/.github/workflows/publish_docker_image.yml new file mode 100644 index 00000000..f36bb714 --- /dev/null +++ b/.github/workflows/publish_docker_image.yml @@ -0,0 +1,83 @@ +name: Publish Docker Image + +on: + push: + branches: + - "main" + - "develop" + tags: + - "v*" + +env: + TORRUST_IDX_BACK_RUN_AS_USER: appuser + +jobs: + check-secret: + runs-on: ubuntu-latest + environment: dockerhub-torrust + outputs: + publish: ${{ steps.check.outputs.publish }} + steps: + - id: check + env: + DOCKER_HUB_USERNAME: "${{ secrets.DOCKER_HUB_USERNAME }}" + if: "${{ env.DOCKER_HUB_USERNAME != '' }}" + run: echo "publish=true" >> $GITHUB_OUTPUT + + test: + needs: check-secret + if: needs.check-secret.outputs.publish == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + components: llvm-tools-preview + - uses: Swatinem/rust-cache@v2 + - name: Run Tests + run: cargo test + + dockerhub: + needs: test + if: needs.check-secret.outputs.publish == 'true' + runs-on: ubuntu-latest + environment: dockerhub-torrust + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Docker meta + id: meta + uses: docker/metadata-action@v4 + with: + images: | + # For example: torrust/index-backend + "${{ secrets.DOCKER_HUB_USERNAME }}/${{secrets.DOCKER_HUB_REPOSITORY_NAME }}" + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + + - name: Login to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Build and push + uses: docker/build-push-action@v4 + with: + context: . + file: ./Dockerfile + build-args: | + RUN_AS_USER=${{ env.TORRUST_IDX_BACK_RUN_AS_USER }} + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3d7b0b30..10c62fb8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,3 +1,5 @@ +name: Publish Github Release + on: push: branches: @@ -23,6 +25,9 @@ jobs: - uses: Swatinem/rust-cache@v1 - name: Run tests run: cargo test + - name: Stop databases + working-directory: ./tests + run: docker-compose down tag: needs: test diff --git a/.github/workflows/test_docker.yml b/.github/workflows/test_docker.yml new file mode 100644 index 00000000..30da662d --- /dev/null +++ b/.github/workflows/test_docker.yml @@ -0,0 +1,26 @@ +name: Test Docker build + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Build docker image + uses: docker/build-push-action@v4 + with: + context: . + file: ./Dockerfile + push: false + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Build docker-compose images + run: docker compose build diff --git a/.gitignore b/.gitignore index 42a0fc28..eb90c276 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ +/.coverage/ /.env /config.toml -/data.db* /data_v2.db* +/data.db* +/storage/ /target -/uploads/ \ No newline at end of file +/uploads/ + diff --git a/Cargo.toml b/Cargo.toml index 1eb837e9..ab3fd7ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,9 @@ default-run = "main" [profile.dev.package.sqlx-macros] opt-level = 3 +[features] +e2e-tests = [] + [dependencies] actix-web = "4.0.0-beta.8" actix-multipart = "0.4.0-beta.5" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..08744910 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,72 @@ +FROM clux/muslrust:stable AS chef +WORKDIR /app +RUN cargo install cargo-chef + + +FROM chef AS planner +WORKDIR /app +COPY . . +RUN cargo chef prepare --recipe-path recipe.json + + +FROM chef as development +WORKDIR /app +ARG UID=1000 +ARG RUN_AS_USER=appuser +ARG IDX_BACK_API_PORT=3000 +# Add the app user for development +ENV USER=appuser +ENV UID=$UID +RUN adduser --uid "${UID}" "${USER}" +# Build dependencies +COPY --from=planner /app/recipe.json recipe.json +RUN cargo chef cook --recipe-path recipe.json +# Build the application +COPY . . +RUN cargo build --bin main +USER $RUN_AS_USER:$RUN_AS_USER +EXPOSE $IDX_BACK_API_PORT/tcp +CMD ["cargo", "run"] + + +FROM chef AS builder +WORKDIR /app +ARG UID=1000 +# Add the app user for production +ENV USER=appuser +ENV UID=$UID +RUN adduser \ + --disabled-password \ + --gecos "" \ + --home "/nonexistent" \ + --shell "/sbin/nologin" \ + --no-create-home \ + --uid "${UID}" \ + "${USER}" +# Build dependencies +COPY --from=planner /app/recipe.json recipe.json +RUN cargo chef cook --release --target x86_64-unknown-linux-musl --recipe-path recipe.json +# Build the application +COPY . . +RUN cargo build --release --target x86_64-unknown-linux-musl --bin main +# Strip the binary +# More info: https://github.com/LukeMathWalker/cargo-chef/issues/149 +RUN strip /app/target/x86_64-unknown-linux-musl/release/main + + +FROM alpine:latest +WORKDIR /app +ARG RUN_AS_USER=appuser +ARG IDX_BACK_API_PORT=3000 +RUN apk --no-cache add ca-certificates +ENV TZ=Etc/UTC +ENV RUN_AS_USER=$RUN_AS_USER +COPY --from=builder /etc/passwd /etc/passwd +COPY --from=builder /etc/group /etc/group +COPY --from=builder --chown=$RUN_AS_USER \ + /app/target/x86_64-unknown-linux-musl/release/main \ + /app/main +RUN chown -R $RUN_AS_USER:$RUN_AS_USER /app +USER $RUN_AS_USER:$RUN_AS_USER +EXPOSE $IDX_BACK_API_PORT/tcp +ENTRYPOINT ["/app/main"] \ No newline at end of file diff --git a/bin/install.sh b/bin/install.sh new file mode 100755 index 00000000..041863b2 --- /dev/null +++ b/bin/install.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +# Generate the default settings file if it does not exist +if ! [ -f "./config.toml" ]; then + cp ./config.toml.local ./config.toml +fi + +# Generate storage directory if it does not exist +mkdir -p "./storage/database" + +# Generate the sqlite database for the index baclend if it does not exist +if ! [ -f "./storage/database/data.db" ]; then + # todo: it should get the path from config.toml and only do it when we use sqlite + touch ./storage/database/data.db + echo ";" | sqlite3 ./storage/database/data.db +fi + +# Generate the sqlite database for the tracker if it does not exist +if ! [ -f "./storage/database/tracker.db" ]; then + touch ./storage/database/tracker.db + echo ";" | sqlite3 ./storage/database/tracker.db +fi diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 00000000..35447943 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,94 @@ +name: torrust +services: + + idx-back: + build: + context: . + args: + RUN_AS_USER: appuser + UID: ${TORRUST_IDX_BACK_USER_UID:-1000} + target: development + user: ${TORRUST_IDX_BACK_USER_UID:-1000}:${TORRUST_IDX_BACK_USER_UID:-1000} + tty: true + environment: + - TORRUST_IDX_BACK_CONFIG=${TORRUST_IDX_BACK_CONFIG} + - CARGO_HOME=/home/appuser/.cargo + networks: + - server_side + ports: + - 3000:3000 + # todo: implement healthcheck + #healthcheck: + # test: + # [ + # "CMD-SHELL", + # "cargo run healthcheck" + # ] + # interval: 10s + # retries: 5 + # start_period: 10s + # timeout: 3s + volumes: + - ./:/app + - ~/.cargo:/home/appuser/.cargo + depends_on: + - tracker + + tracker: + image: torrust/tracker:develop + user: ${TORRUST_TRACKER_USER_UID:-1000}:${TORRUST_TRACKER_USER_UID:-1000} + tty: true + environment: + - TORRUST_TRACKER_CONFIG=${TORRUST_TRACKER_CONFIG} + - TORRUST_TRACKER_API_TOKEN=${TORRUST_TRACKER_API_TOKEN:-MyAccessToken} + networks: + - server_side + ports: + - 6969:6969/udp + - 1212:1212/tcp + # todo: implement healthcheck + #healthcheck: + # test: + # [ + # "CMD-SHELL", + # "/app/main healthcheck" + # ] + # interval: 10s + # retries: 5 + # start_period: 10s + # timeout: 3s + volumes: + - ./storage:/app/storage + depends_on: + - mysql + + mysql: + image: mysql:8.0 + command: '--default-authentication-plugin=mysql_native_password' + healthcheck: + test: + [ + 'CMD-SHELL', + 'mysqladmin ping -h 127.0.0.1 --password="$$(cat /run/secrets/db-password)" --silent' + ] + interval: 3s + retries: 5 + start_period: 30s + environment: + - MYSQL_ROOT_HOST=% + - MYSQL_ROOT_PASSWORD=root_secret_password + - MYSQL_DATABASE=torrust_index_backend + - MYSQL_USER=db_user + - MYSQL_PASSWORD=db_user_secret_password + networks: + - server_side + ports: + - 3306:3306 + volumes: + - mysql_data:/var/lib/mysql + +networks: + server_side: {} + +volumes: + mysql_data: {} diff --git a/config-idx-back.toml.local b/config-idx-back.toml.local new file mode 100644 index 00000000..1051dcb9 --- /dev/null +++ b/config-idx-back.toml.local @@ -0,0 +1,32 @@ +[website] +name = "Torrust" + +[tracker] +url = "udp://tracker:6969" +mode = "Public" +api_url = "http://tracker:1212" +token = "MyAccessToken" +token_valid_seconds = 7257600 + +[net] +port = 3000 + +[auth] +email_on_signup = "Optional" +min_password_length = 6 +max_password_length = 64 +secret_key = "MaxVerstappenWC2021" + +[database] +connect_url = "sqlite://storage/database/data.db?mode=rwc" # SQLite +#connect_url = "mysql://root:root_secret_password@mysql:3306/torrust_index_backend" # MySQL +torrent_info_update_interval = 3600 + +[mail] +email_verification_enabled = false +from = "example@email.com" +reply_to = "noreply@email.com" +username = "" +password = "" +server = "" +port = 25 diff --git a/config-tracker.toml.local b/config-tracker.toml.local new file mode 100644 index 00000000..82ceb285 --- /dev/null +++ b/config-tracker.toml.local @@ -0,0 +1,34 @@ +log_level = "info" +mode = "public" +db_driver = "Sqlite3" +db_path = "./storage/database/tracker.db" +announce_interval = 120 +min_announce_interval = 120 +max_peer_timeout = 900 +on_reverse_proxy = false +external_ip = "0.0.0.0" +tracker_usage_statistics = true +persistent_torrent_completed_stat = false +inactive_peer_cleanup_interval = 600 +remove_peerless_torrents = true + +[[udp_trackers]] +enabled = true +bind_address = "0.0.0.0:6969" + +[[http_trackers]] +enabled = false +bind_address = "0.0.0.0:7070" +ssl_enabled = false +ssl_cert_path = "" +ssl_key_path = "" + +[http_api] +enabled = true +bind_address = "0.0.0.0:1212" +ssl_enabled = false +ssl_cert_path = "" +ssl_key_path = "" + +[http_api.access_tokens] +admin = "MyAccessToken" diff --git a/config.toml.local b/config.toml.local new file mode 100644 index 00000000..c8154bc9 --- /dev/null +++ b/config.toml.local @@ -0,0 +1,31 @@ +[website] +name = "Torrust" + +[tracker] +url = "udp://localhost:6969" +mode = "Public" +api_url = "http://localhost:1212" +token = "MyAccessToken" +token_valid_seconds = 7257600 + +[net] +port = 3000 + +[auth] +email_on_signup = "Optional" +min_password_length = 6 +max_password_length = 64 +secret_key = "MaxVerstappenWC2021" + +[database] +connect_url = "sqlite://storage/database/data.db?mode=rwc" +torrent_info_update_interval = 3600 + +[mail] +email_verification_enabled = false +from = "example@email.com" +reply_to = "noreply@email.com" +username = "" +password = "" +server = "" +port = 25 diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 00000000..664e58b8 --- /dev/null +++ b/docker/README.md @@ -0,0 +1,156 @@ +# Docker + +## Requirements + +- Docker version 20.10.21 +- You need to create the `storage` directory with this structure and files: + +```s +$ tree storage/ +storage/ +└── database +  ├── data.db +   └── tracker.db +``` + +## Dev environment + +### With docker + +Build and run locally: + +```s +docker context use default +export TORRUST_IDX_BACK_USER_UID=1000 +./docker/bin/build.sh $TORRUST_IDX_BACK_USER_UID +./bin/install.sh +./docker/bin/run.sh $TORRUST_IDX_BACK_USER_UID +``` + +Run using the pre-built public docker image: + +```s +export TORRUST_IDX_BACK_USER_UID=1000 +docker run -it \ + --user="$TORRUST_IDX_BACK_USER_UID" \ + --publish 3000:3000/tcp \ + --volume "$(pwd)/storage":"/app/storage" \ + torrust/index-backend +``` + +> NOTES: +> +> - You have to create the SQLite DB (`data.db`) and configuration (`config.toml`) before running the index backend. See `bin/install.sh`. +> - You have to replace the user UID (`1000`) with yours. +> - Remember to switch to your default docker context `docker context use default`. + +### With docker-compose + +The docker-compose configuration includes the MySQL service configuration. If you want to use MySQL instead of SQLite you have to change your `config.toml` or `config-idx-back.toml.local` configuration from: + +```toml +connect_url = "sqlite://storage/database/data.db?mode=rwc" +``` + +to: + +```toml +connect_url = "mysql://root:root_secret_password@mysql:3306/torrust_index_backend" +``` + +If you want to inject an environment variable into docker-compose you can use the file `.env`. There is a template `.env.local`. + +Build and run it locally: + +```s +TORRUST_IDX_BACK_USER_UID=${TORRUST_IDX_BACK_USER_UID:-1000} \ + TORRUST_IDX_BACK_CONFIG=$(cat config-idx-back.toml.local) \ + TORRUST_TRACKER_CONFIG=$(cat config-tracker.toml.local) \ + TORRUST_TRACKER_API_TOKEN=${TORRUST_TRACKER_API_TOKEN:-MyAccessToken} \ + docker compose up -d --build +``` + +After running the "up" command you will have three running containers: + +```s +$ docker ps +CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES +e35b14edaceb torrust-idx-back "cargo run" 19 seconds ago Up 17 seconds 0.0.0.0:3000->3000/tcp, :::3000->3000/tcp torrust-idx-back-1 +ddbad9fb496a torrust/tracker:develop "/app/torrust-tracker" 19 seconds ago Up 18 seconds 0.0.0.0:1212->1212/tcp, :::1212->1212/tcp, 0.0.0.0:6969->6969/udp, :::6969->6969/udp, 7070/tcp torrust-tracker-1 +f1d991d62170 mysql:8.0 "docker-entrypoint.s…" 3 hours ago Up 18 seconds (healthy) 0.0.0.0:3306->3306/tcp, :::3306->3306/tcp, 33060/tcp torrust-mysql-1 + torrust-mysql-1 +``` + +And you should be able to use the application, for example making a request to the API: + + + +The Tracker API is available at: + + + +> NOTICE: You have to bind the tracker services to the wildcard IP `0.0.0.0` to make it accessible from the host. + +You can stop the containers with: + +```s +docker compose down +``` + +Additionally, you can delete all resources (containers, volumes, networks) with: + +```s +docker compose down -v +``` + +### Access Mysql with docker + +These are some useful commands for MySQL. + +Open a shell in the MySQL container using docker or docker-compose. + +```s +docker exec -it torrust-mysql-1 /bin/bash +docker compose exec mysql /bin/bash +``` + +Connect to MySQL from inside the MySQL container or from the host: + +```s +mysql -h127.0.0.1 -uroot -proot_secret_password +``` + +The when MySQL container is started the first time, it creates the database, user, and permissions needed. +If you see the error "Host is not allowed to connect to this MySQL server" you can check that users have the right permissions in the database. Make sure the user `root` and `db_user` can connect from any host (`%`). + +```s +mysql> SELECT host, user FROM mysql.user; ++-----------+------------------+ +| host | user | ++-----------+------------------+ +| % | db_user | +| % | root | +| localhost | mysql.infoschema | +| localhost | mysql.session | +| localhost | mysql.sys | +| localhost | root | ++-----------+------------------+ +6 rows in set (0.00 sec) +``` + +```s +mysql> show databases; ++-----------------------+ +| Database | ++-----------------------+ +| information_schema | +| mysql | +| performance_schema | +| sys | +| torrust_index_backend | +| torrust_tracker | ++-----------------------+ +6 rows in set (0,00 sec) +``` + +If the database, user or permissions are not created the reason could be the MySQL container volume can be corrupted. Delete it and start again the containers. diff --git a/docker/bin/build.sh b/docker/bin/build.sh new file mode 100755 index 00000000..96766624 --- /dev/null +++ b/docker/bin/build.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +TORRUST_IDX_BACK_USER_UID=${TORRUST_IDX_BACK_USER_UID:-1000} +TORRUST_IDX_BACK_RUN_AS_USER=${TORRUST_IDX_BACK_RUN_AS_USER:-appuser} + +echo "Building docker image ..." +echo "TORRUST_IDX_BACK_USER_UID: $TORRUST_IDX_BACK_USER_UID" +echo "TORRUST_IDX_BACK_RUN_AS_USER: $TORRUST_IDX_BACK_RUN_AS_USER" + +docker build \ + --build-arg UID="$TORRUST_IDX_BACK_USER_UID" \ + --build-arg RUN_AS_USER="$TORRUST_IDX_BACK_RUN_AS_USER" \ + -t torrust-index-backend . diff --git a/docker/bin/e2e-env-down.sh b/docker/bin/e2e-env-down.sh new file mode 100755 index 00000000..5e50d101 --- /dev/null +++ b/docker/bin/e2e-env-down.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +docker compose down diff --git a/docker/bin/e2e-env-up.sh b/docker/bin/e2e-env-up.sh new file mode 100755 index 00000000..a5de770c --- /dev/null +++ b/docker/bin/e2e-env-up.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +TORRUST_IDX_BACK_USER_UID=${TORRUST_IDX_BACK_USER_UID:-1000} \ + docker compose build + +TORRUST_IDX_BACK_USER_UID=${TORRUST_IDX_BACK_USER_UID:-1000} \ + TORRUST_IDX_BACK_CONFIG=$(cat config-idx-back.toml.local) \ + TORRUST_TRACKER_CONFIG=$(cat config-tracker.toml.local) \ + TORRUST_TRACKER_API_TOKEN=${TORRUST_TRACKER_API_TOKEN:-MyAccessToken} \ + docker compose up -d diff --git a/docker/bin/install.sh b/docker/bin/install.sh new file mode 100755 index 00000000..a5896937 --- /dev/null +++ b/docker/bin/install.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +./docker/bin/build.sh +./bin/install.sh diff --git a/docker/bin/run-e2e-tests.sh b/docker/bin/run-e2e-tests.sh new file mode 100755 index 00000000..5eb63c33 --- /dev/null +++ b/docker/bin/run-e2e-tests.sh @@ -0,0 +1,57 @@ +#!/bin/bash + +CURRENT_USER_NAME=$(whoami) +CURRENT_USER_ID=$(id -u) +echo "User name: $CURRENT_USER_NAME" +echo "User id: $CURRENT_USER_ID" + +TORRUST_IDX_BACK_USER_UID=$CURRENT_USER_ID +TORRUST_TRACKER_USER_UID=$CURRENT_USER_ID +export TORRUST_IDX_BACK_USER_UID +export TORRUST_TRACKER_USER_UID + +wait_for_container_to_be_healthy() { + local container_name="$1" + local max_retries="$2" + local retry_interval="$3" + local retry_count=0 + + while [ $retry_count -lt "$max_retries" ]; do + container_health="$(docker inspect --format='{{json .State.Health}}' "$container_name")" + if [ "$container_health" != "{}" ]; then + container_status="$(echo "$container_health" | jq -r '.Status')" + if [ "$container_status" == "healthy" ]; then + echo "Container $container_name is healthy" + return 0 + fi + fi + + retry_count=$((retry_count + 1)) + echo "Waiting for container $container_name to become healthy (attempt $retry_count of $max_retries)..." + sleep "$retry_interval" + done + + echo "Timeout reached, container $container_name is not healthy" + return 1 +} + +cp .env.local .env +./bin/install.sh + +# Start E2E testing environment +./docker/bin/e2e-env-up.sh + +wait_for_container_to_be_healthy torrust-mysql-1 10 3 +# todo: implement healthchecks for tracker and backend and wait until they are healthy +#wait_for_container torrust-tracker-1 10 3 +#wait_for_container torrust-idx-back-1 10 3 +sleep 20s + +# Just to make sure that everything is up and running +docker ps + +# Run E2E tests +cargo test --features e2e-tests + +# Stop E2E testing environment +./docker/bin/e2e-env-down.sh diff --git a/docker/bin/run.sh b/docker/bin/run.sh new file mode 100755 index 00000000..92417f9a --- /dev/null +++ b/docker/bin/run.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +TORRUST_IDX_BACK_USER_UID=${TORRUST_IDX_BACK_USER_UID:-1000} +TORRUST_IDX_BACK_CONFIG=$(cat config.toml) + +docker run -it \ + --user="$TORRUST_IDX_BACK_USER_UID" \ + --publish 3000:3000/tcp \ + --env TORRUST_IDX_BACK_CONFIG="$TORRUST_IDX_BACK_CONFIG" \ + --volume "$(pwd)/storage":"/app/storage" \ + torrust-index-backend diff --git a/tests/e2e/client.rs b/tests/e2e/client.rs new file mode 100644 index 00000000..3d249d66 --- /dev/null +++ b/tests/e2e/client.rs @@ -0,0 +1,67 @@ +use reqwest::Response; + +use crate::e2e::connection_info::ConnectionInfo; +use crate::e2e::http::{Query, ReqwestQuery}; + +/// API Client +pub struct Client { + connection_info: ConnectionInfo, + base_path: String, +} + +impl Client { + pub fn new(connection_info: ConnectionInfo) -> Self { + Self { + connection_info, + base_path: "/".to_string(), + } + } + + pub async fn entrypoint(&self) -> Response { + self.get("", Query::default()).await + } + + pub async fn get(&self, path: &str, params: Query) -> Response { + self.get_request_with_query(path, params).await + } + + /* + pub async fn post(&self, path: &str) -> Response { + reqwest::Client::new().post(self.base_url(path).clone()).send().await.unwrap() + } + + async fn delete(&self, path: &str) -> Response { + reqwest::Client::new() + .delete(self.base_url(path).clone()) + .send() + .await + .unwrap() + } + + pub async fn get_request(&self, path: &str) -> Response { + get(&self.base_url(path), None).await + } + */ + + pub async fn get_request_with_query(&self, path: &str, params: Query) -> Response { + get(&self.base_url(path), Some(params)).await + } + + fn base_url(&self, path: &str) -> String { + format!("http://{}{}{path}", &self.connection_info.bind_address, &self.base_path) + } +} + +async fn get(path: &str, query: Option) -> Response { + match query { + Some(params) => reqwest::Client::builder() + .build() + .unwrap() + .get(path) + .query(&ReqwestQuery::from(params)) + .send() + .await + .unwrap(), + None => reqwest::Client::builder().build().unwrap().get(path).send().await.unwrap(), + } +} diff --git a/tests/e2e/connection_info.rs b/tests/e2e/connection_info.rs new file mode 100644 index 00000000..f70dae6f --- /dev/null +++ b/tests/e2e/connection_info.rs @@ -0,0 +1,16 @@ +pub fn connection_with_no_token(bind_address: &str) -> ConnectionInfo { + ConnectionInfo::anonymous(bind_address) +} + +#[derive(Clone)] +pub struct ConnectionInfo { + pub bind_address: String, +} + +impl ConnectionInfo { + pub fn anonymous(bind_address: &str) -> Self { + Self { + bind_address: bind_address.to_string(), + } + } +} diff --git a/tests/e2e/contexts/about.rs b/tests/e2e/contexts/about.rs new file mode 100644 index 00000000..99bcb276 --- /dev/null +++ b/tests/e2e/contexts/about.rs @@ -0,0 +1,21 @@ +use crate::e2e::client::Client; +use crate::e2e::connection_info::connection_with_no_token; + +#[tokio::test] +#[cfg_attr(not(feature = "e2e-tests"), ignore)] +async fn it_should_load_the_about_page_at_the_api_entrypoint() { + let client = Client::new(connection_with_no_token("localhost:3000")); + + let response = client.entrypoint().await; + + assert_eq!(response.status(), 200); + assert_eq!(response.headers().get("content-type").unwrap(), "text/html; charset=utf-8"); + + let title = format!("About"); + let response_text = response.text().await.unwrap(); + + assert!( + response_text.contains(&title), + ":\n response: `\"{response_text}\"`\n does not contain: `\"{title}\"`." + ); +} diff --git a/tests/e2e/contexts/mod.rs b/tests/e2e/contexts/mod.rs new file mode 100644 index 00000000..ced75210 --- /dev/null +++ b/tests/e2e/contexts/mod.rs @@ -0,0 +1 @@ +pub mod about; diff --git a/tests/e2e/http.rs b/tests/e2e/http.rs new file mode 100644 index 00000000..d682027f --- /dev/null +++ b/tests/e2e/http.rs @@ -0,0 +1,54 @@ +pub type ReqwestQuery = Vec; +pub type ReqwestQueryParam = (String, String); + +/// URL Query component +#[derive(Default, Debug)] +pub struct Query { + params: Vec, +} + +impl Query { + pub fn empty() -> Self { + Self { params: vec![] } + } + + pub fn params(params: Vec) -> Self { + Self { params } + } + + pub fn add_param(&mut self, param: QueryParam) { + self.params.push(param); + } +} + +impl From for ReqwestQuery { + fn from(url_search_params: Query) -> Self { + url_search_params + .params + .iter() + .map(|param| ReqwestQueryParam::from((*param).clone())) + .collect() + } +} + +/// URL query param +#[derive(Clone, Debug)] +pub struct QueryParam { + name: String, + value: String, +} + +impl QueryParam { + pub fn new(name: &str, value: &str) -> Self { + Self { + name: name.to_string(), + value: value.to_string(), + } + } +} + +impl From for ReqwestQueryParam { + fn from(param: QueryParam) -> Self { + (param.name, param.value) + } +} diff --git a/tests/e2e/mod.rs b/tests/e2e/mod.rs new file mode 100644 index 00000000..35de4dcf --- /dev/null +++ b/tests/e2e/mod.rs @@ -0,0 +1,9 @@ +//! End-to-end tests. +//! +//! ``` +//! cargo test -- --ignored +//! ``` +mod client; +mod connection_info; +mod contexts; +mod http; diff --git a/tests/mod.rs b/tests/mod.rs index 27bea3bd..f90fa4f2 100644 --- a/tests/mod.rs +++ b/tests/mod.rs @@ -1,2 +1,3 @@ mod databases; +mod e2e; pub mod upgrades;