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
| Application | Responsibility |
|---|---|
| dApp | Serves exam content to the candidate and observes the station. |
| wApp | Entrypoint for candidates to create accounts, verify identity, view certifications, and register for exams. Captures biometrics, and can operate as second proctoring device. |
| aAPI | Owns candidate identity and the attempt path. The only application candidate devices reach. Reads content through cAPI; writes attempts and evidence to mDb. |
| cAPI | Sole 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. |
| cDb | Exam content and its whole history. Self-hosted Dolt. |
| mDb | Candidate identity, attempts, responses, and evidence. All candidate data. |
| eDd | Where 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. |
| eDb | Everything 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
404for/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
- on pushes to main where
release-container.yml- builds then pushes the containers to DOCR, then calls
deploy.yml
- builds then pushes the containers to DOCR, then calls
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.orgaddresses
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 deployedIMAGE- registry URL orprevious
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.
| Timing | Result |
|---|---|
| Run 1 has not started its release-please job | Run 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 B | pending, refreshes the release PRs, and its matrix is empty. |
| Run 1 already past release-please | Run 1 releases A only. Run 2 waits, then releases B. |
| Three or more merges | Run 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
.envfiles, duplication of things likeSUCH_SUCH_DOMAIN - many
.envfiles, but root.envfor 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_RATERUST_LOGAAPI_PORT13001
PLATFORM_NETWORKplatform
IMAGE- leave empty to build from source. Komodo sets on deploys.
apps/curriculum-api
ENVIRONMENT- required
SENTRY_DSN- required outside development
SENTRY_TRACES_SAMPLE_RATERUST_LOGCAPI_PORT13002
PLATFORM_NETWORKplatform
IMAGE- leave empty to build from source. Komodo sets on deploys.
apps/examiner-dashboard
ENVIRONMENT- required
SENTRY_DSN- required outside development
SENTRY_TRACES_SAMPLE_RATERUST_LOGEDD_PORT13003
PLATFORM_NETWORKplatform
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_PORT13004
PLATFORM_NETWORKplatform
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
- required unless
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.shauto-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