A Solar Powered GitHub Runner

created: sábado, abr. 15, 2023

Cuanto más avanza nuestro pequeño proyecto, más código producimos. Y como la mayoría de nuestra comunidad hoy en día, alojamos nuestro código en GitHub.

Así que durante el desarrollo discutimos las implicaciones de una arquitectura DownToZero para algo como un proceso de GitHub. Rápidamente identificamos dos tipos de acciones que deben realizarse en la infraestructura.

La primera es la compilación CI, que siempre debe proporcionar retroalimentación inmediata al desarrollador. Verifica el cumplimiento así como la integridad del código y usualmente está profundamente involucrada en el proceso de desarrollo. Estos trabajos son sensibles al tiempo porque alguien usualmente los está esperando.

La segunda categoría que identificamos es un poco diferente. Con el auge de dependabot y otros escáneres de seguridad, vimos que cada vez más pipelines se activaban por estos bots. Lo que pasa es que queremos ejecutar la pipeline para revisar nuestras dependencias y mantener nuestra base de código actualizada, pero al mismo tiempo, nadie está esperando esas pipelines. Así que no haría diferencia que esas pipelines se retrasaran.

Por eso revisemos la segunda categoría y veamos si podemos construir algo dentro de GitHub. Bueno, GitHub permite a cualquiera adjuntar runners autoalojados a cualquier proyecto (alojar tus propios runners). Si miras el proceso, es relativamente sencillo: descargar el runner, adjuntarlo a tu organización o repositorio y luego ejecutar el script shell. También hay un pequeño auxiliar que convierte este runner en un servicio systemd, para que no tengamos que iniciar y detener el servicio nosotros mismos.

Mirando la distribución de trabajos, GitHub dice que el trabajo en cola se mantiene durante 24 horas. Dentro de ese periodo el trabajo debe ser tomado o expirará. Así que 24 horas es técnicamente tiempo suficiente para esperar a que salga el sol, sin importar cuándo se haya generado el trabajo.

Con esto cubierto, empezamos a mirar nuestra configuración local y cómo podemos lograr tal planificación de capacidad. Nuestra configuración actual es como sigue.

solar-powered-runner

  1. tenemos paneles solares que producen energía
  2. tenemos máquinas locales que pueden consumir esa energía y tienen acceso a internet
  3. GitHub provee la cola persistente de trabajos para nuestro runner

No tenemos ningún almacenamiento con baterías conectado, porque eso haría todo el sistema más caro y complejo.

Todas las métricas, como la producción energética del panel solar o el consumo energético de los servidores, son monitoreadas por dispositivos independientes tasmota (CloudFree EU Smart Plug).

Así que conectamos todo. Por comodidad, instalamos ubuntu 22.10 (el mismo usado para el runner alojado en GitHub) en nuestras máquinas. También instalamos la cadena de herramientas que necesitábamos, como rustup, gcc-musl, protobuf.

Ahora, escribimos 3 servicios systemd independientes.

1) Servicio Dtz-Edge

El primer servicio está siempre corriendo y lee la producción energética desde HomeAssistant (aquí es donde se agregan nuestros datos de energía). También toma en cuenta qué otros dispositivos están corriendo y cuánto consumo de energía tienen. Implementa el siguiente modelo de estados:

solar state model

Definición del servicio systemd

[Unit]
Description=dtz edge Service

[Service]
Type=simple
WorkingDirectory=/root/dtz-edge
ExecStart=!/root/dtz-edge/busy.sh
Restart=always

[Install]
Alias=dtz-edge
WantedBy=multi-user.target

Script shell busy.sh (versión abreviada)

#!/bin/bash

for (( ; ; ))
do

POWER=`curl -H 'Authorization: Bearer token1' -H "Content-Type: application/json" http://192.168.178.76:8123/api/states/sensor.solar_panel_energy_power 2> /dev/null | jq -r .state`

METER=`curl -H 'Authorization: Bearer token1' -H "Content-Type: application/json" http://192.168.178.76:8123/api/states/sensor.tasmota_energy_power_4 2> /dev/null | jq -r .state`

