Hardware in the loop notturno su GitHub Actions

Integrare un progetto embedded nel sistema di continuous integration (CI) GitHub Actions.

In questo articolo vedremo come integrare un progetto embedded nel sistema di continuous integration (CI) GitHub Actions, al fine di automatizzare l’attività di testing su schede hardware. Vedremo, inoltre, come eseguire testing notturno sui rami in fase di sviluppo.

Continuous Integration

Una parte molto importante dello sviluppo software è rappresentata dalla fase di testing. Al fine di garantire la non regressione di funzionalità e scongiurare il sopravvento di bug, sappiamo che è sempre buona norma aggiungere test di unità e di integrazione. Tali test si eseguono solitamente in locale, ma un processo di sviluppo software ben strutturato non mancherà di prendere in considerazione l’utilizzo di uno strumento di continuous integration (CI), per automatizzare l’esecuzione di tali test. Più in dettaglio, non si tratta solo di uno strumento per eseguire attività, ma è anche una strategia di sviluppo vera e propria. In definitiva, il processo di sviluppo software ci consente di utilizzare in modo più efficace gli strumenti presenti sul mercato per realizzare progetti professionali. In questo articolo faremo riferimento alla CI di GitHub, ovvero GitHub Actions (GA), per l’automatizzazione dei workflow di sviluppo (qui trovi un video di introduzione alle GitHub Actions).

Tutto bene, però dato che  abbiamo accennato ad un progetto embedded, come possiamo integrare test su hardware vero e proprio, tramite GitHub Actions?

Hardware in the Loop

Al fine di validare completamente il firmware di un progetto embedded, è necessario integrare nel nostro workflow il passo di hardware in the loop (HIL). Quest’ultimo consiste nell’avere un banchino completo di schede e attrezzature per la simulazione e la verifica delle stesse. Eseguiremo tale passo di workflow ogni volta che facciamo push sul repository GitHub. Spesso integrare la componente hardware risulta più difficile, rispetto ai soli test di unità. Tuttavia, raggiungere questo obiettivo renderà possibile l’esecuzione di test end-to-end sul sistema completo.

Ciò che vogliamo realizzare si riassume con l’immagine seguente, in cui si vede un controllo di CI aggiuntivo su una PR aperta, che rappresenta i test end-to-end:

Controllo CI aggiuntivo

L’utilizzo base delle Actions però prevede esecutori o runner che girano su cloud. Quindi come fare per accedere al nostro banchino attrezzato con tutte le schede e gli strumenti necessari?

Self-Hosted Runner

La soluzione ci viene fornita direttamente da GitHub, per mezzo di configurazione di un self-hosted runner. In poche parole, esso è un esecutore che gira su una nostra macchina locale, adibita al testing e che riesca ad accedere alle schede embedded. Facciamo riferimento in particolare ad una macchina basata su Linux/Ubuntu. Questa operazione è molto semplice, perché è sufficiente seguire le istruzioni per l’aggiunta di un self-hosted runner, sia a livello di progetto GitHub che di componenti software nella nostra macchina locale. Se la procedura va a buon fine, il runner sarà visibile ed online sul nostro progetto:

Self-hosted runner

Rispetto alle istruzioni fornite da GitHub, abbiamo deciso di configurare il runner per girare all’interno di un container Docker. Non è strettamente necessario, ma così facendo risulta più semplice installare il runner su una distribuzione Linux pulita (in questo caso, Ubuntu), oppure eventualmente avviare più runner nella stessa macchina.

Le parti salienti del Dockerfile per la configurazione del runner sono le seguenti:

# Set same user and docker id as the host's,
# in order to run docker from the container.
ARG UID
ARG GID
ENV USER=builder
RUN useradd -u ${UID} -s /bin/bash --create-home ${USER} \
&& groupadd docker -g ${GID} \
&& usermod -aG docker ${USER}
USER ${USER}
WORKDIR /home/${USER}


