release.sh prueft sein fertiges Archiv binaer gegen die Werte aus config.local.cmake und bricht ab, wenn es etwas findet. deploy.sh signierte dagegen ungeprueft, was in build-nocreds/ lag - und genau dieser Satz ist die sichere Vorgabe, die im Automatikmodus an fremde Lampen in Funkreichweite geht. Der Weg dorthin ist Bedienfehler, nicht Programmfehler: ein von Hand konfiguriertes build-nocreds/<ziel>, ein gesetztes NINLIGHT_NOCREDS_BUILD_REL=build. Das Ergebnis waere, dass das Panel "ohne WLAN" anzeigt, waehrend das WLAN-Passwort des Bedieners unwiderruflich in fremde Lampen wandert. Derselbe Pruefkoerper wie in release.sh, angewandt auf $STAGE/migrate-nocreds und VOR dem Uebertragen - bei einem Treffer geht kein Byte an den Pi. Szenario 33 stellt den undichten Abzug nach (mit Gegenprobe, dass der Abzug die Zugangsdaten wirklich traegt), Szenario 34 grenzt ab, dass migrate-wlan sie weiterhin tragen darf. Mutationsproben: Pruefblock entfernt = 6 Zusagen rot (die Zugangsdaten landen beim Ziel); Pruefung auf den ganzen STAGE ausgeweitet = 4 Zusagen rot.
540 lines
26 KiB
Bash
Executable File
540 lines
26 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Liefert Firmware-Images und den Stationscode an den Pi aus.
|
|
#
|
|
# Aufruf: tools/station/deploy.sh <pi-host> [target ...]
|
|
# Ohne target-Argumente werden alle in build/*/ gefundenen _ota.img uebertragen.
|
|
set -euo pipefail
|
|
HOST=${1:?Aufruf: tools/station/deploy.sh <pi-host> [target ...]}
|
|
shift || true
|
|
HERE=$(cd "$(dirname "$0")" && pwd)
|
|
ROOT=$(cd "$HERE/../.." && pwd)
|
|
MKIMAGE="$ROOT/tools/mkimage/nl_mkimage.py"
|
|
# /usr/bin/ssh statt einem ssh im PATH, das sich anders verhaelt (siehe A3-Notiz im
|
|
# Abnahmebericht). Ueberschreibbar, damit tests/test_deploy.sh gegen einen Stub statt
|
|
# einen echten Pi laufen kann - dasselbe Prinzip wie bei NINLIGHT_STATION_ROOT unten.
|
|
SSH_BIN=${NINLIGHT_SSH_BIN:-/usr/bin/ssh}
|
|
|
|
declare -a FILES=()
|
|
if [ $# -gt 0 ]; then
|
|
# Gezielter Deploy: nur die genannten Geraetetypen werden ersetzt, alle
|
|
# anderen Images im Zielverzeichnis bleiben unangetastet (siehe FULL_SYNC
|
|
# unten).
|
|
FULL_SYNC=0
|
|
for t in "$@"; do
|
|
f="$ROOT/build/$t/ninlight_${t}_ota.img"
|
|
[ -f "$f" ] || { echo "Fehler: $f fehlt - erst bauen" >&2; exit 1; }
|
|
FILES+=("$f")
|
|
done
|
|
else
|
|
FULL_SYNC=1
|
|
# Zwei Build-Verzeichnisse koennen denselben Image-Dateinamen liefern (z.B.
|
|
# build/evk/ und ein Nebenschauplatz wie build/evk-onb/, das ohne WLAN-
|
|
# Zugangsdaten gebaut wurde). Landen beide flach im selben STAGE/$base,
|
|
# entscheidet allein die Sortierreihenfolge, welches gewinnt - und die
|
|
# Trailer-Pruefung merkt nichts, weil in beiden derselbe Geraetetyp steht.
|
|
# Ein zweiter Fundort fuer denselben Dateinamen ist deshalb ein Abbruch,
|
|
# kein Zufall.
|
|
declare -A SEEN_AT=()
|
|
while IFS= read -r f; do
|
|
base=$(basename "$f")
|
|
if [ -n "${SEEN_AT[$base]:-}" ]; then
|
|
echo "Fehler: $base mehrfach gefunden - ${SEEN_AT[$base]} und $f. Voller Deploy abgebrochen, welches Bild echt ist, ist nicht entscheidbar. Nicht benoetigtes Build-Verzeichnis loeschen oder gezielt deployen." >&2
|
|
exit 1
|
|
fi
|
|
SEEN_AT[$base]=$f
|
|
FILES+=("$f")
|
|
done < <(find "$ROOT/build" -maxdepth 2 -name 'ninlight_*_ota.img' | sort)
|
|
[ ${#FILES[@]} -gt 0 ] || { echo "Fehler: keine Images unter $ROOT/build gefunden" >&2; exit 1; }
|
|
fi
|
|
|
|
echo "== Images pruefen =="
|
|
STAGE=$(mktemp -d)
|
|
trap 'rm -rf "$STAGE"' EXIT
|
|
printf '%-26s %-10s %s\n' GERAET VERSION BYTES
|
|
declare -A NINL_VERSION=()
|
|
for f in "${FILES[@]}"; do
|
|
base=$(basename "$f")
|
|
target=${base#ninlight_}; target=${target%_ota.img}
|
|
# Ein Image, das die Pruefung nicht besteht, wuerde jede Lampe ablehnen -
|
|
# das faellt sonst erst am Geraet auf.
|
|
out=$(python3 "$MKIMAGE" --verify "$f" --expect-device "$target") || {
|
|
echo "Fehler: $base besteht die Trailer-Pruefung nicht, nichts uebertragen" >&2; exit 1; }
|
|
# shellcheck disable=SC2086
|
|
set -- $out
|
|
printf '%-26s %-10s %s\n' "$1" "$2" "$3"
|
|
NINL_VERSION[$target]="$2"
|
|
cp "$f" "$STAGE/$base"
|
|
done
|
|
|
|
echo "== Migrationsimages signieren =="
|
|
# Die Signierschluessel gehoeren nie ins Repo und nie auf den Pi - sie werden hier nur
|
|
# gelesen, unter $NINLIGHT_SIGN_DIR (Vorgabe ~/workspace/fw-sign auf DIESEM Rechner).
|
|
# Fehlt das Verzeichnis oder ein einzelner Schluessel, entfaellt die jeweilige
|
|
# Migrationsvariante mit einer Meldung - die Rettungsimages (_ota.img) werden trotzdem
|
|
# ausgeliefert (Exit-Code bleibt 0).
|
|
# ${HOME:-} statt $HOME: unter `set -u` (z.B. via cron oder env -i) waere ein
|
|
# unbound-variable-Abbruch hier voellig unnoetig, denn selbst ohne Migrationsimages
|
|
# sollen die Rettungsimages ausgeliefert werden (Fixrunde 1, Kleinigkeit 1).
|
|
SIGN_DIR=${NINLIGHT_SIGN_DIR:-${HOME:-}/workspace/fw-sign}
|
|
KEY_PROD="$SIGN_DIR/QCX_Device_CA/qcx_firmware_signing_key.pem"
|
|
KEY_DEV="$SIGN_DIR/fw_sign.pem"
|
|
NL_SIGN="$ROOT/tools/mkimage/nl_sign.py"
|
|
# MIGRATE_COMPLETE=1 bedeutet: dieser Lauf hat mit BEIDEN Schluesseln signiert, das
|
|
# lokale STAGE/migrate beschreibt also den vollstaendigen Satz an Migrationsvarianten
|
|
# fuer FILES. Nur dann darf der Abgleich auf dem Pi mit --delete arbeiten bzw. das
|
|
# Manifest blind ersetzen - sonst wuerden Images/Eintraege einer Variante verschwinden,
|
|
# die dieser Lauf mangels Schluessel gar nicht neu bauen konnte (Fixrunde 1, C2/I1).
|
|
MIGRATE_COMPLETE=0
|
|
# Dasselbe fuer den Satz OHNE WLAN-Zugangsdaten (siehe unten): "vollstaendig" heisst hier
|
|
# zusaetzlich, dass fuer JEDES Ziel ein Abzug aus build-nocreds/ vorlag - fehlt auch nur
|
|
# einer, darf --delete auf dem Pi nicht laufen.
|
|
NOCREDS_COMPLETE=0
|
|
# Wo die Abzuege OHNE einkompilierte WLAN-Zugangsdaten liegen (tools/build_nocreds.sh,
|
|
# dieselbe Variable wie dort und in tools/release.sh).
|
|
NOCREDS_BUILD_REL=${NINLIGHT_NOCREDS_BUILD_REL:-build-nocreds}
|
|
|
|
# Signiert einen kompletten Satz Migrationsimages und schreibt sein manifest.json.
|
|
# $1 = Quellverzeichnis der Abzuege (build bzw. build-nocreds), absolut
|
|
# $2 = Zielverzeichnis unterhalb von $STAGE (migrate-wlan bzw. migrate-nocreds)
|
|
# $3 = "strict": ein fehlendes _gz.img ist ein Abbruch. "lax": es wird uebersprungen.
|
|
# Gibt 0 zurueck, wenn der Satz VOLLSTAENDIG entstanden ist (kein Ziel uebersprungen),
|
|
# sonst 1. Legt $STAGE/$2 nur an, wenn wirklich etwas darin landet - ein leeres
|
|
# Verzeichnis wuerde auf dem Pi mit --delete einen vorhandenen Satz leerraeumen.
|
|
sign_set() {
|
|
local src_root=$1 stage_sub=$2 strictness=$3
|
|
local complete=1 produced=0
|
|
local f base target gz
|
|
for f in "${FILES[@]}"; do
|
|
base=$(basename "$f")
|
|
target=${base#ninlight_}; target=${target%_ota.img}
|
|
# Signiert wird IMMER das rohe _gz.img, niemals das fertige _ota.img: beide
|
|
# belegen denselben 512-Byte-Block am Dateiende (Trailer bzw. Signatur), ein
|
|
# signiertes _ota.img waere ein Image, das die alte Lampe ablehnt oder halb
|
|
# annimmt.
|
|
gz="$src_root/$target/ninlight_${target}_gz.img"
|
|
if [ ! -f "$gz" ]; then
|
|
if [ "$strictness" = "strict" ]; then
|
|
echo "Fehler: $gz fehlt - kein Migrationsimage fuer $target moeglich" >&2; exit 1
|
|
fi
|
|
echo "Hinweis: $gz fehlt - $target fehlt im Bildsatz '$stage_sub'" >&2
|
|
complete=0
|
|
continue
|
|
fi
|
|
mkdir -p "$STAGE/$stage_sub"
|
|
if [ "$HAVE_PROD" -eq 1 ]; then
|
|
python3 "$NL_SIGN" sign --key "$KEY_PROD" --in "$gz" --out "$STAGE/$stage_sub/${target}_prod.img" \
|
|
|| { echo "Fehler: Signieren von $target (prod, $stage_sub) fehlgeschlagen" >&2; exit 1; }
|
|
produced=1
|
|
fi
|
|
if [ "$HAVE_DEV" -eq 1 ]; then
|
|
python3 "$NL_SIGN" sign --key "$KEY_DEV" --in "$gz" --out "$STAGE/$stage_sub/${target}_dev.img" \
|
|
|| { echo "Fehler: Signieren von $target (dev, $stage_sub) fehlgeschlagen" >&2; exit 1; }
|
|
produced=1
|
|
fi
|
|
done
|
|
[ "$produced" -eq 1 ] || return 1
|
|
local VERSION_ARGS=()
|
|
local t
|
|
for t in "${!NINL_VERSION[@]}"; do
|
|
VERSION_ARGS+=("$t=${NINL_VERSION[$t]}")
|
|
done
|
|
# manifest.json aus den entstandenen Dateien - Groesse und sha256 werden hier neu
|
|
# berechnet statt aus dem Signierschritt uebernommen, damit die Datei die Wahrheit
|
|
# ueber das tatsaechlich abgelegte Image sagt. Form haelt sich an die Spec
|
|
# (docs/superpowers/spec, Abschnitt 4) und damit an das, was server.py tatsaechlich
|
|
# liest. Fixrunde 2, N2: "ninlight_version" steht PRO EINTRAG, nicht (mehr nur)
|
|
# manifestweit - ein einziges Feld fuer das ganze Manifest war schon falsch, sobald
|
|
# der Merge unten (bei nur einem Schluessel oder gezieltem Deploy) alte und neue
|
|
# Eintraege mit unterschiedlichen Firmware-Versionen zusammenfuehrt. Das
|
|
# manifestweite Feld bleibt nur als Bestandskompatibilitaet erhalten und wird nur
|
|
# gesetzt, wenn wirklich alle Eintraege dieselbe Version tragen.
|
|
python3 - "$STAGE/$stage_sub" "${VERSION_ARGS[@]}" <<'PY'
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
|
|
migrate_dir = sys.argv[1]
|
|
version_map = dict(a.split("=", 1) for a in sys.argv[2:])
|
|
entries = []
|
|
for name in sorted(os.listdir(migrate_dir)):
|
|
if name.endswith("_prod.img"):
|
|
target, variant = name[: -len("_prod.img")], "prod"
|
|
elif name.endswith("_dev.img"):
|
|
target, variant = name[: -len("_dev.img")], "dev"
|
|
else:
|
|
continue
|
|
with open(os.path.join(migrate_dir, name), "rb") as fh:
|
|
data = fh.read()
|
|
entries.append({
|
|
"target": target,
|
|
"variant": variant,
|
|
"file": name,
|
|
"bytes": len(data),
|
|
"sha256": hashlib.sha256(data).hexdigest(),
|
|
"ninlight_version": version_map.get(target, ""),
|
|
})
|
|
|
|
manifest = {
|
|
"built": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
"images": entries,
|
|
}
|
|
versions = {e["ninlight_version"] for e in entries}
|
|
if len(versions) == 1:
|
|
(version,) = versions
|
|
if version:
|
|
manifest["ninlight_version"] = version
|
|
with open(os.path.join(migrate_dir, "manifest.json"), "w") as fh:
|
|
json.dump(manifest, fh, indent=2, sort_keys=True)
|
|
fh.write("\n")
|
|
PY
|
|
[ "$complete" -eq 1 ]
|
|
}
|
|
|
|
HAVE_PROD=0
|
|
HAVE_DEV=0
|
|
if [ -d "$SIGN_DIR" ]; then
|
|
if [ -f "$KEY_PROD" ]; then
|
|
HAVE_PROD=1
|
|
else
|
|
echo "Hinweis: Produktionsschluessel fehlt - keine _prod.img-Migrationsimages" >&2
|
|
fi
|
|
if [ -f "$KEY_DEV" ]; then
|
|
HAVE_DEV=1
|
|
else
|
|
echo "Hinweis: Entwicklungsschluessel fehlt - keine _dev.img-Migrationsimages" >&2
|
|
fi
|
|
if [ "$HAVE_PROD" -eq 1 ] || [ "$HAVE_DEV" -eq 1 ]; then
|
|
BOTH_KEYS=0
|
|
[ "$HAVE_PROD" -eq 1 ] && [ "$HAVE_DEV" -eq 1 ] && BOTH_KEYS=1
|
|
|
|
# ZWEI Bildsaetze, weil die WLAN-Zugangsdaten zur BAUZEIT in der Firmware stecken
|
|
# und die Station beide vorhalten soll (der Bediener schaltet per
|
|
# "ninlight-station bildsatz" bzw. am Taster um):
|
|
#
|
|
# migrate-wlan/ aus build/ - Lampe haengt sofort im eigenen WLAN
|
|
# migrate-nocreds/ aus build-nocreds/ - Lampe startet im Onboarding-AP
|
|
#
|
|
# Der Satz aus build/ ist streng: dort liegen die _ota.img, die oben schon geprueft
|
|
# wurden, ein fehlendes _gz.img daneben ist ein kaputter Baum. Der Satz aus
|
|
# build-nocreds/ ist nachsichtig: er entsteht aus einem EIGENEN, optionalen Bau
|
|
# (tools/build_nocreds.sh), und ein Deploy darf nicht daran scheitern, dass der noch
|
|
# nicht gelaufen ist - dann fehlt der Satz eben, und die Station liefert in dieser
|
|
# Stellung sichtbar nichts aus, statt heimlich den anderen Satz zu nehmen.
|
|
echo " Bildsatz migrate-wlan (aus build/)"
|
|
if sign_set "$ROOT/build" migrate-wlan strict; then
|
|
[ "$BOTH_KEYS" -eq 1 ] && MIGRATE_COMPLETE=1
|
|
fi
|
|
echo " Bildsatz migrate-nocreds (aus $NOCREDS_BUILD_REL/)"
|
|
if sign_set "$ROOT/$NOCREDS_BUILD_REL" migrate-nocreds lax; then
|
|
[ "$BOTH_KEYS" -eq 1 ] && NOCREDS_COMPLETE=1
|
|
else
|
|
if [ ! -d "$STAGE/migrate-nocreds" ]; then
|
|
echo "Hinweis: keine Abzuege unter $ROOT/$NOCREDS_BUILD_REL - der Bildsatz 'ohne-wlan' bleibt auf dem Pi unveraendert. Erzeugen mit tools/build_nocreds.sh." >&2
|
|
fi
|
|
fi
|
|
else
|
|
echo "Hinweis: $SIGN_DIR enthaelt weder Produktions- noch Entwicklungsschluessel - keine Migrationsimages erzeugt" >&2
|
|
fi
|
|
else
|
|
echo "Hinweis: Schluesselverzeichnis $SIGN_DIR nicht gefunden - keine Migrationsimages erzeugt" >&2
|
|
fi
|
|
|
|
# Der Bildsatz "ohne-wlan" ist die SICHERE VORGABE: er geht im Automatikmodus an jede
|
|
# alte Klyqa-Lampe in Funkreichweite, auch an fremde, und das Flashen ist nicht
|
|
# umkehrbar. tools/release.sh prueft sein fertiges Archiv deshalb binaer gegen die Werte
|
|
# aus config.local.cmake (dort "Auf Zugangsdaten pruefen") - deploy.sh signierte
|
|
# dagegen ungeprueft, was in build-nocreds/ lag.
|
|
#
|
|
# Der Weg dorthin ist Bedienfehler, nicht Programmfehler: ein von Hand konfiguriertes
|
|
# build-nocreds/<ziel>, oder ein gesetztes NINLIGHT_NOCREDS_BUILD_REL=build. Das
|
|
# Ergebnis waere, dass das Panel "ohne WLAN" anzeigt, waehrend das WLAN-Passwort des
|
|
# Bedieners unwiderruflich in fremde Lampen wandert - der teure, nicht rueckholbare
|
|
# Fehler. Lieber ein abgebrochener Deploy.
|
|
#
|
|
# Geprueft wird VOR dem Uebertragen, es ist also noch nichts beim Pi angekommen. Nur
|
|
# dieser eine Satz: migrate-wlan/ SOLL die Zugangsdaten enthalten, das ist sein Zweck.
|
|
# Der Pruefkoerper ist bis auf die Meldung derselbe wie in tools/release.sh.
|
|
if [ -d "$STAGE/migrate-nocreds" ]; then
|
|
echo "== Bildsatz 'ohne-wlan' auf Zugangsdaten pruefen =="
|
|
LOCAL="$ROOT/config.local.cmake"
|
|
if [ -f "$LOCAL" ]; then
|
|
python3 - "$STAGE/migrate-nocreds" "$LOCAL" <<'CHECKPY' || exit 1
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
stage, local = sys.argv[1], sys.argv[2]
|
|
text = open(local, encoding="utf-8", errors="replace").read()
|
|
# Nur die drei Werte, die wirklich geheim sind. NINLIGHT_AP_PASSWORD und der
|
|
# Vorgabetoken stehen oeffentlich im Repo (README, Bruno) - sie hier mitzupruefen
|
|
# haette nur falsche Alarme erzeugt.
|
|
SECRETS = ("NINLIGHT_WIFI_SSID", "NINLIGHT_WIFI_PASSWORD", "NINLIGHT_TOKEN")
|
|
PUBLIC = {"", "ninlight", "aabbccddeeff0011223344", "ninlight-recovery", "change-me-please"}
|
|
needles = []
|
|
for name in SECRETS:
|
|
m = re.search(r'^\s*set\s*\(\s*%s\s+"([^"]*)"' % name, text, re.M)
|
|
if m and m.group(1) not in PUBLIC:
|
|
needles.append((name, m.group(1).encode()))
|
|
|
|
hits = []
|
|
for root, _dirs, files in os.walk(stage):
|
|
for fn in sorted(files):
|
|
path = os.path.join(root, fn)
|
|
with open(path, "rb") as fh:
|
|
data = fh.read()
|
|
for name, needle in needles:
|
|
if needle in data:
|
|
hits.append("%s in %s" % (name, os.path.relpath(path, stage)))
|
|
if hits:
|
|
sys.stderr.write(
|
|
"Fehler: Zugangsdaten aus config.local.cmake im Bildsatz 'ohne-wlan' gefunden, "
|
|
"nichts uebertragen:\n " + "\n ".join(hits) + "\n")
|
|
sys.exit(1)
|
|
print(" sauber (%d geprueft)" % len(needles))
|
|
CHECKPY
|
|
else
|
|
echo " uebersprungen - $LOCAL nicht vorhanden" >&2
|
|
fi
|
|
fi
|
|
|
|
echo "== uebertragen =="
|
|
# -c (Inhaltsvergleich per Checksumme statt Groesse+Zeitstempel): ein neues
|
|
# Image kann zufaellig dieselbe Byte-Groesse wie das alte haben, und bei
|
|
# schnell aufeinanderfolgenden Deploys reicht rsyncs Sekundenaufloesung fuer
|
|
# den Zeitstempel nicht, um das als Aenderung zu erkennen. Ohne -c wuerde
|
|
# rsync ein solches Update dann stillschweigend ueberspringen.
|
|
#
|
|
# /tmp/ninlight-images auf dem Pi ist nur eine fluechtige Ablage fuer diesen
|
|
# Lauf - hier darf --delete unbedingt ran, das betrifft nicht das live
|
|
# ausgelieferte Verzeichnis.
|
|
# -e: rsync soll fuer den Transport zum Pi denselben ssh wie oben benutzen.
|
|
rsync -a -c --delete -e "$SSH_BIN" "$STAGE/" "$HOST:/tmp/ninlight-images/"
|
|
rsync -a -e "$SSH_BIN" "$HERE/server.py" "$HERE/products.py" "$HERE/ninlight-station" \
|
|
"$HERE/ninlight-station.service" "$HERE/ninlight-display.service" "$HERE/ninlight-button.service" \
|
|
"$HERE/ninlight-poweroff.service" "$HERE/ninlight-reboot.service" \
|
|
"$HOST:/tmp/"
|
|
# Das Anzeigeverzeichnis als Ganzes (render.py, display.py, epdconfig.py, die beiden
|
|
# uebernommenen Panel-Treiber und HERKUNFT.md - der Herkunftsnachweis gehoert mit auf
|
|
# den Pi, nicht nur ins Repo). --delete gilt hier nur fuer die fluechtige Ablage unter
|
|
# /tmp; __pycache__ bleibt draussen, sonst landen die .pyc dieses Rechners (andere
|
|
# Python-Version als auf dem Pi) im Zielverzeichnis und bleiben dort liegen.
|
|
rsync -a -c --delete --exclude=__pycache__ -e "$SSH_BIN" "$HERE/display/" "$HOST:/tmp/ninlight-display/"
|
|
# Das Tasterverzeichnis genauso wie das Anzeigeverzeichnis: als Ganzes, ohne
|
|
# __pycache__ dieses Rechners.
|
|
rsync -a -c --delete --exclude=__pycache__ -e "$SSH_BIN" "$HERE/button/" "$HOST:/tmp/ninlight-button/"
|
|
# Pfade als Variablen mit Default: so kann ein Test die Installation gegen
|
|
# ein Sandbox-Verzeichnis statt gegen echte Systempfade laufen lassen.
|
|
"$SSH_BIN" "$HOST" bash -s -- "$FULL_SYNC" "$MIGRATE_COMPLETE" "$NOCREDS_COMPLETE" <<'REMOTE'
|
|
set -e
|
|
FULL_SYNC=$1
|
|
MIGRATE_COMPLETE=$2
|
|
NOCREDS_COMPLETE=$3
|
|
ROOT_DIR=${NINLIGHT_STATION_ROOT:-/opt/ninlight-station}
|
|
BIN_PATH=${NINLIGHT_STATION_BIN:-/usr/local/bin/ninlight-station}
|
|
UNIT_PATH=${NINLIGHT_STATION_UNIT:-/etc/systemd/system/ninlight-station.service}
|
|
DISPLAY_UNIT_PATH=${NINLIGHT_DISPLAY_UNIT:-/etc/systemd/system/ninlight-display.service}
|
|
BUTTON_UNIT_PATH=${NINLIGHT_BUTTON_UNIT:-/etc/systemd/system/ninlight-button.service}
|
|
POWEROFF_UNIT_PATH=${NINLIGHT_POWEROFF_UNIT:-/etc/systemd/system/ninlight-poweroff.service}
|
|
REBOOT_UNIT_PATH=${NINLIGHT_REBOOT_UNIT:-/etc/systemd/system/ninlight-reboot.service}
|
|
|
|
sudo install -d -m 0755 "$ROOT_DIR/images"
|
|
if [ "$FULL_SYNC" -eq 1 ]; then
|
|
# Vollabgleich: alle Images ausserhalb der aktuellen Auslieferung entfernen.
|
|
# Vorher zeigen, WELCHE das sind - sonst faellt ein geloeschtes Image erst
|
|
# auf, wenn jemand mit einer toten Lampe vor der Station steht. Ermittelt
|
|
# per einfachem Namensvergleich (portabel, kein Verlass auf rsyncs
|
|
# "deleting"-Ausgabe, die ohne -v gar nicht erscheint).
|
|
OLD_LIST=$(find "$ROOT_DIR/images" -maxdepth 1 -name 'ninlight_*_ota.img' -exec basename {} \; 2>/dev/null | sort)
|
|
NEW_LIST=$(find /tmp/ninlight-images -maxdepth 1 -name 'ninlight_*_ota.img' -exec basename {} \; | sort)
|
|
REMOVED=""
|
|
if [ -n "$OLD_LIST" ]; then
|
|
REMOVED=$(comm -23 <(printf '%s\n' "$OLD_LIST") <(printf '%s\n' "$NEW_LIST"))
|
|
fi
|
|
if [ -n "$REMOVED" ]; then
|
|
echo " voller Abgleich - folgende Images werden entfernt:"
|
|
echo "$REMOVED" | sed 's/^/ /'
|
|
else
|
|
echo " voller Abgleich - keine Images werden entfernt"
|
|
fi
|
|
# migrate/ wird gleich in einem eigenen Schritt behandelt (siehe unten) - hier
|
|
# ausklammern, sonst wuerde --delete es leeren, wenn dieser Deploy ohne
|
|
# Schluesselverzeichnis lief und /tmp/ninlight-images/migrate deshalb gar nicht
|
|
# existiert.
|
|
sudo rsync -a -c --delete --exclude=/migrate-wlan --exclude=/migrate-nocreds /tmp/ninlight-images/ "$ROOT_DIR/images/"
|
|
else
|
|
# Gezielter Deploy: nur die uebertragenen Dateien ersetzen/ergaenzen, alle
|
|
# anderen Geraetetypen im Zielverzeichnis bleiben erhalten (kein --delete).
|
|
echo " gezielter Deploy - andere Geraetetypen im Zielverzeichnis bleiben erhalten"
|
|
sudo rsync -a -c --exclude=/migrate-wlan --exclude=/migrate-nocreds /tmp/ninlight-images/ "$ROOT_DIR/images/"
|
|
fi
|
|
# $1 = Name des Bildsatzverzeichnisses, $2 = 1, wenn dieser Lauf den Satz VOLLSTAENDIG
|
|
# beschreibt (beide Schluessel, alle Ziele) und --delete deshalb erlaubt ist.
|
|
#
|
|
# Migrationsimages getrennt behandeln: fehlt migrate/ in der Uebertragung (weil auf
|
|
# dem Entwicklungsrechner kein Schluesselverzeichnis oder gar kein Schluessel
|
|
# vorhanden war), darf ein vorhandenes migrate/ auf dem Pi NICHT geleert werden -
|
|
# sonst zerstoert ein Deploy ohne Schluesselzugriff die Bestueckung frueherer Deploys
|
|
# (derselbe Fehler wie frueher bei den _ota.img, siehe oben, nur diesmal beim Fehlen
|
|
# statt beim Ersetzen).
|
|
sync_migrate_set() {
|
|
set=$1
|
|
complete=$2
|
|
if [ -d "/tmp/ninlight-images/$set" ]; then
|
|
sudo install -d -m 0755 "$ROOT_DIR/images/$set"
|
|
if [ "$FULL_SYNC" -eq 1 ] && [ "$complete" -eq 1 ]; then
|
|
# Nur wenn dieser Lauf mit BEIDEN Schluesseln signiert hat, beschreibt
|
|
# STAGE/migrate den vollstaendigen Soll-Zustand - erst dann darf --delete
|
|
# ueberzaehlige Dateien wegraeumen und manifest.json blind ersetzt werden.
|
|
sudo rsync -a -c --delete "/tmp/ninlight-images/$set/" "$ROOT_DIR/images/$set/"
|
|
else
|
|
# Gezielter Deploy ODER ein Voll-Deploy mit nur einem der beiden Schluessel:
|
|
# in beiden Faellen kennt dieser Lauf nur einen Teil dessen, was auf dem Pi liegen
|
|
# soll. rsync ohne --delete laesst alles Nicht-Uebertragene unangetastet (Fixrunde
|
|
# 1, C2 - sonst raeumt ein Voll-Deploy mit nur dem dev-Schluessel saemtliche
|
|
# _prod.img weg). manifest.json wird deshalb auch nicht einfach ueberschrieben,
|
|
# sondern mit dem, was schon da ist, zusammengefuehrt (Fixrunde 1, I1 - sonst
|
|
# verschwinden die Eintraege aller Ziele/Varianten, die dieser Lauf nicht neu
|
|
# gebaut hat, obwohl ihre Images unveraendert liegen bleiben).
|
|
sudo rsync -a -c --exclude=/manifest.json "/tmp/ninlight-images/$set/" "$ROOT_DIR/images/$set/"
|
|
if [ -f "/tmp/ninlight-images/$set/manifest.json" ]; then
|
|
sudo python3 - "$ROOT_DIR/images/$set/manifest.json" "/tmp/ninlight-images/$set/manifest.json" <<'MERGE'
|
|
import json
|
|
import sys
|
|
|
|
manifest_path, new_manifest_path = sys.argv[1], sys.argv[2]
|
|
with open(new_manifest_path) as fh:
|
|
new_manifest = json.load(fh)
|
|
if not isinstance(new_manifest, dict):
|
|
new_manifest = {}
|
|
try:
|
|
with open(manifest_path) as fh:
|
|
old_manifest = json.load(fh)
|
|
except (OSError, ValueError):
|
|
old_manifest = {}
|
|
# Fixrunde 2, N1: ein BESTEHENDES manifest.json kann gueltiges JSON sein und trotzdem
|
|
# die falsche Form haben (z.B. "[]", "null", ein String, oder ein Eintrag, der selbst
|
|
# kein Objekt ist) - das darf niemals mitten im Deploy mit AttributeError abbrechen,
|
|
# denn dieser Merge steht VOR der Installation von server.py/products.py/CLI/Unit und
|
|
# vor dem Neustart: ein Abbruch hier liesse den Pi mit neuen Images, aber altem
|
|
# Stationscode zurueck.
|
|
if not isinstance(old_manifest, dict):
|
|
old_manifest = {}
|
|
old_images = old_manifest.get("images")
|
|
if not isinstance(old_images, list):
|
|
old_images = []
|
|
by_key = {(e.get("target"), e.get("variant")): e for e in old_images if isinstance(e, dict)}
|
|
new_images = new_manifest.get("images")
|
|
if not isinstance(new_images, list):
|
|
new_images = []
|
|
for e in new_images:
|
|
if isinstance(e, dict):
|
|
by_key[(e.get("target"), e.get("variant"))] = e
|
|
merged_images = sorted(by_key.values(), key=lambda e: (e.get("target") or "", e.get("variant") or ""))
|
|
merged = {
|
|
"built": new_manifest.get("built"),
|
|
"images": merged_images,
|
|
}
|
|
# Fixrunde 2, N2: das manifestweite "ninlight_version" darf nach dem Merge nicht mehr
|
|
# einfach vom neuen Manifest uebernommen werden - die uebernommenen Alt-Eintraege
|
|
# koennen eine andere Version tragen (das war die alte Version dieses Bugs: ein
|
|
# Voll-Deploy mit nur dem dev-Schluessel hat die neue Firmware-Version ueber ein
|
|
# unveraendert liegen gebliebenes altes _prod.img geschrieben). Jeder Eintrag traegt
|
|
# seine eigene Version; das Top-Level-Feld ist nur noch eine Bequemlichkeit fuer den
|
|
# Fall, dass wirklich alle Eintraege uebereinstimmen.
|
|
versions = {e.get("ninlight_version") for e in merged_images}
|
|
if len(versions) == 1:
|
|
(version,) = versions
|
|
if isinstance(version, str) and version:
|
|
merged["ninlight_version"] = version
|
|
with open(manifest_path, "w") as fh:
|
|
json.dump(merged, fh, indent=2, sort_keys=True)
|
|
fh.write("\n")
|
|
MERGE
|
|
fi
|
|
fi
|
|
else
|
|
# Weder "kein Schluesselverzeichnis" noch "Verzeichnis vorhanden, aber leer" laesst
|
|
# sich von hier aus unterscheiden - beide Faelle liefern ein leeres migrate/ von
|
|
# diesem Rechner. Die lokale Meldung (deploy.sh, "Hinweis: ...") ist bereits genau;
|
|
# diese hier behauptet absichtlich keinen bestimmten Grund (Fixrunde 2, Kleinigkeit).
|
|
echo " keine Migrationsimages fuer $set von diesem Rechner - $set/ auf dem Pi bleibt unangetastet"
|
|
fi
|
|
}
|
|
|
|
# Beide Bildsaetze. Sie sind voneinander unabhaengig: liegt nur einer von diesem Rechner
|
|
# vor (z.B. weil tools/build_nocreds.sh noch nicht gelaufen ist), bleibt der andere auf
|
|
# dem Pi unveraendert - der Umschalter zeigt dann eben auf einen Satz, der fehlt, und die
|
|
# Station liefert in dieser Stellung sichtbar nichts aus.
|
|
sync_migrate_set migrate-wlan "$MIGRATE_COMPLETE"
|
|
sync_migrate_set migrate-nocreds "$NOCREDS_COMPLETE"
|
|
# Altbestand: bis zum Umschalter hiess das einzige Verzeichnis "migrate" und enthielt die
|
|
# Abzuege MIT WLAN-Zugangsdaten. Es wird von niemandem mehr gelesen; liegen bleiben darf
|
|
# es trotzdem nicht, sonst glaubt der naechste Mensch am Pi, es sei der gueltige Satz.
|
|
if [ -d "$ROOT_DIR/images/migrate" ]; then
|
|
sudo rm -rf "$ROOT_DIR/images/migrate"
|
|
echo " Altbestand entfernt: images/migrate (heisst jetzt images/migrate-wlan)"
|
|
fi
|
|
sudo install -m 0755 /tmp/server.py "$ROOT_DIR/server.py"
|
|
sudo install -m 0644 /tmp/products.py "$ROOT_DIR/products.py"
|
|
sudo install -m 0755 /tmp/ninlight-station "$BIN_PATH"
|
|
sudo install -m 0644 /tmp/ninlight-station.service "$UNIT_PATH"
|
|
# Anzeige: Verzeichnis und Unit. --delete ist hier richtig (anders als bei den Images):
|
|
# display/ ist reiner Code aus dem Repo, es gibt keinen Bestand, der einen Deploy
|
|
# ueberleben muesste - eine im Repo geloeschte Datei muss auch hier verschwinden.
|
|
sudo install -d -m 0755 "$ROOT_DIR/display"
|
|
sudo rsync -a -c --delete /tmp/ninlight-display/ "$ROOT_DIR/display/"
|
|
sudo install -m 0644 /tmp/ninlight-display.service "$DISPLAY_UNIT_PATH"
|
|
# Taster: dieselbe Begruendung wie bei der Anzeige - button/ ist reiner Code aus dem
|
|
# Repo, es gibt keinen Bestand, der einen Deploy ueberleben muesste.
|
|
sudo install -d -m 0755 "$ROOT_DIR/button"
|
|
sudo rsync -a -c --delete /tmp/ninlight-button/ "$ROOT_DIR/button/"
|
|
sudo install -m 0644 /tmp/ninlight-button.service "$BUTTON_UNIT_PATH"
|
|
# Abschlussbild beim Herunterfahren/Neustart. Nur die Dateien - aktiviert werden sie in
|
|
# setup_pi.sh (ein Deploy soll keine Units einschalten, die vorher aus waren).
|
|
sudo install -m 0644 /tmp/ninlight-poweroff.service "$POWEROFF_UNIT_PATH"
|
|
sudo install -m 0644 /tmp/ninlight-reboot.service "$REBOOT_UNIT_PATH"
|
|
rm -rf /tmp/ninlight-images /tmp/server.py /tmp/products.py /tmp/ninlight-station /tmp/ninlight-station.service \
|
|
/tmp/ninlight-display /tmp/ninlight-display.service /tmp/ninlight-button /tmp/ninlight-button.service \
|
|
/tmp/ninlight-poweroff.service /tmp/ninlight-reboot.service
|
|
sudo systemctl daemon-reload
|
|
if systemctl is-active --quiet ninlight-station; then
|
|
if sudo systemctl restart ninlight-station; then
|
|
echo " Dienst neu gestartet"
|
|
else
|
|
echo " Fehler: Dienst lief, Neustart nach dem Deploy ist aber fehlgeschlagen" >&2
|
|
exit 1
|
|
fi
|
|
else
|
|
echo " Dienst laeuft nicht (Station ist aus)"
|
|
fi
|
|
# Die Anzeige laeuft mit dem gerade ersetzten Code weiter, bis sie neu gestartet wird.
|
|
# Ein Fehlschlag ist hier aber ausdruecklich KEIN Deploy-Fehler (anders als oben beim
|
|
# Hauptdienst): die Anzeige ist nirgends Voraussetzung fuer eine Migration, und ein
|
|
# Deploy, der wegen des Panels rot wird, verleitet nur dazu, den echten Fehler oben zu
|
|
# uebersehen.
|
|
if systemctl is-active --quiet ninlight-display; then
|
|
if sudo systemctl restart ninlight-display; then
|
|
echo " Anzeigedienst neu gestartet"
|
|
else
|
|
echo " Warnung: Anzeigedienst lief, Neustart ist fehlgeschlagen - journalctl -u ninlight-display" >&2
|
|
fi
|
|
else
|
|
echo " Anzeigedienst laeuft nicht"
|
|
fi
|
|
# Taster: dieselbe weiche Behandlung wie die Anzeige (siehe deren Begruendung oben) - er
|
|
# ist nirgends Voraussetzung fuer eine Migration, ein Deploy darf daran nicht scheitern.
|
|
if systemctl is-active --quiet ninlight-button; then
|
|
if sudo systemctl restart ninlight-button; then
|
|
echo " Tasterdienst neu gestartet"
|
|
else
|
|
echo " Warnung: Tasterdienst lief, Neustart ist fehlgeschlagen - journalctl -u ninlight-button" >&2
|
|
fi
|
|
else
|
|
echo " Tasterdienst laeuft nicht"
|
|
fi
|
|
REMOTE
|
|
|
|
echo "fertig."
|