October 30, 2022
Building systems from the ground up is sort of like building a house. You start with the idea: I'd love a two-story Tudor with a wrap-around porch and a laid-brick path to the front door.
At Prelude, our idea was different. Ambitious even. Guided by our mission of making security more accessible, we wanted to release a first-of-its-kind runtime that could run on top of any device and constantly detect security holes, known or unknown.
(More on Detect later though.. this is coming soon.)
The next step in building a house is drawing up the blueprints.
At Prelude, this meant separating the logical components it would take to realize our idea. To start, we needed a set of probes that could run anywhere - Windows laptops, Linux servers, traffic lights, submarines - you name it. These runtimes would need to connect to a backend system of sorts, which we walled behind an API and called the Prelude Service. This API would act as the central hub of communications, dispatching requests to a set of micro services.
One service, Frequency, is used for analytics and billing. Another, Compute, is used to handle compilation and testing activities from our open-source TTP building tool, Operator. Each app is written in Python.
Building this set of micro services meant we'd encounter code duplication. When you build micro service applications, a common desire is to keep the code uniform. Uniform code is easier to maintain as the supporting team doesn't need to juggle multiple design patterns or frameworks. But uniform code often comes with duplication and inconsistency.
Why do lines of code matter? The general rule of thumb is that for every 1,000 lines of code there are 15+ unknown software and security bugs. More code doesn’t just make it harder to maintain your system over time, it makes it less secure.
If each service requires access to a relational database, each may implement the exact same connection pooling code. Over time, this code will drift between services. For example, one service may need to return the row ID from all SQL statements whereas this would be noise to the other services. And on and on it goes.
Here at Prelude, we needed an asynchronous backbone for our micro services containing connective tissue for common databases, routing for API requests and most of all, we needed something that would keep our code tight and uniform.
Here's how we did it.
Applications often rely on a config file to supply passwords, connection details, and other environment-specific key/value pairs. Vertebrae provides a Config module that loads these properties into a global object that can be accessed anywhere in your application. The module accepts a YML file containing your properties. You can access them later on via Config.find(name).
If you have values stored in environment variables that match the keys in your YML file, the environment variable values will be used instead. This feature enables you to specify a config file for local development and environment variables for production deployments.
Note in the image above how we load an env.yml file into the Config module as the first line of code in an application.
Vertebrae's core module consists of Server and Application classes. A server is an object that contains your entire micro service (think wrapper). An application is an API that hangs off the server. A server can hold n-number of applications, one for each unique API port you want to serve.
In the above case, our Server is injected with a single Application and an array of service modules.
By creating multiple Applications, you can serve different ports simultaneously.
The last line starts the process and makes your applications available.
While your application may be a micro service itself, Vertebrae allows you to construct an internal network of micro services as well. A mesh network of services, if you will.
This design allowed us to float between object-oriented and functional concepts while keeping the code tight.
Any Python class can be converted into a Vertebrae Service by implementing the core Service module. A Vertebrae Service must accept a name in its constructor, which can be used by other service classes to interact with it. In addition, each service class gets a logger for free (from the base class) and handlers to your databases, referenced through the db() function on the base class.
Note a few things from the example:
Vertebrae is backed by several databases:
By default, each of these - except directory - is turned off by default. If you provide connection details for a particular database to your Config module then Vertebrae will open an asynchronous handler to that database.
Even the directory database is asynchronous, using aiofiles under the covers to perform async read/write/traversal operations. The default local directory used is ~/.vertebrae, which you can override in your Config module.
For example, if your config YML looks like this, you'll open handlers to Postgres and Redis and change the location for the directory database:
Remember, you can reference any database from any Service class by using the syntax self.db(‘relational’), self.db(‘directory’), self.db(‘s3’), and so forth.
Check the docs for available functions on each database type.
Recall from instantiating your Server that Vertebrae requires an array of route classes for each Application. A route is any old Python class that contains a routes() function. This function must contain 1+ Route objects, which represent each endpoint you want on your API.
A route accepts a method (GET/POST/PUT/DELETE), a route (the endpoint address) and a handler (the function you want to call when the route is hit).
Vertebrae uses aiohttp under the covers, as you can see in the return type of the handler above.
Every application gets a /ping route by default. This route is useful as a health check by load balancers.
Route classes, like any class in your application, can interact with any Service module through Service.find.
Want to add authentication? We decided not to be opinionated (yet) on authentication inside Vertebrae. But to be helpful, here's how we're doing it at Prelude.
Each Route handler has a decorator that wraps the handler with the authentication we want. For example:
The allowed decorator contains logic to do authentication on the request and pass the request object into the handler after passing the authentication.
The really neat thing here is the special strip_request function that pulls all data passed into the API - query parameters, POST data, URL interpolation - and wraps it into a dict that is sent into the called route (as a parameter called “data”).
Whether building a house or designing a set of micro services, moving from ideation to design to implementation is a rewarding journey. Hopefully, the Vertebrae framework makes your own applications reach their potential.