# Install GitHub runner
ARG PROJECT
ARG TOKEN
ENV VERS=2.300.2
ENV ARCH=actions-runner-linux-x64-${VERS}.tar.gz
ENV NAME=my-hil-runner
RUN curl -o ${ARCH} -L https://github.com/actions/runner/releases/download/v${VERS}/${ARCH} \
&& echo "ed5bf2799c1ef7b2dd607df66e6b676dff8c44fb359c6fedc9ebf7db53339f0c ${ARCH}" | shasum -a 256 -c \
&& tar xf ${ARCH} \
&& rm ${ARCH} \
&& ./config.sh \
--url https://github.com/develersrl/${PROJECT} --token ${TOKEN} \
--unattended --name ${NAME} --labels 'HIL' --replace

La prima parte del Dockerfile configura l’utente builder e lo aggiunge al gruppo docker. Infatti, un dettaglio importante è che il progetto embedded fa uso di Docker a sua volta, al fine di rendere riproducibili le build. Senza entrare nel dettaglio tecnico, Docker su embedded facilita la gestione della toolchain e la compilazione da parte degli sviluppatori. Ci troviamo di fronte perciò ad un caso di docker-in-docker. I valori UID e GID sono parametri passati al momento della build dell’immagine Docker. Essi fanno riferimento rispettivamente all’ID dell’utente Linux che esegue il runner e all’ID del gruppo docker di sistema.

La seconda parte del Dockerfile, invece, scarica il runner da GitHub e lo configura in modo appropriato, per il progetto definito dal parametro PROJECT. Per completare l’associazione è necessario l’argomento TOKEN, che ci viene fornito dalla procedura di GitHub per l’aggiunta del runner.
In definitiva, tralasciando la valorizzazione delle variabili che abbiamo già menzionato, l’immagine Docker può essere costruita col comando docker build:

docker build -t "my-hil-runner"
--build-arg PROJECT=my-project \
--build-arg TOKEN=XXXXXXXX \
--build-arg UID=100 \
--build-arg GID=200 \
- < Dockerfile

Vediamo adesso come avviare il container Docker nella nostra macchina Ubuntu.

Avvio del Runner Docker via Systemd

Il pacchetto GitHub per la distribuzione del runner prevede due modalità di avvio: (i) utilizzare uno script da console, oppure (ii) tramite una unità Systemd. Visto che  stiamo parlando di una CI, vogliamo che il runner sia messo in esecuzione al boot di sistema. Si rende perciò necessaria una unità Systemd, che faccia partire il runner come container Docker.

[Unit]
Description=GitHub Actions Self Hosted Runner
After=network-online.target
After=docker.target


[Service]
ExecStart=/<path>/<to>/start_script.sh
ExecStop=docker stop %n
User=<user>
WorkingDirectory=/<path>/<to>/<dir>
TimeoutStopSec=5min


[Install]
WantedBy=multi-user.target

Lo script di esecuzione del runner, riferito come start_script.sh nell’unità Systemd, è sostanzialmente un wrapper per il comando docker run:

#!/bin/bash


image="my-hil-runner"


docker run --rm --net=host \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /home/builder \
-v /tmp \
-e CID=${image} \
--name ${image} ${image} ./bin/runsvc.sh

Da notare alcune particolarità per l’avvio del container:

Adesso che il runner è funzionante ed associato al progetto, non ci resta che scrivere dei workflow GitHub Actions per utilizzarlo.

Workflow notturno per rami di sviluppo

La parte che esegue davvero la verifica del firmware si basa sulla definizione di un workflow GitHub Actions con una logica che si basa a grandi linee su questi concetti:

  1. Il banchino di verifica è unico, quindi solitamente viene usato in modo attivo dagli sviluppatori durante le ore diurne di lavoro.
  2. Di norma un workflow CI parte su ogni push del codice verso il repository remoto. Però i test end-to-end possono disturbare le normali attività di sviluppo e viceversa, dato che è necessario l’uso esclusivo del banchino.
  3. I test end-to-end sono molto più lenti di quelli di unità/integrazione. Possono durare ad esempio 30 minuti. Se abbiamo molti rami di sviluppo, o anche molti push sullo stesso ramo, il banchino potrebbe risultare occupato per molto tempo.
  4. I test end-to-end dovrebbero essere eseguiti durante la notte, quando non vanno in conflitto con lo sviluppo software.

