A Docker Compose stack that runs the deployed images (quay.io/schulcloudverbund/*, thr theme, tag 33.40 — the versions schulcloud-thueringen.de reports) rather than a rebuild of main, so what we develop against is the deployed artefact. It exists to produce the states we can otherwise only observe read-only: log in as the teacher, grade, then read it back the way the MCP server does. Faithful where it matters and honest where it isn't: - Feature flags in env/api.env are a replay of GET /api/v3/config/public from the live instance, not a hand-picked set; instance identity mirrors the thr group_vars from dof_app_deploy. - The proxy is generated from the deployment's own ingress table (scripts/gen-proxy-conf.py) so the legacy-client / SPA / API path split matches production; getting it wrong tests a different application. - Valkey runs in `single` mode so the JWT whitelist expires sessions the way production does, rather than the in-memory shortcut that hides it. - No external OAuth / Schulportal login (excluded by request and not reproducible locally), no BigBlueButton; each divergence is marked at the line it affects. Everything binds to 127.0.0.1 and uses the upstream dev credentials, which are public. Profiles keep the heavy pieces opt-in: `tools` adds Etherpad/H5P/tldraw/ Collabora, `av` adds ClamAV, `preview` adds thumbnailing. seed.sh loads the upstream demo school (the same call the deployment's init job makes) and registers MinIO as the legacy storage provider, which has no seed data on purpose. The demo data already contains the grading states that are hard to obtain from the real account — a feedback-only grade and a 100% one — which is what surfaced the past-due submitted-text scrape gap. One config finding baked in: file-storage and h5p validate a token's issuer/audience against JWT_DOMAIN (default "localhost"), while the API stamps SC_DOMAIN; without keeping them equal, the homework page's file lookups 401. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
205 lines
8.5 KiB
Markdown
205 lines
8.5 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 external tools (Etherpad, 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, proxy | ~4 GB images |
|
|
| `tools` | Etherpad, H5P editor + static files + library install, tldraw server + worker, Collabora | ~4 GB more, Collabora is the bulk |
|
|
| `av` | ClamAV + Clammit | ~1.5 GB resident for the signature database |
|
|
| `preview` | file-preview generator (thumbnails) | small |
|
|
|
|
`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.
|