Naroj site automation

Site content publishment using the GitOps way of working combined with event driven partial updates. This allows us to perform CRUD on a single blog article and have Git/Pipelines and events control the actual site updates based on just a new git tag.

Event-driven architecture (EDA) is an architecture pattern built from decoupled services that publish / route events. In our case this enables us to exchange site updates between build pipelines and the hosting infrastructure which runs this site.

Site repository | CI config | Builds | Dockerfile | Container repository

EDA

High-level

  • Change a blog article (like this one)
  • Push a git tag
  • Gitlab pipeline will run and build + publish a new container image using that git tag
  • Send a HTTP POST to a webhook receiver from the container host
  • The ansible-rule book listening for webhooks will receive the new image tag
  • An ansible playbook executed by the rule book will instruct the container runtime control socket to run the new version
  • Content is published

Scenario

Components

  • Readthedocs this is a very simple static site generator
  • GIT version control of content
  • GitLab pipelines pipeline triggers (our automation runtime)
  • Container host a simple linux server with a container runtime
  • Event receiver we use ansible-rule books to receive events
  • Ansible playbook an ansible playbook which is executed by the event receiver to load a new image version

Steps

Getting from a changed blog article to a new version of the site containing this new changes takes a few steps.

0. Prerequisites

Beyond the obvious we need to have mkdocs installed as a python module in order to generate an actual site from markdown files. Make sure you have python-pip installed. Pip modules;

mkdocs                     1.6.1
mkdocs-material            9.5.49
mkdocs-material-extensions 1.3.1

If you run this on a local machine I recommend you setup a virtualenv, distrobox environment or Docker container to install your dependencies so we keep the base OS clean and untouched. When you would choose for a virtualenv it would take the following steps to set this up;

# create new virtual env
python3 -m venv ~/.venv

# install dependencies
~/.venv/bin/pip install mkdocs mkdocs-material mkdocs-material-extensions

# initiate new readthedocs project
~/.venv/bin/mkdocs new my-site

installation details

1. Change a blog article

A blog article (like this one) is a very simple markdown file. Files are part of a little hierarchy which makes up the site structure.

├── index.md
├── Blog
│   ├── ansible_hosted_vault_service.md
│   ├── dnsdist.md
│   ├── site_automation.md
├── images
│   ├── contact_me.png
│   ├── material.png
│   └── shell.png
├── ISO-27001
│   ├── continuously_monitoring.md
│   └── risks_to_information.md
├── manifest.json
├── Tech
│   ├── containerize.md
│   └── pipelines.md
└── training
    └── index.md

Bare bones git actions to get the entire automation to spin a new version based on a content update.

git add Blog/site_automation.md
git commit -m "making progress on site automation article"
git push
git tag 1.5
git push --tags

Scenario

VIM is my editor of choice but any plain text editor will work.

2. Local test

Before publishing a new version of the site we would like to make sure it looks the way we expect. In order to do that we run a new local version on our machine. Currently this site is too small for a full staging environment so this will be done on the local work station.

Running the serve command from the project directory will spin up a development http server serving local files.

➜  naroj.eu git:(main) ✗ ~/.venv/bin/mkdocs serve --config-file mkdocs.yml
INFO    -  Building documentation...
INFO    -  Cleaning site directory
INFO    -  Doc file 'Blog/dnsdist.md' contains an absolute link '/../images/DNSDistOutgoingDoH1.png', it was left as is.
INFO    -  Doc file 'Blog/dnsdist.md' contains an absolute link '/../images/dnsdist_metrics.png', it was left as is.
INFO    -  Documentation built in 0.40 seconds
INFO    -  [21:55:23] Watching paths for changes: 'readthedocs', 'mkdocs.yml'
INFO    -  [21:55:23] Serving on http://127.0.0.1:8000/
INFO    -  [21:55:35] Browser connected: http://localhost:8000/
INFO    -  [21:55:37] Browser connected: http://localhost:8000/

3. GitLab pipelines

Using GitLab's pipelines we automate the whole process of changing a local file and getting it finally published on our domain name. Pipelines respond to receiving a new git tag which indicate a new version of the site is desired.

Scenario

Variables we need to run our build;

