Skip to main content

Backend Architecture

FastAPI-based backend with PostgreSQL, Redis, and LXD container management.

Directory Structure

backend/app/
├── api/ # API endpoint routers
│ ├── admin.py # Admin CRUD (users, modules, exams, labs)
│ ├── dashboard.py # User dashboard stats & activity
│ ├── exams.py # Exam operations
│ ├── laboratories.py # Lab operations
│ ├── lessons.py # Lesson content fetching
│ ├── modules.py # Module listing
│ ├── profile.py # User profile
│ └── ...
├── crud/ # Database CRUD operations
│ ├── base.py # Generic base CRUD class
│ ├── exam.py # Exam, attempt, task result CRUD
│ ├── module.py # Module CRUD
│ └── user.py # User CRUD + authentication
├── db/ # Database layer
│ ├── models.py # SQLAlchemy ORM models
│ └── init_db.py # DB initialization + seeding
├── schemas/ # Pydantic request/response schemas
│ ├── auth.py, user.py, exam.py, laboratory.py, ...
├── services/ # Business logic services
│ ├── exam_verifier.py # Runs bash verification in containers
│ ├── laboratory_verifier.py
│ └── lessons_service.py # Fetches markdown from GitLab repo
├── auth.py # JWT creation, validation, get_current_user
├── config.py # Pydantic Settings (reads from .env)
├── database.py # AsyncSession factory
├── lxc_manager.py # LXD API client wrapper
├── main.py # FastAPI app, lifespan, core routes
└── websocket.py # WebSocket terminal handler (SSH ↔ browser)

Key Components

1. Authentication (auth.py)

JWT Bearer tokens with HS256. Two FastAPI dependencies:

  • get_current_user — validates token, returns User. Raises 401 for missing/invalid token, 403 for inactive user.
  • require_admin — wraps get_current_user, raises 403 if role != 'admin'.

2. Database Models (db/models.py)

Three-tier lesson system: Module → Section → Theme

Exam system: Exam → ExamTask with ExamAttempt → ExamTaskResult

Labs system: Laboratory → LaboratoryTask with LaboratoryAttempt → LaboratoryTaskResult

Container tracking: Container stores all LXD containers (sandbox + exam + lab)

3. LXD Management (lxc_manager.py)

Wraps pylxd client connecting to https://10.0.0.3:8443 via TLS client certificates.

# All LXD operations are blocking — always wrap with asyncio.to_thread()
container = await asyncio.to_thread(lxc_manager.create_container, name, image)

4. Exam Verifier (services/exam_verifier.py)

SSH into container via jump host, execute bash verification scripts, parse PASS/FAIL output, write results to DB.

5. WebSocket Terminal (websocket.py)

Upgrades HTTP to WebSocket, then SSH-connects to the container via jump host and bridges stdin/stdout bidirectionally.

API Endpoints Summary

Authentication

MethodPathAuthDescription
POST/api/auth/registerPublicRegister new user
POST/api/auth/loginPublicLogin, returns JWT
GET/api/auth/meStudentCurrent user info

Containers

MethodPathAuthDescription
POST/api/containers/createStudentCreate sandbox container
GET/api/containers/sandboxStudentGet user's sandbox
DELETE/api/containers/{id}StudentDelete container
WS/ws/terminal/{id}?token=StudentTerminal WebSocket

Exams

MethodPathAuthDescription
GET/api/examsStudentList active exams
GET/api/exams/{id}StudentExam details + tasks
POST/api/exams/{id}/startStudentStart exam (creates container)
POST/api/exams/{id}/submitStudentSubmit + auto-grade
GET/api/exams/attempts/{id}/resultsStudentDetailed results

Admin

MethodPathAuthDescription
GET/api/admin/statsAdminSystem statistics
GET/POST/api/admin/usersAdminList / create users
DELETE/api/admin/users/{id}AdminDelete user
GET/POST/api/admin/examsAdminList / create exams

Configuration

All configuration is in backend/app/config.py as Pydantic BaseSettings. Values are read from environment variables (or backend/.env).

Key variables:

DATABASE_URL=postgresql+asyncpg://...
SECRET_KEY=...
REDIS_URL=redis://...
LXD_ENDPOINT=https://10.0.0.3:8443
LXD_CERT_PATH=/app/.lxd/client.crt
LXD_KEY_PATH=/app/.lxd/client.key
LXD_SSH_JUMP_HOST=65.109.236.163
LESSONS_REPO_URL=git@gitlab.com:zsaidov1988/lessons-content.git
LESSONS_SSH_KEY_PATH=/app/private_key

See docs/standards/config.md for the full variable list.

Troubleshooting

ErrorLikely CauseFix
MissingGreenletLazy-loading SQLAlchemy relation outside async contextUse selectinload() in query
SSH authentication failedMissing or incorrect SSH key mountCheck /app/private_key exists with 600 permissions
LXD connection refusedWrong endpoint or expired certsVerify LXD_ENDPOINT and cert paths
422 Unprocessable EntityBad request body (Pydantic validation)Check request schema against Swagger UI