Files
Schulcloud-MCP/local-instance/README.md
MechaCat02 0ae9198428 Run Etherpad by default: without it every topic page 500s
The legacy client requests an Etherpad session on every topic page whose
lesson has contents, without ever checking whether the lesson contains a
pad — controllers/topics.js collects `etherpadPads` and then ignores it.
With Etherpad unreachable the request fails, `validUntil` comes back
undefined, and `new Date(undefined * 1000)` makes Express reject the
session cookie: "option expires is invalid", rendered as a 500.

So Etherpad was only nominally optional. Moving it out of the `tools`
profile also matches the live deployment, which always runs it.

The topic pages that still 500 are courses the signed-in user is not a
member of; the same page returns 200 for its own teacher. That is the
legacy client rendering a 403 as a 500, upstream behaviour we don't own.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-13 14:34:12 +02:00

213 lines
9.0 KiB
Markdown

# A local Schulcloud
A Docker Compose stack that runs a real Schulcloud instance on this machine,
modelled on **schulcloud-thueringen.de** — the instance this MCP server reads.
It exists so that the parts of the product we can only observe from a student's
account can also be *produced*: log in as the teacher, grade a submission, then
look at it through `list_submissions` and see whether what we render matches
what the student is shown.
> Everything here is a throwaway development instance. The credentials are the
> upstream development defaults, published in the upstream repositories. Bind
> nothing beyond `127.0.0.1`.
## Quick start
```bash
cd local-instance
docker compose up -d # ~5 min on a cold cache
./scripts/seed.sh # loads the demo school; takes another minute or two
```
Then open <http://localhost:4400> and sign in as
`demo-schueler@schul-cloud.org` / `schulcloud` (Fritz Schmidt, a student with
graded submissions). `seed.sh` prints the full account list on completion.
> The demo password differs by account — an upstream quirk, not ours. The two
> `demo-*` accounts use `schulcloud`, the built-in `admin`/`lehrer` and the
> teacher `klara.fall` use `Schulcloud1!`, and the `*.qa` accounts use
> `Schulcloud1qa!`.
To add the remaining external tools (H5P, tldraw, Collabora):
```bash
docker compose --profile tools up -d
```
## Why the images are the interesting part
`quay.io/schulcloudverbund/*` are the **same images the live instance runs**,
built for the `thr` theme, and they are public. So this is not a rebuild of
`main` that may have drifted — it is the deployed artefact, pinned to the
version the live instance reports:
```console
$ curl -s https://schulcloud-thueringen.de/version
{"client": {"version": "33.40.0"}, "nuxt-client": {"version": "33.40.1"},
"server": {"version": "33.40.2"}, "dof_app_deploy": "33.40.2"}
```
Hence `SC_VERSION` defaults to `33.40`. To pin something else:
```bash
SC_VERSION=33.27 docker compose up -d
```
`CLAUDE.md` says *live behaviour beats upstream source* — the clones in
`vendor/` track `main` and may be ahead of what is deployed. Running the
deployed images is how that rule is honoured here rather than worked around.
## What is faithful, and what is not
Faithful, and load-bearing for what we test:
| | |
|---|---|
| **Images** | the live instance's own, `thr` theme, tag `33.40` |
| **Feature flags** | `env/api.env` is a replay of `GET /api/v3/config/public` from the live instance, not a hand-picked set |
| **Instance identity** | `env/shared.env` mirrors `dof_app_deploy/ansible/group_vars/thr/instance_cfg.yml` |
| **URL routing** | `proxy/nginx.conf` is generated from the deployment's own ingress table (see below) |
| **Session model** | Valkey in `single` mode, so the JWT whitelist expires sessions exactly as production does |
| **Seed data** | the upstream demo school, including graded and ungraded submissions |
Deliberately different:
- **No external OAuth / Schulportal login.** Excluded by request, and it is the
one part that cannot be stood up locally. Local login is username + password
against the seeded accounts.
- **No BigBlueButton.** `FEATURE_VIDEOCONFERENCE_ENABLED` is off; upstream it is
on. BBB is a separate product of its own scale.
- **`FEATURE_CONSENT_NECESSARY=false`**, or every seeded user hits a consent
wall before reaching any content.
- **No LDAP / TSP sync, no Nextcloud, no calendar service.**
- **Antivirus off by default** — see the `av` profile.
Every one of these is marked in the env files at the line it affects.
## Layout
```
docker-compose.yml the stack; profiles: (default) | tools | av | preview
env/ one file per service, all values local-only
proxy/nginx.conf GENERATED — the single origin, see scripts/gen-proxy-conf.py
scripts/seed.sh loads the demo school via the management app
scripts/minio-init.sh creates the S3 buckets each service expects
etherpad/APIKEY.txt fixed Etherpad API key, matching env/api.env
```
### Ports
| Port | |
|---|---|
| **4400** | **the instance** — everything a browser touches |
| 3030 | server API, direct |
| 3333 | management app (seeding only, not part of a running instance) |
| 3100 / 4000 | legacy client / SPA, direct |
| 4444 | file-storage |
| 9900 / 9901 | MinIO S3 API / console (`miniouser` / `miniouser`) |
| 9980 | Collabora (`tools` profile) |
| 27019 | MongoDB |
| 6381 | Valkey |
| 15673 | RabbitMQ management |
Everything binds to `127.0.0.1`. Port numbers are shifted off their upstream
defaults where those commonly collide (Mongo, Valkey, RabbitMQ, MinIO) — note
`docker-compose.override.yml` in the repo root already uses 8080 and 55432.
## The proxy is generated, not written
The live instance is one origin whose paths are split across the legacy client,
the SPA, and several APIs. That split is not cosmetic: `/rooms/courses-list` is
the SPA, `/courses/:id` is the legacy client, and `/api/v3/file/` is a
different service from `/api/v3/`. Get it wrong and you are testing a different
application from the one students use.
The rules live in `dof_app_deploy/ansible/group_vars/all/x_ingress.yml` (46 of
them) plus a per-path ingress inside each service repo. Transcribing that by
hand invites drift, so `scripts/gen-proxy-conf.py` reads the deployment repo and
emits `proxy/nginx.conf`:
```bash
python3 scripts/gen-proxy-conf.py > proxy/nginx.conf
docker compose restart proxy
```
It needs the upstream clones in `../vendor` (gitignored — see the root README),
which is why the output is committed.
Two details in there are worth keeping:
- Every `proxy_pass` goes through a **variable plus a `resolver`**, so names are
resolved per request. With a literal upstream, nginx refuses to start
whenever an optional profile is down — which is the normal case.
- **Etherpad gets the deployment's own rewrite rules**, not a plain
`proxy_pass`. It is mounted under a prefix it knows nothing about; without the
rewrites a pad loads and then silently never syncs.
## Seeding
`scripts/seed.sh` does what the real deployment's init job does: it asks the
management app to load `backup/setup/*.json`, which ships **inside the server
image**. It also registers MinIO in the `storageproviders` collection, which has
no seed data on purpose (it holds credentials) and without which legacy file
uploads fail.
Re-running it is safe; collections are replaced, not appended to.
### What the demo data already contains
The seed includes 50 tasks and 24 submissions covering the grading states that
are otherwise hard to obtain — including the two this server renders:
| | |
|---|---|
| `graded: true`, `grade: null`, `gradeComment` set | graded by feedback alone |
| `graded: true`, `grade: 100`, `gradeComment` set | a percentage **and** feedback |
| `graded: false`, `submitted: true` | handed in, not yet looked at |
The second row matters: no submission in the real account has ever had a
numeric grade, so `formatGradeState`'s percentage branch had never been seen
against real data. Here it can be.
## Profiles
| Profile | Services | Cost |
|---|---|---|
| *(default)* | Mongo, Valkey, RabbitMQ, MinIO, api, management, board-collaboration, admin-api, file-storage, client, nuxt, Etherpad, proxy | ~4.7 GB images |
| `tools` | H5P editor + static files + library install, tldraw server + worker, Collabora | ~3.3 GB more, Collabora is the bulk |
| `av` | ClamAV + Clammit | ~1.5 GB resident for the signature database |
| `preview` | file-preview generator (thumbnails) | small |
Etherpad is in the default profile even though it is an external tool. The
legacy client requests an Etherpad session on **every** topic page whose lesson
has contents — it never checks whether a pad is actually present. With Etherpad
down that call fails, `validUntil` comes back undefined, and Express rejects the
resulting session cookie with *"option expires is invalid"*: a 500 on every
topic page, pad or no pad. The live deployment always runs it, so this is the
faithful configuration as well as the working one.
`av` is opt-in for a reason beyond size: with `ENABLE_FILE_SECURITY_CHECK=true`
but no scanner reachable, every upload stays at
`securityCheck.status=pending` and can never be downloaded. Turn the flag in
`env/file-storage.env` on **together with** the profile, or neither.
## Troubleshooting
**Everything 502s.** The proxy is up before the apps are. `docker compose logs
-f api` — the server waits for Mongo to become primary.
**Mongo never becomes healthy.** It runs as a single-node replica set because
the migration runner opens transactions, which Mongo refuses on a standalone.
The healthcheck initiates the set on first boot; give it ~30 s.
**Login succeeds, then immediately bounces back.** The `jwt` cookie is being
dropped. Check `COOKIE__SECURE=false` in `env/client.env` — the proxy speaks
plain HTTP locally.
**A file uploads but will not download.** See the `av` note above.
**H5P element stays empty.** `docker compose --profile tools run --rm
h5p-libraries` and watch it finish; the editor has nothing to offer until the
content types are in the bucket.