variables:
  REGISTRY_NAME: registry.gitlab.com/naroj
  IMAGE_NAME: naroj.eu
  CONTAINER_NAME: naroj-eu
  CONTAINER_NETWORK: backend
  WHOOPER_ACTION: update-container
  WHOOPER_ENDPOINT: https://naroj.eu/event

Build site stage

Build a new version of the site in the pipeline to validate all content is valid. This will render markdown into plain HTML/CSS in the site directory.

build-mkdocs:
  stage: build
  image: python:3.11-slim-buster
  script:
    - echo "Installing MkDocs..."
      pip install -r requirements.txt
    - echo "Building MkDocs site..."
      python3 -m mkdocs build --config-file mkdocs.yml
  artifacts:
    paths:
      - site/

Build docker image stage

The docker image build takes the generated HTML & CSS from the previous step and adds it to an OCI image we can run in about any container runtime. Dockerfile needed to build the image.

build-docker-image:
  stage: push
  script:
    - echo "Logging in to GitLab Container Registry..."
    - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
    - echo "Building Docker image..."
    - docker build -t $CI_REGISTRY/naroj/$IMAGE_NAME:$CI_COMMIT_TAG .
    - docker tag  $CI_REGISTRY/naroj/$IMAGE_NAME:$CI_COMMIT_TAG $CI_REGISTRY/naroj/$IMAGE_NAME:latest
    #- echo "Pushing Docker image..."
    - docker push $CI_REGISTRY/naroj/$IMAGE_NAME:$CI_COMMIT_TAG
    - docker push $CI_REGISTRY/naroj/$IMAGE_NAME:latest
  only:
    - tags

Activate docker image stage

In this step we send an event using the 'https://naroj.eu/event' endpoint which tells our hosting infrastructure a new version of the site is ready to be deployed. This event receiver (called event-whooper) will take this event in after authentication and uses ansible-rulebook logic to talk to the container runtime API to make sure our desired version will be running in an idempotent manner.

activate-docker-image:
  stage: post
  image: archlinux:base
  script:
    - |
      curl -v -u "pipeline:$EVENT_PASS" -H 'content-type: application/json' \
      -d "{\"action\": \"$WHOOPER_ACTION\", \"name\": \"$CONTAINER_NAME\", \"registry\": \"$REGISTRY_NAME\", \"image\": \"$IMAGE_NAME\", \"tag\": \"$CI_COMMIT_TAG\", \"network\": \"$CONTAINER_NETWORK\"}" \
      $WHOOPER_ENDPOINT
  only:
    - tags

Event payload structure

{
  "action": "(update-container)",
  "name": "(name of our container process)",
  "registry": "(container registry)",
  "image": "(newly build container image)",
  "tag": "(git tag pushed after last content change)",
  "network": "(network our container is running in)"
}

4. Ansible rule book

Ansible rule book will receive a pipe line event containing the instruction to update a running container with a new version.

Ansible Rulebooks are a feature of Event-Driven Ansible (EDA) that enable automation based on events. They define a set of rules written in YAML, which specify event sources (e.g., webhooks, message queues) and actions to take when specific conditions are met. These rulebooks allow users to dynamically respond to real-time events, integrating Ansible's automation capabilities with event-driven architectures for tasks like scaling, notifications, or configuration management.

Scenario

Rule book

- name: Rulebook to control containers on locahost
  hosts: localhost
  gather_facts: false
  sources:
      - ansible.eda.webhook:
          host: 0.0.0.0
          port: 6000
  rules:
    - name: Launch playbook on update-container payload
      condition: |
        event.payload.action == "update-container" 
        and event.payload.image is defined
        and event.payload.name is defined
        and event.payload.tag is defined
      action:
        run_playbook:
          name: update_container.yml
          extra_vars:
            payload: "{{ event.payload }}"

Rulebook source

Payload data structure

{
  "action": "update-container",
  "image": "naroj.eu",
  "name": "naroj-eu",
  "registry": "registry.gitlab.com/naroj",
  "tag": "1.5",
  "network": "backend"
}

The action will be executed when the input payload structure matches the eda condition. This acion executes the docker ansible module which needs exposure to the local docker API. The action playbook (update_container.yml) invokes the docker_container module which idempotently ensures the container image is running with the specified version passed by the payload definition.

