Skip to content

Repository files navigation

Podfall

Podfall is a platform for interactive programming tasks. An author describes a task — the services it needs, the starter files, and the checks that define "solved" — and a learner gets a workspace in the browser where their code runs in a sandboxed Kubernetes pod and is graded automatically.

Built solo as a learning project (March 2026 – present) to understand how a multi-service Spring Boot system behaves end to end: authentication, per-service databases, asynchronous events, containerized deployment, and running untrusted code safely.

Stack: Java 25 · Spring Boot 4.1 · PostgreSQL · Redis · Gradle · Docker · Kubernetes (Helm) · React 19


Contents


What it does

For learners. Browse a task catalog, open a task, and get a workspace with a file tree, a terminal, and live container logs. Your code runs server-side, so nothing has to be installed locally. Pressing "verify" runs the task's checks and returns a per-check result.

For authors. A wizard for defining a task: which services it spins up (an app container plus optional PostgreSQL, MySQL, MongoDB, Redis, RabbitMQ, Kafka or Elasticsearch), the starter file bundle, and the checks that grade a submission. Bundles are uploaded to S3-compatible storage and reviewed before publishing.

Checks are the core idea — a task is defined by what must be observably true of a running solution, not by comparing output strings:

Check type What it does
HTTP Calls an endpoint of the running solution and asserts on status code and response body
LOG Matches container logs against a substring or regex
DATABASE Runs a read-only query against the session's database; any statement other than SELECT is rejected
QUERY Queries a non-relational service (Redis, MongoDB) through the container's CLI
COMMAND Executes a shell command inside the container and asserts on its result
TEST_SUITE Runs the task's own test command and reports the outcome

HTTP and database checks are wrapped in a retry layer, because a freshly started container is usually not ready on the first attempt.


Architecture

Eight backend services, a React frontend, and a shared library. Each backend service owns its own PostgreSQL database and its own Flyway migrations — no shared schema.

Service Port Responsibility
frontend 8080 React 19 + TypeScript + Vite + Tailwind SPA
auth-service 8081 Registration, login, JWT issuing, refresh tokens, OAuth2, subscription tier
catalog-service 8082 Published task catalog: browsing, search
creator-service 8083 Task authoring, bundle upload, review and publishing
runtime-service 8084 Kubernetes session orchestration, workspace, terminal, logs, verification, grading
notification-service 8085 Transactional email, triggered by events
progress-service 8086 Per-user completions and progress stats
analytics-service 8087 Aggregates grading events into platform metrics
payment-service 8088 Stripe Checkout, billing portal, subscription webhooks
platform-core Shared library: JWT validation filter, S3 access, rate limiting, observability config, event DTOs

runtime-service is by far the largest and most interesting service; catalog, progress, analytics and notification are deliberately thin.

flowchart TD
    Browser --> Ingress["Traefik ingress"]

    Ingress --> Auth["auth-service"]
    Ingress --> Catalog["catalog-service"]
    Ingress --> Creator["creator-service"]
    Ingress --> Runtime["runtime-service"]
    Ingress --> Payment["payment-service"]

    Creator -->|"publishes task"| Catalog
    Creator -->|"task bundle"| S3[("S3 / MinIO")]
    Runtime -->|"reads bundle"| S3
    Payment -->|"activates tier"| Auth

    Runtime --> K8s["Kubernetes API"]
    K8s --> Session["session namespace<br/>(gVisor pod + services)"]

    Auth --> Rabbit[["RabbitMQ"]]
    Runtime --> Rabbit
    Rabbit --> Notification["notification-service"]
    Rabbit --> Analytics["analytics-service"]

    Auth --> Redis[("Redis")]
    Runtime --> Redis
Loading

How a session works

  1. The learner starts a task. runtime-service checks their subscription tier against monthly usage limits.
  2. A dedicated Kubernetes namespace is created for the session, with a NetworkPolicy restricting traffic and RBAC scoped to that namespace.
  3. If the task needs a custom image, it is built inside the cluster with Kaniko — a Job that builds and pushes without a Docker daemon, so no container runtime socket is ever mounted.
  4. Pods are scheduled with the gVisor RuntimeClass. gVisor intercepts the container's system calls in a user-space kernel instead of passing them to the host kernel, which shrinks the attack surface for code the platform did not write. This is why the platform never needs Docker-in-Docker or anything installed on the learner's machine.
  5. The workspace loads the task's file bundle from S3; the terminal and log streaming both go through the Kubernetes API (exec and log endpoints) rather than a custom agent inside the container.
  6. On verify, each check runs against the live session (HTTP call, log read, read-only SQL, or exec), results are scored against the task's rubric, the attempt is persisted, and a GradingCompletedEvent is published for analytics.
  7. Idle sessions are reaped by a cleanup job, which deletes the namespace and everything in it.

Implementation notes

Things that took the most thought, and where the interesting code lives.

Authentication (auth-service, platform-core). Short-lived JWT access tokens plus refresh tokens persisted in PostgreSQL and rotated on every use, so a leaked refresh token has a bounded lifetime and can be revoked server-side. Tokens are delivered as HttpOnly Secure SameSite=Strict cookies and stripped from response bodies, which keeps them out of reach of page scripts. Other services don't call auth on every request — they validate the signature locally through a shared filter in platform-core.