Non abbiamo trovato nessuna action che facesse al caso nostro, quindi abbiamo deciso di utilizzare manualmente le API REST di controllo per interagire con la CI.

Passo 1: Workflow ad orario notturno

Per prima cosa ci serve un workflow che parta ad un certo orario serale, rappresentato da un file in questa posizione (rispetto alla cartella root di progetto): .github/workflows/wheel.yml

name: Nightly Maintenance


on:
  # manual trigger
  workflow_dispatch:


  # 20.00 UTC, Monday-Friday daily
  # (21.00 CET or 22.00 CEST)
  # It runs on the latest commit of the default branch (main)
  # https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule
  schedule:
    - cron: "0 20 * * MON-FRI"


jobs:
  wheel:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Trigger manual workflows
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          ./script/gh_trigger_workflow

Alle ore (circa) 20:00 UTC di ogni giorno lavorativo della settimana, si programma di eseguire un’azione specifica. In particolare l’azione consiste nell’eseguire uno script il cui scopo è avviare i test end-to-end sulle pull-request (PR) in attesa di essere verificate.

Passo 2: Recupero delle PR aperte.

Lo script in oggetto, riferito dal nome gh_trigger_workflow, si occupa di far partire il workflow effettivo di hardware in the loop (hil.yml) sulle PR aperte e sul ramo main:

#!/bin/bash
# Trigger HIL workflow on pending PRs


function gh_api {
  local repo="https://api.github.com/repos/develersrl/my-project"
  local command=$1
  shift
  gh api ${repo}${command} \
    -H "Accept: application/vnd.github+json" \
    -H "X-GitHub-Api-Version: 2022-11-28" \
    "$@"
}


# Trigger default branch
default_latest_sha=$(gh_api /commits \
  -X GET \
  -F "per_page=1" \
  -F "page=1" \
  --jq '.[].sha')


hil_name="Hardware in the Loop"
default_hil_found=0
while read line; do
  echo "$line" | grep -q "$hil_name" && {
    default_hil_found=1
    break
  } || true
done < <(gh_api /commits/$default_latest_sha/check-runs \
  --jq '.check_runs[] | [.name, .status] | @tsv' \
)


[ $default_hil_found -eq 0 ] && {
  echo "trigger hil for default branch ($default_latest_sha)"
  gh workflow run hil.yml --ref main || true
}


# Trigger pending PR branches
gh pr list --search \
  "is:open status:pending status:failure" \
  --json headRefName \
  --jq '.[].headRefName' \
| while read branch; do
  echo "trigger hil for branch $branch"
  gh workflow run hil.yml --ref $branch || true
done

Possiamo notare l’utilizzo del tool gh, ovvero lo strumento command line ufficiale di GitHub, per interagire con la CI ed il repository. La funzione gh_api è un wrapper su tale strumento per evitare di ripetere i parametri fissi. Ad esempio, la prima operazione sul repository consiste nel recuperare il valore SHA dell’ultimo commit del ramo main. Per fare questo si utilizza l’endpoint /commits, filtrando poi il risultato con la sintassi di jq, per valorizzare la variabile default_latest_sha.

Successivamente lo script procede recuperando la lista dei controlli di CI eseguiti sull’ultimo commit del ramo main, tramite l’endpoint /check-runs. Se il workflow con nome “Hardware in the loop” (vedere paragrafo successivo) non è stato trovato, allora significa che non è stato eseguito e quindi sarà fatto partire tramite il comando:

$> gh workflow run hil.yml --ref main

Eseguire i test sull’ultimo commit del ramo main è molto utile per evitare regressioni, soprattutto per il rilascio di versioni.

