OONI API Services
OONI API components are broken up into smaller pieces that can be more easily deployed and managed without worrying too much about the blast radius caused by the deployment of a larger component.
To this end, we divide the OONI API sub-components into what we call “services”.
Each service (found in the ooniapi/services
folder) should have a narrowly
defined scope. It’s optimal to slice services up depending on how critical they
are (tier0, tier1, tier2) and what kinds of resources they need to access
(databases, object store, specific hosts, etc.).
There is also a common
folder for all shared logic common to every component.
We follow the principles of a building a 12 factor app:
-
I. Codebase, ooni/backend is the monorepo that tracks all the deploys of ooni/backend/ooniapi services
-
II. Dependencies, each dependency should be tracked in a single source of truth, which is the
pyproject.toml
for that service. -
III. Config, configuration is defined in a pydantically typed
Settings
object which can be overriden for testing and configured for each environment (dev, test, prod). -
IV. Backing services, it should be possible to swap out a backing service without needing any changes to the codebase. This makes it easy and agile to switch our postgresql deployment for a self hosted one or use a managed instance when that makes sense. Try to write tests and perform development in an environment as close to production as possible.
-
V. Build, release, run, the app should be built into a single docker image that can be provisioned with the right config for that environment. Configuration is provisioned in a way that’s specific to the enviroment (eg. Github Actions, AWS ECS, pytest).
-
VI. Processes, the service should define a single process call at the end of the Dockerfile which, assuming correct configuration, should be stateless.
-
VII. Port binding, services expose a HTTP service running with uvicorn bound to port 80 contained in a docker image. It’s up to the container host to decide how to map port 80 to a local port.
-
VIII. Concurrency, it should be possible to run multiple instances of an OONI API Service to scale horizontally, since each concurrent instance doesn’t share anything.
-
IX. Disposability, each service should shutdown gracefully when they receive a SIGTERM, but also be robust against a sudden death, by being a crash-only software.
-
X. Dev/prod parity, what we run on our machine is as close as possible to what we run in production. This means we setup local backing services (postgresql, clickhouse) and try to minimize the amount of stuff we mock in order to carry out testing.
-
XI. Logs, our logs are written to
STDOUT
/STDERR
. It’s up to the orchestration system to figure out what to do with them. -
XII. Admin processes, one-off admin tasks should be run inside of the docker image for the particular revision you are running that task for. For example if you need to run a DB migration you should do so from a docker container running the specific version of the sofware you want to update. Avoid the temptation to prematurely automate one-off tasks that don’t need to be run so often.
List of OONI API Services
Tier0
ooniprobe
, (akaprobe_services
), where probes send their measurements and get metadata to run experiments;oonirun
, CRUD for editing OONI Run links, but also for probes to get the descriptors and run tests (this fact is what makes it tier0);prioritization
, CRUD for editing the priorities of URLs and populating the appropriate clickhouse tables so they can be used by probe;fastpath
, responsible for taking measurements blobs sent toooniprobe
service and storing them in s3;
Tier1
data
, (aka OONI Data Kraken), where Explorer and other clients access the observations data and experiment results;findings
, (akaincidents
) backend for findings pages on explorer;measurements
, backend for aggregation and list measurement endpoints (note also probe uses this, so it’s maybe on the high end of tier1);- “
Tier2
testlists
, for proposing changes to the test-lists and submitting a github PR;
Developing a service
Quickstart
For most python services the dev setup looks like this:
-
Install hatch
-
Run
make run
to start the service locally -
Run
make test
to run the tests -
Run
make docker-build
to build a docker image of that service
Implementation details
Each python based service should use hatch as a python project manager.
Code layout
You can get some help in bootstrapping the project by running:
Notice how each service should always start with the ooni
prefix.
If you plan to use packages from the ooniapi/common
directory tree you should
setup a symlink for it:
You should also make the following adjustments to the pyproject.toml
:
and then
and then replace the [tool.hatch.envs.default]
sections with:
You should then update the LICENSE.txt file to this:
You should then create a src/ooniservicename/main.py
file that contains an app
that can be called by uvicorn:
FastAPI code style
Main boilerplate
Makefile
Each service must include a Makefile
which defines some common tasks that can
be run with make
.
Not every task needs to be defined in the Makefile, just the ones which are
expected to be called by other services. To this end the Makefile
acts an API
of sorts, providing a consistent way to perform certain
SDLC tasks by
hiding the implementation details on how they might happen for that particular
service.
It’s recommended you implement the following Makefile
targets for your
service:
test
, runs the full test suite for the service.run
, starts the service locally for use in development. If possible do so with live-reload support.build
, builds a source and binary distribution for the service and places it inside ofdist/
.docker-build
, builds a tagged docker image of the current service revision.docker-push
, pushes the docker image to the correct destination repository.docker-smoketest
, performs a simple smoketest of the built docker image to make sure nothing broke in building it. It doesn’t have to be an extensive test, but just check that the service is able to start and that there is nothing obviously broken with the build (eg. broken imports).clean
, perform any necessary cleanup to restore the environment to a clean state (i.e. as if you just cloned the repo).imagedefinitions.json
, generate animagedefinitions.json
file and place it in theCWD
to be used by codepipeline.
Be sure to properly define the .PHONY targets
Here is a sample template for your makefile:
Dockerfile
The docker file should be as simple as it can be, yet understandable. Since
third parties might be looking at the Dockerfile as well, try to ensure that
it’s understandable how the software is built and run without needing to lookup
too many other files. For example try to avoid calling the Makefile
or other
auxilary scripts, since that creates unncessary indirection.
Split the builder from the runner process and make sure the runner, so that the runner is clean from any dependenceis that are not needed at runtime.
The builder should produce a binary distribution that can be installed on the runner.
If there are auxilarity admin tools that are needed, they should be copied over
to the runner and placed inside of the WORKDIR
.
The Dockerfile should take BUILD_LABEL
as an argument. BUILD_LABEL
can be
injected into the built application to track the specific commit and timestamp
of the build, like so:
Here is a sample Dockerfile
for a python based service:
It’s recommended you also implement a smoke test for the built docker image.
Here is a sample:
Build spec
The service must implement a buildspec.yml
file that specifies how to
run the build on code build.
Try to keep the buildspec as simple and non-specific to CodeBuild as possible, so that we can decide to move to another build pipeline if needed in the future.
Here is a sample buildspec.yml
file:
Please note how the imagedefinitions file is being copied to the
${CODEBUILD_SRC_DIR}
.