DEV Community

Gábor Boros
Gábor Boros

Posted on • Originally published at gaboros.hu on

Effortless mass change management with Hammurabi

If you have ever developed microservices, you already know some of the challenges around it. If it’s done right, development can speed up, the cognitive complexity of the services can be decreased, and there are many many more advantages over monoliths. On the other hand, you have to solve different problems, like keeping a level of uniformity among services developed independently by even geographically distributed teams to reduce the maintenance costs. When we list the pros and cons of microservices, we always forget the most crucial question: How will we keep the services uniform?

The Challenge

Let’s imagine the following situation: You’ve created 5 microservices. One day you get the task to change the gunicorn configuration for all the services. Not a hard job. It’s around 10 minutes with quality git commit messages. Some months later, it turns out that you need to fine-tune the configuration, so you spend that 10 minutes again.

Now, what if you have over 70 Python services and 6 different project layouts generated from templates — which evolved during the years — where gunicorn configuration is at different places? What if you have to deal with conditions like only do modifications if the service is running on Python 3 or Scala? I think you get it.

The Solution

Since we required something for various stacks across the company, we created a proof of concept, which was able to handle simple automation tasks. After serving us for a year, we replaced the proof of concept with its open-source variant, Hammurabi.

Hammurabi is an extensible CLI tool designed for enforcing user-defined rules on a git repository. It integrates well with both git and GitHub to make sure that the execution happens on a separate branch and the committed changes are pushed to the target repository in the form of a pull request.

GitHub Pull Request of Hammurabi

Its rule execution engine allows us to write complex rule sets and implement changes more efficiently. Thanks to its modularity, the default rules can be extended by open-source and private rules as well. This trait, and the execution chain, makes Hammurabi powerful, especially when it is integrated into CI/CD workflows.

Take the previous gunicorn configuration as an example. In the case where we have to write a rule for changing the configuration we could write the following.

from pathlib import Path
from hammurabi import Pillar, Law, LineReplaced

gunicorn_config_path = Path("./gunicorn.conf.py")

gunicorn_workers_set = Law(
    name='Gunicorn worker settings',
    description="Make sure gunicorn workers are configured",
    rules=(
        LineReplaced(
            name='Worker class is set to gevent',
            path=gunicorn_config_path,
            target=r'^(\s+)?worker_class\ =\ .*',
            text='worker_class = "gevent"',
        ),
    )
)

pillar = Pillar()
pillar.register(gunicorn_workers_set)
Enter fullscreen mode Exit fullscreen mode

Let’s check what we did so far. We created a Law (line 6–17) which is the logical collection of rules. Laws are responsible for executing the rules assigned to them. On the other hand, rules are the units which are doing the work. Every rule has an input and output depending on its type, this ensures that the rules can be chained like pipes or in a fan-out pattern — called children in Hammurabi.

In the example above we created a Rule (line 10–15) which states the following: Make sure that any line matching the regex ^(\s+)?worker_class\ =\ .* is replaced by worker_class = "gevent" in the file gunicorn.conf.py.

We did not talk about lines 19–20 yet, so let’s take a closer look at that. As you may remember, Hammurabi — the sixth king in the Babylonian dynasty — had a finger-shaped black stone stele (pillar), where all his rules were registered. This is exactly what’s done here. We register all the laws that we would like to execute later on. The Pillar is responsible for the execution process and handling possible exceptions.

When we execute the registered laws, we will see that the target is replaced successfully by worker_class = "gevent". Although the rule will run perfectly, we missed something. The regex will match even for the text which would replace our target. To fix this issue, we can use Preconditions. Preconditions are another building block which can prevent the execution of a rule or law. Now let see how we can use preconditions to prevent the replacement if the text equals the target.

from pathlib import Path
from hammurabi import Pillar, Law, LineReplaced, IsLineNotExists

gunicorn_config_path = Path("./gunicorn.conf.py")

gunicorn_workers_set = Law(
    name='Gunicorn worker settings',
    description="Make sure gunicorn workers are configured",
    rules=(
        LineReplaced(
            name='Worker class is set to gevent',
            path=gunicorn_config_path,
            target=r'^(\s+)?worker_class\ =\ .*',
            text='worker_class = "gevent"',
            preconditions=[
              IsLineNotExists(
                path=gunicorn_config_path,
                criteria=r'^(\s+)?worker_class\ =\ "gevent"'
              )
            ]
        ),
    )
)

pillar = Pillar()
pillar.register(gunicorn_workers_set)
Enter fullscreen mode Exit fullscreen mode

In line 15–20 we defined our precondition IsLineNotExists. The rule will be executed in case all of the preconditions passed, so we made sure we will not replace the line if not needed.

Hammurabi is shipped with some batteries included. Let’s imagine the case when you have a service descriptor file, which describes what’s the name of the service, how much memory is needed by the application, or even the stack of the service. At Prezi, we have a descriptor file like this, so we need a custom Precondition to make sure we are not running Python-related rules and laws on Scala services. For these cases, it is possible to define one’s own Rules or Preconditions.

Since custom Preconditions should not be available for anyone else, we implemented that in our own package, which extends Hammurabi’s default rules and preconditions.

import yaml

from pathlib import Path
from typing import Any, Dict, Iterable

from hammurabi.preconditions.base import Precondition


class ServiceYamlMixin:
    service_config: Dict[str, Any] = dict()

    def _load_service_yaml(self):
        try:
            self.service_config = yaml.safe_load(Path("service.yml").read_text())
        except FileNotFoundError:
            self.service_config = {}


class HasStack(Precondition, ServiceYamlMixin):
    def __init__(self, stacks: Iterable[str] = (), **kwargs):
        stacks = self.validate(stacks, required=True)
        super().__init__(None, stacks, **kwargs)

    def pre_task_hook(self):
        self._load_service_yaml()

    def task(self) -> bool:
        return self.service_config.get("stack") in self.param
Enter fullscreen mode Exit fullscreen mode

As you see, thanks to the building blocks Hammurabi has, we can create highly customizable and powerful automation pipelines for our repositories.

We believe in open-source and care about our communities. I truly hope this tool can help developers around the world to keep their services uniform.

Thank you for reading!

Credits

Cover photo by Emilio Guzman on Unsplash.

Top comments (0)