SALDO=$((POWER - METER))
echo "Saldo: $SALDO (solar: $POWER)"

CURRENT_HOUR=`date +%H`

if [ $CURRENT_HOUR -gt 17 ]; then
  service cheap-energy stop
  service actions.runner.DownToZero-Cloud.dtz-edge1 stop
  echo "sleep till tomorrow (10h)"
  rtcwake -m disk -s 36000
fi
if [ $SALDO -gt 70 ]; then
    echo "more then 70: $SALDO"
    service cheap-energy start
    service actions.runner.DownToZero-Cloud.dtz-edge1 start
    sleep 300;
else
    service cheap-energy stop
    rtcwake -m mem -s 660
fi
done

2) Servicio Cheap-Energy

Este servicio solo mantiene el estado de que hay energía barata disponible. Así que cuando este servicio systemd está activo, significa que hay energía disponible; cuando se detiene, todos los workers deberían apagarse. Por eso usamos este servicio como un proxy para facilitar la gestión.

[Unit]
Description=cheap energy

[Service]
Type=simple
WorkingDirectory=/root/dtz-edge
ExecStart=!/root/dtz-edge/cheap-energy.sh
Restart=always

[Install]
Alias=cheap-energy
WantedBy=multi-user.target

El script que ejecutamos aquí es simplemente un comando sleep.

#!/bin/bash

sleep infinity

3) Servicio GitHub Runner

Seguimos las instrucciones proporcionadas por GitHub e instalamos el runner como un servicio systemd.

sudo ./svc.sh install

Esto ya nos proporcionó la definición correcta del servicio y la única cosa que necesitábamos cambiar era la línea de dependencia del servicio. Porque ahora queremos que este servicio corra siempre que el servicio cheap-energy esté activo, y también se detenga cuando se detenga cheap-energy.

Así que cambiamos la definición de nuestro servicio (actions.runner.DownToZero-Cloud.dtz-edge1.service) para incluir la descripción BindsTo.

[Unit]
Description=GitHub Actions Runner (DownToZero-Cloud.dtz-edge1)
After=network.target
BindsTo=cheap-energy.service

[Service]
ExecStart=/home/user1/gh-dtz-org/runsvc.sh
User=user1
WorkingDirectory=/home/user1/gh-dtz-org
KillMode=process
KillSignal=SIGTERM
TimeoutStopSec=5min

[Install]
WantedBy=multi-user.target

Ahora que tenemos la parte de hardware de la solución configurada, volvamos al lado de GitHub. Ahora tenemos 2 tipos de runners en nuestra interfaz de GitHub. Uno es el runner alojado por GitHub, donde queremos que se ejecuten nuestras tareas tipo 1, y otro es nuestro grupo dtz-edge que solo se activa cuando hay suficiente energía solar.

solar pool in GitHub

Dividamos nuestras definiciones de pipeline.

Para los trabajos tipo 1, todo puede mantenerse como una pipeline normal de GitHub.

name: build
on:
  workflow_dispatch:
  push:
    branches:
    - main
jobs:
  build:
    permissions: write-all
    runs-on: ubuntu-latest

Para los trabajos tipo 2, es decir, trabajos que queremos ejecutar con retardo en las máquinas alimentadas por energía solar, solo tenemos que definir la sección de disparadores on para incluir los escenarios que deberían ser soportados aquí. En nuestro caso empezamos haciendo esto para todos los pull-requests. Luego, lo único que se necesita cambiar es la expresión runs-on. Aquí colocamos nuestro runner recién generado.

name: pr
on:
  workflow_dispatch:
  pull_request:
jobs:
  test:
    name: coverage
    runs-on: self-hosted

Así que ahora, cada vez que dependabot nos envíe algunas actualizaciones para fusionar, o algún otro bot quiera revisar tests y cobertura de código, esos trabajos se ejecutarán cuando tengamos los recursos para hacerlo.

Como un beneficio adicional, tampoco tenemos que pagar más por estos runners adicionales. Los runners on-premises son gratuitos (en el sentido de facturación de GitHub).