Hugo - Auto Publish

Nachdem ich nun meine Webseite und den Blog auf Hugo habe, hatte ich noch noch das Problem, wie aktualisiere ich nun die Webseite am Server, ohne ständig die Webseite über FTP/SFTP zu aktualsieren.

Das Bash-Skript

Die erste Idee war, das ganze über ein Bash-Skript am Server zu realisieren, was nach einigen Versuche sehr gut funktioniert.

SSH-Schlüssel

Dieser Teil wird nur benötigt, wenn das Projekt nicht öffentlich zugänglich ist

Hierfür muss noch ein paar zusätzliche Einstellungen vorgenommen werden, wenn die Verbindung wie bei mir über SSH aufgebaut wird. Zuerst benötigt man einen SSH-Schlüssel, den man am Repository hinterlegt. Zum erzeugen des Schlüssels wird der nachfolgende Befehl verwendet:

ssh-keygen -t rsa

Als erste wird der Dateiname abgefragt, sollte man nur eine Datei benötigen werden, kann diese beibehalten werden, sonst bitte einen neuen Namen vergeben (Standardname ist “.ssh/id_rsa.pub”). Danach noch 2 Abfragen für Passwort auf die Datei, was im Falle von autmoatischen Deploy schlecht wäre.

Sollte man einen neuen Namen vergeben haben, muss noch ein Eintrag in der “conf”-Datei erzeugt. Ein eigene Datei mit “.conf” als Dateiendung ist auch möglich. Ein Eintrage für die Config hat folgendes Format:

Host gitea.example.com
        User gitea
        IdentityFile ~/.ssh/giteaKey

Nun muss der Key noch am Projekt hinterlegt werden, hierfür wird bei Gitea das Projekt in der Weboberfläche geöffnet. Über Einstellungen -> Deploy-Schlüssel -> Deploy-Schlüssel hinzufügen muss der komplette Eintrag aus der “.pub”-Datei kopiert werden und mit Deploy-Schlüssel hinzufügen hinterlegt werden.

Zum Testen ob die Anmeldung nun funktioniert, kann über ssh-Kommando ein Verbindungsversuch durchgeführt werden:

ssh gitea.example.com

Wenn die Verbindung erfolgreich aufgebaut werden konnte, erscheint eine Willkommensnachricht. Sonst müssen ndie Einstellungen nochmal geprüft werden ob die richtigen Schlüssel verwendet werden.

Das Skript

Hier erstmal das Skript, im Nachgang ncoh die Beschreibung:

#!/bin/bash

## Variablen entsprechend der eigenen Verwendung anpassen
# Name des Servers, für den SSH-Server-Key
repo_srv=gitea.example.com
# Url zum Repository
repo_url=ssh://gitea@$repo_srv/my-user/my-hugo-site.git
# Pfad in dem das Repo abgelegt wird, sollte nicht der WWW-Ordner sein
repo_dir=/temp/repo
# Pfad zum Theme, das bei bedarf explizit nachgeleaden werden soll
repo_dir_theme=./themes/my-theme
# Zielverzeichnis in dem die Webseite liegt
web_root_dir=/home/myuser/wwwRoot

# Laufzeitvariablen
debug_active=0
theme_active=0
force_active=0

while test $# -gt 0; do
  # Die erlaubten Paramter durchprüfe und übernehmen
  case "$1" in 
    "debug")
      debug_active=1;;
    "theme")
      theme_active=1;;
    "force")
      force_active=1;;
  esac
  # Die Parameterauflistung weiterschieben
  shift
done

## TODO An dieser Stelle wird noch Code eingefügt

# Ab ins Home-Verzeichniss
pushd ~ &> /dev/null

[[ $debug_active -ne 0 ]] && echo "Aufrufparameter"
[[ $debug_active -ne 0 ]] && echo "> Debug: $debug_active"
[[ $debug_active -ne 0 ]] && echo "> Theme: $theme_active"
[[ $debug_active -ne 0 ]] && echo "> Force: $force_active"