Abuse protection. Rate limiting is distributed rather than per-instance: Bucket4j token buckets in Redis, configured per endpoint. Repeated failed logins escalate through tiered lockouts (temporary, extended, freeze) and trigger a Cloudflare Turnstile CAPTCHA, with a circuit breaker so a Turnstile outage doesn't take login down with it.

Read-only database checks (DatabaseCheckExecutor). Task authors can assert on the state of a session database, but a check must not be able to mutate or destroy it. The executor parses and rejects anything that isn't a SELECT before the statement reaches the driver.

Sandboxing. gVisor RuntimeClass plus per-session namespaces, NetworkPolicy isolation, non-root containers, readOnlyRootFilesystem, dropped capabilities, and the RuntimeDefault seccomp profile. Kaniko removes the need to expose a Docker socket for image builds.

Events. RabbitMQ carries UserRegisteredEvent, PasswordResetRequestedEvent and GradingCompletedEvent. Publishers sit behind an interface with a no-op implementation, so the whole platform runs without a broker when messaging is switched off — useful locally and in tests.


Running it locally

Local development runs on a real local Kubernetes cluster rather than Docker Compose, because the gVisor sandbox and namespace-per-session model have no Compose equivalent.

Prerequisites: Git, Docker, kubectl, Helm.

git clone https://github.com/kzhunmax/podfall.git
cd podfall

# k3s: installs gVisor (runsc), builds images, deploys the Helm charts
./scripts/k3s-dev-up.sh

# or, for a kind-based cluster
./scripts/dev-k8s-up.sh

Once the pods are healthy the application is on http://localhost. Grafana is a ClusterIP service, so reach it with a port-forward:

kubectl -n podfall port-forward svc/grafana 3000:3000
# then http://localhost:3000 — default credentials admin / admin

Everything runs without external accounts. Optional integrations — Google/GitHub OAuth2 login, Novu email, Stripe billing, Turnstile CAPTCHA, the S3 malware-scan Lambda — stay disabled until you supply credentials. See deploy/helm/podfall/values-secrets.example.yaml for the full list and override them with -f your-secrets.yaml.


Testing and CI

Roughly 130 unit test classes (JUnit 5, Mockito, fabric8's Kubernetes mock server for orchestration code) and 11 integration tests that run against real dependencies in Testcontainers — PostgreSQL, Redis, MinIO, RabbitMQ — plus WireMock for third-party HTTP. Contract tests check that creator-service projections stay compatible with what catalog-service and runtime-service consume.

./gradlew check       # unit tests, JaCoCo verification, Spotless
./gradlew intTest     # Testcontainers integration tests
./gradlew intTestDocker  # opt-in tests needing a real k3s cluster

./scripts/verify-all.sh              # everything CI runs, locally
./scripts/verify-all.sh --skip-build # without Docker builds

cd frontend && npm ci && npm run test:coverage

GitHub Actions gates:

Workflow Trigger Checks
dev-gate-tests PR → dev Trivy filesystem scan, frontend lint + Vitest, Spotless, unit tests, JaCoCo, Testcontainers integration tests
prod-gate-tests PR → main Flyway migrate/validate/idempotency across all 7 schemas, all images built, SBOM, Trivy image scan
helm Helm changes helm lint, helm template, kubeconform against every values profile, kind smoke install
publish-*-images push to dev, v* tags Path-filtered image publishing to GHCR

Tests that need a real cluster are tagged kubernetes and excluded from PR gates — they run through the manual integration-manual workflow.


Deployment

Helm charts in deploy/helm/: podfall for the application, podfall-observability for the telemetry stack. Terraform in infrastructure/terraform/ provisions GKE with a gVisor sandbox node pool, Cloud SQL, and Artifact Registry on GCP, plus an S3 bucket and a malware-scan Lambda on AWS. Argo CD manifests in deploy/argocd/ sync the production release from Git.

Observability differs by environment on purpose: locally the full stack runs in-cluster (OpenTelemetry Collector, Prometheus, Loki, Tempo, Mimir, Pyroscope, Grafana), while production ships telemetry to Grafana Cloud through Alloy and keeps nothing stateful in the cluster. scripts/verify-prod.sh fails the deployment if an in-cluster telemetry pod shows up in production.


Known limitations

Honest list of where this project stops.

  • Not currently hosted. The GKE cluster is not running, so there is no public URL — the platform has to be started locally. Cost, not capability.
  • Grading v2 is behind a flag. The scored-rubric API (GradingController) is complete but ships with GRADING_V2_ENABLED=false; the earlier verification endpoint is what's active by default.
  • payment-service has no automated tests. It was the last service written and Stripe flows were validated manually against test keys. It's the first gap I'd close.
  • The JaCoCo gate is 20% line coverage, which is a regression floor rather than a target. Coverage is concentrated where it matters — auth, creator and runtime — and thin in the smaller services.
  • Cluster bootstrap is manual. Argo CD, External Secrets, Traefik and cert-manager are installed by hand before Argo takes over; only the application release is GitOps-managed.
  • Single-region, single-cluster. No multi-region story, no autoscaling tuning, no load testing to establish real capacity limits.
  • Frontend is functional, not polished. It exists to exercise the backend.

Repository layout

services/          Spring Boot services, platform-core library, contract tests
frontend/          React 19 + Vite SPA
deploy/helm/       Application and observability Helm charts
deploy/argocd/     Argo CD application manifests
infrastructure/    Terraform (GCP + AWS), observability configs, malware-scan Lambda
scripts/           Cluster bring-up, teardown, and CI-equivalent verification

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages