docs/API.md gains the endpoint and what it returns, why `play` is the bigger and lesser of the two, the element-by-id route found while probing, and the per-library shapes with the traps in them — the string "true", the inline cloze markers, the correct-option-first convention, and the UI subtrees that drown an exercise in button labels. CLAUDE.md's "no quiz of its own" note said a contentId was the only handle onto the content. That was the reason nobody looked further, so it now says where to look instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
313 lines
14 KiB
Markdown
313 lines
14 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
|
|
(minio-loopback: see "Simulating a teacher")
|
|
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.
|
|
|
|
## Simulating a teacher
|
|
|
|
`scripts/simulate-teacher.mjs` creates, edits and deletes the things teachers
|
|
create, so the MCP server can be run against content the real account has never
|
|
held. It is the only thing in this repository that writes to a Schulcloud, and
|
|
it refuses to run against anything but a localhost address.
|
|
|
|
```bash
|
|
node scripts/simulate-teacher.mjs create # course, room, topic, task, board,
|
|
# columns, cards, rich text, link,
|
|
# Etherpad pad, folder, files
|
|
node scripts/simulate-teacher.mjs update # rename and rewrite all of it
|
|
node scripts/simulate-teacher.mjs delete # remove it again
|
|
node scripts/simulate-teacher.mjs files # only the file-manager part, onto an existing fixture
|
|
```
|
|
|
|
It also fills the **file manager** ("Dateien") — the legacy file system behind
|
|
Persönliche, Kurs-, Team- and Geteilte Dateien, a separate store from the board
|
|
files above — with a course folder tree ("Arbeitsblätter/Woche 1"), a team
|
|
folder, a folder in the demo student's own files, and a teacher's file shared
|
|
read-only with the student. The student is added to the adopted team so the
|
|
team files are visible from the account under test. Every file carries a unique
|
|
search term, so search over the index can be checked per area.
|
|
|
|
Legacy file uploads only work because of two changes to the stack:
|
|
|
|
- **`minio-loopback`.** The legacy service signs upload and download URLs for
|
|
its storage provider's single endpoint, and uses that endpoint for its own S3
|
|
calls. `minio:9000` works inside the compose network and nowhere else, so the
|
|
browser and the MCP server on the host were handed URLs they could not open.
|
|
`seed.sh` now registers `http://localhost:9900`, and this socat sidecar,
|
|
sharing the api container's network namespace, forwards that address to
|
|
MinIO — the same url then works from the api, the browser and the host.
|
|
After recreating `api`, recreate `minio-loopback` too: it lives in api's
|
|
network namespace.
|
|
- **The school bucket is created up front.** The legacy service makes
|
|
`bucket-<schoolId>` on first upload and then calls `PutBucketCors`, which
|
|
MinIO does not implement, so the first upload failed with *"A header you
|
|
provided implies functionality that is not implemented"*. `minio-init.sh`
|
|
creates the demo school's bucket, and an existing bucket skips both calls.
|
|
|
|
Ids are kept in `.simulate-teacher.json` between phases, so the MCP server can
|
|
be pointed at the instance in between:
|
|
|
|
```bash
|
|
eval "$(./scripts/mcp-env.sh)" # as the demo student
|
|
cd .. && npm run smoke # 82 checks against the local instance
|
|
```
|
|
|
|
`mcp-env.sh` points the index at its own database, `schulcloud_local`, and the
|
|
mirror at `tmp/mirror-local` — not just the instance at this one, and it switches
|
|
WebUntis off, since this instance has no timetable and the key in the root `.env`
|
|
is the real school's. The root
|
|
`.env` normally targets the live account, and process env beats `--env-file`, so
|
|
without that a local smoke run would crawl these fixtures into the live index,
|
|
where a per-course refresh then carries them forward indefinitely. Create the
|
|
database once:
|
|
|
|
```bash
|
|
docker exec schulcloud-mcp-db psql -U schulcloud -d postgres \
|
|
-c "CREATE DATABASE schulcloud_local OWNER schulcloud"
|
|
```
|
|
|
|
Without it the server does not fail; it runs live-only, and smoke reports the
|
|
smaller, index-free check count.
|
|
|
|
It also builds a **room** ("Raum"): rooms are a separate space from courses,
|
|
and the naming misleads — the sidebar's *Kurse* entry points at
|
|
`/rooms/courses-overview` while *Räume* points at `/rooms`. The fixture adds the
|
|
demo student to one room and leaves a second room without them, so "only the
|
|
rooms I belong to" is testable rather than assumed.
|
|
|
|
Two things it does **not** do, because the API does not allow them:
|
|
|
|
- **Teams cannot be created.** The legacy service registers
|
|
`['find','get','update','patch','remove']` and no `create`, and v3 has no team
|
|
route beyond news and create-room; `POST /teams` answers 405. The script
|
|
adopts a seeded team and edits that instead.
|
|
- **A board is created unpublished.** Students get 403 on it while the course
|
|
page still lists its title, so `create` publishes the main board and leaves a
|
|
second one as a draft on purpose — both states are worth testing against.
|
|
|
|
`seed.sh` also drags the demo data into the present. The seed ships courses that
|
|
ended in 2018 and homework due in 2017, and the v3 endpoints filter on those
|
|
dates: a student sees no tasks at all in an ended course, which makes the whole
|
|
student-facing surface look empty for reasons that have nothing to do with the
|
|
code under test.
|
|
|
|
## 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.
|
|
|
|
**Previews never appear, and `/api/v3/file/preview/...` answers 404
|
|
PREVIEW_NOT_POSSIBLE.** Two causes, both local. First, with no virus scanner
|
|
(see the `av` note) every upload stays `securityCheck.status=pending`, and a
|
|
record that has not been scanned reports `previewStatus: awaiting_scan_status`
|
|
— previews are gated on the scan. Second, the `file-preview` image ships an
|
|
ImageMagick policy written for an older ImageMagick than the 7.1.2 it actually
|
|
contains, so every coder it needs is denied and each attempt fails with
|
|
*"attempt to perform an operation not authorized by the security policy"* —
|
|
which the API surfaces as a 404. `file-preview/policy.xml` is mounted over the
|
|
image's own to fix the second; the first is inherent to running without `av`.
|
|
|
|
**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.
|