Files
Schulcloud-MCP/local-instance/docker-compose.yml
MechaCat02 3a168e37e5 local-instance: simulate a teacher, and run the files-storage consumer
`scripts/simulate-teacher.mjs` creates, edits and deletes what teachers
create — course, room, topic, task, board, columns, cards, rich text,
link, Etherpad pad, folder, files — so the MCP server can be exercised
against content the real account has never held. It is the only thing
here that writes to a Schulcloud and refuses any non-localhost address.
`scripts/mcp-env.sh` points the server and CLI at the instance.

Two gaps it exposed in the stack itself:

The files-storage AMQP consumer is a separate entrypoint, and we were
running only the HTTP one. Nothing was bound to the `files-storage`
exchange, so `TaskService.delete` — which awaits deleteFilesOfParent
over AMQP before touching the task — hung until the request timeout.
Deleting any task or topic answered 408 with the entity still there.

The demo data is dated 2017-2018 and the v3 endpoints filter on those
dates, so a student saw no tasks at all. seed.sh now brings courses and
homework into the present, which is the difference between a fixture
that exercises the student-facing surface and one that looks empty.

Teams turn out to be uncreatable through the API (the legacy service
registers no `create`, v3 has no route), and a new board is unpublished
and 403s for students, so the simulation adopts a seeded team and leaves
one board a draft on purpose.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-13 15:40:46 +02:00

297 lines
11 KiB
YAML