L’ultima parte dello script, infine, usa lo stesso approccio per elencare le PR che risultano ancora aperte ed in stato di attesa, dal punto di vista dei controlli CI. Per ogni PR della lista ottenuta si procede a lanciare il workflow hil.yml. Il runner farà poi la verifica di tutte queste PR, una dopo l’altra.

Passo 3: Esecuzione dei test end-to-end su una PR.

Come accennato ai punti precedenti, l’esecuzione vera e propria dei test end-to-end su una PR si realizza tramite un workflow separato, con percorso .github/workflows/hil.yml. Tale workflow è molto specifico di progetto, però ci sono alcuni passi importanti da commentare.

name: Hardware in the Loop


on:
workflow_dispatch:


jobs:
  hil:
    runs-on: [self-hosted, Linux, HIL]
    permissions: write-all


    steps:
      - uses: actions/checkout@v3


      - name: Report check in progress
        if: always()
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          curl \
            -X POST \
            -H "Accept: application/vnd.github+json" \
            -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
            -H "X-GitHub-Api-Version: 2022-11-28" \
            https://api.github.com/repos/develersrl/my-project/check-runs \
            -d '{"name":"${{ github.workflow }}",
                 "head_sha":"${{ github.sha }}",
                 "status":"in_progress"}'


      - name: E2E Tests


      [...]


      - name: Report check completed
        if: always()
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          curl \
            -X POST \
            -H "Accept: application/vnd.github+json" \
            -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
            -H "X-GitHub-Api-Version: 2022-11-28" \
            https://api.github.com/repos/develersrl/my-project/check-runs \
            -d '{"name":"${{ github.workflow }}",
                 "head_sha":"${{ github.sha }}",
                 "status":"completed",
                 "conclusion":"${{ job.status }}"}'

Tralasciando la parte corposa di istruzioni per eseguire test veri e propri (che prevede anche la riprogrammazione col firmware di sviluppo di tutte le schede embedded coinvolte), all’inizio possiamo notare uno step chiamato “Report check in progress”. Lo step iniziale utilizza l’API /check-runs, questa volta tramite l’utility curl, per creare un nuovo check che mostri graficamente l’animazione del workflow in atto di esecuzione, nello stato dei controlli CI di una PR.

Animazione del workflow in atto di esecuzione, nello stato dei controlli CI di una PR

Al contrario lo step finale, nominato “Report check completed”, serve per assegnare lo stato di completamento al check di CI della PR, che può essere di successo o fallimento.

Step finale, controlli completati

Conclusioni

Ai fini dello sviluppo di firmware per un progetto embedded, l’utilizzo della pratica HIL è stato davvero vantaggioso. Non solo deleghiamo ad uno strumento automatico una parte piuttosto noiosa di test manuali, bensì rendiamo tutto il processo di sviluppo software complessivamente più robusto. In tutta questa storia ci sono ovviamente anche dei punti di svantaggio. La prima osservazione riguarda il confronto con app a pagamento che offrono dei servizi, come ad esempio i controlli CI per la valutazione della copertura. Queste app spesso raggiungono livelli più alti di integrazione, coprendo delle casistiche che non vengono immediatamente in mente. Ad esempio, potrebbe essere utile avere un testo di output con informazioni aggiuntive per un controllo CI e non solo uno stato finale di successo o fallimento. Poi c’è da menzionare una certa difficoltà nell’implementare la funzionalità voluta sulla nostra CI. Non è semplice fare debugging di un workflow, che parte con un certo ritardo, ci mette del tempo a completare, si perde a volte l’output di eventuali comandi che falliscono, etc. Non da meno, il vincolo di dover fare push force (anche sul ramo principale) o PR di prova. In particolare, su GitHub una PR di prova non si può cancellare del tutto, ma soltanto chiudere. Essa rimarrà per sempre nella storia del progetto. Infine, le API REST sono un po’ intricate ed a volte non è chiaro come si concretizzano sull’interfaccia web, per ottenere quello che abbiamo in mente. Insomma, ci vuole molta pazienza, ma ne vale la pena!