- name: Web blog
  community.docker.docker_container:
    name: "{{ payload.name }}"
    state: started
    restart_policy: always
    recreate: true
    image: "{{ payload.image }}:{{ payload.tag }}"
    networks:
      - "{{ payload.network }}"

Rule book logs

********************************************************************************
2025-01-12 23:02:08,259 - ansible_rulebook.rule_set_runner - DEBUG - Posting data to ruleset Rulebook to control containers on locahost => {'payload': {'action': 'update-container', 'name': 'naroj-eu', 'registry': 'registry.gitlab.com/naroj', 'image': 'naroj.eu', 'tag': '1.5', 'network': 'backend'}, 'meta': {'endpoint': '', 'headers': {'Host': 'naroj.eu', 'User-Agent': 'curl/8.11.1', 'Content-Length': '148', 'Accept': '*/*', 'Content-Type': 'application/json', 'X-Forwarded-For': 'XX.229.XX.XX', 'X-Forwarded-Host': 'naroj.eu', 'X-Forwarded-Proto': 'https', 'Accept-Encoding': 'gzip'}, 'source': {'name': 'ansible.eda.webhook', 'type': 'ansible.eda.webhook'}, 'received_at': '2025-01-12T23:02:08.254428Z', 'uuid': 'b00c0a2e-607a-419c-b74b-a2ceec3b1798'}}
2025-01-12 23:02:08 265 [main] DEBUG org.drools.ansible.rulebook.integration.api.rulesengine.RegisterOnlyAgendaFilter - Activation of effective rule "Launch playbook on update-container payload" with facts: {m={payload={action=update-container, name=naroj-eu, registry=registry.gitlab.com/naroj, image=naroj.eu, tag=1.5, network=backend}, meta={endpoint=, headers={Host=naroj.eu, User-Agent=curl/8.11.1, Content-Length=148, Accept=*/*, Content-Type=application/json, X-Forwarded-For=XX.229.XX.XX, X-Forwarded-Host=naroj.eu, X-Forwarded-Proto=https, Accept-Encoding=gzip}, source={name=ansible.eda.webhook, type=ansible.eda.webhook}, received_at=2025-01-12T23:02:08.254428Z, uuid=b00c0a2e-607a-419c-b74b-a2ceec3b1798}}}
2025-01-12 23:02:08,267 - drools.ruleset - DEBUG - Calling rule : Launch playbook on update-container payload in session: 1
2025-01-12 23:02:08,267 - ansible_rulebook.rule_generator - DEBUG - callback calling Launch playbook on update-container payload
2025-01-12 23:02:08,268 - ansible_rulebook.rule_set_runner - DEBUG - Creating action task action::run_playbook::Rulebook to control containers on locahost::Launch playbook on update-container payload
2025-01-12 23:02:08,268 - ansible_rulebook.rule_set_runner - DEBUG - call_action run_playbook
2025-01-12 23:02:08,268 - ansible_rulebook.rule_set_runner - DEBUG - substitute_variables [{'name': 'update_container.yml', 'extra_vars': {'payload': '{{ event.payload }}'}}] [{'event': {'payload': {'action': 'update-container', 'name': 'naroj-eu', 'registry': 'registry.gitlab.com/naroj', 'image': 'naroj.eu', 'tag': '1.5', 'network': 'backend'}, 'meta': {'endpoint': '', 'headers': {'Host': 'naroj.eu', 'User-Agent': 'curl/8.11.1', 'Content-Length': '148', 'Accept': '*/*', 'Content-Type': 'application/json', 'X-Forwarded-For': 'XX.229.XX.X1', 'X-Forwarded-Host': 'naroj.eu', 'X-Forwarded-Proto': 'https', 'Accept-Encoding': 'gzip'}, 'source': {'name': 'ansible.eda.webhook', 'type': 'ansible.eda.webhook'}, 'received_at': '2025-01-12T23:02:08.254428Z', 'uuid': 'b00c0a2e-607a-419c-b74b-a2ceec3b1798'}}}]
2025-01-12 23:02:08,271 - ansible_rulebook.rule_set_runner - DEBUG - action args: {'name': 'update_container.yml', 'extra_vars': {'payload': {'action': 'update-container', 'name': 'naroj-eu', 'registry': 'registry.gitlab.com/naroj', 'image': 'naroj.eu', 'tag': '1.5', 'network': 'backend'}}}