# A local Schulcloud, as close to schulcloud-thueringen.de as it can be made
# without its external identity provider.
#
# The application images are the *same* images the real instance runs
# (quay.io/schulcloudverbund, thr theme, tag 33.40 — see README.md), so the
# behaviour under test is the deployed behaviour, not a rebuild of main.
#
# docker compose up -d core: login, courses, boards, files
# docker compose --profile tools up -d + h5p, tldraw, collabora
# docker compose --profile av up -d + virus scanning of uploads
#
# Everything here is a throwaway dev instance: the credentials are the upstream
# development defaults and are published in the upstream repositories. Do not
# expose any of it beyond localhost.
x-sc-version: &sc-version "${SC_VERSION:-33.40}"
x-server-image: &server-image
image: quay.io/schulcloudverbund/schulcloud-server:${SC_VERSION:-33.40}
env_file: [env/shared.env, env/jwt.env, env/api.env]
depends_on:
mongo: {condition: service_healthy}
valkey: {condition: service_started}
rabbitmq: {condition: service_healthy}
restart: unless-stopped
services:
# ---------------------------------------------------------------- infra ---
mongo:
image: docker.io/mongo:7
# Single-node replica set rather than a bare mongod: the server's migration
# runner opens transactions, which mongo refuses outside a replica set.
command: ["--replSet", "rs0", "--bind_ip_all"]
volumes:
- mongo-data:/data/db
ports: ["127.0.0.1:27019:27017"]
healthcheck:
# Initiates the replica set on first start and reports healthy once the
# node is actually primary, which is what every other service waits for.
test: >-
mongosh --quiet --eval '
try { rs.status() } catch (e) { rs.initiate({_id:"rs0",members:[{_id:0,host:"mongo:27017"}]}) }
quit(db.hello().isWritablePrimary ? 0 : 1)'
interval: 5s
timeout: 10s
retries: 30
start_period: 10s
restart: unless-stopped
valkey:
# The JWT whitelist. Sessions die when their key expires, exactly as in
# production — this is the piece that makes local session testing honest.
image: docker.io/valkey/valkey:8-alpine
ports: ["127.0.0.1:6381:6379"]
restart: unless-stopped
rabbitmq:
image: docker.io/rabbitmq:4-management-alpine
ports: ["127.0.0.1:15673:15672"]
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
interval: 10s
timeout: 10s
retries: 20
start_period: 20s
restart: unless-stopped
minio:
# Stands in for the S3 provider the real instance uses. Buckets are created
# by minio-init below.
image: quay.io/minio/minio:latest
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: miniouser
MINIO_ROOT_PASSWORD: miniouser
volumes:
- minio-data:/data
ports:
- "127.0.0.1:9900:9000" # S3 API
- "127.0.0.1:9901:9001" # console (miniouser / miniouser)
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 5s
timeout: 5s
retries: 30
restart: unless-stopped
minio-init:
image: quay.io/minio/mc:latest
depends_on:
minio: {condition: service_healthy}
entrypoint: ["/bin/sh", "/init.sh"]
volumes:
- ./scripts/minio-init.sh:/init.sh:ro
restart: "no"
# ------------------------------------------------------- schulcloud api ---
api:
<<: *server-image
container_name: sc-api
command: ["dist/apps/server/apps/server.app"]
ports: ["127.0.0.1:3030:3030"]
management:
# Not part of the running instance — it exposes the seeding and migration
# endpoints that the real deployment's init job calls, and nothing else.
<<: *server-image
command: ["dist/apps/server/apps/management.app"]
# Port and base path are hardcoded to 3333 and /api in management.app.ts;
# PORT is not read here.
ports: ["127.0.0.1:3333:3333"]
board-collaboration:
# The websocket behind column boards. Without it a board renders once and
# then never updates.
<<: *server-image
command: ["dist/apps/server/apps/board-collaboration.app"]
environment:
PORT: "4450"
admin-api:
<<: *server-image
command: ["dist/apps/server/apps/admin-api-server.app"]
environment:
PORT: "4030"
file-storage:
image: quay.io/schulcloudverbund/file-storage:${SC_VERSION:-33.40}
env_file: [env/shared.env, env/jwt.env, env/file-storage.env]
depends_on:
mongo: {condition: service_healthy}
rabbitmq: {condition: service_healthy}
minio: {condition: service_healthy}
ports: ["127.0.0.1:4444:4444"]
restart: unless-stopped
file-storage-consumer:
# The AMQP half of files-storage, and a separate entrypoint from the HTTP
# one: only `files-storage-consumer.app` registers FilesStorageConsumer, so
# running the HTTP app alone leaves the `files-storage` exchange with no
# queue bound to it.
#
# The symptom is not a file problem. TaskService.delete awaits
# deleteFilesOfParent over AMQP before touching the task, so with nothing
# consuming, deleting a task or a topic hangs until the request timeout and
# answers 408 REQUEST_TIMEOUT with the entity still there. Copying a course
# goes the same way.
image: quay.io/schulcloudverbund/file-storage:${SC_VERSION:-33.40}
command: ["dist/apps/files-storage-consumer.app.js"]
env_file: [env/shared.env, env/jwt.env, env/file-storage.env]
depends_on:
mongo: {condition: service_healthy}
rabbitmq: {condition: service_healthy}
minio: {condition: service_healthy}
restart: unless-stopped
file-preview:
# Generates thumbnails via ImageMagick, driven off RabbitMQ. Optional: with
# it absent, files still upload and download, they just have no preview.
image: quay.io/schulcloudverbund/file-storage:file-preview-${SC_VERSION:-33.40}
profiles: ["preview"]
env_file: [env/shared.env, env/jwt.env, env/file-storage.env]
depends_on:
rabbitmq: {condition: service_healthy}
minio: {condition: service_healthy}
restart: unless-stopped
# -------------------------------------------------------------- clients ---
client:
# The legacy UI. Still owns "/" and much of the course view.
image: quay.io/schulcloudverbund/schulcloud-client-thr:${SC_VERSION:-33.40}
env_file: [env/shared.env, env/jwt.env, env/client.env]
depends_on: [api]
ports: ["127.0.0.1:3100:3100"]
restart: unless-stopped
nuxt:
# The Vue SPA, built for the thr theme. The image is an nginx that
# templates env vars into its config at start.
image: quay.io/schulcloudverbund/schulcloud-frontend-thr:${SC_VERSION:-33.40}
env_file: [env/nuxt.env]
ports: ["127.0.0.1:4000:4000"]
restart: unless-stopped
etherpad:
# The collaborative text editor element.
#
# Core, not a "tool", however much it looks like one: the legacy client asks
# the server for an Etherpad session on *every* topic page whose lesson has
# contents, without checking whether the lesson contains a pad at all
# (controllers/topics.js builds `etherpadPads` and then never reads it).
# Unreachable, that call fails, `validUntil` arrives undefined, and
# `new Date(undefined * 1000)` makes Express reject the session cookie —
# "option expires is invalid", a 500 on every topic page. The live
# deployment always runs Etherpad (ETHERPAD_REPLICAS: 1), so keeping it in
# the default profile is both the working and the faithful choice.
image: docker.io/etherpad/etherpad:3.3.3
env_file: [env/etherpad.env]
volumes:
- ./etherpad/APIKEY.txt:/opt/etherpad-lite/APIKEY.txt:ro
depends_on:
mongo: {condition: service_healthy}
restart: unless-stopped
proxy:
# The single origin. Everything a browser touches goes through here, so the
# app sees one host the way it does in production.
image: docker.io/nginx:1.29-alpine
volumes:
- ./proxy/nginx.conf:/etc/nginx/conf.d/default.conf:ro
ports: ["127.0.0.1:4400:4400"]
depends_on: [api, client, nuxt]
restart: unless-stopped
# -------------------------------------------------- external tools -------
h5p-editor:
image: quay.io/schulcloudverbund/h5p-server:${SC_VERSION:-33.40}
profiles: ["tools"]
command: ["dist/apps/h5p-editor.app"]
env_file: [env/shared.env, env/jwt.env, env/h5p.env]
environment:
PORT: "4448"
depends_on:
mongo: {condition: service_healthy}
minio: {condition: service_healthy}
restart: unless-stopped
h5p-staticfiles:
image: quay.io/schulcloudverbund/h5p-server:static-files-${SC_VERSION:-33.40}
profiles: ["tools"]
restart: unless-stopped
h5p-libraries:
# One-shot: installs the H5P content types listed in env/h5p.env into the
# library bucket. Exits when done; re-run it after changing that list.
image: quay.io/schulcloudverbund/h5p-server:${SC_VERSION:-33.40}
profiles: ["tools"]
command: ["dist/apps/h5p-library-management.app"]
env_file: [env/shared.env, env/jwt.env, env/h5p.env]
depends_on:
mongo: {condition: service_healthy}
minio-init: {condition: service_completed_successfully}
restart: "no"
tldraw-server:
# The whiteboard element.
image: quay.io/schulcloudverbund/tldraw-server:${SC_VERSION:-33.40}
profiles: ["tools"]
command: ["dist/apps/tldraw-server.app.js"]
env_file: [env/shared.env, env/jwt.env, env/tldraw.env]
depends_on: [valkey, minio]
restart: unless-stopped
tldraw-worker:
image: quay.io/schulcloudverbund/tldraw-server:${SC_VERSION:-33.40}
profiles: ["tools"]
command: ["dist/apps/tldraw-worker.app.js"]
env_file: [env/shared.env, env/jwt.env, env/tldraw.env]
depends_on: [valkey, minio]
restart: unless-stopped
collabora:
# Office document editing. Reached by the browser directly on :9980, the
# way the real deployment puts it on its own hostname.
image: docker.io/collabora/code:latest
profiles: ["tools"]
environment:
extra_params: --o:ssl.enable=false --o:ssl.termination=false
domain: ".*"
aliasgroup1: "http://localhost:4400"
ports: ["127.0.0.1:9980:9980"]
cap_add: ["MKNOD"]
restart: unless-stopped
# ------------------------------------------------------------ antivirus ---
clamav:
# ~1.5 GB resident once the signature database loads, hence its own profile.
image: docker.io/clamav/clamav:1.5.3
profiles: ["av"]
volumes:
- clamav-db:/var/lib/clamav
restart: unless-stopped
clammit:
image: ghcr.io/dbildungsplattform/clammit:0.9.1
profiles: ["av"]
environment:
CLAMMIT_CLAMD_URL: tcp://clamav:3310
CLAMMIT_LISTEN: 0.0.0.0:8438
depends_on: [clamav]
restart: unless-stopped
volumes:
mongo-data:
minio-data:
clamav-db: