Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

The Exam Platform is a group of services involved in the creation, editing, and serving of freeCodeCamp certified exams.

Get started with the architecture of the platfrom.

Architecture

Architecture

graph LR
  subgraph candidate[Candidate devices]
    dApp["Desktop App<br/>[dApp]"]
    wApp["Web App<br/>[wApp]"]
  end

  aAPI["Auth API<br/>[aAPI]"]
  cAPI["Curriculum API<br/>[cAPI]"]
  cDb[("Curriculum Database<br/>[cDb]")]
  mDb[("Moderation Database<br/>[mDb]")]
  eDd["Examiner Dashboard<br/>[eDd]"]
  eDb[("Examiner Database<br/>[eDb]")]

  dApp <--> aAPI
  wApp <--> aAPI
  aAPI --> cAPI
  cAPI <--> cDb
  aAPI <--> mDb
  eDd <--> cAPI
  eDd <--> mDb
  eDd <--> eDb
  eDd --- cDbClone[("Authoring clone<br/>[Dolt]")]
  cDb -. fetch, driven by cAPI .-> cDbClone
  cDbClone -. clone and schema pull, read-only .-> cDb
ApplicationResponsibility
dAppServes exam content to the candidate and observes the station.
wAppEntrypoint for candidates to create accounts, verify identity, view certifications, and register for exams. Captures biometrics, and can operate as second proctoring device.
aAPIOwns candidate identity and the attempt path. The only application candidate devices reach. Reads content through cAPI; writes attempts and evidence to mDb.
cAPISole holder of cDb credentials and sole writer of cDb. Fetches authored history from eDd’s clone, fast-forwards dev, owns promotion, and serves redacted content to aAPI.
cDbExam content and its whole history. Self-hosted Dolt.
mDbCandidate identity, attempts, responses, and evidence. All candidate data.
eDdWhere examiners author, review, promote, and moderate. Owns examiner identity, roles, and scopes, and runs its own Dolt instance - the authoring clone - where all authoring happens. This is both a server and web-app.
eDbEverything eDd owns that is not versioned curriculum content.

Auth

All service-to-service traffic stays on the private platform network. Each database grants per-service users with least privilege.

Examiner → eDd

graph LR
  examiner(["Examiner"]) -->|"OIDC SSO login, session cookie; authZ by roles and scopes in eDb, enforced by eDd server"| eDd["eDd"]

dApp → aAPI

graph LR
  dApp["dApp"] -->|"Candidate login, short-lived bearer session token; authZ scoped to candidate's own attempt"| aAPI["aAPI"]

wApp → aAPI

graph LR
  wApp["wApp"] -->|"One-time pairing code from dApp attempt, short-lived bearer session token; authZ scoped to paired attempt"| aAPI["aAPI"]

aAPI → cAPI

graph LR
  aAPI["aAPI"] -->|"mTLS, client cert identifies aAPI; authZ read-only redacted content"| cAPI["cAPI"]

eDd → cAPI

graph LR
  eDd["eDd"] -->|"mTLS, client cert identifies eDd; authZ trigger fetch, fast-forward and promotion on behalf of examiner role"| cAPI["cAPI"]

aAPI → mDb

graph LR
  aAPI["aAPI"] -->|"aAPI DB user; authZ read/write candidates, attempts, responses, evidence"| mDb[("mDb")]

eDd → mDb

graph LR
  eDd["eDd"] -->|"eDd DB user; authZ read candidate data, write moderation outcomes only"| mDb[("mDb")]

eDd → eDb

graph LR
  eDd["eDd"] -->|"eDd DB user; authZ full access, sole user"| eDb[("eDb")]

eDd → Authoring clone

graph LR
  eDd["eDd"] -->|"eDd Dolt user; authZ read/write authoring branches"| clone[("Authoring clone")]

cAPI → cDb

graph LR
  cAPI["cAPI"] -->|"cAPI Dolt user, sole cDb credential holder; authZ sole writer"| cDb[("cDb")]

cDb → Authoring clone (fetch, driven by cAPI)

graph LR
  cDb[("cDb")] -->|"TLS, remotesapi fetch-only user on clone; authZ read authored history"| clone[("Authoring clone")]

Authoring clone → cDb (clone and schema pull)

graph LR
  clone[("Authoring clone")] -->|"TLS, remotesapi read-only user on cDb; authZ clone and pull, no push"| cDb[("cDb")]

Deployment

Follow this to deploy the platform’s services.

Spec

This is the specification describing the deployment of the services. Currently, all services are deployed on a single Digital Ocean Droplet.

TODO: unconsidered: coordinating breaking changes. perhaps a non-issue, provided all changes are backwards compatible - rolling migrations.

Tooling

  • Caddy
  • Docker
  • GitHub
  • Google Cloud
  • Komodo
  • release-please

Caddy

As public services are all hosted on the same VM, Caddy handles routing requests from the various domains to the service ports. The domains are DNS-only, A records registered on Cloudflare, all pointing at the public IP of the VM.

There is a main deploy/Caddyfile that is the VM entrypoint interpolating the domains, and reverse-proxying to the LAN ports:

{$KOMODO_DOMAIN} {
  reverse_proxy core:9120
}

{$EXAMINER_DASHBOARD_DOMAIN} {
  reverse_proxy examiner-dashboard:13003
}

All public apps need to be added to this config. The interpolated values come from deploy/.env.

TODO: Add actual caddyfile path/ref.

Notes

  • The Komodo domain proxy should not expose the periphery
    • 404 for /ws/periphery

Docker

All services/components are containerized, and a docker network (platform) is created for the services to communicate through. deploy/bootstrap.sh creates this network on initial creation.

Komodo manages the container stacks.

GitHub

The images are built on GHA runners, and pushed to DOCR.

  • docs.yml
    • on pushes to main where docs/ changes, GH Pages deployment is made
  • release-container.yml
    • builds then pushes the containers to DOCR, then calls deploy.yml
  • release.yml
    • release-please pr action
    • calls release-container.yml

Google Cloud

The Examiner Dashboard and Exam Platform apps use Google OAuth. These two clients need to be created in Google Cloud Console.

TODO: redirect URIs

Notes

  • Clients are scoped as being internal
  • Servers perform check that email matches only @freecodecamp.org addresses

Komodo

Komodo manages the containers. Its own compose file lives in deploy/. It does not manage itself.

Komodo handles rollbacks by going to the previous healthy image, or just with another GitOps deployment. There is a deploy “Action” in Komodo that is called through the deploy GHA calling a webhook with:

  • APP - name of app being deployed
  • IMAGE - registry URL or previous

release-please

There is one release-please config per service. A change to that service creates a release PR. Upon merge, GHA builds the service image and pushes to DOCR.

TimingResult
Run 1 has not started its release-please jobRun 1 releases both. container gets a 2-entry matrix and builds each at its own sha (that app’s release PR merge commit). Run 2 finds nothing
before merge Bpending, refreshes the release PRs, and its matrix is empty.
Run 1 already past release-pleaseRun 1 releases A only. Run 2 waits, then releases B.
Three or more mergesRun 2’s waiting job is replaced by run 3, which releases whatever is still unreleased. Nothing is lost.

A release always builds in the same run that created it. So, no release is orphaned and none is built twice. If a release-please step fails, the other apps’ releases still build (continue-on-error), and the last step fails the job.

Environment Variables

Environment variables are a question mark

Questions

  • many .env files, duplication of things like SUCH_SUCH_DOMAIN
  • many .env files, but root .env for any common vars
  • exam-platform/env/common.env
  • separate variables vs secrets (different files)
  • variables in .env, secrets in a secret store (cloud)
  • build vs runtime

Variables

apps/auth-api

  • ENVIRONMENT
    • required
  • SENTRY_DSN
    • required outside development
  • SENTRY_TRACES_SAMPLE_RATE
  • RUST_LOG
  • AAPI_PORT
    • 13001
  • PLATFORM_NETWORK
    • platform
  • IMAGE
    • leave empty to build from source. Komodo sets on deploys.

apps/curriculum-api

  • ENVIRONMENT
    • required
  • SENTRY_DSN
    • required outside development
  • SENTRY_TRACES_SAMPLE_RATE
  • RUST_LOG
  • CAPI_PORT
    • 13002
  • PLATFORM_NETWORK
    • platform
  • IMAGE
    • leave empty to build from source. Komodo sets on deploys.

apps/examiner-dashboard

  • ENVIRONMENT
    • required
  • SENTRY_DSN
    • required outside development
  • SENTRY_TRACES_SAMPLE_RATE
  • RUST_LOG
  • EDD_PORT
    • 13003
  • PLATFORM_NETWORK
    • platform
  • IMAGE
    • leave empty to build from source. Komodo sets on deploys.

apps/web-app

  • PUBLIC_AUTH_API_URL
    • build time
    • auth API origin the browser calls
    • http://127.0.0.1:13001
  • WAPP_PORT
    • 13004
  • PLATFORM_NETWORK
    • platform
  • IMAGE

apps/desktop-app

  • ENVIRONMENT
    • compile time
    • production
  • SENTRY_DSN
    • compile time
    • required unless ENVIRONMENT=development
  • AUTH_API_URL
    • required unless ENVIRONMENT=development
    • http://127.0.0.1:13001

deploy/

  • KOMODO_DOMAIN

  • EXAMINER_DASHBOARD_DOMAIN

  • KOMODO_INIT_ADMIN_USERNAME

  • KOMODO_INIT_ADMIN_PASSWORD

  • KOMODO_DATABASE_USERNAME

  • KOMODO_DATABASE_PASSWORD

  • KOMODO_JWT_SECRET

  • KOMODO_WEBHOOK_SECRET

  • KOMODO_GOOGLE_OAUTH_ENABLED

  • KOMODO_GOOGLE_OAUTH_ID

  • KOMODO_GOOGLE_OAUTH_SECRET

  • bootstrap.sh auto-generates the empty secrets with openssl rand -hex 32.

Misc

To Keep In Mind

  • attempts need dApp and wApp version
  • backups/snapshots should be taken on cadence, as well as immediately before a deploy
  • consider deployment strategy for services
    • in general, forcing backwards compatible bumps is only way forward
    • each service is deployed with redundancy (rollover)
    • clients and servers should have round-one version compatibility
      • forced client updates

Examiner Dashboard

  • ability to delete branches
  • separate branch management from change requests
    • promoting dev -> stg -> prd should be its own thing