From 971a39a128b1b33863eddcbe4a92866c40efa88d Mon Sep 17 00:00:00 2001 From: Matti Airas Date: Wed, 10 Jun 2026 14:59:27 +0300 Subject: [PATCH 01/16] feat(migrate): add OpenPlotter backup script Collects Signal K, InfluxDB 2, Grafana and OpenCPN data from a live OpenPlotter system to a USB stick as tar archives (ownership preserved across FAT/exFAT), with a manifest and a completeness gate that prints SAFE TO REFLASH only after verification. Part of halos-org/docs#25. --- docs/migrate/openplotter-backup.sh | 461 +++++++++++++++++++++++++++++ 1 file changed, 461 insertions(+) create mode 100755 docs/migrate/openplotter-backup.sh diff --git a/docs/migrate/openplotter-backup.sh b/docs/migrate/openplotter-backup.sh new file mode 100755 index 0000000..3dab5dd --- /dev/null +++ b/docs/migrate/openplotter-backup.sh @@ -0,0 +1,461 @@ +#!/usr/bin/env bash +# +# openplotter-backup.sh — back up an OpenPlotter (Raspberry Pi OS) marine setup +# to a USB stick, in preparation for an in-place reflash to HaLOS. +# +# Runs on the LIVE OpenPlotter system. Copies four data domains to a USB stick: +# - Signal K (~/.signalk, minus node_modules and raw logs) +# - InfluxDB 2 (/var/lib/influxdb, raw data directory) +# - Grafana (/var/lib/grafana/grafana.db, dashboards + datasources) +# - OpenCPN (~/.opencpn and ~/.local/share/opencpn) +# +# Each domain is stored as a tar archive so ownership and permissions survive +# even on a FAT/exFAT stick. The companion halos-restore.sh reinstates them on +# the freshly-flashed HaLOS device. +# +# IMPORTANT: this backs up ONLY the four migration domains above — it is NOT a +# full-system backup. In-place migration wipes the device, so save anything else +# you care about (personal files, charts outside ~/.opencpn, custom /etc config, +# apps you installed yourself) separately before reflashing. The script proves +# only that the migration backup itself is complete; it cannot vouch for the +# rest of your disk. +# +# Usage: +# bash openplotter-backup.sh [--force] [--user NAME] [USB_MOUNT_PATH] +# +# Run as your normal user (e.g. pi); the script uses sudo where it needs root. + +set -euo pipefail + +readonly MANIFEST_FORMAT_VERSION=1 +readonly BACKUP_SUBDIR="halos-migration" +readonly FAT_FILE_LIMIT=$((4 * 1024 * 1024 * 1024)) # 4 GiB per-file cap on FAT32 + +# --- output helpers ---------------------------------------------------------- + +info() { printf '\033[1m[*]\033[0m %s\n' "$*"; } +warn() { printf '\033[33m[!]\033[0m %s\n' "$*" >&2; } +err() { printf '\033[31m[x]\033[0m %s\n' "$*" >&2; } +die() { + err "$*" + exit 1 +} + +# --- globals populated during run ------------------------------------------- + +SUDO="" +REAL_USER="" +USER_HOME="" +DEST_ROOT="" +DEST="" +FORCE=0 +declare -a STOPPED_SERVICES=() +SERVICES_RESTARTED=0 +SUCCESS=0 + +usage() { + # Print the leading comment block (everything after the shebang up to the + # first non-comment line), stripped of the leading "# ". + awk 'NR==1 {next} /^#/ {sub(/^# ?/, ""); print; next} {exit}' "$0" + exit "${1:-0}" +} + +# Restart any services we stopped, on any exit path. Runs via EXIT trap so an +# interrupted or failed backup never leaves the user's system dark. +cleanup() { + local rc=$? + if ((SERVICES_RESTARTED == 0)) && ((${#STOPPED_SERVICES[@]} > 0)); then + info "Restarting services on the source system..." + start_services + fi + if ((rc != 0)) && ((SUCCESS == 0)); then + err "Backup did NOT complete. Do not reflash — the backup is incomplete." + fi + return $rc +} + +# --- privilege / user resolution -------------------------------------------- + +resolve_user() { + local want="${1:-}" + if [[ -n "$want" ]]; then + REAL_USER="$want" + elif [[ ${EUID} -eq 0 ]]; then + if [[ -n "${SUDO_USER:-}" && "${SUDO_USER}" != "root" ]]; then + REAL_USER="${SUDO_USER}" + else + die "Run as your normal user (not root), or pass --user NAME so I know whose ~/.signalk and ~/.opencpn to back up." + fi + else + REAL_USER="${USER}" + fi + + USER_HOME="$(getent passwd "$REAL_USER" | cut -d: -f6)" + [[ -n "$USER_HOME" && -d "$USER_HOME" ]] || die "Cannot resolve home directory for user '$REAL_USER'." + + # Privileged operations (service control, reading /var/lib) need root. + if [[ ${EUID} -eq 0 ]]; then + SUDO="" + elif command -v sudo >/dev/null 2>&1; then + SUDO="sudo" + else + die "This script needs root for some steps but sudo is not available. Re-run as root with --user $REAL_USER." + fi +} + +# --- USB target selection ---------------------------------------------------- + +# Suggest mounted removable filesystems under the usual auto-mount roots. +suggest_usb_mounts() { + local m + for m in /media/"$REAL_USER"/* /media/* /run/media/"$REAL_USER"/* /mnt/*; do + [[ -d "$m" ]] || continue + mountpoint -q "$m" 2>/dev/null || continue + printf '%s\n' "$m" + done | sort -u +} + +select_dest_root() { + local given="${1:-}" + if [[ -n "$given" ]]; then + [[ -d "$given" ]] || die "USB path '$given' does not exist." + mountpoint -q "$given" 2>/dev/null || warn "'$given' is not a mount point — make sure your USB stick is actually mounted there." + DEST_ROOT="$given" + return + fi + + local -a candidates=() + mapfile -t candidates < <(suggest_usb_mounts) + if ((${#candidates[@]} == 0)); then + die "No mounted USB stick found. Insert and mount a stick, then re-run — or pass the mount path explicitly: bash $0 /media/$REAL_USER/MYSTICK" + fi + + info "Detected these mounted removable filesystems:" + local i=1 c + for c in "${candidates[@]}"; do + printf ' %d) %s (%s free)\n' "$i" "$c" "$(human_free "$c")" + ((i++)) + done + + local choice + read -r -p "Which one is your backup USB stick? [1-${#candidates[@]}] " choice + if ! [[ "$choice" =~ ^[0-9]+$ ]] || ((choice < 1 || choice > ${#candidates[@]})); then + die "Invalid selection." + fi + DEST_ROOT="${candidates[$((choice - 1))]}" +} + +human_free() { + df -h --output=avail "$1" 2>/dev/null | tail -1 | tr -d ' ' +} + +fs_type() { + df -T "$1" 2>/dev/null | awk 'NR==2 {print $2}' +} + +avail_bytes() { + # df -B1 reports available bytes in the 4th column. + df -B1 --output=avail "$1" 2>/dev/null | tail -1 | tr -d ' ' +} + +# --- size measurement / preflight ------------------------------------------- + +dir_bytes() { + # Sum of a path's apparent size in bytes, 0 if absent. + local path="$1" + [[ -e "$path" ]] || { + echo 0 + return + } + $SUDO du -sb "$path" 2>/dev/null | cut -f1 +} + +SK_BYTES=0 +INFLUX_BYTES=0 +GRAFANA_BYTES=0 +OPENCPN_BYTES=0 + +measure_sources() { + info "Measuring source data..." + [[ -d "$USER_HOME/.signalk" ]] && SK_BYTES="$(dir_bytes "$USER_HOME/.signalk")" + [[ -d /var/lib/influxdb ]] && INFLUX_BYTES="$(dir_bytes /var/lib/influxdb)" + [[ -f /var/lib/grafana/grafana.db ]] && GRAFANA_BYTES="$(dir_bytes /var/lib/grafana/grafana.db)" + local oc=0 a b + a="$(dir_bytes "$USER_HOME/.opencpn")" + b="$(dir_bytes "$USER_HOME/.local/share/opencpn")" + oc=$((a + b)) + OPENCPN_BYTES=$oc +} + +preflight() { + local total=$((SK_BYTES + INFLUX_BYTES + GRAFANA_BYTES + OPENCPN_BYTES)) + ((total > 0)) || die "Found none of Signal K, InfluxDB, Grafana or OpenCPN data on this system. Nothing to back up." + + local avail fstype + avail="$(avail_bytes "$DEST_ROOT")" + fstype="$(fs_type "$DEST_ROOT")" + + info "Source data total: $(bytes_to_h "$total"). USB free: $(human_free "$DEST_ROOT") (filesystem: ${fstype:-unknown})." + + # FAT32 cannot hold a single file larger than 4 GiB — the InfluxDB archive + # is the one likely to exceed it. + if [[ "$fstype" == "vfat" || "$fstype" == "msdos" ]] && ((INFLUX_BYTES > FAT_FILE_LIMIT)); then + die "The USB stick is FAT32, which cannot store files larger than 4 GiB, but the InfluxDB data is $(bytes_to_h "$INFLUX_BYTES"). Reformat the stick as exFAT or ext4 and try again." + fi + + # Require the stick to hold the data with a little headroom for tar overhead. + local needed=$((total + total / 20 + 16 * 1024 * 1024)) + if [[ -n "$avail" ]] && ((avail < needed)); then + die "USB stick is too small: needs about $(bytes_to_h "$needed") free, has $(human_free "$DEST_ROOT"). Use a larger stick." + fi +} + +bytes_to_h() { + numfmt --to=iec --suffix=B "$1" 2>/dev/null || printf '%s bytes' "$1" +} + +# --- service control --------------------------------------------------------- + +# Echo the unit names that exist on this system among our known candidates, +# including any signalk* unit (OpenPlotter's exact name varies). signalk.socket +# is listed before signalk.service so it stops first — otherwise its socket +# activation (ListenStream=3000) would restart the server on the next client +# connection, mid-copy. +detect_services() { + local known=(influxdb.service grafana-server.service signalk.socket signalk.service) u + mapfile -t -O "${#known[@]}" known < <( + systemctl list-unit-files --no-legend 'signalk*.service' 2>/dev/null | awk '{print $1}' + ) + declare -A uniq=() + for u in "${known[@]}"; do + [[ -n "$u" ]] || continue + [[ -n "${uniq[$u]:-}" ]] && continue + uniq[$u]=1 + if $SUDO systemctl list-unit-files "$u" >/dev/null 2>&1 || + $SUDO systemctl is-active --quiet "$u" 2>/dev/null; then + printf '%s\n' "$u" + fi + done +} + +stop_services() { + local -a units=() + mapfile -t units < <(detect_services) + local u + for u in "${units[@]}"; do + if $SUDO systemctl is-active --quiet "$u" 2>/dev/null; then + info "Stopping $u for a consistent copy..." + $SUDO systemctl stop "$u" + STOPPED_SERVICES+=("$u") + fi + done +} + +start_services() { + local u + for u in "${STOPPED_SERVICES[@]}"; do + $SUDO systemctl start "$u" 2>/dev/null || + warn "Could not restart $u — start it manually with: sudo systemctl start $u" + done + SERVICES_RESTARTED=1 +} + +# --- backup ------------------------------------------------------------------ + +prepare_dest() { + DEST="$DEST_ROOT/$BACKUP_SUBDIR" + if [[ -e "$DEST" ]] && [[ -n "$(ls -A "$DEST" 2>/dev/null)" ]]; then + ((FORCE == 1)) || + die "A backup already exists at $DEST. Use --force to overwrite it, or pick an empty stick." + warn "Overwriting existing backup at $DEST (--force)." + rm -rf "${DEST:?}"/* + fi + mkdir -p "$DEST" +} + +# tar a directory's contents (paths relative to the directory) into dest. +tar_dir() { + local src="$1" out="$2" + shift 2 + $SUDO tar --numeric-owner -C "$src" -cf "$out" "$@" . +} + +backup_signalk() { + ((SK_BYTES > 0)) || { + info "No Signal K data — skipping." + return + } + info "Backing up Signal K ($(bytes_to_h "$SK_BYTES"))..." + tar_dir "$USER_HOME/.signalk" "$DEST/signalk.tar" \ + --exclude=./node_modules --exclude='./skserver-raw_*.log' +} + +backup_influxdb() { + ((INFLUX_BYTES > 0)) || { + info "No InfluxDB data — skipping." + return + } + info "Backing up InfluxDB ($(bytes_to_h "$INFLUX_BYTES")) — this is the big one, please wait..." + tar_dir /var/lib/influxdb "$DEST/influxdb2.tar" +} + +backup_grafana() { + ((GRAFANA_BYTES > 0)) || { + info "No Grafana database — skipping." + return + } + info "Backing up Grafana dashboards..." + $SUDO cp "/var/lib/grafana/grafana.db" "$DEST/grafana.db" + $SUDO chmod a+r "$DEST/grafana.db" +} + +backup_opencpn() { + ((OPENCPN_BYTES > 0)) || { + info "No OpenCPN data — skipping." + return + } + info "Please make sure OpenCPN is CLOSED (it writes its config on exit)." + local ans + read -r -p "Is OpenCPN closed? [y/N] " ans + [[ "$ans" =~ ^[Yy]$ ]] || die "Close OpenCPN and re-run." + if [[ -d "$USER_HOME/.opencpn" ]]; then + info "Backing up OpenCPN config..." + tar_dir "$USER_HOME/.opencpn" "$DEST/opencpn-config.tar" + warn_relocated_charts + fi + if [[ -d "$USER_HOME/.local/share/opencpn" ]]; then + info "Backing up OpenCPN plugin data..." + tar_dir "$USER_HOME/.local/share/opencpn" "$DEST/opencpn-share.tar" + fi +} + +# Charts stored outside ~/.opencpn (user-chosen folders) are not captured. +warn_relocated_charts() { + local conf="$USER_HOME/.opencpn/opencpn.conf" + [[ -f "$conf" ]] || return 0 + local dirs + dirs="$(grep -a -i 'ChartDir' "$conf" 2>/dev/null | grep -av "$USER_HOME/.opencpn" || true)" + if [[ -n "$dirs" ]]; then + warn "OpenCPN references chart folders outside ~/.opencpn. These are NOT backed up automatically:" + printf '%s\n' "$dirs" | sed 's/^/ /' >&2 + warn "Copy those chart folders to your USB stick manually if you need them." + fi +} + +# --- manifest + verification ------------------------------------------------- + +present() { (($1 > 0)) && echo present || echo absent; } + +write_manifest() { + local mf="$DEST/manifest.txt" + { + echo "format_version=$MANIFEST_FORMAT_VERSION" + echo "created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" + echo "source_host=$(hostname)" + echo "source_user=$REAL_USER" + echo "signalk=$(present "$SK_BYTES")" + echo "signalk_bytes=$SK_BYTES" + echo "influxdb=$(present "$INFLUX_BYTES")" + echo "influxdb_bytes=$INFLUX_BYTES" + echo "grafana=$(present "$GRAFANA_BYTES")" + echo "grafana_bytes=$GRAFANA_BYTES" + echo "opencpn=$(present "$OPENCPN_BYTES")" + echo "opencpn_bytes=$OPENCPN_BYTES" + } >"$mf" +} + +# Confirm each present domain produced a non-empty artifact, and that the +# InfluxDB archive actually contains the boltdb (its core metadata file). +verify_backup() { + info "Verifying backup integrity..." + local ok=1 + + check_artifact() { + local label="$1" path="$2" + if [[ ! -s "$path" ]]; then + err "Missing or empty: $label ($path)" + ok=0 + fi + } + + ((SK_BYTES > 0)) && check_artifact "Signal K" "$DEST/signalk.tar" + ((GRAFANA_BYTES > 0)) && check_artifact "Grafana" "$DEST/grafana.db" + [[ -d "$USER_HOME/.opencpn" ]] && check_artifact "OpenCPN config" "$DEST/opencpn-config.tar" + [[ -d "$USER_HOME/.local/share/opencpn" ]] && check_artifact "OpenCPN share" "$DEST/opencpn-share.tar" + + if ((INFLUX_BYTES > 0)); then + check_artifact "InfluxDB" "$DEST/influxdb2.tar" + if [[ -s "$DEST/influxdb2.tar" ]] && + ! tar -tf "$DEST/influxdb2.tar" 2>/dev/null | grep -q 'influxd\.bolt'; then + err "InfluxDB archive is missing influxd.bolt — the data is incomplete." + ok=0 + fi + fi + + ((ok == 1)) || die "Verification failed. The backup is NOT safe to rely on." + + # The completeness marker: only written once everything checks out. + echo "complete=yes" >>"$DEST/manifest.txt" + sync +} + +# --- main -------------------------------------------------------------------- + +main() { + local user_arg="" dest_arg="" + while (($# > 0)); do + case "$1" in + -h | --help) usage 0 ;; + --force) FORCE=1 ;; + --user) + shift + user_arg="${1:-}" + [[ -n "$user_arg" ]] || die "--user needs a name." + ;; + -*) die "Unknown option: $1 (try --help)" ;; + *) dest_arg="$1" ;; + esac + shift + done + + resolve_user "$user_arg" + trap cleanup EXIT + + info "HaLOS migration backup — source user: $REAL_USER" + select_dest_root "$dest_arg" + measure_sources + preflight + prepare_dest + + stop_services + + backup_signalk + backup_influxdb + backup_grafana + backup_opencpn + + write_manifest + verify_backup + + start_services + + SUCCESS=1 + echo + info "Migration backup written to: $DEST" + printf '\033[1;32m========================================\n' + printf ' MIGRATION BACKUP COMPLETE AND VERIFIED\n' + printf '========================================\033[0m\n' + echo "Signal K, InfluxDB, Grafana and OpenCPN are backed up and verified" + echo "on the USB stick." + echo + warn "This is NOT a full-system backup. Personal files, charts stored" + warn "outside ~/.opencpn, custom /etc configuration and any apps you" + warn "installed yourself are NOT included — copy those to the stick now if" + warn "you need them." + echo + echo "Once you have also saved anything else you care about, you can" + echo "reflash. Keep the stick safe through the reflash, then run" + echo "halos-restore.sh on the new HaLOS system to restore your data." +} + +main "$@" From 5c249b3c300019b971b7a4c51128474080db3577 Mon Sep 17 00:00:00 2001 From: Matti Airas Date: Wed, 10 Jun 2026 22:39:51 +0300 Subject: [PATCH 02/16] feat(migrate): add restore script with Signal K and OpenCPN Restore-side skeleton: USB/backup detection, manifest format-version and completeness validation, marine-HaLOS guard, and Signal K + OpenCPN restore with HaLOS defaults preserved aside for rollback. InfluxDB and Grafana restore follow. Part of halos-org/docs#26. --- docs/migrate/halos-restore.sh | 260 ++++++++++++++++++++++++++++++++++ 1 file changed, 260 insertions(+) create mode 100755 docs/migrate/halos-restore.sh diff --git a/docs/migrate/halos-restore.sh b/docs/migrate/halos-restore.sh new file mode 100755 index 0000000..ddb2ee7 --- /dev/null +++ b/docs/migrate/halos-restore.sh @@ -0,0 +1,260 @@ +#!/usr/bin/env bash +# +# halos-restore.sh — restore an OpenPlotter backup onto a fresh marine HaLOS. +# +# Runs on the freshly-flashed HaLOS device. Reads the USB stick produced by +# openplotter-backup.sh and reinstates four data domains: +# - Signal K (~/.signalk configuration, plugins, charts) +# - OpenCPN (config + plugin data on the legacy desktop) +# - InfluxDB 2 (raw data-directory swap; historical telemetry) +# - Grafana (dashboards + datasources re-imported via the API) +# +# Boat data is aligned to HaLOS marine defaults: the telemetry org/bucket is +# renamed to "marine" so HaLOS' own provisioned Grafana datasource serves the +# history and ongoing Signal K logging continues into the right bucket. +# +# HaLOS defaults are preserved aside before any swap (*.halos-default) so a +# failed restore can be rolled back. +# +# Usage: +# bash halos-restore.sh [USB_MOUNT_PATH | BACKUP_DIR] +# +# Run as your normal user (e.g. pi); the script uses sudo where it needs root. + +set -euo pipefail + +readonly SUPPORTED_FORMAT_VERSION=1 +readonly BACKUP_SUBDIR="halos-migration" +readonly CA_ROOT=/var/lib/container-apps + +readonly SK_PKG=marine-signalk-server-container + +# --- output helpers ---------------------------------------------------------- + +info() { printf '\033[1m[*]\033[0m %s\n' "$*"; } +warn() { printf '\033[33m[!]\033[0m %s\n' "$*" >&2; } +err() { printf '\033[31m[x]\033[0m %s\n' "$*" >&2; } +die() { + err "$*" + exit 1 +} + +SUDO="" +REAL_USER="" +USER_HOME="" +SRC="" +declare -a SUMMARY=() + +usage() { + awk 'NR==1 {next} /^#/ {sub(/^# ?/, ""); print; next} {exit}' "$0" + exit "${1:-0}" +} + +note() { SUMMARY+=("$*"); } + +# --- privilege / user resolution -------------------------------------------- + +resolve_privs() { + if [[ ${EUID} -eq 0 ]]; then + SUDO="" + REAL_USER="${SUDO_USER:-root}" + else + command -v sudo >/dev/null 2>&1 || die "This script needs root; install sudo or run as root." + SUDO="sudo" + REAL_USER="${USER}" + fi + USER_HOME="$(getent passwd "$REAL_USER" | cut -d: -f6)" + [[ -n "$USER_HOME" && -d "$USER_HOME" ]] || die "Cannot resolve home directory for '$REAL_USER'." +} + +# --- source / manifest ------------------------------------------------------- + +suggest_mounts() { + local m + for m in /media/"$REAL_USER"/* /media/* /run/media/"$REAL_USER"/* /mnt/*; do + [[ -d "$m" ]] || continue + mountpoint -q "$m" 2>/dev/null || continue + printf '%s\n' "$m" + done | sort -u +} + +# Resolve SRC to the directory that directly contains manifest.txt. +locate_backup() { + local given="${1:-}" + if [[ -n "$given" ]]; then + if [[ -f "$given/manifest.txt" ]]; then + SRC="$given" + elif [[ -f "$given/$BACKUP_SUBDIR/manifest.txt" ]]; then + SRC="$given/$BACKUP_SUBDIR" + else + die "No backup manifest found under '$given'." + fi + return + fi + + local -a found=() m + while IFS= read -r m; do + [[ -f "$m/$BACKUP_SUBDIR/manifest.txt" ]] && found+=("$m/$BACKUP_SUBDIR") + done < <(suggest_mounts) + + case "${#found[@]}" in + 0) die "No migration backup found on any mounted USB stick. Plug in the stick from the backup step, or pass its path." ;; + 1) SRC="${found[0]}" ;; + *) + info "Multiple backups found:" + local i=1 f + for f in "${found[@]}"; do + printf ' %d) %s\n' "$i" "$f" + ((i++)) + done + local choice + read -r -p "Which one? [1-${#found[@]}] " choice + if ! [[ "$choice" =~ ^[0-9]+$ ]] || ((choice < 1 || choice > ${#found[@]})); then + die "Invalid selection." + fi + SRC="${found[$((choice - 1))]}" + ;; + esac +} + +mf_get() { + local key="$1" + grep -E "^${key}=" "$SRC/manifest.txt" 2>/dev/null | head -1 | cut -d= -f2- +} + +validate_manifest() { + [[ -f "$SRC/manifest.txt" ]] || die "No manifest at $SRC." + local ver complete + ver="$(mf_get format_version)" + complete="$(mf_get complete)" + [[ "$ver" == "$SUPPORTED_FORMAT_VERSION" ]] || + die "Backup format version '$ver' is not supported by this restore script (expected $SUPPORTED_FORMAT_VERSION). Use a matching version of the migration scripts." + [[ "$complete" == "yes" ]] || + die "Backup is marked incomplete (no completeness marker). Re-run the backup until it prints SAFE TO REFLASH." + info "Backup: from $(mf_get source_host) (user $(mf_get source_user)), created $(mf_get created)." +} + +# --- environment checks ------------------------------------------------------ + +pkg_installed() { dpkg-query -W -f='${Status}' "$1" 2>/dev/null | grep -q "install ok installed"; } + +require_marine_halos() { + pkg_installed "$SK_PKG" || [[ -d "$CA_ROOT/$SK_PKG" ]] || + die "This does not look like a marine HaLOS install ($SK_PKG not found). Run this on a freshly-flashed marine HaLOS device." +} + +# Move a path aside to .halos-default unless that backup already exists. +preserve_default() { + local path="$1" + [[ -e "$path" ]] || return 0 + if [[ -e "$path.halos-default" ]]; then + warn "$path.halos-default already exists — leaving it (earlier restore?), not overwriting." + return 0 + fi + $SUDO mv "$path" "$path.halos-default" +} + +# --- Signal K ---------------------------------------------------------------- + +restore_signalk() { + [[ "$(mf_get signalk)" == "present" ]] || { + info "No Signal K data in backup — skipping." + return 0 + } + [[ -f "$SRC/signalk.tar" ]] || die "Manifest claims Signal K data but signalk.tar is missing." + + local data_root="$CA_ROOT/$SK_PKG/data" + local target="$data_root/data" + info "Restoring Signal K configuration..." + + $SUDO systemctl stop "$SK_PKG.service" 2>/dev/null || true + preserve_default "$target" + $SUDO mkdir -p "$target" + $SUDO tar -C "$target" -xf "$SRC/signalk.tar" + # The host data dir is owned by the invoking user (pi); match it. + $SUDO chown -R "$REAL_USER:$REAL_USER" "$target" + + $SUDO systemctl start "$SK_PKG.service" + verify_signalk +} + +verify_signalk() { + local i + for i in $(seq 1 30); do + if curl -fsk http://localhost:3000/signalk >/dev/null 2>&1; then + local users + users="$($SUDO python3 -c "import json;d=json.load(open('$CA_ROOT/$SK_PKG/data/data/security.json'));print(','.join(u.get('username','?') for u in d.get('users',[])))" 2>/dev/null || echo '?')" + note "Signal K: restored and responding (users: ${users:-none})." + info "Signal K is up (users: ${users:-none})." + return 0 + fi + sleep 2 + done + warn "Signal K did not respond on :3000 within 60s. Check: sudo journalctl -u $SK_PKG.service" + warn "The previous HaLOS data is preserved at $CA_ROOT/$SK_PKG/data/data.halos-default for rollback." + note "Signal K: restored but did not come up — needs manual check." +} + +# --- OpenCPN ----------------------------------------------------------------- + +restore_opencpn() { + [[ "$(mf_get opencpn)" == "present" ]] || { + info "No OpenCPN data in backup — skipping." + return 0 + } + if pgrep -x opencpn >/dev/null 2>&1; then + warn "OpenCPN is running — close it before restoring, or its config will be overwritten on its next exit." + fi + info "Restoring OpenCPN data..." + restore_home_tar "$SRC/opencpn-config.tar" "$USER_HOME/.opencpn" + restore_home_tar "$SRC/opencpn-share.tar" "$USER_HOME/.local/share/opencpn" + note "OpenCPN: config + plugin data restored to $USER_HOME." +} + +# Extract a tar into a user-owned home directory, preserving the old one aside. +restore_home_tar() { + local tarfile="$1" dest="$2" + [[ -f "$tarfile" ]] || return 0 + preserve_default "$dest" + mkdir -p "$dest" + tar -C "$dest" -xf "$tarfile" + $SUDO chown -R "$REAL_USER:$REAL_USER" "$dest" +} + +# --- summary ----------------------------------------------------------------- + +print_summary() { + echo + info "Restore summary:" + local line + for line in "${SUMMARY[@]}"; do + printf ' - %s\n' "$line" + done +} + +# --- main -------------------------------------------------------------------- + +main() { + local arg="" + while (($# > 0)); do + case "$1" in + -h | --help) usage 0 ;; + -*) die "Unknown option: $1 (try --help)" ;; + *) arg="$1" ;; + esac + shift + done + + resolve_privs + locate_backup "$arg" + validate_manifest + require_marine_halos + + restore_signalk + restore_opencpn + + print_summary + info "Done." +} + +main "$@" From f1504b213f9cdc65ee9d3c0be3c14dc3d48020cd Mon Sep 17 00:00:00 2001 From: Matti Airas Date: Wed, 10 Jun 2026 22:53:12 +0300 Subject: [PATCH 03/16] feat(migrate): add InfluxDB and Grafana restore Raw InfluxDB data-dir swap with offline operator-token minting from the boltdb, org/bucket rename to marine, v1 DBRP, and a smoke query. Grafana boat datasources/dashboards re-imported via the API with the minted token, degrading gracefully on newer Grafana schemas. Validated end-to-end on a live HaLOS device: data queryable via Flux and InfluxQL, token survives service restart, ownership pi:root 700, Grafana admin:admin import works. Part of halos-org/docs#27. --- docs/migrate/halos-restore.sh | 319 +++++++++++++++++++++++++++++++++- 1 file changed, 318 insertions(+), 1 deletion(-) diff --git a/docs/migrate/halos-restore.sh b/docs/migrate/halos-restore.sh index ddb2ee7..991b094 100755 --- a/docs/migrate/halos-restore.sh +++ b/docs/migrate/halos-restore.sh @@ -28,6 +28,11 @@ readonly BACKUP_SUBDIR="halos-migration" readonly CA_ROOT=/var/lib/container-apps readonly SK_PKG=marine-signalk-server-container +readonly INFLUX_PKG=marine-influxdb-container +readonly GRAFANA_PKG=marine-grafana-container +readonly INFLUX_IMG=influxdb:2.9.1 +readonly INFLUX_CONTAINER=influxdb +readonly GRAFANA_CONTAINER=grafana # --- output helpers ---------------------------------------------------------- @@ -130,7 +135,7 @@ validate_manifest() { [[ "$ver" == "$SUPPORTED_FORMAT_VERSION" ]] || die "Backup format version '$ver' is not supported by this restore script (expected $SUPPORTED_FORMAT_VERSION). Use a matching version of the migration scripts." [[ "$complete" == "yes" ]] || - die "Backup is marked incomplete (no completeness marker). Re-run the backup until it prints SAFE TO REFLASH." + die "Backup is marked incomplete (no completeness marker). Re-run the backup until it prints MIGRATION BACKUP COMPLETE AND VERIFIED." info "Backup: from $(mf_get source_host) (user $(mf_get source_user)), created $(mf_get created)." } @@ -221,6 +226,316 @@ restore_home_tar() { $SUDO chown -R "$REAL_USER:$REAL_USER" "$dest" } +# --- InfluxDB ---------------------------------------------------------------- + +ensure_pkg() { + local pkg="$1" + pkg_installed "$pkg" && return 0 + info "Installing $pkg..." + $SUDO apt-get update -qq + $SUDO apt-get install -y "$pkg" >/dev/null +} + +# Ensure the package has run once so its data dir / env exist, then leave the +# service stopped for the swap. +init_then_stop_influx() { + if [[ ! -d "$CA_ROOT/$INFLUX_PKG/data/db" || ! -f "/etc/container-apps/$INFLUX_PKG/env" ]]; then + info "Letting InfluxDB initialize once..." + $SUDO systemctl start "$INFLUX_PKG.service" + wait_container_healthy "$INFLUX_CONTAINER" 60 || true + fi + $SUDO systemctl stop "$INFLUX_PKG.service" 2>/dev/null || true + $SUDO docker rm -f "$INFLUX_CONTAINER" >/dev/null 2>&1 || true +} + +wait_container_healthy() { + local name="$1" timeout="${2:-60}" i + for i in $(seq 1 "$timeout"); do + [[ "$($SUDO docker inspect -f '{{.State.Health.Status}}' "$name" 2>/dev/null)" == healthy ]] && return 0 + sleep 1 + done + return 1 +} + +# Mint a fresh operator token from the swapped-in boltdb (server must be down). +# Echoes: "||" +mint_operator_token() { + local db="$1" user org out token + user="$(recovery_list "$db" user)" + org="$(recovery_list "$db" org)" + [[ -n "$user" && -n "$org" ]] || die "Could not read a user/org from the boat InfluxDB boltdb." + out="$($SUDO docker run --rm -v "$db:/var/lib/influxdb2" "$INFLUX_IMG" \ + influxd recovery auth create-operator --username "$user" --org "$org" \ + --bolt-path /var/lib/influxdb2/influxd.bolt 2>/dev/null)" + token="$(printf '%s\n' "$out" | grep -oE '[A-Za-z0-9_-]{80,}==' | head -1)" + [[ -n "$token" ]] || die "Failed to mint an InfluxDB operator token from the boltdb." + printf '%s|%s|%s' "$user" "$org" "$token" +} + +# First data row's Name from `influxd recovery list` (server down). +recovery_list() { + local db="$1" kind="$2" + $SUDO docker run --rm -v "$db:/var/lib/influxdb2" "$INFLUX_IMG" \ + influxd recovery "$kind" list --bolt-path /var/lib/influxdb2/influxd.bolt 2>/dev/null | + awk 'NR>1 && NF>=2 {print $2; exit}' +} + +iexec() { $SUDO docker exec "$INFLUX_CONTAINER" influx "$@"; } + +restore_influxdb() { + [[ "$(mf_get influxdb)" == "present" ]] || { + info "No InfluxDB data in backup — skipping." + return 0 + } + [[ -f "$SRC/influxdb2.tar" ]] || die "Manifest claims InfluxDB data but influxdb2.tar is missing." + + ensure_pkg "$INFLUX_PKG" + init_then_stop_influx + + local db="$CA_ROOT/$INFLUX_PKG/data/db" + info "Swapping in boat InfluxDB data (this can take a while for large datasets)..." + preserve_default "$db" + $SUDO mkdir -p "$db" + $SUDO tar -C "$db" -xf "$SRC/influxdb2.tar" + mirror_default_ownership "$db" + + info "Minting a fresh operator token from the boat database..." + local triple user org token + triple="$(mint_operator_token "$db")" + user="${triple%%|*}" + token="${triple##*|}" + org="${triple#*|}" + org="${org%|*}" + + inject_influx_token "$token" + + info "Starting InfluxDB with the boat data..." + $SUDO systemctl start "$INFLUX_PKG.service" + wait_container_healthy "$INFLUX_CONTAINER" 90 || + die "InfluxDB did not become healthy. Boat data may be from a newer InfluxDB than HaLOS ships ($INFLUX_IMG). Default preserved at $db.halos-default. Check: sudo docker logs $INFLUX_CONTAINER" + + align_to_marine "$org" "$token" + note "InfluxDB: boat data restored (source user '$user', org '$org' renamed to 'marine')." +} + +mirror_default_ownership() { + local db="$1" ref="$1.halos-default" owner mode + if [[ -e "$ref" ]]; then + owner="$($SUDO stat -c '%U:%G' "$ref")" + mode="$($SUDO stat -c '%a' "$ref")" + else + owner="$REAL_USER:root" + mode=700 + fi + $SUDO chown -R "$owner" "$db" + $SUDO chmod "$mode" "$db" +} + +inject_influx_token() { + local token="$1" env="/etc/container-apps/$INFLUX_PKG/env" + [[ -f "$env" ]] || die "InfluxDB env file $env not found." + [[ -e "$env.halos-default" ]] || $SUDO cp "$env" "$env.halos-default" + if $SUDO grep -q '^INFLUXDB_ADMIN_TOKEN=' "$env"; then + $SUDO sed -i "s|^INFLUXDB_ADMIN_TOKEN=.*|INFLUXDB_ADMIN_TOKEN=$token|" "$env" + else + printf 'INFLUXDB_ADMIN_TOKEN=%s\n' "$token" | $SUDO tee -a "$env" >/dev/null + fi +} + +# Rename the telemetry org+bucket to "marine" so HaLOS' provisioned datasource +# serves the history, and add a v1 DBRP for InfluxQL/Grafana compatibility. +align_to_marine() { + local org="$1" token="$2" + wait_influx_ready "$token" + + local org_id buck + org_id="$(iexec org list --token "$token" 2>/dev/null | awk -v n="$org" 'NR>1 && $2==n {print $1; exit}')" + buck="$(select_telemetry_bucket "$org" "$token")" + local buck_id="${buck%%|*}" buck_name="${buck##*|}" + [[ -n "$org_id" && -n "$buck_id" ]] || die "Could not resolve org/bucket ids for the rename." + + if [[ "$org" != marine ]]; then + iexec org update --id "$org_id" --name marine --token "$token" >/dev/null 2>&1 || + warn "Could not rename org '$org' to 'marine' (already taken?). HaLOS' default datasource may not resolve the data." + fi + if [[ "$buck_name" != marine ]]; then + iexec bucket update --id "$buck_id" --name marine --token "$token" >/dev/null 2>&1 || + warn "Could not rename bucket '$buck_name' to 'marine'." + fi + iexec v1 dbrp create --bucket-id "$buck_id" --db marine --rp autogen --default \ + --org marine --token "$token" >/dev/null 2>&1 || + warn "Could not create the marine/autogen DBRP (may already exist)." + + smoke_query "$token" +} + +wait_influx_ready() { + local token="$1" i + for i in $(seq 1 30); do + iexec org list --token "$token" >/dev/null 2>&1 && return 0 + sleep 2 + done + die "InfluxDB is up but not answering authenticated queries with the minted token." +} + +# Echo "|" of the telemetry bucket, prompting if more than one exists. +select_telemetry_bucket() { + local org="$1" token="$2" + local -a buckets=() + mapfile -t buckets < <(iexec bucket list --org "$org" --token "$token" 2>/dev/null | + awk 'NR>1 && $2 !~ /^_/ {print $1"|"$2}') + case "${#buckets[@]}" in + 0) die "No data buckets found in the boat InfluxDB." ;; + 1) printf '%s' "${buckets[0]}" ;; + *) + warn "Multiple buckets found — choose the main telemetry bucket to map to 'marine':" >&2 + local i=1 b + for b in "${buckets[@]}"; do + printf ' %d) %s\n' "$i" "${b##*|}" >&2 + ((i++)) + done + local choice + read -r -p "Bucket to rename to 'marine' [1-${#buckets[@]}] " choice + if ! [[ "$choice" =~ ^[0-9]+$ ]] || ((choice < 1 || choice > ${#buckets[@]})); then + die "Invalid selection." + fi + printf '%s' "${buckets[$((choice - 1))]}" + ;; + esac +} + +smoke_query() { + local token="$1" out + out="$(iexec query 'from(bucket:"marine") |> range(start:-520w) |> first()' \ + --org marine --token "$token" 2>/dev/null | grep -c _value || true)" + if (($(printf '%s' "${out:-0}") > 0)); then + info "Smoke test passed: historical telemetry is queryable in the 'marine' bucket." + else + warn "Smoke test returned no rows — the bucket may be empty or queries need a wider range." + fi +} + +# --- Grafana ----------------------------------------------------------------- + +grafana_ip() { + $SUDO docker inspect "$GRAFANA_CONTAINER" \ + -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' 2>/dev/null +} + +restore_grafana() { + [[ "$(mf_get grafana)" == "present" ]] || { + info "No Grafana data in backup — skipping." + return 0 + } + [[ -f "$SRC/grafana.db" ]] || { + warn "Manifest claims Grafana data but grafana.db is missing — skipping dashboards." + return 0 + } + # The minted token lets the boat datasource authenticate against the new data. + local token + token="$($SUDO grep -E '^INFLUXDB_ADMIN_TOKEN=' "/etc/container-apps/$INFLUX_PKG/env" 2>/dev/null | cut -d= -f2-)" + + ensure_pkg "$GRAFANA_PKG" + $SUDO systemctl start "$GRAFANA_PKG.service" 2>/dev/null || true + wait_container_healthy "$GRAFANA_CONTAINER" 60 || true + + local ip + ip="$(grafana_ip)" + [[ -n "$ip" ]] || { + warn "Could not find the Grafana container IP — skipping dashboard import. Your data is still available via HaLOS' built-in marine dashboards." + return 0 + } + + info "Importing boat dashboards and datasources into Grafana..." + import_grafana "$SRC/grafana.db" "http://$ip:3000" "$token" || { + warn "Grafana import did not fully succeed. Your historical data is still available via HaLOS' built-in marine dashboards." + note "Grafana: import incomplete — see warnings above." + return 0 + } + note "Grafana: boat dashboards and datasources imported." +} + +# Re-create boat InfluxDB datasources (preserving UID, injecting the minted +# token) and import boat dashboards via the Grafana HTTP API. +import_grafana() { + local db="$1" url="$2" token="$3" + GRAFANA_URL="$url" GRAFANA_DB="$db" INFLUX_TOKEN="$token" \ + python3 - <<'PY' +import json, os, sqlite3, urllib.request, urllib.error, base64 + +url = os.environ["GRAFANA_URL"].rstrip("/") +db = os.environ["GRAFANA_DB"] +token = os.environ.get("INFLUX_TOKEN", "") +auth = base64.b64encode(b"admin:admin").decode() + +def call(method, path, payload): + req = urllib.request.Request( + url + path, data=json.dumps(payload).encode(), + method=method, + headers={"Content-Type": "application/json", + "Authorization": "Basic " + auth}) + try: + with urllib.request.urlopen(req, timeout=15) as r: + return r.status, r.read().decode() + except urllib.error.HTTPError as e: + return e.code, e.read().decode() + except Exception as e: + return 0, str(e) + +con = sqlite3.connect(db) +con.row_factory = sqlite3.Row + +ds_ok = ds_fail = 0 +for row in con.execute("SELECT uid,name,type,access,url,database,json_data FROM data_source"): + if (row["type"] or "") != "influxdb": + continue + jd = json.loads(row["json_data"] or "{}") + jd.setdefault("httpMode", "POST") + jd.setdefault("httpHeaderName1", "Authorization") + payload = { + "uid": row["uid"], "name": row["name"], "type": "influxdb", + "access": row["access"] or "proxy", "url": "http://influxdb:8086", + "database": row["database"], "jsonData": jd, "isDefault": False, + "secureJsonData": {"httpHeaderValue1": "Token " + token}, + } + st, _ = call("POST", "/api/datasources", payload) + if st == 409: # already exists -> not fatal + ds_ok += 1 + elif 200 <= st < 300: + ds_ok += 1 + else: + ds_fail += 1 + +dash_ok = dash_fail = 0 +# Older Grafana stores dashboards as JSON in dashboard.data (is_folder=0). +# Newer Grafana uses a different store and leaves this empty/absent; in that +# case we simply import nothing — HaLOS ships its own marine dashboards. +try: + rows = con.execute("SELECT data FROM dashboard WHERE is_folder=0").fetchall() +except sqlite3.OperationalError: + rows = [] + print("note: this Grafana version does not expose dashboards in dashboard.data") +for row in rows: + try: + model = json.loads(row["data"]) + except Exception: + dash_fail += 1 + continue + model["id"] = None + st, _ = call("POST", "/api/dashboards/db", + {"dashboard": model, "overwrite": True, + "message": "Restored from OpenPlotter backup"}) + if 200 <= st < 300: + dash_ok += 1 + else: + dash_fail += 1 + +print(f"datasources: {ds_ok} ok, {ds_fail} failed; dashboards: {dash_ok} ok, {dash_fail} failed") +# Fail only if we could not import anything at all. +raise SystemExit(0 if (ds_fail == 0 and dash_fail == 0) else 1) +PY +} + # --- summary ----------------------------------------------------------------- print_summary() { @@ -252,6 +567,8 @@ main() { restore_signalk restore_opencpn + restore_influxdb + restore_grafana print_summary info "Done." From 1d09e99d057067f80643d445cc8a43275874639f Mon Sep 17 00:00:00 2001 From: Matti Airas Date: Wed, 10 Jun 2026 22:59:20 +0300 Subject: [PATCH 04/16] docs(migrate): add OpenPlotter migration runbook User-facing runbook for the in-place OpenPlotter to HaLOS migration: USB sizing, fetch-then-run backup with the safe-to-reflash gate, link to existing flashing docs, restore, and post-migration notes (Signal K bucket reconciliation, best-effort dashboards, OpenCPN charts, rollback). Adds the nav entry; scripts serve verbatim at /migrate/*.sh. Closes halos-org/docs#28, halos-org/docs#29. --- docs/user-guide/openplotter-migration.md | 99 ++++++++++++++++++++++++ mkdocs.yml | 1 + 2 files changed, 100 insertions(+) create mode 100644 docs/user-guide/openplotter-migration.md diff --git a/docs/user-guide/openplotter-migration.md b/docs/user-guide/openplotter-migration.md new file mode 100644 index 0000000..2bad73f --- /dev/null +++ b/docs/user-guide/openplotter-migration.md @@ -0,0 +1,99 @@ +# Migrating from OpenPlotter + +This guide moves your data from an existing OpenPlotter (Raspberry Pi OS) system to a fresh marine HaLOS install on the **same device**, using an external USB stick to carry the data across the reflash. Two scripts do the work: one backs up your data on OpenPlotter, the other restores it on HaLOS. If you'd rather move the data by hand, [**Data locations**](#data-locations) at the end of this guide maps every source path to its place on HaLOS. + +The scripts migrate four things, and nothing else: + +- **Signal K** — server configuration, plugins, users, and charts +- **InfluxDB** — your full history of logged sensor data +- **Grafana** — datasources and (where possible) dashboards +- **OpenCPN** — configuration and plugin data + +!!! danger "Back up more than just these four before you reflash" + Installing HaLOS wipes the disk, and the wipe is irreversible. The backup script copies **only** the four domains above — everything else is left behind: personal files, charts stored outside `~/.opencpn`, custom `/etc` configuration, and any apps you installed yourself. Copy anything else you care about to the stick before you reflash. Until the restore succeeds, that stick is the only copy of your data. + +## Before you start + +- **A USB stick large enough for your data.** InfluxDB history is usually the biggest part and can run to tens of gigabytes. The backup script measures your data and refuses to start if the stick is too small, so a 64 GB stick is a safe choice. Format it as exFAT or ext4 if your InfluxDB data exceeds 4 GB (FAT32 cannot hold files that large). +- **Both devices online.** The scripts are downloaded over the network on each device. +- **Run as your normal user** (for example `pi`). The scripts use `sudo` only where they need to. + +## Step 1 — Back up on OpenPlotter + +On the running OpenPlotter system, plug in the USB stick, then download and run the backup script: + +```bash +curl -fsSLO https://docs.halos.fi/migrate/openplotter-backup.sh +bash openplotter-backup.sh +``` + +The script stops Signal K, InfluxDB and Grafana for a consistent copy (and asks you to close OpenCPN), copies each part to the stick, and verifies the result. When the migration backup is complete and verified it prints: + +``` +======================================== + MIGRATION BACKUP COMPLETE AND VERIFIED +======================================== +``` + +followed by a reminder that this covers only the four migrated domains. If you do not see that banner, the backup is incomplete — re-run the script and resolve any error it reports. Remember the banner confirms only the migration backup; copy anything else you need (see the danger note above) before you reflash. + +!!! tip "Keep the stick safe" + Leave the USB stick plugged in, or set it aside somewhere safe. It is your only copy of the data until the restore on HaLOS succeeds. + +## Step 2 — Flash HaLOS + +Flash the **Marine** HaLOS image onto the device as usual. See [Choosing an Image](../getting-started/choosing-an-image.md) and [Quick Start](../getting-started/quick-start.md). Do not reuse or wipe the USB stick. + +## Step 3 — Restore on HaLOS + +Boot the freshly-flashed HaLOS device, plug in the same USB stick, then download and run the restore script: + +```bash +curl -fsSLO https://docs.halos.fi/migrate/halos-restore.sh +bash halos-restore.sh +``` + +The script finds the backup on the stick, checks it is complete, and restores each part. For InfluxDB it installs the marine InfluxDB app if needed, swaps in your data, and renames your telemetry bucket to `marine` so HaLOS' built-in dashboards and datasource work against your history. If your old system had more than one data bucket, it asks which one is your main telemetry bucket. + +When it finishes it prints a summary of what was restored. + +## What is migrated, and what is not + +| Migrated | Not migrated | +|----------|--------------| +| Signal K configuration and data | Other home-directory files | +| InfluxDB history (all buckets) | System configuration under `/etc` | +| Grafana datasources and dashboards | Docker apps you installed yourself | +| OpenCPN config and plugin data | Charts stored outside `~/.opencpn` | + +## After migrating + +- **Signal K logging into InfluxDB.** Your telemetry bucket is renamed to `marine`. HaLOS configures the Signal K → InfluxDB plugin for the `marine` bucket automatically, but if your old setup used a custom bucket name, open the Signal K admin UI and confirm the InfluxDB plugin is writing to `marine` so new data continues to be logged. +- **Grafana dashboards.** Your historical data is always available through HaLOS' built-in marine dashboards. Importing your *own* dashboards is best-effort and depends on the Grafana version your OpenPlotter system ran; if they do not appear, recreate them or use the built-in ones. +- **OpenCPN charts.** Charts you stored in a custom folder (outside `~/.opencpn`) are not copied automatically. The backup script warns you about these — copy them to the stick and into place manually. + +!!! note "Rolling back" + Before swapping in your data, the restore script moves the fresh HaLOS defaults aside to `*.halos-default` (for example `…/data/db.halos-default`). If a restore goes wrong, those let you return to the clean state. + +## Troubleshooting + +- **"Backup is marked incomplete"** — the backup did not finish. Re-run `openplotter-backup.sh` on the source system until it prints `MIGRATION BACKUP COMPLETE AND VERIFIED`. +- **InfluxDB does not become healthy on restore** — your OpenPlotter InfluxDB may be newer than the version HaLOS ships. The restore leaves your previous state at `…/data/db.halos-default`. Check `sudo docker logs influxdb`. +- **No completion banner and the stick is full** — use a larger stick; the backup needs room for your whole InfluxDB history. + +## Data locations + +The scripts above handle these paths for you. This section is a reference for anyone moving the data by hand. On OpenPlotter each service keeps its data in the standard location used by its Debian package, so these paths apply equally to most plain Raspberry Pi OS setups that installed Signal K, InfluxDB, Grafana or OpenCPN from apt. On HaLOS the same services run as containers, so their data lives under `/var/lib/container-apps//data/`. + +| Data | OpenPlotter / Raspberry Pi OS | HaLOS | +|------|-------------------------------|-------| +| Signal K | `~/.signalk` | `/var/lib/container-apps/marine-signalk-server-container/data/data` | +| InfluxDB 2 | `/var/lib/influxdb` (bolt at `/var/lib/influxdb/influxd.bolt`, engine at `/var/lib/influxdb/engine`) | `/var/lib/container-apps/marine-influxdb-container/data/db` | +| Grafana | `/var/lib/grafana/grafana.db` | `marine-grafana-container` (re-imported, see below) | +| OpenCPN | `~/.opencpn`, `~/.local/share/opencpn` | same paths in your HaLOS home directory | + +A few details worth knowing: + +- **InfluxDB path differs by install method.** The Debian package stores data in `/var/lib/influxdb`, while the InfluxDB Docker image HaLOS uses keeps it at `/var/lib/influxdb2` internally. The restore handles the translation; you only need the source path if you copy data by hand. +- **Grafana is re-imported, not copied.** Rather than swapping the SQLite database into place, the restore reads your OpenPlotter `grafana.db` and re-creates its datasources and dashboards through the HaLOS Grafana API. This is why dashboard import is best-effort across Grafana versions. +- **OpenCPN stays in the home directory.** Its config and plugin data restore to the same `~/.opencpn` and `~/.local/share/opencpn` paths on HaLOS. diff --git a/mkdocs.yml b/mkdocs.yml index 60bebf2..a1dc36a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -87,6 +87,7 @@ nav: - Marine Apps: user-guide/marine-apps.md - GPS: user-guide/gps.md - App Data and Migration: user-guide/app-data.md + - Migrating from OpenPlotter: user-guide/openplotter-migration.md - Networking: user-guide/networking.md - Remote Access: user-guide/remote-access-vpn.md - Troubleshooting: user-guide/troubleshooting.md From 7ea4f87af8fc7598b18f11bada70c0e02ffe6454 Mon Sep 17 00:00:00 2001 From: Matti Airas Date: Thu, 11 Jun 2026 15:57:34 +0300 Subject: [PATCH 05/16] refactor(restore): drop dead url column and printf wrapper --- docs/migrate/halos-restore.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/migrate/halos-restore.sh b/docs/migrate/halos-restore.sh index 991b094..f89b9b0 100755 --- a/docs/migrate/halos-restore.sh +++ b/docs/migrate/halos-restore.sh @@ -408,7 +408,7 @@ smoke_query() { local token="$1" out out="$(iexec query 'from(bucket:"marine") |> range(start:-520w) |> first()' \ --org marine --token "$token" 2>/dev/null | grep -c _value || true)" - if (($(printf '%s' "${out:-0}") > 0)); then + if (( ${out:-0} > 0 )); then info "Smoke test passed: historical telemetry is queryable in the 'marine' bucket." else warn "Smoke test returned no rows — the bucket may be empty or queries need a wider range." @@ -486,7 +486,7 @@ con = sqlite3.connect(db) con.row_factory = sqlite3.Row ds_ok = ds_fail = 0 -for row in con.execute("SELECT uid,name,type,access,url,database,json_data FROM data_source"): +for row in con.execute("SELECT uid,name,type,access,database,json_data FROM data_source"): if (row["type"] or "") != "influxdb": continue jd = json.loads(row["json_data"] or "{}") From 344ce503714c968042728dbdf55ffb441632c0b1 Mon Sep 17 00:00:00 2001 From: Matti Airas Date: Thu, 11 Jun 2026 16:11:25 +0300 Subject: [PATCH 06/16] fix(migrate): harden token handling and flush before verify --- docs/migrate/halos-restore.sh | 7 ++++++- docs/migrate/openplotter-backup.sh | 3 +++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/migrate/halos-restore.sh b/docs/migrate/halos-restore.sh index f89b9b0..f9dca29 100755 --- a/docs/migrate/halos-restore.sh +++ b/docs/migrate/halos-restore.sh @@ -267,7 +267,7 @@ mint_operator_token() { out="$($SUDO docker run --rm -v "$db:/var/lib/influxdb2" "$INFLUX_IMG" \ influxd recovery auth create-operator --username "$user" --org "$org" \ --bolt-path /var/lib/influxdb2/influxd.bolt 2>/dev/null)" - token="$(printf '%s\n' "$out" | grep -oE '[A-Za-z0-9_-]{80,}==' | head -1)" + token="$(printf '%s\n' "$out" | grep -oE '[A-Za-z0-9_-]{80,}={0,2}' | head -1)" [[ -n "$token" ]] || die "Failed to mint an InfluxDB operator token from the boltdb." printf '%s|%s|%s' "$user" "$org" "$token" } @@ -434,6 +434,11 @@ restore_grafana() { # The minted token lets the boat datasource authenticate against the new data. local token token="$($SUDO grep -E '^INFLUXDB_ADMIN_TOKEN=' "/etc/container-apps/$INFLUX_PKG/env" 2>/dev/null | cut -d= -f2-)" + [[ -n "$token" ]] || { + warn "Could not read the InfluxDB token from /etc/container-apps/$INFLUX_PKG/env — skipping dashboard import. You can redo it manually later; your data is still available via HaLOS' built-in marine dashboards." + note "Grafana: import skipped — InfluxDB token missing." + return 0 + } ensure_pkg "$GRAFANA_PKG" $SUDO systemctl start "$GRAFANA_PKG.service" 2>/dev/null || true diff --git a/docs/migrate/openplotter-backup.sh b/docs/migrate/openplotter-backup.sh index 3dab5dd..7add55f 100755 --- a/docs/migrate/openplotter-backup.sh +++ b/docs/migrate/openplotter-backup.sh @@ -368,6 +368,9 @@ write_manifest() { # InfluxDB archive actually contains the boltdb (its core metadata file). verify_backup() { info "Verifying backup integrity..." + # Flush the archives to disk first so the read-backs below validate + # persisted data, not the page cache. + sync local ok=1 check_artifact() { From 8967e7c073f9ba4f01afaa05cb1bf083a0cd830a Mon Sep 17 00:00:00 2001 From: Matti Airas Date: Thu, 11 Jun 2026 16:37:50 +0300 Subject: [PATCH 07/16] fix(backup): verify engine data, tighten checks and credentials --- docs/migrate/openplotter-backup.sh | 70 +++++++++++++++++++++--------- 1 file changed, 50 insertions(+), 20 deletions(-) diff --git a/docs/migrate/openplotter-backup.sh b/docs/migrate/openplotter-backup.sh index 7add55f..2bb8bf6 100755 --- a/docs/migrate/openplotter-backup.sh +++ b/docs/migrate/openplotter-backup.sh @@ -173,6 +173,8 @@ dir_bytes() { SK_BYTES=0 INFLUX_BYTES=0 GRAFANA_BYTES=0 +OPENCPN_CONF_BYTES=0 +OPENCPN_SHARE_BYTES=0 OPENCPN_BYTES=0 measure_sources() { @@ -180,11 +182,9 @@ measure_sources() { [[ -d "$USER_HOME/.signalk" ]] && SK_BYTES="$(dir_bytes "$USER_HOME/.signalk")" [[ -d /var/lib/influxdb ]] && INFLUX_BYTES="$(dir_bytes /var/lib/influxdb)" [[ -f /var/lib/grafana/grafana.db ]] && GRAFANA_BYTES="$(dir_bytes /var/lib/grafana/grafana.db)" - local oc=0 a b - a="$(dir_bytes "$USER_HOME/.opencpn")" - b="$(dir_bytes "$USER_HOME/.local/share/opencpn")" - oc=$((a + b)) - OPENCPN_BYTES=$oc + OPENCPN_CONF_BYTES="$(dir_bytes "$USER_HOME/.opencpn")" + OPENCPN_SHARE_BYTES="$(dir_bytes "$USER_HOME/.local/share/opencpn")" + OPENCPN_BYTES=$((OPENCPN_CONF_BYTES + OPENCPN_SHARE_BYTES)) } preflight() { @@ -198,9 +198,15 @@ preflight() { info "Source data total: $(bytes_to_h "$total"). USB free: $(human_free "$DEST_ROOT") (filesystem: ${fstype:-unknown})." # FAT32 cannot hold a single file larger than 4 GiB — the InfluxDB archive - # is the one likely to exceed it. - if [[ "$fstype" == "vfat" || "$fstype" == "msdos" ]] && ((INFLUX_BYTES > FAT_FILE_LIMIT)); then - die "The USB stick is FAT32, which cannot store files larger than 4 GiB, but the InfluxDB data is $(bytes_to_h "$INFLUX_BYTES"). Reformat the stick as exFAT or ext4 and try again." + # is the one likely to exceed it. FUSE mounts (exFAT/NTFS drivers) report + # "fuseblk", hiding the real filesystem; those usually have no 4 GiB limit, + # so only warn. + if ((INFLUX_BYTES > FAT_FILE_LIMIT)); then + if [[ "$fstype" == "vfat" || "$fstype" == "msdos" ]]; then + die "The USB stick is FAT32, which cannot store files larger than 4 GiB, but the InfluxDB data is $(bytes_to_h "$INFLUX_BYTES"). Reformat the stick as exFAT or ext4 and try again." + elif [[ "$fstype" == "fuseblk" || -z "$fstype" ]]; then + warn "Could not confirm the USB filesystem type (${fstype:-unknown}). If the stick is FAT32 it cannot hold the $(bytes_to_h "$INFLUX_BYTES") InfluxDB archive — should the backup fail mid-write, reformat as exFAT or ext4." + fi fi # Require the stick to hold the data with a little headroom for tar overhead. @@ -251,9 +257,11 @@ stop_services() { done } +# Restart in reverse stop order, so signalk.service comes up before its socket. start_services() { - local u - for u in "${STOPPED_SERVICES[@]}"; do + local i u + for ((i = ${#STOPPED_SERVICES[@]} - 1; i >= 0; i--)); do + u="${STOPPED_SERVICES[$i]}" $SUDO systemctl start "$u" 2>/dev/null || warn "Could not restart $u — start it manually with: sudo systemctl start $u" done @@ -306,7 +314,10 @@ backup_grafana() { } info "Backing up Grafana dashboards..." $SUDO cp "/var/lib/grafana/grafana.db" "$DEST/grafana.db" - $SUDO chmod a+r "$DEST/grafana.db" + # The DB holds datasource secrets; keep it owner-only for the user, who + # reads it without root on the restore side. No-op on FAT sticks. + $SUDO chown "$REAL_USER" "$DEST/grafana.db" 2>/dev/null || true + $SUDO chmod 600 "$DEST/grafana.db" 2>/dev/null || true } backup_opencpn() { @@ -318,12 +329,12 @@ backup_opencpn() { local ans read -r -p "Is OpenCPN closed? [y/N] " ans [[ "$ans" =~ ^[Yy]$ ]] || die "Close OpenCPN and re-run." - if [[ -d "$USER_HOME/.opencpn" ]]; then + if ((OPENCPN_CONF_BYTES > 0)); then info "Backing up OpenCPN config..." tar_dir "$USER_HOME/.opencpn" "$DEST/opencpn-config.tar" warn_relocated_charts fi - if [[ -d "$USER_HOME/.local/share/opencpn" ]]; then + if ((OPENCPN_SHARE_BYTES > 0)); then info "Backing up OpenCPN plugin data..." tar_dir "$USER_HOME/.local/share/opencpn" "$DEST/opencpn-share.tar" fi @@ -365,7 +376,8 @@ write_manifest() { } # Confirm each present domain produced a non-empty artifact, and that the -# InfluxDB archive actually contains the boltdb (its core metadata file). +# InfluxDB archive actually contains both the boltdb (metadata) and the +# engine/ tree (the time-series data itself) at a plausible size. verify_backup() { info "Verifying backup integrity..." # Flush the archives to disk first so the read-backs below validate @@ -383,15 +395,29 @@ verify_backup() { ((SK_BYTES > 0)) && check_artifact "Signal K" "$DEST/signalk.tar" ((GRAFANA_BYTES > 0)) && check_artifact "Grafana" "$DEST/grafana.db" - [[ -d "$USER_HOME/.opencpn" ]] && check_artifact "OpenCPN config" "$DEST/opencpn-config.tar" - [[ -d "$USER_HOME/.local/share/opencpn" ]] && check_artifact "OpenCPN share" "$DEST/opencpn-share.tar" + ((OPENCPN_CONF_BYTES > 0)) && check_artifact "OpenCPN config" "$DEST/opencpn-config.tar" + ((OPENCPN_SHARE_BYTES > 0)) && check_artifact "OpenCPN share" "$DEST/opencpn-share.tar" if ((INFLUX_BYTES > 0)); then check_artifact "InfluxDB" "$DEST/influxdb2.tar" - if [[ -s "$DEST/influxdb2.tar" ]] && - ! tar -tf "$DEST/influxdb2.tar" 2>/dev/null | grep -q 'influxd\.bolt'; then - err "InfluxDB archive is missing influxd.bolt — the data is incomplete." - ok=0 + if [[ -s "$DEST/influxdb2.tar" ]]; then + local listing + listing="$(tar -tf "$DEST/influxdb2.tar" 2>/dev/null)" || listing="" + if ! grep -q 'influxd\.bolt' <<<"$listing"; then + err "InfluxDB archive is missing influxd.bolt — the data is incomplete." + ok=0 + fi + # The boltdb is only metadata; the history lives under engine/. + if [[ -d /var/lib/influxdb/engine ]] && ! grep -q '^\./engine/' <<<"$listing"; then + err "InfluxDB archive is missing the engine/ data directory — the telemetry history is NOT in the backup." + ok=0 + fi + local tar_size + tar_size="$(stat -c %s "$DEST/influxdb2.tar" 2>/dev/null || echo 0)" + if ((tar_size < INFLUX_BYTES / 2)); then + err "InfluxDB archive ($(bytes_to_h "$tar_size")) is far smaller than the measured source data ($(bytes_to_h "$INFLUX_BYTES")) — it looks truncated." + ok=0 + fi fi fi @@ -456,6 +482,10 @@ main() { warn "installed yourself are NOT included — copy those to the stick now if" warn "you need them." echo + warn "The backup contains credentials (Signal K user accounts, Grafana and" + warn "InfluxDB secrets). Treat the stick as sensitive and wipe it once the" + warn "migration is done." + echo echo "Once you have also saved anything else you care about, you can" echo "reflash. Keep the stick safe through the reflash, then run" echo "halos-restore.sh on the new HaLOS system to restore your data." From 93a728831c3d4baabed43eb29eb0bfd4fa604376 Mon Sep 17 00:00:00 2001 From: Matti Airas Date: Thu, 11 Jun 2026 16:37:50 +0300 Subject: [PATCH 08/16] fix(restore): handle odd names, partial failures, image drift --- docs/migrate/halos-restore.sh | 102 ++++++++++++++++++++++++++++------ 1 file changed, 85 insertions(+), 17 deletions(-) diff --git a/docs/migrate/halos-restore.sh b/docs/migrate/halos-restore.sh index f9dca29..4df2cb8 100755 --- a/docs/migrate/halos-restore.sh +++ b/docs/migrate/halos-restore.sh @@ -30,10 +30,14 @@ readonly CA_ROOT=/var/lib/container-apps readonly SK_PKG=marine-signalk-server-container readonly INFLUX_PKG=marine-influxdb-container readonly GRAFANA_PKG=marine-grafana-container -readonly INFLUX_IMG=influxdb:2.9.1 readonly INFLUX_CONTAINER=influxdb readonly GRAFANA_CONTAINER=grafana +# Image used for `influxd recovery` runs against the boat boltdb. Overridden +# with whatever image the installed influxdb container actually runs, so the +# recovery tooling always matches the server HaLOS ships. +INFLUX_IMG=influxdb:2.9.1 + # --- output helpers ---------------------------------------------------------- info() { printf '\033[1m[*]\033[0m %s\n' "$*"; } @@ -48,8 +52,25 @@ SUDO="" REAL_USER="" USER_HOME="" SRC="" +PENDING_RESTART="" declare -a SUMMARY=() +# A restore step stops a service, swaps data, and starts it again. If the +# script dies inside that window, restart the service so a failed restore +# never leaves the system dark, and point at the rollback copies. +on_exit() { + local rc=$? + if ((rc != 0)); then + if [[ -n "$PENDING_RESTART" ]]; then + warn "Restarting $PENDING_RESTART after the failed restore step..." + $SUDO systemctl start "$PENDING_RESTART" 2>/dev/null || + warn "Could not restart $PENDING_RESTART — start it manually: sudo systemctl start $PENDING_RESTART" + fi + err "Restore did not complete. Any data directory already swapped keeps its fresh HaLOS copy beside it as *.halos-default; after fixing the reported problem it is safe to re-run this script." + fi + return $rc +} + usage() { awk 'NR==1 {next} /^#/ {sub(/^# ?/, ""); print; next} {exit}' "$0" exit "${1:-0}" @@ -173,6 +194,7 @@ restore_signalk() { info "Restoring Signal K configuration..." $SUDO systemctl stop "$SK_PKG.service" 2>/dev/null || true + PENDING_RESTART="$SK_PKG.service" preserve_default "$target" $SUDO mkdir -p "$target" $SUDO tar -C "$target" -xf "$SRC/signalk.tar" @@ -180,6 +202,7 @@ restore_signalk() { $SUDO chown -R "$REAL_USER:$REAL_USER" "$target" $SUDO systemctl start "$SK_PKG.service" + PENDING_RESTART="" verify_signalk } @@ -244,8 +267,21 @@ init_then_stop_influx() { $SUDO systemctl start "$INFLUX_PKG.service" wait_container_healthy "$INFLUX_CONTAINER" 60 || true fi + # Run the recovery tooling with the same image as the installed server. + local img + img="$($SUDO docker inspect -f '{{.Config.Image}}' "$INFLUX_CONTAINER" 2>/dev/null || true)" + [[ -n "$img" ]] && INFLUX_IMG="$img" $SUDO systemctl stop "$INFLUX_PKG.service" 2>/dev/null || true + PENDING_RESTART="$INFLUX_PKG.service" $SUDO docker rm -f "$INFLUX_CONTAINER" >/dev/null 2>&1 || true + ensure_influx_image +} + +ensure_influx_image() { + $SUDO docker image inspect "$INFLUX_IMG" >/dev/null 2>&1 && return 0 + info "Pulling $INFLUX_IMG for the InfluxDB recovery tooling..." + $SUDO docker pull "$INFLUX_IMG" >/dev/null 2>&1 || + die "InfluxDB image $INFLUX_IMG is not available locally and could not be pulled. Connect the device to the internet and re-run." } wait_container_healthy() { @@ -258,7 +294,7 @@ wait_container_healthy() { } # Mint a fresh operator token from the swapped-in boltdb (server must be down). -# Echoes: "||" +# Echoes three lines: username, org name, token. mint_operator_token() { local db="$1" user org out token user="$(recovery_list "$db" user)" @@ -269,15 +305,17 @@ mint_operator_token() { --bolt-path /var/lib/influxdb2/influxd.bolt 2>/dev/null)" token="$(printf '%s\n' "$out" | grep -oE '[A-Za-z0-9_-]{80,}={0,2}' | head -1)" [[ -n "$token" ]] || die "Failed to mint an InfluxDB operator token from the boltdb." - printf '%s|%s|%s' "$user" "$org" "$token" + printf '%s\n%s\n%s\n' "$user" "$org" "$token" } # First data row's Name from `influxd recovery list` (server down). +# The output is " "; the name is everything after the ID so names +# containing spaces survive. recovery_list() { local db="$1" kind="$2" $SUDO docker run --rm -v "$db:/var/lib/influxdb2" "$INFLUX_IMG" \ influxd recovery "$kind" list --bolt-path /var/lib/influxdb2/influxd.bolt 2>/dev/null | - awk 'NR>1 && NF>=2 {print $2; exit}' + awk 'NR>1 && NF>=2 {sub(/^[^ \t]+[ \t]+/, ""); sub(/[ \t]+$/, ""); print; exit}' } iexec() { $SUDO docker exec "$INFLUX_CONTAINER" influx "$@"; } @@ -300,17 +338,20 @@ restore_influxdb() { mirror_default_ownership "$db" info "Minting a fresh operator token from the boat database..." - local triple user org token - triple="$(mint_operator_token "$db")" - user="${triple%%|*}" - token="${triple##*|}" - org="${triple#*|}" - org="${org%|*}" + local creds user org token + creds="$(mint_operator_token "$db")" + { + read -r user + read -r org + read -r token + } <<<"$creds" + [[ -n "$user" && -n "$org" && -n "$token" ]] || die "Could not parse the minted InfluxDB credentials." inject_influx_token "$token" info "Starting InfluxDB with the boat data..." $SUDO systemctl start "$INFLUX_PKG.service" + PENDING_RESTART="" wait_container_healthy "$INFLUX_CONTAINER" 90 || die "InfluxDB did not become healthy. Boat data may be from a newer InfluxDB than HaLOS ships ($INFLUX_IMG). Default preserved at $db.halos-default. Check: sudo docker logs $INFLUX_CONTAINER" @@ -349,9 +390,17 @@ align_to_marine() { wait_influx_ready "$token" local org_id buck - org_id="$(iexec org list --token "$token" 2>/dev/null | awk -v n="$org" 'NR>1 && $2==n {print $1; exit}')" + org_id="$(iexec org list --token "$token" --json 2>/dev/null | + ORG_NAME="$org" python3 -c ' +import json, os, sys +try: + orgs = json.load(sys.stdin) +except Exception: + orgs = [] +print(next((o["id"] for o in orgs if o.get("name") == os.environ["ORG_NAME"]), "")) +')" buck="$(select_telemetry_bucket "$org" "$token")" - local buck_id="${buck%%|*}" buck_name="${buck##*|}" + local buck_id="${buck%%$'\t'*}" buck_name="${buck#*$'\t'}" [[ -n "$org_id" && -n "$buck_id" ]] || die "Could not resolve org/bucket ids for the rename." if [[ "$org" != marine ]]; then @@ -378,12 +427,23 @@ wait_influx_ready() { die "InfluxDB is up but not answering authenticated queries with the minted token." } -# Echo "|" of the telemetry bucket, prompting if more than one exists. +# Echo "\t" of the telemetry bucket, prompting if more than one +# exists. JSON output keeps bucket names with spaces intact. select_telemetry_bucket() { local org="$1" token="$2" local -a buckets=() - mapfile -t buckets < <(iexec bucket list --org "$org" --token "$token" 2>/dev/null | - awk 'NR>1 && $2 !~ /^_/ {print $1"|"$2}') + mapfile -t buckets < <(iexec bucket list --org "$org" --token "$token" --json 2>/dev/null | + python3 -c ' +import json, sys +try: + rows = json.load(sys.stdin) +except Exception: + rows = [] +for b in rows: + name = b.get("name", "") + if name and not name.startswith("_"): + print(b["id"] + "\t" + name) +') case "${#buckets[@]}" in 0) die "No data buckets found in the boat InfluxDB." ;; 1) printf '%s' "${buckets[0]}" ;; @@ -391,7 +451,7 @@ select_telemetry_bucket() { warn "Multiple buckets found — choose the main telemetry bucket to map to 'marine':" >&2 local i=1 b for b in "${buckets[@]}"; do - printf ' %d) %s\n' "$i" "${b##*|}" >&2 + printf ' %d) %s\n' "$i" "${b#*$'\t'}" >&2 ((i++)) done local choice @@ -490,7 +550,7 @@ def call(method, path, payload): con = sqlite3.connect(db) con.row_factory = sqlite3.Row -ds_ok = ds_fail = 0 +ds_ok = ds_fail = auth_fail = 0 for row in con.execute("SELECT uid,name,type,access,database,json_data FROM data_source"): if (row["type"] or "") != "influxdb": continue @@ -509,6 +569,8 @@ for row in con.execute("SELECT uid,name,type,access,database,json_data FROM data elif 200 <= st < 300: ds_ok += 1 else: + if st in (401, 403): + auth_fail += 1 ds_fail += 1 dash_ok = dash_fail = 0 @@ -533,9 +595,14 @@ for row in rows: if 200 <= st < 300: dash_ok += 1 else: + if st in (401, 403): + auth_fail += 1 dash_fail += 1 print(f"datasources: {ds_ok} ok, {ds_fail} failed; dashboards: {dash_ok} ok, {dash_fail} failed") +if auth_fail: + print("note: Grafana rejected the default admin credentials — this HaLOS " + "Grafana does not accept admin:admin, so the import cannot proceed.") # Fail only if we could not import anything at all. raise SystemExit(0 if (ds_fail == 0 and dash_fail == 0) else 1) PY @@ -566,6 +633,7 @@ main() { done resolve_privs + trap on_exit EXIT locate_backup "$arg" validate_manifest require_marine_halos From d9bba064e21c95a7003f1898022124c0a40982dc Mon Sep 17 00:00:00 2001 From: Matti Airas Date: Thu, 11 Jun 2026 16:37:50 +0300 Subject: [PATCH 09/16] docs(migrate): note re-run safety and stick sensitivity --- docs/user-guide/openplotter-migration.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/user-guide/openplotter-migration.md b/docs/user-guide/openplotter-migration.md index 2bad73f..6ae53de 100644 --- a/docs/user-guide/openplotter-migration.md +++ b/docs/user-guide/openplotter-migration.md @@ -38,7 +38,7 @@ The script stops Signal K, InfluxDB and Grafana for a consistent copy (and asks followed by a reminder that this covers only the four migrated domains. If you do not see that banner, the backup is incomplete — re-run the script and resolve any error it reports. Remember the banner confirms only the migration backup; copy anything else you need (see the danger note above) before you reflash. !!! tip "Keep the stick safe" - Leave the USB stick plugged in, or set it aside somewhere safe. It is your only copy of the data until the restore on HaLOS succeeds. + Leave the USB stick plugged in, or set it aside somewhere safe. It is your only copy of the data until the restore on HaLOS succeeds. It also contains credentials (Signal K user accounts, Grafana and InfluxDB secrets), so don't hand it around — and wipe it once the migration is done. ## Step 2 — Flash HaLOS @@ -78,6 +78,7 @@ When it finishes it prints a summary of what was restored. ## Troubleshooting - **"Backup is marked incomplete"** — the backup did not finish. Re-run `openplotter-backup.sh` on the source system until it prints `MIGRATION BACKUP COMPLETE AND VERIFIED`. +- **A restore failed partway** — fix the problem it reported and run `halos-restore.sh` again; it re-extracts everything from the stick, and the `*.halos-default` copies from the first run are kept as the rollback state. - **InfluxDB does not become healthy on restore** — your OpenPlotter InfluxDB may be newer than the version HaLOS ships. The restore leaves your previous state at `…/data/db.halos-default`. Check `sudo docker logs influxdb`. - **No completion banner and the stick is full** — use a larger stick; the backup needs room for your whole InfluxDB history. From beebb234b6469dfaf49d7a8a9f00ee9465aac4cc Mon Sep 17 00:00:00 2001 From: Matti Airas Date: Thu, 11 Jun 2026 16:37:50 +0300 Subject: [PATCH 10/16] ci: lint migration scripts and check verbatim serving --- .github/workflows/ci.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f300026 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Lint migration scripts + run: | + bash -n docs/migrate/*.sh + shellcheck docs/migrate/*.sh + - uses: astral-sh/setup-uv@v5 + - run: uv sync + - run: uv run mkdocs build --strict + - name: Verify scripts are served verbatim + run: | + for f in docs/migrate/*.sh; do + cmp "$f" "site/migrate/$(basename "$f")" + done From d38c8a5c43243fadce44b83244285a4c6d5ae144 Mon Sep 17 00:00:00 2001 From: Matti Airas Date: Thu, 11 Jun 2026 17:12:46 +0300 Subject: [PATCH 11/16] fix(migrate): drop incorrect InfluxDB version-skew explanation --- docs/migrate/halos-restore.sh | 2 +- docs/user-guide/openplotter-migration.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/migrate/halos-restore.sh b/docs/migrate/halos-restore.sh index 4df2cb8..2f2dce2 100755 --- a/docs/migrate/halos-restore.sh +++ b/docs/migrate/halos-restore.sh @@ -353,7 +353,7 @@ restore_influxdb() { $SUDO systemctl start "$INFLUX_PKG.service" PENDING_RESTART="" wait_container_healthy "$INFLUX_CONTAINER" 90 || - die "InfluxDB did not become healthy. Boat data may be from a newer InfluxDB than HaLOS ships ($INFLUX_IMG). Default preserved at $db.halos-default. Check: sudo docker logs $INFLUX_CONTAINER" + die "InfluxDB did not become healthy with the restored data. Default preserved at $db.halos-default. Check: sudo docker logs $INFLUX_CONTAINER" align_to_marine "$org" "$token" note "InfluxDB: boat data restored (source user '$user', org '$org' renamed to 'marine')." diff --git a/docs/user-guide/openplotter-migration.md b/docs/user-guide/openplotter-migration.md index 6ae53de..1772951 100644 --- a/docs/user-guide/openplotter-migration.md +++ b/docs/user-guide/openplotter-migration.md @@ -79,7 +79,7 @@ When it finishes it prints a summary of what was restored. - **"Backup is marked incomplete"** — the backup did not finish. Re-run `openplotter-backup.sh` on the source system until it prints `MIGRATION BACKUP COMPLETE AND VERIFIED`. - **A restore failed partway** — fix the problem it reported and run `halos-restore.sh` again; it re-extracts everything from the stick, and the `*.halos-default` copies from the first run are kept as the rollback state. -- **InfluxDB does not become healthy on restore** — your OpenPlotter InfluxDB may be newer than the version HaLOS ships. The restore leaves your previous state at `…/data/db.halos-default`. Check `sudo docker logs influxdb`. +- **InfluxDB does not become healthy on restore** — InfluxDB could not open the restored data (a damaged copy, for example). The restore leaves the fresh HaLOS state at `…/data/db.halos-default`. Check `sudo docker logs influxdb`. - **No completion banner and the stick is full** — use a larger stick; the backup needs room for your whole InfluxDB history. ## Data locations From 1d1952d4f20d11ad75f8bae61b8723459047c7fc Mon Sep 17 00:00:00 2001 From: Matti Airas Date: Thu, 11 Jun 2026 17:15:38 +0300 Subject: [PATCH 12/16] fix(migrate): point log checks at journalctl, not docker logs --- docs/migrate/halos-restore.sh | 2 +- docs/user-guide/openplotter-migration.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/migrate/halos-restore.sh b/docs/migrate/halos-restore.sh index 2f2dce2..4343ccb 100755 --- a/docs/migrate/halos-restore.sh +++ b/docs/migrate/halos-restore.sh @@ -353,7 +353,7 @@ restore_influxdb() { $SUDO systemctl start "$INFLUX_PKG.service" PENDING_RESTART="" wait_container_healthy "$INFLUX_CONTAINER" 90 || - die "InfluxDB did not become healthy with the restored data. Default preserved at $db.halos-default. Check: sudo docker logs $INFLUX_CONTAINER" + die "InfluxDB did not become healthy with the restored data. Default preserved at $db.halos-default. Check: sudo journalctl -u $INFLUX_PKG.service -e" align_to_marine "$org" "$token" note "InfluxDB: boat data restored (source user '$user', org '$org' renamed to 'marine')." diff --git a/docs/user-guide/openplotter-migration.md b/docs/user-guide/openplotter-migration.md index 1772951..e44eb65 100644 --- a/docs/user-guide/openplotter-migration.md +++ b/docs/user-guide/openplotter-migration.md @@ -79,7 +79,7 @@ When it finishes it prints a summary of what was restored. - **"Backup is marked incomplete"** — the backup did not finish. Re-run `openplotter-backup.sh` on the source system until it prints `MIGRATION BACKUP COMPLETE AND VERIFIED`. - **A restore failed partway** — fix the problem it reported and run `halos-restore.sh` again; it re-extracts everything from the stick, and the `*.halos-default` copies from the first run are kept as the rollback state. -- **InfluxDB does not become healthy on restore** — InfluxDB could not open the restored data (a damaged copy, for example). The restore leaves the fresh HaLOS state at `…/data/db.halos-default`. Check `sudo docker logs influxdb`. +- **InfluxDB does not become healthy on restore** — InfluxDB could not open the restored data (a damaged copy, for example). The restore leaves the fresh HaLOS state at `…/data/db.halos-default`. Check `sudo journalctl -u marine-influxdb-container.service -e`. - **No completion banner and the stick is full** — use a larger stick; the backup needs room for your whole InfluxDB history. ## Data locations From 82e6fa0182f2b3da455f861fb775d1baad4664f9 Mon Sep 17 00:00:00 2001 From: Matti Airas Date: Thu, 11 Jun 2026 17:19:13 +0300 Subject: [PATCH 13/16] docs(troubleshooting): check container logs via journalctl --- docs/user-guide/troubleshooting.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/user-guide/troubleshooting.md b/docs/user-guide/troubleshooting.md index b227c5d..7bafe07 100644 --- a/docs/user-guide/troubleshooting.md +++ b/docs/user-guide/troubleshooting.md @@ -183,10 +183,10 @@ sudo systemctl restart avahi-daemon sudo systemctl status .service ``` -2. Check Docker: +2. Check the container state and logs (container apps log to the journal): ```bash sudo docker ps -a - sudo docker logs + sudo journalctl -u .service -e ``` 3. Check disk space — containers need room for images: From 0b0feb5db8bcead34e24897f003d09e6d5e5dbb3 Mon Sep 17 00:00:00 2001 From: Matti Airas Date: Thu, 11 Jun 2026 17:21:23 +0300 Subject: [PATCH 14/16] docs(migrate): give actual Grafana data path in locations table --- docs/user-guide/openplotter-migration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user-guide/openplotter-migration.md b/docs/user-guide/openplotter-migration.md index e44eb65..894e396 100644 --- a/docs/user-guide/openplotter-migration.md +++ b/docs/user-guide/openplotter-migration.md @@ -90,7 +90,7 @@ The scripts above handle these paths for you. This section is a reference for an |------|-------------------------------|-------| | Signal K | `~/.signalk` | `/var/lib/container-apps/marine-signalk-server-container/data/data` | | InfluxDB 2 | `/var/lib/influxdb` (bolt at `/var/lib/influxdb/influxd.bolt`, engine at `/var/lib/influxdb/engine`) | `/var/lib/container-apps/marine-influxdb-container/data/db` | -| Grafana | `/var/lib/grafana/grafana.db` | `marine-grafana-container` (re-imported, see below) | +| Grafana | `/var/lib/grafana/grafana.db` | `/var/lib/container-apps/marine-grafana-container/data/data/grafana.db` (re-imported, not copied — see below) | | OpenCPN | `~/.opencpn`, `~/.local/share/opencpn` | same paths in your HaLOS home directory | A few details worth knowing: From 0a533c7f242273dee2adaa629685fa73d2b58a1f Mon Sep 17 00:00:00 2001 From: Matti Airas Date: Thu, 11 Jun 2026 17:38:41 +0300 Subject: [PATCH 15/16] docs(migrate): drop influx path bullet, table covers it --- docs/user-guide/openplotter-migration.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/user-guide/openplotter-migration.md b/docs/user-guide/openplotter-migration.md index 894e396..2074810 100644 --- a/docs/user-guide/openplotter-migration.md +++ b/docs/user-guide/openplotter-migration.md @@ -95,6 +95,5 @@ The scripts above handle these paths for you. This section is a reference for an A few details worth knowing: -- **InfluxDB path differs by install method.** The Debian package stores data in `/var/lib/influxdb`, while the InfluxDB Docker image HaLOS uses keeps it at `/var/lib/influxdb2` internally. The restore handles the translation; you only need the source path if you copy data by hand. - **Grafana is re-imported, not copied.** Rather than swapping the SQLite database into place, the restore reads your OpenPlotter `grafana.db` and re-creates its datasources and dashboards through the HaLOS Grafana API. This is why dashboard import is best-effort across Grafana versions. - **OpenCPN stays in the home directory.** Its config and plugin data restore to the same `~/.opencpn` and `~/.local/share/opencpn` paths on HaLOS. From f8df94d241e623b54480319d6ccb0b4a03aad40b Mon Sep 17 00:00:00 2001 From: Matti Airas Date: Fri, 12 Jun 2026 09:01:39 +0300 Subject: [PATCH 16/16] docs(migrate): drop re-import note from data locations The data locations table is a reference for manual migration; how the script transfers Grafana data is irrelevant there. Co-Authored-By: Claude Fable 5 --- docs/user-guide/openplotter-migration.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/user-guide/openplotter-migration.md b/docs/user-guide/openplotter-migration.md index 2074810..31f646a 100644 --- a/docs/user-guide/openplotter-migration.md +++ b/docs/user-guide/openplotter-migration.md @@ -90,10 +90,9 @@ The scripts above handle these paths for you. This section is a reference for an |------|-------------------------------|-------| | Signal K | `~/.signalk` | `/var/lib/container-apps/marine-signalk-server-container/data/data` | | InfluxDB 2 | `/var/lib/influxdb` (bolt at `/var/lib/influxdb/influxd.bolt`, engine at `/var/lib/influxdb/engine`) | `/var/lib/container-apps/marine-influxdb-container/data/db` | -| Grafana | `/var/lib/grafana/grafana.db` | `/var/lib/container-apps/marine-grafana-container/data/data/grafana.db` (re-imported, not copied — see below) | +| Grafana | `/var/lib/grafana/grafana.db` | `/var/lib/container-apps/marine-grafana-container/data/data/grafana.db` | | OpenCPN | `~/.opencpn`, `~/.local/share/opencpn` | same paths in your HaLOS home directory | -A few details worth knowing: +One detail worth knowing: -- **Grafana is re-imported, not copied.** Rather than swapping the SQLite database into place, the restore reads your OpenPlotter `grafana.db` and re-creates its datasources and dashboards through the HaLOS Grafana API. This is why dashboard import is best-effort across Grafana versions. - **OpenCPN stays in the home directory.** Its config and plugin data restore to the same `~/.opencpn` and `~/.local/share/opencpn` paths on HaLOS.