mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
The entrypoint removed the old tree before it copied the new one. A copy that failed left the volume without a working Moodle, and every restart removed again instead of recovering. It now copies into a staging directory inside the volume and swaps the trees once the copy is complete, so a failed copy leaves the old tree in place. An image that ships an older release than the volume records no longer replaces it and exits with a message instead. Two instances on different releases would otherwise overwrite each other's tree. upgrade.php reports "No upgrade needed" on stderr, so the task counted every run on a current site as a change. It reads stderr now. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
201 lines
12 KiB
Markdown
201 lines
12 KiB
Markdown
# Moodle
|
|
|
|
## Description
|
|
|
|
Ignite the learning experience with [Moodle](https://moodle.org/), a powerful and versatile platform for online education that energizes classrooms and fosters interactive learning. Moodle delivers a comprehensive set of tools for creating, managing, and sharing educational content, supporting collaboration among educators and learners alike.
|
|
|
|
## Overview
|
|
|
|
This role deploys Moodle using Docker, automating the setup of both the Moodle application and its underlying MariaDB database. It integrates with an NGINX reverse proxy to ensure secure and efficient web access and uses persistent storage to safeguard your data and configuration.
|
|
|
|
## Cosmos
|
|
|
|
The diagram places Moodle in the Infinito.Nexus cosmos: the components it deploys (capabilities), the central services it consumes (dependencies), and its outward reach (federation and bridged external networks).
|
|
|
|
```mermaid
|
|
flowchart LR
|
|
subgraph deps [Dependencies]
|
|
dep_svc_ai_litellm["svc-ai-litellm 🐳🐝"]
|
|
dep_svc_bkp_volume_2_local["svc-bkp-volume-2-local 💻"]
|
|
dep_svc_db_mariadb["svc-db-mariadb 🐳🐝"]
|
|
dep_svc_db_openldap["svc-db-openldap 🐳🐝"]
|
|
dep_svc_net_tor["svc-net-tor 🐳🐝"]
|
|
dep_web_app_dashboard["web-app-dashboard 🐳🐝"]
|
|
dep_web_app_hermes["web-app-hermes 🐳🐝"]
|
|
dep_web_app_keycloak["web-app-keycloak 🐳🐝"]
|
|
dep_web_app_matomo["web-app-matomo 🐳🐝"]
|
|
dep_web_app_openclaw["web-app-openclaw 🐳🐝"]
|
|
dep_web_app_openwebui["web-app-openwebui 🐳🐝"]
|
|
dep_web_app_prometheus["web-app-prometheus 🐳🐝"]
|
|
dep_web_app_stalwart["web-app-stalwart 🐳🐝"]
|
|
dep_web_svc_css["web-svc-css 💻"]
|
|
dep_web_svc_logout["web-svc-logout 🐳🐝"]
|
|
end
|
|
subgraph role [web-app-moodle 🐳🐝]
|
|
svc_litellm["litellm"]
|
|
svc_sso["sso"]
|
|
svc_logout["logout"]
|
|
svc_dashboard["dashboard"]
|
|
svc_matomo["matomo"]
|
|
svc_email["email"]
|
|
svc_ldap["ldap"]
|
|
svc_mariadb["mariadb"]
|
|
svc_moodle["moodle"]
|
|
svc_nginx["nginx"]
|
|
svc_cron["cron"]
|
|
svc_css["css"]
|
|
svc_prometheus["prometheus"]
|
|
svc_tor["tor"]
|
|
svc_container_backup["container_backup"]
|
|
svc_openwebui["openwebui"]
|
|
svc_hermes["hermes"]
|
|
svc_openclaw["openclaw"]
|
|
svc_flowise["flowise ❌"]
|
|
end
|
|
dep_svc_ai_litellm -. "0..1" .-> svc_litellm
|
|
dep_svc_bkp_volume_2_local -. "0..1" .-> svc_container_backup
|
|
dep_svc_db_mariadb -. "0..1" .-> svc_mariadb
|
|
dep_svc_db_openldap -. "0..1" .-> svc_ldap
|
|
dep_svc_net_tor -. "0..1" .-> svc_tor
|
|
dep_web_app_dashboard -. "0..1" .-> svc_dashboard
|
|
dep_web_app_hermes -. "0..1" .-> svc_hermes
|
|
dep_web_app_keycloak -. "0..1" .-> svc_sso
|
|
dep_web_app_matomo -. "0..1" .-> svc_matomo
|
|
dep_web_app_openclaw -. "0..1" .-> svc_openclaw
|
|
dep_web_app_openwebui -. "0..1" .-> svc_openwebui
|
|
dep_web_app_prometheus -. "0..1" .-> svc_prometheus
|
|
dep_web_app_stalwart -. "0..1" .-> svc_email
|
|
dep_web_svc_css -. "0..1" .-> svc_css
|
|
dep_web_svc_logout -. "0..1" .-> svc_logout
|
|
```
|
|
|
|
Solid `1:1` edges are fixed relationships; dashed `0..1` edges are conditional (enabled only in matching deployments); red `0..0` edges are turned off in this role. Node markers show the role's deploy modes (💻 host, 🐳 compose, 🐝 swarm); ❌ marks a service that is explicitly turned off, and ⚙️ an Ansible role dependency declared in `meta/main.yml`.
|
|
|
|
## Features
|
|
|
|
- **Comprehensive e-Learning Platform:** Offers an extensive array of features including course management, assessment tools, and collaborative resources.
|
|
- **Customizable Interface:** Tailor the look and feel of your learning environment with numerous themes and plugins.
|
|
- **Scalable Deployment:** Leverage Docker for a portable and scalable installation that adapts as your user base grows.
|
|
- **Robust Data Management:** Secure and reliable storage of both the Moodle application and user data through Docker volumes.
|
|
- **Secure Web Access:** Configured to work seamlessly behind an NGINX reverse proxy for enhanced security and performance.
|
|
- **Single Sign-On (SSO) / OpenID Connect (OIDC):** Seamless integration with external identity providers for centralized authentication.
|
|
|
|
## Quick Setup
|
|
|
|
### Development
|
|
|
|
Clone, set up the workstation, and deploy Moodle onto the local stack:
|
|
|
|
```bash
|
|
git clone https://github.com/infinito-nexus/core.git
|
|
cd core
|
|
make onboard
|
|
make compose-deploy mode=reinstall apps=web-app-moodle full_cycle=false
|
|
```
|
|
|
|
### Production
|
|
|
|
Run the published image to provision the inventory and deploy Moodle to a managed server (the mounted volume persists the inventory):
|
|
|
|
```bash
|
|
APP=web-app-moodle
|
|
HOST=<your-server>
|
|
DOMAIN=<your-domain>
|
|
TLS_MODE=self_signed
|
|
SSH_PUBLIC_KEY="<your-ssh-public-key>"
|
|
|
|
docker run --rm -it \
|
|
-v "$PWD/inventories:/etc/infinito.nexus/inventories" \
|
|
-e APP="$APP" -e HOST="$HOST" -e DOMAIN="$DOMAIN" -e TLS_MODE="$TLS_MODE" -e SSH_PUBLIC_KEY="$SSH_PUBLIC_KEY" \
|
|
ghcr.io/infinito-nexus/core/debian bash -c '
|
|
INVENTORY=/etc/infinito.nexus/inventories/production
|
|
infinito administration inventory provision "$INVENTORY" \
|
|
--inventory-file "$INVENTORY/devices.yml" \
|
|
--host "$HOST" \
|
|
--include "$APP" \
|
|
--vars "{\"TLS_MODE\": \"$TLS_MODE\", \"DOMAIN_PRIMARY\": \"$DOMAIN\", \"users\": {\"administrator\": {\"authorized_keys\": [\"$SSH_PUBLIC_KEY\"]}}}" &&
|
|
infinito administration deploy dedicated "$INVENTORY/devices.yml" \
|
|
--password-file "$INVENTORY/.password" \
|
|
--diff -vv'
|
|
```
|
|
|
|
## MCP server
|
|
|
|
The role exposes a Model Context Protocol surface through the `webservice_mcp` protocol plugin, baked into the image at `public/webservice/mcp` and served by the NGINX sidecar.
|
|
|
|
| Property | Value |
|
|
| --- | --- |
|
|
| Endpoint | `/webservice/mcp/server.php` on the canonical domain |
|
|
| Container URL | `http://nginx:80/webservice/mcp/server.php` on the shared MCP overlay |
|
|
| Transport | Streamable HTTP (JSON-RPC 2.0 over `POST`) |
|
|
| Auth | `Authorization: Bearer <token>`, a Moodle web-service token |
|
|
| Subject | the `administrator` account |
|
|
| Default state | off; `mcp.enabled` turns true when `web-app-hermes`, `web-app-openclaw` or `web-app-openwebui` is in the inventory |
|
|
|
|
Provisioning runs in `tasks/utils/mcp.yml`: it copies the plugin into the code volume, runs `admin/cli/upgrade.php`, enables `enablewebservices`, appends `mcp` to `webserviceprotocols`, creates the `infinito_mcp` external service, attaches the read-only functions, mints the permanent token and stores it under `users.administrator.tokens['web-app-moodle']`.
|
|
|
|
Tool categories exposed by the external service:
|
|
|
|
- site metadata (`core_webservice_get_site_info`)
|
|
- course catalogue (`core_course_get_courses`, `core_enrol_get_users_courses`)
|
|
- user lookup (`core_user_get_users_by_field`)
|
|
- calendar events (`core_calendar_get_calendar_events`)
|
|
|
|
All of them are read-only; `mcp.tools.mutating_tools_enabled` is `false` and no write function is attached.
|
|
|
|
### Default state
|
|
|
|
Off. `mcp.enabled` is true only while `web-app-hermes`, `web-app-openclaw` or `web-app-openwebui` is part of the deployment.
|
|
|
|
### Authorization subject
|
|
|
|
`auth_subject: administrator`: the web-service token is issued against the
|
|
administrator account, so a call carries that account's rights regardless of who
|
|
asked the client. The attached functions are read-only, which bounds what those
|
|
rights can do here. Reaching the tool server is gated on the role's `mcp` RBAC
|
|
group.
|
|
|
|
### Canonical origin
|
|
|
|
Moodle compares every request against `$CFG->wwwroot`. A probe that reaches the endpoint over the internal HTTP origin is answered with a redirect to the canonical HTTPS URL rather than with JSON, so callers must send the canonical `Host` header and `X-Forwarded-Proto: https`.
|
|
|
|
### How to disable
|
|
|
|
Remove the MCP client roles, or pin `mcp.enabled: false` for this role. The web-service token is then not issued and the protocol plugin stays unconfigured.
|
|
|
|
## Outbound HTTP policy
|
|
|
|
Moodle ships a cURL blocklist that rejects every request to `10.0.0.0/8`, `172.16.0.0/12` and `192.168.0.0/16`, and only permits ports 80 and 443. Both settings gate `\core\files\curl_security_helper`, which `auth_oidc` consults for the Keycloak token exchange and `\core\http_client` consults for the AI provider request. The gateway answers on `192.168.80.0/24`, and on an onion node `container_extra_hosts` pins the Keycloak authority to the Docker bridge gateway, which `NETWORK_DOCKER_ADDRESS_POOLS` draws from `10.208.0.0/12`. With the upstream defaults the OIDC login obtains no token and the AI provider cannot reach the gateway.
|
|
|
|
`tasks/01_manager_ops.yml` therefore rewrites `curlsecurityblockedhosts` to `MOODLE_EGRESS_BLOCKED_HOSTS` and extends `curlsecurityallowedport` with the port of `MOODLE_LITELLM_CHAT_ENDPOINT` when the `litellm` service is enabled. Loopback, `0.0.0.0` and the cloud metadata address stay blocked; the RFC1918 ranges are opened. The Docker address pool and the bridge gateway that serves the pinned Keycloak authority both live inside them.
|
|
|
|
This is an accepted trade-off, not an oversight. Moodle offers no allowlist, so a single internal host cannot be permitted without unblocking the range it sits in. The cost is that any account holding a URL-fetching capability, such as `block/rss_client:myaddinstance` or a `repository/url` instance, can make the server issue requests to sibling containers. Grant those capabilities only to roles you trust with that reach, or keep the upstream blocklist and accept that SSO login and the AI surface do not work.
|
|
|
|
## Image source
|
|
|
|
This role builds its own Moodle image from upstream Moodle source on top of the official `php:8.3-fpm` base and installs the Composer dependencies that source declares. The NGINX sidecar serves the `public` directory of the code volume and hands every other path to Moodle's router.
|
|
|
|
## Updates
|
|
|
|
The image carries the Moodle release pinned as `services.moodle.app_version` in `meta/services.yml`. On start the entrypoint compares that release with the one recorded in the code volume. When the image is newer, it copies the image's tree next to the old one and swaps them once the copy is complete. An image that is older than the recorded release refuses to start. `tasks/01_manager_ops.yml` then runs `admin/cli/upgrade.php`, which migrates the database.
|
|
|
|
Plugins installed through the web interface live only in the code volume and are not carried over to the next release. Declare a plugin under `meta/addons/` so the image ships it.
|
|
|
|
## Further Resources
|
|
|
|
- [Moodle Official Website](https://moodle.org/)
|
|
- [Moodle Developer Documentation: Docker images](https://moodledev.io/general/app/development/setup/docker-images)
|
|
- [moodlehq/moodle-docker](https://github.com/moodlehq/moodle-docker) (extension list reference)
|
|
|
|
## Persona contract opt-outs
|
|
|
|
This role declares `PERSONA_ADMINISTRATOR_BLOCKED` in `templates/playwright.env.j2`. Moodle's site administrator is created by `install_database.php` in `tasks/01_manager_ops.yml` and realigned on every pass, both from the `administrator` user, so the native form and the identity provider carry one secret. In the SSO variant `tasks/04_oidc.yml` sets `alternateloginurl` so `/login/index.php` bounces to the OIDC plugin and rewrites every `mdl_user` row to `auth='oidc'`, so no form accepts either secret; the SSO-off variants in `meta/variants.yml` have no OIDC chain to substitute. One env file is rendered per role, not per variant, so the flag has to hold for the worst case.
|
|
|
|
Biber keeps a runnable journey: `files/playwright/test-ldap-variant.js` drives the native LDAP-bind login and `files/playwright/test-profile-readonly.js` asserts the locked profile fields. The path back to the administrator persona is a deploy step that hands the site administrator a credential the persona env already carries.
|
|
|
|
## Credits
|
|
|
|
Implemented by **[Kevin Veen-Birkenbach](https://www.veen.world)**.
|
|
Part of the [Infinito.Nexus Project](https://s.infinito.nexus/code) and maintained by [Kevin Veen-Birkenbach](https://www.veen.world).
|
|
Licensed under the [Infinito.Nexus Community License (Non-Commercial)](https://s.infinito.nexus/license).
|