diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000000..581d979a684 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +# The project tree is bind-mounted at runtime; the build context only needs +# docker/. Keeping this tight makes `docker compose build` near-instant. +* +!docker/ diff --git a/README.md b/README.md index 95bfcdf383a..fb2eccf8ddb 100755 --- a/README.md +++ b/README.md @@ -56,6 +56,20 @@ availability of, external services/applications. You can try out Chamilo at https://campus.chamilo.net/ (use the "Teach courses" option to give yourself creation rights). +## Local development with Docker + +The quickest way to get Chamilo running on your own machine. You only need +Docker — no PHP, MySQL, Node.js or Apache to install: + +~~~~ +docker/setup.sh +~~~~ + +Then open and log in as `admin` / `admin`. + +- **[SETUP.md](SETUP.md)** — step-by-step guide, ports, tests, troubleshooting +- **[docker/README.md](docker/README.md)** — how the stack is built and why + ## Quick install **IMPORTANT** Chamilo 2.0 is in its validation phase right now. diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 00000000000..ac40fd3d23d --- /dev/null +++ b/SETUP.md @@ -0,0 +1,293 @@ +# How to run Chamilo 2.0 on your computer + +This guide gets Chamilo running on any computer using Docker. + +You do **not** need to install PHP, MySQL, Node.js, or Apache. Docker handles +all of it. You only install Docker. + +Works on **macOS**, **Windows**, and **Linux**. Works on both Intel and Apple +Silicon (M1/M2/M3/M4) chips. + +--- + +## Step 1 — Install Docker + +Pick your system: + +| System | What to install | +|---|---| +| macOS | [Docker Desktop for Mac](https://docs.docker.com/desktop/install/mac-install/) | +| Windows | [Docker Desktop for Windows](https://docs.docker.com/desktop/install/windows-install/) | +| Linux | [Docker Engine](https://docs.docker.com/engine/install/) + [Compose plugin](https://docs.docker.com/compose/install/linux/) | + +**Windows users:** run all the commands in this guide from **Git Bash** or +**WSL2**, not from PowerShell. The scripts are bash scripts. + +Then start Docker Desktop and check it works: + +```bash +docker --version +docker compose version +``` + +If both print a version number, you're good. + +**You also need about 10 GB of free disk space.** Docker downloads several +images and the project installs its own dependencies. + +--- + +## Step 2 — Get the code + +```bash +git clone https://github.com/chamilo/chamilo-lms.git chamilo +cd chamilo +git checkout 2.0 +``` + +Already have the folder? Just `cd` into it. + +--- + +## Step 3 — Run one command + +```bash +docker/setup.sh +``` + +Now wait. **The first run takes a long time — plan for 30 to 60 minutes.** +It is downloading and building a lot. This is normal. + +Here's roughly what it's doing, so you know it isn't stuck: + +| Stage | Roughly how long | +|---|---| +| Building the PHP image | 3–5 min | +| Downloading MariaDB, Redis, Mailpit, Selenium | 5–40 min (depends on your internet) | +| Installing PHP libraries (`composer install`) | 5–10 min | +| Building the frontend (`yarn` + webpack) | 10–20 min | +| Installing Chamilo itself | 1–2 min | +| Setting up the test database | 1–2 min | + +When it finishes, it prints a summary with all your addresses. + +> The Selenium download is the slowest part and it is only needed for browser +> tests. If it feels stuck, it's almost certainly still downloading. + +--- + +## Step 4 — Open it + +Go to: + +``` +http://localhost:8380 +``` + +Log in: + +- **Username:** `admin` +- **Password:** `admin` + +That's it. You have a working Chamilo. + +--- + +## Your addresses + +| What it is | Address | +|---|---| +| Chamilo | http://localhost:8380 | +| API documentation | http://localhost:8380/api | +| Emails the app sends | http://localhost:8325 | +| Watch browser tests live | http://localhost:7900 | +| Database | `localhost:3307` — user `chamilo`, password `chamilo` | + +Chamilo never really sends email here. Everything it tries to send is caught by +**Mailpit** at http://localhost:8325 so you can read it. Handy for testing +"forgot my password" and sign-up emails. + +--- + +## Everyday commands + +Run all of these from the project folder. + +```bash +docker/setup.sh # start everything (safe to run any time) +docker compose down # stop everything (your data is kept) +docker compose ps # see what's running +docker compose logs -f web # watch the error log +``` + +To get back to work after stopping, just run `docker/setup.sh` again. It will be +fast the second time — it skips everything already done. + +Need a terminal inside the container? + +```bash +docker compose exec web bash +``` + +Need the Symfony console? + +```bash +docker compose exec web php bin/console cache:clear +docker compose exec web php bin/console debug:router +``` + +--- + +## Running the tests + +```bash +docker/test.sh phpunit # code tests, about 4 minutes +docker/test.sh behat # browser tests, several HOURS +docker/test.sh phpstan # finds possible bugs +docker/test.sh ecs # checks code style +docker/test.sh psalm # another bug finder +``` + +### Read this before you panic + +**Some tests already fail. That is not your fault and not a broken setup.** + +Chamilo 2.0 is unfinished — its own README says it's "in its validation phase". +These are the numbers you should expect on a clean install: + +| Test | Expected result | +|---|---| +| PHPUnit | **299 pass, 54 fail** (out of 353) | +| PHPStan | **603 problems found** | +| ECS | **12 style problems** | +| Behat | some features fail | + +**How to use these numbers:** run the tests *before* you change anything and +write down what you get. That's your baseline. If you change code and the number +of failures goes *up*, you broke something. If it stays the same, you're fine. + +Most of the PHPUnit failures come from one single cause — a missing +`cascade: ['persist']` setting on a database relationship. One small fix would +clear about 24 of the 54. + +Browser tests are slow because a real Chrome browser clicks through the site. +Roughly **50 seconds per scenario**. You usually want to run just one file: + +```bash +docker/test.sh behat features/actionUserLogin.feature +``` + +You can watch it happen live at http://localhost:7900. + +--- + +## If a port is already taken + +This is the most common problem on a new computer. You'll see: + +``` +Bind for 0.0.0.0:8380 failed: port is already allocated +``` + +It means another program is already using that port. Find out who: + +```bash +docker ps --format '{{.Names}} {{.Ports}}' +``` + +To change the Chamilo port, use the script — don't edit files by hand. The port +appears in four files that must all agree, and the script keeps them in step: + +```bash +docker/set-port.sh # show the current port +docker/set-port.sh 9000 # change it to 9000 +``` + +It refuses if the port is already used by another container, and tells you which +one. Then apply the change: + +```bash +docker compose down +docker/setup.sh --fresh +``` + +`--fresh` is required here, not optional: Chamilo saves its own web address in +the database when it installs, so the old port would linger in every link. + +The other ports are easier — change only `docker-compose.yml`: + +| Port | Line to change | Used for | +|---|---|---| +| 8325 | `- "8325:8025"` | Mailpit | +| 3307 | `- "3307:3306"` | Database | +| 4444 / 7900 | under `web` | Selenium | + +--- + +## Common problems + +**"Docker is not running"** +Start Docker Desktop and wait for its icon to stop animating. + +**The page has no styling, or looks broken** +The frontend wasn't built. Run: +```bash +docker/setup.sh --assets +``` + +**`Environment variable not found: "APP_LOCALE"`** +There's an empty `.env` file. Delete it and run setup again: +```bash +rm .env && docker/setup.sh +``` +(Never create an empty `.env`. Chamilo reads its defaults from `.env.dist`, and +it only does that when `.env` does not exist at all.) + +**Browser tests say `ERR_CONNECTION_REFUSED`** +Selenium lost its network link. Restart it: +```bash +docker compose --profile behat up -d --force-recreate selenium +``` + +**Browser tests hang forever** +The Selenium browser died. Same fix as above. + +**Everything is broken and I want to start over** +```bash +docker/setup.sh --fresh +``` +This deletes the database and reinstalls Chamilo from scratch. Takes a few +minutes. Your code is not touched. + +**I want to delete absolutely everything, including Docker images** +```bash +docker compose down -v +``` +Warning: this deletes your database. The next `docker/setup.sh` starts over. + +--- + +## What's your login again? + +| | | +|---|---| +| Chamilo admin | `admin` / `admin` | +| Database user | `chamilo` / `chamilo` | +| Database root | `root` / `root` | +| Database name | `chamilo` | + +These are throwaway passwords for local development only. **Never use this +setup on a real server.** It runs in debug mode with weak passwords. + +--- + +## Want the technical details? + +This guide covers getting it running. If you need to understand *why* the setup +is built the way it is — why Apache uses port 8380 inside the container, why +Selenium is pinned to an old version, how the environment files layer — read: + +**[docker/README.md](docker/README.md)** + +Every unusual choice in there is explained, because each one was a real bug that +had to be tracked down. diff --git a/config/packages/dev/zz_docker_mailer.yaml b/config/packages/dev/zz_docker_mailer.yaml new file mode 100755 index 00000000000..0286fdbe1d8 --- /dev/null +++ b/config/packages/dev/zz_docker_mailer.yaml @@ -0,0 +1,9 @@ +# Docker stack: route dev mail to Mailpit (http://localhost:8025) instead of +# discarding it, so registration / password-reset flows are testable. +# +# config/packages/mailer.yaml hardcodes 'null://null' including a when@dev +# block; files under config/packages/dev/ load afterwards, so this wins. +# Filename is zz_-prefixed to sort last within the directory. +framework: + mailer: + dsn: 'smtp://mailpit:1025' diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000000..f6d1221928c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,139 @@ +# Chamilo LMS 2.0 — local development / test stack +# +# docker compose up -d --build bring the stack up +# docker/setup.sh install Chamilo + prepare the test DB +# docker/test.sh phpunit|behat|... run a test tier +# +# Port note: Apache listens on 8380 *inside* the container and is published on +# host 8380, so the URL is `http://localhost:8380` from the host browser, from +# Behat, and from the Chrome container alike. Chamilo persists root_web at +# install time, so a mismatch there breaks every redirect in the browser tests. +# To change it, edit HTTP_PORT in docker/php/Dockerfile and the `ports:` entry +# below together — they must stay equal. + +name: chamilo + +services: + web: + build: + context: . + dockerfile: docker/php/Dockerfile + restart: unless-stopped + ports: + - "8380:8380" # Chamilo — must equal HTTP_PORT in docker/php/Dockerfile + - "4444:4444" # Selenium Grid (shares this container's netns) + - "7900:7900" # Selenium noVNC — watch the browser tests run + environment: + # Consumed only by docker/php/entrypoint.sh. Deliberately NOT named + # DATABASE_* so they cannot shadow .env / .env.test — see entrypoint.sh. + WAIT_DB_HOST: db + WAIT_DB_PORT: "3306" + WAIT_DB_USER: chamilo + WAIT_DB_PASSWORD: chamilo + JWT_PASSPHRASE: your_secret_passphrase + # Keeps `yarn`/`composer` caches off the bind mount + COMPOSER_HOME: /tmp/composer + volumes: + - .:/var/www/chamilo:cached + # Named volumes: keep heavy, platform-specific trees out of the macOS + # bind mount. node_modules holds linux/arm64 native binaries that would + # clash with the host's darwin/arm64 build. + - node_modules:/var/www/chamilo/node_modules + - var_cache:/var/www/chamilo/var/cache + depends_on: + db: + condition: service_healthy + redis: + condition: service_started + networks: [chamilo] + + db: + image: mariadb:10.11 + restart: unless-stopped + ports: + - "3307:3306" # 3307 on the host to avoid clashing with a local MySQL + environment: + MARIADB_ROOT_PASSWORD: root + MARIADB_DATABASE: chamilo + MARIADB_USER: chamilo + MARIADB_PASSWORD: chamilo + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + # Chamilo's queries are not ONLY_FULL_GROUP_BY-clean; doctrine.yaml strips + # the mode per-connection, but the legacy public/main/* code uses its own + # connections, so it has to be off server-wide too. + - --sql-mode=NO_ENGINE_SUBSTITUTION + - --innodb-buffer-pool-size=512M + - --max-allowed-packet=256M + volumes: + - db_data:/var/lib/mysql + - ./docker/db/init:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 5s + timeout: 5s + retries: 20 + start_period: 30s + networks: [chamilo] + + redis: + image: redis:7-alpine + restart: unless-stopped + command: ["redis-server", "--appendonly", "yes"] + volumes: + - redis_data:/data + networks: [chamilo] + + # Catches every outgoing mail — registration, password reset, notifications. + # UI at http://localhost:8325 (8025 is taken by another stack on this machine) + mailpit: + image: axllent/mailpit:latest + restart: unless-stopped + ports: + - "8325:8025" + environment: + MP_SMTP_AUTH_ACCEPT_ANY: "1" + MP_SMTP_AUTH_ALLOW_INSECURE: "1" + networks: [chamilo] + + # Behat's browser. Shares the web container's network namespace so that + # Chrome resolves http://localhost:8380 to Apache, and Behat (running inside + # `web`) reaches the grid at 127.0.0.1:4444 — exactly what tests/behat/behat.yml + # already expects, so no config override is needed for wd_host. + # + # Pinned to Selenium 4.1.4 on purpose. behat/mink-selenium2-driver 1.7 talks + # through instaclick/php-webdriver, which only speaks the legacy JSON Wire + # protocol. Grid 4.1.x still converts that to W3C; later releases dropped the + # conversion and answer every request with + # No nodes support the capabilities in the request: [] + # CI pins selenium-server 4.1.3 for the same reason. `seleniarm` is the + # community arm64 build of that era — the official selenium/ images only + # gained arm64 from 4.11 onwards, by which point the legacy protocol was gone. + # + # Moving to a current grid means adding a W3C driver (Selenium4Factory wants + # Behat\Mink\Driver\Selenium4Driver, which is not in composer.json) and + # editing tests/behat/behat.yml — an upstream change, not a local one. + selenium: + image: seleniarm/standalone-chromium:4.1.4-20220429 + restart: unless-stopped + network_mode: "service:web" + shm_size: 2gb + environment: + SE_NODE_MAX_SESSIONS: "1" + SE_NODE_SESSION_TIMEOUT: "300" + SE_START_XVFB: "true" + SE_SCREEN_WIDTH: "1920" + SE_SCREEN_HEIGHT: "1080" + depends_on: + - web + profiles: [behat] + +volumes: + db_data: + redis_data: + node_modules: + var_cache: + +networks: + chamilo: diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 00000000000..285696d7f99 --- /dev/null +++ b/docker/README.md @@ -0,0 +1,219 @@ +# Chamilo LMS 2.0 — Docker development & test stack + +Everything runs in containers; nothing but Docker is needed on the host. + +## Quick start + +```bash +docker/setup.sh # build, start, install the portal, prepare the test DB +``` + +Then open and log in as **admin / admin**. + +```bash +docker/setup.sh --fresh # wipe the DB + .env and reinstall from scratch +docker/setup.sh --assets # also run yarn install + encore production +``` + +## What's in the stack + +| Service | Image | Host address | Purpose | +|-----------|------------------------------------|-------------------------|---------| +| `web` | built from `docker/php/Dockerfile` | | PHP 8.2 + Apache; also the CLI container for console/phpunit/behat | +| `db` | `mariadb:10.11` | `127.0.0.1:3307` | `chamilo` and `chamilo_test` schemas | +| `redis` | `redis:7-alpine` | — | Symfony lock store (`LOCK_DSN`) | +| `mailpit` | `axllent/mailpit` | | Catches all outgoing mail | +| `selenium`| `seleniarm/standalone-chromium:4.1.4` | | Behat's browser; `behat` compose profile | + +All images are arm64-native, so this runs without emulation on Apple Silicon. + +## Running tests + +```bash +docker/test.sh phpunit # resets chamilo_test, then runs +docker/test.sh phpunit --no-reset --filter UserTest +docker/test.sh behat # the full CI feature list +docker/test.sh behat features/actionUserLogin.feature +docker/test.sh phpstan +docker/test.sh psalm +docker/test.sh ecs +docker/test.sh all # phpunit + phpstan + ecs +``` + +Watch the browser tests run live at (no password). + +Behat is slow — roughly 50s per scenario through a real browser, so +`course.feature` (30 scenarios) takes ~25 minutes on its own and the full CI list +is a multi-hour run. Each feature gets a fresh grid and a 40-minute cap; +override with `BEHAT_TIMEOUT=`. + +Coverage uses pcov, which is installed but left off (loading it slows every CLI +call). Enable it per-command: + +```bash +docker compose exec web php -d extension=pcov.so -d pcov.enabled=1 \ + bin/phpunit --coverage-text +``` + +## Everyday commands + +```bash +docker compose exec web bash # shell +docker compose exec web php bin/console cache:clear +docker compose exec web php bin/console debug:router +docker compose exec db mariadb -uchamilo -pchamilo chamilo +docker compose logs -f web +docker compose down # stop +docker compose down -v # stop and delete all data +``` + +## Design notes + +Five things here are less obvious than they look. + +**Apache listens on 8380 inside the container, not 80.** Chamilo persists +`root_web` in the database at install time, and every redirect is built from it. +Publishing `8380:8380` rather than `8380:80` means the URL is +`http://localhost:8380` from the host browser, from Behat, and from the Chrome +container alike — no mismatch, no broken redirects in the browser tests. + +The consequence is that the port appears in four files which must agree: +`docker/php/Dockerfile` (`ARG HTTP_PORT`), `docker-compose.yml` (`ports:`), +`docker/lib.sh` (`CHAMILO_PORT`) and `tests/behat/behat.docker.yml` (`base_url`). +Use `docker/set-port.sh ` rather than editing them by hand. + +**The `selenium` container uses `network_mode: service:web`.** Sharing the web +container's network namespace means Chrome resolves `http://localhost:8380` to +Apache, while Behat — running inside `web` — reaches the grid at +`127.0.0.1:4444`, which is exactly what `tests/behat/behat.yml` already +specifies. Selenium's ports are published through `web`'s port mappings. + +**Behat runs against `tests/behat/behat.docker.yml`, not `BEHAT_PARAMS`.** Only +`base_url` differs from the upstream `behat.yml` (it needs the `:8380` port), but +the override cannot be done with the `BEHAT_PARAMS` environment variable: +Behat 3.29's `ConfigurationLoader::loadConfiguration()` appends the *file* config +after the env-var config, and later entries win the merge — so `behat.yml`'s +`base_url: http://localhost` silently beat it and Chrome reported +`ERR_CONNECTION_REFUSED` against port 80. The extra config sits next to +`behat.yml` so `%paths.base%` still resolves for the suite path and for +`FeatureContext` autoloading. + +**`setup.sh` rewrites `access_url` after installing.** The wizard stores +`http://localhost/` — without the port — and Chamilo builds absolute URLs +(assets, mail links, learning-path and document paths) from that row, so the +portal would otherwise emit links to port 80. + +**The DB wait-loop in `entrypoint.sh` uses `WAIT_DB_*`, not `DATABASE_*`.** Real +environment variables take precedence over `.env` files in Symfony's Dotenv, so +exporting `DATABASE_NAME` into the container would override `.env.test`'s +`chamilo_test` and point the PHPUnit suite at the live database. + +### Environment files + +Symfony loads `.env` → `.env.local` → `.env.$APP_ENV` → `.env.$APP_ENV.local`, +and **skips `.env.local` when `APP_ENV=test`**. That gives clean separation: + +- `.env` — generated by the install wizard. Do not hand-edit. +- `.env.local` — stack overrides for dev/prod (DB host `db`, Redis lock, `APP_ENV=dev`). +- `.env.test.local` — points PHPUnit at `chamilo_test` on the `db` service. + +Both `.local` files are gitignored. + +> **Never create an empty `.env`.** Symfony's Dotenv falls back to `.env.dist` +> only when `.env` is *absent*; an empty file shadows that fallback and every +> console command then dies with `Environment variable not found: "APP_LOCALE"`. +> The wizard writes the real `.env`; before that, `.env.dist` is what supplies +> the defaults. `setup.sh --fresh` therefore removes `.env` rather than +> truncating it. + +### The test database is `chamilo_test_test` + +`config/packages/doctrine.yaml` appends `dbname_suffix: '_test'` under +`when@test`, so `DATABASE_NAME=chamilo_test` resolves to the physical database +`chamilo_test_test`. Setting `DATABASE_NAME=chamilo` would give a tidier +`chamilo_test`, but if that suffix ever stopped being applied, +`doctrine:schema:drop --full-database` would hit the live portal. The extra +`_test` is the safer trade. `docker/db/init/01-databases.sql` grants the +`chamilo` user a `chamilo\_%` wildcard so ParaTest's per-worker databases work +too. + +> Note: `docker compose` also reads `./.env` for its own variable substitution. +> The compose file deliberately contains no `${...}` references, so Chamilo's +> `.env` cannot affect it. + +### Volumes + +`node_modules` and `var/cache` are named volumes rather than part of the bind +mount. `node_modules` holds linux/arm64 native binaries (sass, esbuild) that +would collide with a host `yarn install` built for darwin/arm64; `var/cache` +avoids thousands of small-file writes crossing the virtiofs boundary. + +`public/build/` **is** bind-mounted, so compiled frontend assets are shared with +the host. `setup.sh` skips the webpack build when `public/build/entrypoints.json` +already exists — pass `--assets` to force a rebuild. + +### Mail + +`config/packages/mailer.yaml` hardcodes `null://null`, including in its +`when@dev` block. `config/packages/dev/zz_docker_mailer.yaml` overrides it to +point at Mailpit (files under `config/packages/dev/` load after +`config/packages/*`, and the `zz_` prefix sorts it last). + +### Switching to prod + +CI runs Behat against `APP_ENV=prod` because the dev toolbar injects markup that +can confuse selector-based assertions. If a browser test behaves oddly, try: + +```bash +sed -i '' "s/APP_ENV='dev'/APP_ENV='prod'/; s/APP_DEBUG='1'/APP_DEBUG='0'/" .env.local +docker compose exec web php bin/console cache:clear +``` + +## Troubleshooting + +**`Environment variable not found: "APP_LOCALE"` / `"TRUSTED_PROXIES"`** +An empty `.env` exists. Delete it — see the warning under *Environment files*. + +**`Bind for 0.0.0.0:XXXX failed: port is already allocated`** +Another stack on this machine owns the port. `docker ps --format '{{.Names}} +{{.Ports}}'` shows who. The stack already avoids the common ones (8080, 8025, +3306) for that reason; change the `ports:` entry in `docker-compose.yml`, and if +it is the Chamilo port, change `HTTP_PORT` in `docker/php/Dockerfile` to match. + +**`No nodes support the capabilities in the request: []`** +The Selenium image drifted to a release that no longer speaks the legacy JSON +Wire protocol. Keep the pin — see the comment above the `selenium` service. + +**Behat: `net::ERR_CONNECTION_REFUSED`** +Behat is using a `base_url` without the `:8380` port. Check that the run passes +`--config behat.docker.yml`; a plain `behat` picks up `behat.yml` and its +port-80 URL. Confirm the port is reachable from the browser's namespace with +`docker compose exec selenium curl -sI http://localhost:8380/`. + +**Behat can't reach the site after recreating `web`** +`selenium` shares the web container's network namespace, so recreating `web` +detaches it. Bring it back with +`docker compose --profile behat up -d --force-recreate selenium`. + +**`Access denied for user 'chamilo'@'%' to database 'chamilo_test_test'`** +The DB volume predates the current `docker/db/init/01-databases.sql`. Init +scripts only run on a fresh volume; replay it by hand with +`docker compose exec -T db mariadb -uroot -proot < docker/db/init/01-databases.sql`. + +## Installing the portal + +Chamilo 2.0 has **no CLI installer** — the wizard at `/main/install/index.php` +is the only supported path. `setup.sh` drives it with a Docker-specific variant +of the project's own install feature (`docker/behat/installDocker.feature`), +copied into `tests/behat/features/` for the run and removed afterwards so a +full-suite `behat` run cannot reinstall the portal mid-suite. + +To install by hand instead, browse to and use: + +| Field | Value | +|----------|-----------| +| Host | `db` | +| Port | `3306` | +| Database | `chamilo` | +| User | `chamilo` | +| Password | `chamilo` | diff --git a/docker/behat/installDocker.feature b/docker/behat/installDocker.feature new file mode 100644 index 00000000000..811cb0a6ea4 --- /dev/null +++ b/docker/behat/installDocker.feature @@ -0,0 +1,48 @@ +# Docker variant of tests/behat/features/actionInstall.feature. +# +# The upstream feature relies on the wizard's defaults (localhost / root / no +# password) because CI runs MySQL on the same host. In the stack the server is +# the `db` service with its own credentials, so step 4 is filled in explicitly. +# +# setup.sh copies this into tests/behat/features/ to run it, then removes it — +# it must not stay there, or a full-suite `behat` run would reinstall the portal +# in the middle of the suite. +@administration +Feature: Install portal (Docker) + + Scenario: Installation process + Given I am on "/main/install/index.php" + And I wait for the page to be loaded when ready + Then I should see "Step 1 - Installation Language" + Then I press "Next" + Then I should see "Step 2 - Requirements" + Then I press "New installation" + Then I wait for the page to be loaded + Then I should see "Step 3 - License" + Then I check "accept_licence" + Then I press "license-next" + Then I should see "Step 4 - Database settings" + Then wait for the page to be loaded + Then I fill in the following: + | dbHostForm | db | + | dbPortForm | 3306 | + | dbUsernameForm | chamilo | + | dbPassForm | chamilo | + | dbNameForm | chamilo | + And I press "Check database connection" + And wait for the page to be loaded + And I press "step4" + Then I should see "Step 5 - Configuration settings" + Then I fill in the following: + | passForm | admin | + | emailForm | admin@example.com | + | mailerDsn | smtp://mailpit:1025 | + | mailerFromEmail | noreply@example.com | + | mailerFromName | Chamilo Docker | + Then I press "step5" + Then I should see "Step 6 - Last check before install" + When I wait for the page to be loaded + And I press "button_step6" + And I wait one minute for the page to be loaded + Then I should see "Step 7" + And I should see "Go to your newly created portal" diff --git a/docker/db/init/01-databases.sql b/docker/db/init/01-databases.sql new file mode 100644 index 00000000000..4360f33f5d7 --- /dev/null +++ b/docker/db/init/01-databases.sql @@ -0,0 +1,23 @@ +-- Chamilo needs two schemas: the live install (`chamilo`, created by the image +-- from MARIADB_DATABASE) and the PHPUnit database. +-- +-- Note the doubled suffix. config/packages/doctrine.yaml has +-- when@test: dbal: dbname_suffix: '_test%env(default::TEST_TOKEN)%' +-- so DATABASE_NAME=chamilo_test in .env.test.local resolves to the physical +-- database `chamilo_test_test`. Keeping DATABASE_NAME at `chamilo_test` rather +-- than `chamilo` is deliberate: if the suffix ever stopped being applied, the +-- suite would fall back to an empty scratch database instead of dropping the +-- live one. +CREATE DATABASE IF NOT EXISTS `chamilo` + CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE DATABASE IF NOT EXISTS `chamilo_test` + CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE DATABASE IF NOT EXISTS `chamilo_test_test` + CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +GRANT ALL PRIVILEGES ON `chamilo`.* TO 'chamilo'@'%'; +-- Wildcard covers chamilo_test, chamilo_test_test and the per-worker +-- chamilo_test_test databases ParaTest would create. +-- Backslash escapes the underscore, which is a LIKE wildcard in grants. +GRANT ALL PRIVILEGES ON `chamilo\_%`.* TO 'chamilo'@'%'; +FLUSH PRIVILEGES; diff --git a/docker/env/env.local.dist b/docker/env/env.local.dist new file mode 100644 index 00000000000..3509a95f4de --- /dev/null +++ b/docker/env/env.local.dist @@ -0,0 +1,32 @@ +# Template for .env.local — docker/setup.sh copies this into the project root on +# first run if .env.local does not exist yet. +# +# The real .env.local is gitignored because it is per-machine local config, but +# the stack does not work without it, so the template is committed. Edit the copy +# in the project root, not this file. +# +# Symfony loads: .env -> .env.local -> .env.$APP_ENV -> .env.$APP_ENV.local, and +# skips .env.local entirely when APP_ENV=test. So this file controls dev/prod +# only, and cannot leak into the PHPUnit run. + +APP_ENV='dev' +APP_DEBUG='1' + +# `db` is the compose service name, not localhost — the database runs in its own +# container. This overrides whatever the install wizard wrote into .env. +DATABASE_HOST='db' +DATABASE_PORT='3306' +DATABASE_NAME='chamilo' +DATABASE_USER='chamilo' +DATABASE_PASSWORD='chamilo' + +LOCK_DSN='redis://redis:6379' + +# Serve the API docs at /api +APP_ENABLE_API_ENTRYPOINT=true + +CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$' + +JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem +JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem +JWT_PASSPHRASE=your_secret_passphrase diff --git a/docker/env/env.test.local.dist b/docker/env/env.test.local.dist new file mode 100644 index 00000000000..fd7b98ff5b2 --- /dev/null +++ b/docker/env/env.test.local.dist @@ -0,0 +1,19 @@ +# Template for .env.test.local — docker/setup.sh copies this into the project +# root on first run if .env.test.local does not exist yet. +# +# The real file is gitignored (per-machine local config) but PHPUnit does not +# work without it: the committed .env.test points DATABASE_HOST at 127.0.0.1 with +# root/root, which is right for CI's local MySQL and wrong inside the stack, +# where the server is the `db` service. +# +# The physical database is `chamilo_test_test`, not `chamilo_test`: +# config/packages/doctrine.yaml appends dbname_suffix '_test' under when@test. +# See docker/db/init/01-databases.sql for why the name is left this way. + +DATABASE_HOST='db' +DATABASE_PORT='3306' +DATABASE_NAME='chamilo_test' +DATABASE_USER='chamilo' +DATABASE_PASSWORD='chamilo' + +LOCK_DSN='flock' diff --git a/docker/lib.sh b/docker/lib.sh new file mode 100644 index 00000000000..00aafc9ee92 --- /dev/null +++ b/docker/lib.sh @@ -0,0 +1,65 @@ +# Shared settings for docker/setup.sh and docker/test.sh. Source, don't run. + +# Must match the published host port in docker-compose.yml AND the HTTP_PORT +# build arg in docker/php/Dockerfile — all three are the same number by design. +CHAMILO_PORT=8380 +BASE_URL="http://localhost:${CHAMILO_PORT}" + +MAILPIT_URL='http://localhost:8325' +SELENIUM_URL='http://localhost:4444' +SELENIUM_VNC_URL='http://localhost:7900' + +# tests/behat/behat.yml hardcodes base_url http://localhost (port 80) for CI, +# where Apache serves on 80. Here it serves on CHAMILO_PORT, so Behat needs a +# different base_url or Chrome gets ERR_CONNECTION_REFUSED. +# +# This is a separate config file rather than a BEHAT_PARAMS override because +# Behat 3.29 merges the file config AFTER the env-var config and later wins — +# so behat.yml's base_url silently beat BEHAT_PARAMS. See the header comment in +# tests/behat/behat.docker.yml. +BEHAT_CONFIG='behat.docker.yml' + +DC=(docker compose) +EXEC=("${DC[@]}" exec -T web) + +bold() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; } +info() { printf ' %s\n' "$*"; } +die() { printf '\n\033[1;31mERROR:\033[0m %s\n' "$*" >&2; exit 1; } + +# Hard ceiling per Behat invocation. Without it, a grid that dies mid-feature +# leaves Behat blocked on a dead session forever (observed: the node was purged +# with "DOWN for too long" and the run never returned). `timeout` comes from +# coreutils in the container, so this works regardless of the host OS. +# +# 40 minutes is generous on purpose: a scenario costs roughly 50s through a real +# browser, and course.feature alone has 30 of them (~25 min). A tighter cap +# silently truncates the feature — it gets killed mid-scenario and prints no +# summary at all, which reads like a crash. Override with BEHAT_TIMEOUT=. +BEHAT_TIMEOUT="${BEHAT_TIMEOUT:-2400}" + +# Run behat inside the web container against the Docker profile. +behat_run() { + "${DC[@]}" exec -T -w /var/www/chamilo/tests/behat web \ + timeout --preserve-status --kill-after=30s "${BEHAT_TIMEOUT}" \ + ../../vendor/behat/behat/bin/behat --config "${BEHAT_CONFIG}" "$@" +} + +wait_for_selenium() { + for i in $(seq 1 90); do + if curl -fsS "${SELENIUM_URL}/status" 2>/dev/null | grep -q '"ready": *true'; then + return 0 + fi + sleep 2 + done + die "Selenium not ready — try: docker compose logs selenium" +} + +# The standalone grid degrades over a long run: its node misses heartbeats, gets +# marked DOWN and is eventually purged, after which every session request fails. +# Recycling the container between features is far cheaper than debugging a +# half-dead grid, and matches CI, which gets a fresh Selenium per job. +reset_selenium() { + "${DC[@]}" --profile behat up -d selenium >/dev/null 2>&1 + "${DC[@]}" restart selenium >/dev/null 2>&1 + wait_for_selenium +} diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile new file mode 100644 index 00000000000..7c3dabf2ed5 --- /dev/null +++ b/docker/php/Dockerfile @@ -0,0 +1,107 @@ +# Chamilo LMS 2.0 — PHP 8.2 + Apache +# Matches the extension/ini set used by .github/workflows/{phpunit,behat}.yml +FROM php:8.2-apache-bookworm + +ARG NODE_MAJOR=22 +# Apache's in-container port. It MUST equal the published host port — see the +# "Design notes" section of docker/README.md. Change it here and in the +# `ports:` list of docker-compose.yml, nowhere else. +ARG HTTP_PORT=8380 +ENV APP_DIR=/var/www/chamilo \ + COMPOSER_ALLOW_SUPERUSER=1 \ + COMPOSER_MEMORY_LIMIT=-1 + +# --------------------------------------------------------------------------- +# System packages: build deps for the PHP extensions + runtime tools Chamilo +# shells out to (ffmpeg for php-ffmpeg, graphviz for graphp/graphviz). +# --------------------------------------------------------------------------- +RUN set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends \ + ca-certificates curl git gnupg unzip \ + default-mysql-client \ + ffmpeg graphviz \ + libcurl4-openssl-dev \ + libfreetype6-dev \ + libicu-dev \ + libjpeg62-turbo-dev \ + libldap2-dev \ + libpng-dev \ + libwebp-dev \ + libxml2-dev \ + libzip-dev \ + zlib1g-dev; \ + rm -rf /var/lib/apt/lists/* + +# --------------------------------------------------------------------------- +# PHP extensions. +# Already bundled in php:8.2 — ctype curl dom fileinfo iconv json libxml +# mbstring pdo simplexml xml xmlreader zlib. The rest are compiled here. +# --------------------------------------------------------------------------- +RUN set -eux; \ + docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp; \ + docker-php-ext-configure ldap --with-libdir="lib/$(dpkg-architecture -q DEB_HOST_MULTIARCH)"; \ + docker-php-ext-install -j"$(nproc)" \ + bcmath \ + calendar \ + exif \ + gd \ + intl \ + ldap \ + mysqli \ + opcache \ + pdo_mysql \ + soap \ + sockets \ + zip; \ + pecl install apcu redis pcov; \ + docker-php-ext-enable apcu redis; \ + rm -rf /tmp/pear + +# pcov is the coverage driver CI uses (`--coverage-clover clover.xml`). Left +# disabled by default because merely loading it slows every CLI run; enable it +# per-command: +# docker compose exec web php -d extension=pcov.so -d pcov.enabled=1 \ +# bin/phpunit --coverage-text +RUN echo '; pcov is installed but off by default — see docker/php/Dockerfile' \ + > /usr/local/etc/php/conf.d/pcov.ini.disabled + +# --------------------------------------------------------------------------- +# Composer + Node/Yarn (webpack encore builds the Vue frontend) +# --------------------------------------------------------------------------- +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +RUN set -eux; \ + mkdir -p /etc/apt/keyrings; \ + curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ + | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg; \ + echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_MAJOR}.x nodistro main" \ + > /etc/apt/sources.list.d/nodesource.list; \ + apt-get update; \ + apt-get install -y --no-install-recommends nodejs; \ + rm -rf /var/lib/apt/lists/*; \ + corepack enable; \ + node --version; npm --version + +# --------------------------------------------------------------------------- +# Apache: listen on HTTP_PORT (see docker/README.md — the in-container port must +# equal the published host port so root_web is identical for the host browser, +# for Behat, and for the Chrome container). +# --------------------------------------------------------------------------- +COPY docker/php/vhost.conf /etc/apache2/sites-available/000-default.conf +COPY docker/php/php.ini /usr/local/etc/php/conf.d/zz-chamilo.ini +COPY docker/php/entrypoint.sh /usr/local/bin/entrypoint + +RUN set -eux; \ + chmod +x /usr/local/bin/entrypoint; \ + a2enmod rewrite headers expires actions; \ + sed -i "s/^Listen 80$/Listen ${HTTP_PORT}/" /etc/apache2/ports.conf; \ + sed -i "s/__HTTP_PORT__/${HTTP_PORT}/g" /etc/apache2/sites-available/000-default.conf; \ + grep -q "^Listen ${HTTP_PORT}$" /etc/apache2/ports.conf; \ + grep -q "VirtualHost \*:${HTTP_PORT}" /etc/apache2/sites-available/000-default.conf + +WORKDIR ${APP_DIR} +EXPOSE ${HTTP_PORT} + +ENTRYPOINT ["/usr/local/bin/entrypoint"] +CMD ["apache2-foreground"] diff --git a/docker/php/entrypoint.sh b/docker/php/entrypoint.sh new file mode 100755 index 00000000000..137f0c8d210 --- /dev/null +++ b/docker/php/entrypoint.sh @@ -0,0 +1,73 @@ +#!/bin/bash +# Chamilo container entrypoint: make the bind-mounted tree writable, wait for +# MariaDB, then hand off to whatever CMD was given (apache2-foreground by default). +set -euo pipefail + +APP_DIR="${APP_DIR:-/var/www/chamilo}" +cd "$APP_DIR" + +log() { printf '\033[36m[entrypoint]\033[0m %s\n' "$*"; } + +# --------------------------------------------------------------------------- +# Writable paths. install.lib.php checks var/ and config/ with is_writable(), +# and the wizard writes .env into the project root. +# Bind mounts from macOS do not carry usable uids, so we go wide like CI does. +# --------------------------------------------------------------------------- +mkdir -p \ + var/cache var/log var/upload var/courses var/themes var/translations \ + config/jwt + +chmod -R 0777 var config 2>/dev/null || log "warn: could not chmod parts of var/ config/" + +# Do NOT create an empty .env here. Symfony's Dotenv falls back to .env.dist +# when .env is absent, and that fallback is what supplies TRUSTED_PROXIES, +# APP_ENCRYPT_METHOD, SOFTWARE_NAME and friends before the wizard has run. +# An empty .env shadows the fallback and every console command dies with +# "Environment variable not found". The wizard creates the real .env itself. +[[ -f .env ]] && chmod 0666 .env 2>/dev/null || true + +# --------------------------------------------------------------------------- +# JWT keypair for lexik/jwt-authentication-bundle (API Platform auth). +# config/jwt/*.pem is gitignored, so it will not exist on a fresh clone. +# --------------------------------------------------------------------------- +JWT_PASS="${JWT_PASSPHRASE:-your_secret_passphrase}" +if [[ ! -f config/jwt/private.pem ]]; then + log "generating JWT keypair" + openssl genpkey -out config/jwt/private.pem \ + -aes-256-cbc -pass "pass:${JWT_PASS}" \ + -algorithm rsa -pkeyopt rsa_keygen_bits:4096 2>/dev/null + openssl pkey -in config/jwt/private.pem -passin "pass:${JWT_PASS}" \ + -out config/jwt/public.pem -pubout 2>/dev/null + chmod 0644 config/jwt/*.pem +fi + +# --------------------------------------------------------------------------- +# Wait for MariaDB. Chamilo's installer fails opaquely if the DB is not up. +# +# These use WAIT_DB_* rather than DATABASE_* on purpose: real environment +# variables take precedence over .env files in Symfony's Dotenv, so exporting +# DATABASE_NAME here would override .env.test's chamilo_test and point the +# PHPUnit suite at the live database. +# --------------------------------------------------------------------------- +if [[ -n "${WAIT_DB_HOST:-}" ]]; then + log "waiting for database at ${WAIT_DB_HOST}:${WAIT_DB_PORT:-3306}" + for i in $(seq 1 60); do + if mysqladmin ping \ + --host="${WAIT_DB_HOST}" \ + --port="${WAIT_DB_PORT:-3306}" \ + --user="${WAIT_DB_USER:-root}" \ + --password="${WAIT_DB_PASSWORD:-}" \ + --silent >/dev/null 2>&1; then + log "database is up" + break + fi + if [[ "$i" == 60 ]]; then + log "ERROR: database did not become ready in 60s" + exit 1 + fi + sleep 1 + done +fi + +log "ready — $*" +exec "$@" diff --git a/docker/php/php.ini b/docker/php/php.ini new file mode 100644 index 00000000000..d0eba1f5f63 --- /dev/null +++ b/docker/php/php.ini @@ -0,0 +1,25 @@ +; Chamilo LMS 2.0 — matches ini-values from .github/workflows/behat.yml +memory_limit = 2048M +max_execution_time = 600 +post_max_size = 256M +upload_max_filesize = 256M +max_file_uploads = 100 +date.timezone = Europe/Paris + +session.cookie_httponly = 1 +session.gc_maxlifetime = 14400 + +display_errors = Off +log_errors = On +error_log = /dev/stderr + +opcache.enable = 1 +opcache.enable_cli = 0 +opcache.memory_consumption = 256 +opcache.max_accelerated_files = 20000 +; Symfony/Chamilo dev: pick up edited PHP files without a container restart +opcache.validate_timestamps = 1 +opcache.revalidate_freq = 0 + +realpath_cache_size = 4096K +realpath_cache_ttl = 600 diff --git a/docker/php/vhost.conf b/docker/php/vhost.conf new file mode 100644 index 00000000000..bdcfca881b9 --- /dev/null +++ b/docker/php/vhost.conf @@ -0,0 +1,28 @@ +# __HTTP_PORT__ is substituted at build time from the HTTP_PORT build arg +# (docker/php/Dockerfile) so the listen port is defined in exactly one place. + + ServerName localhost + DocumentRoot /var/www/chamilo/public/ + + RewriteEngine On + + + AllowOverride All + Require all granted + Options -MultiViews + DirectoryIndex index.php + + + # Mirrors public/main/install/apache.dist.conf + + Require all denied + + + Require all denied + + + ErrorLog /dev/stderr + CustomLog /dev/stdout combined + # Apache would otherwise buffer these behind the container log driver + LogLevel warn + diff --git a/docker/set-port.sh b/docker/set-port.sh new file mode 100755 index 00000000000..7766d2b782a --- /dev/null +++ b/docker/set-port.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# Change the port Chamilo is served on. +# +# docker/set-port.sh 9000 +# docker/set-port.sh # just show the current port +# +# The port lives in four files and they must all agree, because Chamilo stores +# its own web address in the database: the port Apache listens on *inside* the +# container has to be the same one you type in the browser, or every redirect and +# asset URL points somewhere that isn't listening. +# +# After changing it, apply the change with: +# docker compose down && docker/setup.sh --fresh +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +# file : sed match : sed replacement — %PORT% is substituted with the new port. +# Anchored to the specific line in each file so nothing else can be caught. +TARGETS=( + "docker/php/Dockerfile|^ARG HTTP_PORT=[0-9]\{1,\}$|ARG HTTP_PORT=%PORT%" + "docker-compose.yml|^ - \"[0-9]\{1,\}:[0-9]\{1,\}\" # Chamilo| - \"%PORT%:%PORT%\" # Chamilo" + "docker/lib.sh|^CHAMILO_PORT=[0-9]\{1,\}$|CHAMILO_PORT=%PORT%" + "tests/behat/behat.docker.yml|^ base_url: http://localhost:[0-9]\{1,\}$| base_url: http://localhost:%PORT%" +) + +current() { sed -n 's/^CHAMILO_PORT=\([0-9]\{1,\}\)$/\1/p' docker/lib.sh; } + +if [[ $# -eq 0 ]]; then + echo "Chamilo is currently served on port $(current)." + echo "To change it: docker/set-port.sh " + exit 0 +fi + +NEW="$1" +[[ "$NEW" =~ ^[0-9]+$ ]] && (( NEW >= 1024 && NEW <= 65535 )) \ + || { echo "ERROR: '$NEW' is not a port between 1024 and 65535." >&2; exit 2; } + +OLD="$(current)" +if [[ "$NEW" == "$OLD" ]]; then + echo "Already on port $NEW — nothing to do." + exit 0 +fi + +# Refuse a port something else already holds, rather than failing later at +# `docker compose up` with containers half-created. +# +# Published ports are the numbers immediately before "->" in docker's Ports +# column ("127.0.0.1:8080->8080/tcp"). Matching on ":${NEW}->" instead looks +# right but silently fails when the preceding character is a digit, as in an +# IP-qualified binding — so pull the host port out explicitly. +# Our own containers are skipped: they are about to be recreated anyway. +# +# Reads via process substitution rather than a pipe into `while`: piping into a +# loop and then trimming with `head -1` makes the loop die of SIGPIPE, which +# under `set -e -o pipefail` aborts the whole script before any error can be +# printed — an exit code with no explanation. +port_owner() { + local want="$1" name ports p found + while IFS=$'\t' read -r name ports; do + case "$name" in chamilo-*) continue ;; esac + found="$(printf '%s' "$ports" | grep -oE '[0-9]+->' | tr -d '>-' || true)" + for p in $found; do + if [[ "$p" == "$want" ]]; then + printf '%s\n' "$name" + return 0 + fi + done + done < <(docker ps --format '{{.Names}}\t{{.Ports}}' 2>/dev/null || true) + return 0 +} + +holder="$(port_owner "$NEW")" +if [[ -n "$holder" ]]; then + echo "ERROR: port ${NEW} is already used by container '${holder}'." >&2 + echo " Pick a different port, or stop that container first." >&2 + exit 1 +fi + +# sed -i is not portable (BSD vs GNU take different arguments), so write to a +# temp file and move it into place instead. +for entry in "${TARGETS[@]}"; do + IFS='|' read -r file match repl <<<"$entry" + repl="${repl//%PORT%/$NEW}" + + grep -q "$match" "$file" \ + || { echo "ERROR: could not find the port line in ${file}." >&2 + echo " It may have been edited by hand — fix it there manually." >&2 + exit 1; } + + tmp="$(mktemp)" + sed "s|${match}|${repl}|" "$file" >"$tmp" + cat "$tmp" >"$file" # preserves the original file's permissions + rm -f "$tmp" + echo " updated ${file}" +done + +cat < ${NEW} + +Apply it with: + docker compose down + docker/setup.sh --fresh + +Chamilo will then be at http://localhost:${NEW} +(--fresh is required: the old port is recorded in the database.) +EOF diff --git a/docker/setup.sh b/docker/setup.sh new file mode 100755 index 00000000000..01d968d1cd1 --- /dev/null +++ b/docker/setup.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +# Bring up the Chamilo stack and install the portal. +# +# docker/setup.sh full setup (idempotent-ish; skips install if done) +# docker/setup.sh --fresh wipe the database + .env and reinstall from zero +# docker/setup.sh --assets also yarn install + encore production +# +# Mirrors the sequence in .github/workflows/{behat,phpunit}.yml. +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +FRESH=0 +ASSETS=0 +for arg in "$@"; do + case "$arg" in + --fresh) FRESH=1 ;; + --assets) ASSETS=1 ;; + -h|--help) sed -n '2,9p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "unknown option: $arg" >&2; exit 2 ;; + esac +done + +. docker/lib.sh + +docker info >/dev/null 2>&1 || die "Docker is not running. Start Docker Desktop and retry." + +# --------------------------------------------------------------------------- +# .env.local and .env.test.local are gitignored (they are per-machine config) but +# the stack cannot work without them: the committed .env.test points PHPUnit at +# 127.0.0.1 with root/root, which is correct for CI and wrong here. So a fresh +# clone has to have them created, or the test suite fails with connection errors +# and the app boots in prod mode. Templates live in docker/env/. +# Existing files are never overwritten — local edits are respected. +for pair in ".env.local:docker/env/env.local.dist" \ + ".env.test.local:docker/env/env.test.local.dist"; do + target="${pair%%:*}" + template="${pair#*:}" + if [[ ! -f "$target" ]]; then + cp "$template" "$target" + info "created ${target} from ${template}" + fi +done + +# --------------------------------------------------------------------------- +bold "Starting containers" +"${DC[@]}" up -d --build web db redis mailpit +"${DC[@]}" --profile behat up -d selenium + +# --------------------------------------------------------------------------- +bold "Waiting for Apache on ${BASE_URL}" +for i in $(seq 1 60); do + if curl -fsS -o /dev/null -w '%{http_code}' "${BASE_URL}/main/install/index.php" \ + | grep -qE '^(200|30[0-9])$'; then + info "Apache is serving" + break + fi + [[ "$i" == 60 ]] && die "Apache did not respond in 60s. Try: docker compose logs web" + sleep 1 +done + +bold "Waiting for Selenium grid" +wait_for_selenium +info "Selenium grid is ready" + +# --------------------------------------------------------------------------- +if [[ "$FRESH" == 1 ]]; then + bold "Wiping database and .env (--fresh)" + "${DC[@]}" exec -T db mariadb -uroot -proot -e \ + "DROP DATABASE IF EXISTS chamilo; + CREATE DATABASE chamilo CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + GRANT ALL PRIVILEGES ON chamilo.* TO 'chamilo'@'%'; + FLUSH PRIVILEGES;" + # Removed, not emptied: Dotenv falls back to .env.dist only when .env is + # absent, and an empty .env would shadow every default in it. + rm -f .env + "${EXEC[@]}" rm -rf var/cache/dev var/cache/prod var/cache/test + info "database chamilo recreated, .env removed" +fi + +# --------------------------------------------------------------------------- +# Vendor deps. Reused from the host bind mount when already present — vendor/ is +# pure PHP so it is platform-independent. +if [[ ! -f vendor/autoload_runtime.php ]]; then + bold "Installing PHP dependencies" + "${EXEC[@]}" composer install --no-progress --no-interaction +else + info "vendor/ already present — skipping composer install" +fi + +bold "Installing bundle assets" +"${EXEC[@]}" php bin/console assets:install public --no-interaction + +# public/build/ is gitignored, so a fresh clone has no compiled frontend and the +# portal would render without CSS/JS. Build it automatically in that case rather +# than making a first-time user discover the --assets flag from an error message. +if [[ "$ASSETS" == 1 || ! -f public/build/entrypoints.json ]]; then + if [[ "$ASSETS" != 1 ]]; then + info "public/build/ is missing — building the frontend (normal on a fresh clone)" + fi + bold "Building frontend (yarn + encore) — this takes a while" + # node_modules is a named volume, so this builds Linux binaries that will not + # fight a host-side yarn install done for macOS/Windows. + "${EXEC[@]}" yarn install + "${EXEC[@]}" yarn run encore production +else + info "public/build/ already compiled — skipping encore (use --assets to rebuild)" +fi + +# --------------------------------------------------------------------------- +# Install the portal. There is no CLI installer in Chamilo 2.0; the wizard at +# /main/install/index.php is the only supported path, so we drive it with the +# project's own Behat feature. +if grep -q "APP_INSTALLED='1'" .env 2>/dev/null; then + info "Portal already installed (.env has APP_INSTALLED='1'); use --fresh to reinstall" +else + bold "Installing the portal via the install wizard" + cp docker/behat/installDocker.feature tests/behat/features/installDocker.feature + trap 'rm -f tests/behat/features/installDocker.feature' EXIT + if ! behat_run features/installDocker.feature -vv; then + rm -f tests/behat/features/installDocker.feature + die "Install wizard failed. Inspect: docker compose logs web" + fi + rm -f tests/behat/features/installDocker.feature + trap - EXIT + info "portal installed" +fi + +# --------------------------------------------------------------------------- +# The wizard records access_url.url without the port — it comes out as +# "http://localhost/" even though Apache is on ${CHAMILO_PORT}. Chamilo builds +# absolute URLs (assets, mail links, LP and document paths) from this row, so a +# missing port leaves the portal generating links to port 80. Always correct it. +bold "Setting access_url to ${BASE_URL}/" +"${DC[@]}" exec -T db mariadb -uchamilo -pchamilo chamilo \ + -e "UPDATE access_url SET url='${BASE_URL}/' WHERE id=1;" +"${DC[@]}" exec -T db mariadb -uchamilo -pchamilo chamilo \ + -e "SELECT id, url FROM access_url;" + +bold "Clearing cache" +"${EXEC[@]}" php bin/console cache:clear --no-interaction +"${EXEC[@]}" chmod -R 0777 var + +# --------------------------------------------------------------------------- +bold "Preparing the PHPUnit database (chamilo_test)" +"${EXEC[@]}" php bin/console --env=test doctrine:database:create --if-not-exists +"${EXEC[@]}" php bin/console --env=test doctrine:schema:drop --force --full-database +"${EXEC[@]}" php bin/console --env=test doctrine:schema:create +"${EXEC[@]}" php bin/console --env=test doctrine:fixtures:load --no-interaction + +# --------------------------------------------------------------------------- +cat </dev/null | grep -qx web \ + || die "the web container is not running — start it with docker/setup.sh" + +reset_test_db() { + bold "Resetting chamilo_test" + "${EXEC[@]}" php bin/console --env=test doctrine:database:create --if-not-exists + "${EXEC[@]}" php bin/console --env=test doctrine:schema:drop --force --full-database + "${EXEC[@]}" php bin/console --env=test doctrine:schema:create + "${EXEC[@]}" php bin/console --env=test doctrine:fixtures:load --no-interaction +} + +# The CI list from .github/workflows/behat.yml. Features commented out there +# (companyReports, registration, toolExercise) are left out here too. +BEHAT_CI_FEATURES=( + actionUserLogin adminFillUsers adminSettings career class course + course_user_registration courseCategory createUser createUserViaCSV + extraFieldUser profile promotion sessionAccess sessionManagement skill + socialGroup systemAnnouncements ticket toolAgenda toolAnnouncement + toolAttendance toolDocument toolForum toolGroup toolLink toolLp + toolThematic toolWork +) + +cmd="${1:-}" +shift || true + +case "$cmd" in + phpunit) + if [[ "${1:-}" == "--no-reset" ]]; then + shift + else + reset_test_db + fi + bold "PHPUnit" + "${DC[@]}" exec -T web php bin/phpunit --testdox "$@" + ;; + + behat) + if [[ $# -gt 0 ]]; then + targets=("$@") + else + targets=() + for f in "${BEHAT_CI_FEATURES[@]}"; do targets+=("features/${f}.feature"); done + fi + + failed=() + for t in "${targets[@]}"; do + bold "Behat: ${t}" + # Fresh grid per feature — see reset_selenium() in docker/lib.sh. + reset_selenium + if ! behat_run "$t" -v; then + failed+=("$t") + fi + done + + if [[ ${#failed[@]} -gt 0 ]]; then + printf '\n\033[1;31mFailed features (%d):\033[0m\n' "${#failed[@]}" + printf ' %s\n' "${failed[@]}" + exit 1 + fi + bold "All Behat features passed" + ;; + + phpstan) + # phpstan.neon reads the compiled dev container + # (var/cache/dev/Chamilo_KernelDevDebugContainer.xml) for the Symfony + # extension, so it has to exist before the analysis runs. + bold "Warming the dev container for PHPStan" + "${EXEC[@]}" php bin/console cache:warmup --env=dev + bold "PHPStan" + "${DC[@]}" exec -T web vendor/bin/phpstan analyse --memory-limit=-1 "$@" + ;; + + psalm) + bold "Psalm" + "${DC[@]}" exec -T web vendor/bin/psalm --no-cache "$@" + ;; + + ecs) + bold "Easy Coding Standard (dry run)" + "${DC[@]}" exec -T web vendor/bin/ecs check --ansi "$@" + ;; + + all) + reset_test_db + bold "PHPUnit" + "${DC[@]}" exec -T web php bin/phpunit --testdox + "${EXEC[@]}" php bin/console cache:warmup --env=dev + bold "PHPStan" + "${DC[@]}" exec -T web vendor/bin/phpstan analyse --memory-limit=-1 || true + bold "Easy Coding Standard" + "${DC[@]}" exec -T web vendor/bin/ecs check --ansi || true + ;; + + ""|-h|--help) + sed -n '2,11p' "$0" | sed 's/^# \{0,1\}//' + ;; + + *) + die "unknown command: ${cmd} (try: docker/test.sh --help)" + ;; +esac diff --git a/tests/behat/behat.docker.yml b/tests/behat/behat.docker.yml new file mode 100644 index 00000000000..52b6ad07a6c --- /dev/null +++ b/tests/behat/behat.docker.yml @@ -0,0 +1,40 @@ +# Behat profile for the Docker stack (docker/test.sh, docker/setup.sh). +# Generated/maintained alongside docker/ — not part of upstream Chamilo. +# +# Why a whole second config instead of BEHAT_PARAMS: +# Behat 3.29's ConfigurationLoader::loadConfiguration() appends the file config +# AFTER the BEHAT_PARAMS config, and later entries win the merge — so behat.yml's +# `base_url: http://localhost` silently beat any env-var override. +# +# It lives next to behat.yml on purpose: %paths.base% resolves to this file's +# directory, so the suite path and FeatureContext autoloading keep working. +# +# The only change from behat.yml is base_url, which needs the port Apache +# actually listens on inside the container (see docker/README.md). wd_host is +# unchanged: the selenium container shares the web container's network +# namespace, so 127.0.0.1:4444 already reaches the grid. +default: + extensions: + Behat\MinkExtension: + base_url: http://localhost:8380 + default_session: selenium2 + javascript_session: selenium2 + selenium2: + wd_host: "http://127.0.0.1:4444" + browser: chrome + capabilities: + browserName: chrome + extra_capabilities: + "goog:chromeOptions": + args: + - "--headless=new" + - "--no-sandbox" + - "--disable-dev-shm-usage" + - "--disable-gpu" + - "--window-size=1920,1080" + files_path: "%paths.base%/../../" + suites: + default: + paths: ["%paths.base%/features"] + formatters: + pretty: true