# Repo-Ordner erzeugen wenn nicht vorhanden
if [ ! -d "$repo_dir" ]; then
  mkdir $repo_dir
fi

# SSH-Key des SSH-Servers annehmen, wenn es noch nicht vorhanden ist
if ! grep -q "$repo_srv" .ssh/known_hosts ; then
  echo "Get SSH Key"
  ssh-keyscan -t rsa "$repo_srv" >> .ssh/known_hosts
fi

# Der erste Clone des Repositories
if [ -z "$(ls -A $repo_dir)" ]; then
  echo "Clone git-repo"
  git clone --recurse-submodules $repo_url $repo_dir &> /dev/null
fi

pushd $repo_dir &> /dev/null
echo "Aktuellen Stand von Server holen"

cur_hash=`git rev-parse HEAD`
[[ $debug_active -ne 0 ]] && echo "> cur: $cur_hash"

# Aktuellen Stand vom Server holen
git reset --hard origin/main &> /dev/null
git pull --recurse-submodules &> /dev/null

new_hash=`git rev-parse HEAD`
[[ $debug_active -ne 0 ]] && echo "> new: $new_hash"

# Prüfe ob das theme direkt aktualisiert werden soll
if [ $theme_active -ne 0 ]; then
  pushd $repo_dir_theme &> /dev/null
  
  echo "Theme explizit laden"
  
  cur_hash_theme=`git rev-parse HEAD`
  [[ $debug_active -ne 0 ]] && echo "> cur: $cur_hash_theme"
  
  git reset --hard origin/main &> /dev/null
  git pull &> /dev/null
  
  new_hash_theme=`git rev-parse HEAD`
  [[ $debug_active -ne 0 ]] && echo "> new: $new_hash_theme"

  popd &> /dev/null
  # Hash ablöschen, damit im Falle des neuen Themes auch erstellt wird, wenn die Hashes gleich sind
  if [ "$cur_hash_theme" != "$new_hash_theme" ]; then
    echo "> Theme beinhaltet neue Daten"
    cur_hash=empty
  fi
fi

# Explizites neuübersetzen abholen
if [ $force_active -ne 0 ]; then
  cur_hash=empty
fi

# Vorherigen Hash mit den neuen vergleichen ob eine Änderung durchgeführt wurde
if [ "$cur_hash" != "$new_hash" ]; then
  echo "Neue Daten vorhanden, übersetzen und bereitstellen"
  git rev-parse HEAD > commit_id.txt
  
  echo "Erstelle die Seite und kopieren sie auf die Freigabe"
  hugo &> /dev/null
  cp -R ./public/. "$web_root_dir"
fi

popd &> /dev/null
popd &> /dev/null

Das Skript kann über 3 Parameter gesteuert werden, welche namentlich einfach an das Skript angehängt werden, z.B. hugoSync.sh debug. Die Parameter hierfür sind:

  • debug: Schreibt zusätzliche Informationen auf die Konsole
  • theme: In diesen Fall ist das Theme ein Submodule únd kann mit diesem Befehl explizit af den aktuellen Stand gebracht werden
  • force: Eine Aktualisierung der Seite erzwingen

In dem Skript wird das public-Verzeichnis explizit umkopiert, da es zum einen nicht sinnvoll ist, direkt den public-Ordner freizugeben. Zum anderen ist das Skript so vorbereitet, dass es über einen weiteren Parameter so umgestellt werden kann, um eine Test- und eine Produktiv-Seite zu erstellen.

Script direkt test

Mit dem nachfolgenden Befehl, kann es getestet werden.

deploy.sh debug force

CGI-Script

Da ein reines Bash-Skript immer den Nachteil hat, dass man sich nach dem Pushen noch explizit auf den Server verbinden und das Skript ausführen muss. Habe ich nach einer Lösung gesucht, wie das Projekt über einen Webhook aus gitea direkt deployed werden kann, sozusagen vollautomatisiert.

Damit dies funktioniert wird auf dem Server eine Script-Sprache benötigt. Normalerweise wird dafür PHP verwendet. Da ich aber nun schon ein fertiges Bash-Script hatte, wollte ich dies verwenden und habe einen Weg dazu gesucht.

VHost konfigurieren

Am sinnvollsten ist es, dafür im Apache2 einen eigenen VHost zu erstellen. Dieser sollte dann folgende Einstellungen bekommen:

<VirtualHost *:80>
  # Einstellungen über den Server selbst
  ServerName deploy.example.com
  ServerAdmin webmaster@example.com
  # Das Verzeichnis in dem das Dateien liegen, die ausgegeben werden
  DocumentRoot "/home/myuser/wwwRoot"
  # Eigene Log-Dateien für den VHost definieren
  LogLevel warn
  ErrorLog "/var/log/apache2/deploy.example.com-error.log"
  CustomLog "/var/log/apache2/deploy.example.com-access.log" combined
  # Hiermit wird der Benutzer unter dem dieser VHost läuft umgestellt
  <IfModule mpm_itk_module>
    AssignUserId myuser mygrp
  </IfModule>
  # Aktivieren vom CGI auf dem Ordner, der Pfad ist immer absolut anzugeben
  <Directory "/home/myuser/wwwRoot/*">
    Options +ExecCGI
    AddHandler cgi-script .cgi
    # Absicherung, dass nicht jeder einfach die Seite aufrufen darf
    Order deny,allow
    Deny from all
    Allow from 1.1.1.1 #Hier die öffentliche IP des servers einsetzen
    Allow from 127.0.0.0/24
  </Directory>
</VirtualHost>

Pfadangaben müssen entsprechend der eigenen Struktur angepasst werden. Weitere Informationen findet man direkt als Kommentare in der Konfiguration. Hier noch ein paar Worte zu 2 spezifischen Einstellung:

Ich benutze typischerweise einen User pro VHost. Damit dieser dann unter dem User läuft, wird das mpm_itk_module benötigt und die Einstellung AssignUserId myuser mygrp verwendet. Zusätzlich wird dieser Benutzer immer so angelegt, dass er sich nicht direkt anmelden kann, also die Shell wird auf ’/usr/sbin/nologin’ bzw. ’/bin/false’ gesetzt. Dies kann im Nachgang über den Befehl vipw eingestellt werden.

Der Directory-Eintrag ist für das aktivieren der Skript-Engine für das Bash-Skript. Wichtig hierbei ist, dass der Eintrag im Directory immer den vollen Pfad von Root aus definiert wird, und nicht vom DocumentRoot! Der Stern im Pfad bedeutet, dass alle Einstellungen auch für Unterorder gelten. Zuerst wird nun definiert, dass die CGI-Engine aktiv ist und nur die Dateiendung .cgi als Script ausgeführt wird. Die Nachträglichen Einstellungen sind dafür, dass niemand von ausen das Skripte aufrufen kann. Hierbei sollte man die 1.1.1.1 durch die öffentliche Addresse des Gitea-Server austauschen. Das Allow-Segment kann man mehrmals angegeben werden, und wie in der 2ten Zeile gezeigt für einen Adressbereich definiert werden.

Skript anpassen

Damit nun noch die Parameter auch aus der URL gesteuert werden können, muss das obige Skript um die nachfolgenden Zeilen erweitert werden. Hierfür hab ich im oberen Skript einen Kommentar hinterlegt, an dem dieser Teil eingesetzt werden sollte.

...

if [ ! -z "$QUERY_STRING" ]; then
  declare -a pairs
  IFS='&' read -ra pairs <<<"$QUERY_STRING"

  declare -A values
  for pair in "${pairs[@]}"; do
    IFS='=' read -r key value <<<"$pair"
    values["$key"]="$value"
  done
  
  if [ "${values[debug]}" == "1" ]; then
    debug_active=1
  fi
  if [ "${values[theme]}" == "1" ]; then
    theme_active=1
  fi
  if [ "${values[force]}" == "1" ]; then
    force_active=1
  fi
fi

...

Mann kann auch die Informationen aus dem Post-Aufruf ermitteln, da ich das Skript aber auch direkt auf der Konsole aufrufe, hab ich mich dazu entschieden die Commit-Informationen direkt aus dem Repo ermittle.

Hier aber noch die Information, wie man die POST-Daten ermitteln kann:

# Ermittle den Inhalt vom POST, aber nur wenn etwas angegeben wurd, sonst hängt das Skript am `read` bis zum timeout
[ -z "$POST_STRING" -a "$REQUEST_METHOD" = "POST" -a ! -z "$CONTENT_LENGTH" ] && read -n $CONTENT_LENGTH POST_STRING

Beispiel-Request von Gitea:

{
  "repository": {
    "name": "webhook-test",
    "git_url": "git://gitea.example.com/my-user/my-hugo-site.git",
    "ssh_url": "gitea@gitea.example.com/my-user/my-hugo-site.git",
    "clone_url": "https://gitea.example.com/my-user/my-hugo-site.git",
  }
}

Wenn man nun Daten aus dem Json-Result ermitteln will, kann hierfür der Befehl jq verwendet werden. Wenn man aus dem vorherigen Daten die clone_url ermittelt will, kann dies mit dem folgenden Kommando durchgeführt werden:

cloneUrl=`jq -r ".repository.clone_url" <<< "$jsonData"`

Script über den Server aufrufen

Mit dem nachfolgenden Befehl, kann die Webseite vom Server aus getestet werden.

wget -qO - "http://deploy.example.com/deploy.cgi?debug=1&force=1"

Einstellung am gitea

Auf der Webseite am Projekt muss man unter Einstellungen -> Webhooks -> Webhook hinzufügen -> Gitea angewählt werden. Nun noch unter Ziel-URL die URL von der Seite angeben, siehe Test-Aufruf. In meinen Fall hab ich eine Datei pro Repository, daher hab ich es auf POST-Aufruf gelassen. Der GET-Befehl hat nicht funktioniert, da scheinbar die URL zu lange wird, da der komplette Body über die URL mit übermittelt wird. Den Secret-Key sollte man nicht mehr verwenden, da diese eigentlcih seit 1.14 nicht mehr existieren sollte, laut Dokumentation.

Beim Auslösen muss man sich nun selbst entscheidet, bei welchen Events die Seite aufgerufen wird. Ich für meinen Fall hab es auf Benutzerdefinierte Events… umgestellt, und push ausgewählt, da ich nur bei neuen Commits das Skript ausführen möchte. Der Branch-Filter hab ich erstmal nicht verändert, würde aber Sinn machen wenn man z.B. 2 verschiedene Seiten, z.B. eine Test- und eine Produktiv-Seite damit aussteuern möchte.

Wenn man den Hook gespeichert hat und ihn erneut öffnet, kann am unteren Ende das senden getestet werden.

Sollte in der Webhook-Seite ein rotes Aufrufezeichen vor dem Hook gezeichnet sein, ist ein beim letzten Aufruf ein Fehler aufgetreten. Sonst erscheint ein grüner Hacken.

Für weitere Informationen kann man auch die englische Dokumentation bei gitea zu Rate ziehen.

weitere Ideen

Ich werde das ganze so noch umsetzen, dass ich dem Skript als Parameter noch mitgeben kann, ob es in der Test- oder in der Produktiv-Umgebung bereitgesetellt werden soll. Hierfür werden ich einen 2ten Webhook erstellen, der dann das Event entweder auf Erstellen (neue Tags erzeugen) oder auf Release setzen werden. Zusätzlich wird im Falle der Test-Seite dann beim Hugo-Aufruf noch das -D für Draft übergeben, um noch nicht fertige Seite mit darzustellen. Und im Falle der Produktiv-Seite wird beim Hugo-Aufruf noch ein --minify mit angegeben umd die Seite so klein wie möglich zu halten.