parent
7f6ddddf54
commit
4e7dd22630
@ -0,0 +1,452 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Migrate a recognised legacy D-APRS Docker deployment to FreeDMR Data Gateway.
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
CONFIG_DIR=/etc/freedmr
|
||||||
|
DRY_RUN=0
|
||||||
|
ASSUME_YES=0
|
||||||
|
|
||||||
|
usage()
|
||||||
|
{
|
||||||
|
cat <<'USAGE'
|
||||||
|
Usage: upgrade-daprs-to-data-gateway.sh [--config-dir DIR] [--dry-run] [--yes]
|
||||||
|
|
||||||
|
Migrates a recognised legacy D-APRS service in docker-compose.yml to the native
|
||||||
|
FreeDMR Data Gateway and enables its FBP v5 relationship in freedmr.cfg.
|
||||||
|
|
||||||
|
Environment values may be supplied instead of answering prompts:
|
||||||
|
HDSTACK worker count, 1 through 5
|
||||||
|
HDSTACK_BASEID main/aggregator server ID
|
||||||
|
FREEDMR_GATEWAY_APRS_LOGIN_CALL APRS-IS login callsign
|
||||||
|
FREEDMR_GATEWAY_APRS_PASSCODE APRS-IS passcode
|
||||||
|
FREEDMR_GATEWAY_FBP_KEY private FBP key, at most 20 characters
|
||||||
|
USAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
fail()
|
||||||
|
{
|
||||||
|
echo "Upgrade aborted: $*" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt_value()
|
||||||
|
{
|
||||||
|
prompt_text="$1"
|
||||||
|
default_value="$2"
|
||||||
|
if [ ! -r /dev/tty ]
|
||||||
|
then
|
||||||
|
fail "interactive input is required; supply the documented environment values"
|
||||||
|
fi
|
||||||
|
if [ -n "$default_value" ]
|
||||||
|
then
|
||||||
|
printf '%s [%s]: ' "$prompt_text" "$default_value" >/dev/tty
|
||||||
|
else
|
||||||
|
printf '%s: ' "$prompt_text" >/dev/tty
|
||||||
|
fi
|
||||||
|
read -r entered_value </dev/tty
|
||||||
|
if [ -n "$entered_value" ]
|
||||||
|
then
|
||||||
|
printf '%s' "$entered_value"
|
||||||
|
else
|
||||||
|
printf '%s' "$default_value"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
is_unsigned_integer()
|
||||||
|
{
|
||||||
|
case "$1" in
|
||||||
|
''|*[!0-9]*) return 1 ;;
|
||||||
|
*) return 0 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
config_value()
|
||||||
|
{
|
||||||
|
section_name="$1"
|
||||||
|
option_name="$2"
|
||||||
|
awk -v wanted_section="$section_name" -v wanted_option="$option_name" '
|
||||||
|
$0 == "[" wanted_section "]" { in_section = 1; next }
|
||||||
|
in_section && /^\[/ { exit }
|
||||||
|
in_section {
|
||||||
|
split($0, parts, ":")
|
||||||
|
option = parts[1]
|
||||||
|
gsub(/^[[:space:]]+|[[:space:]]+$/, "", option)
|
||||||
|
if (option == wanted_option) {
|
||||||
|
sub(/^[^:]*:[[:space:]]*/, "", $0)
|
||||||
|
sub(/[[:space:]]*([#;].*)?$/, "", $0)
|
||||||
|
print $0
|
||||||
|
exit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
' "$FREEDMR_CONFIG"
|
||||||
|
}
|
||||||
|
|
||||||
|
compose_environment_value()
|
||||||
|
{
|
||||||
|
wanted="$1"
|
||||||
|
awk -v wanted="$wanted" '
|
||||||
|
{
|
||||||
|
line = $0
|
||||||
|
sub(/^[[:space:]]*-[[:space:]]*/, "", line)
|
||||||
|
sub(/^[[:space:]]*/, "", line)
|
||||||
|
if (index(line, wanted "=") == 1) {
|
||||||
|
sub("^" wanted "=", "", line)
|
||||||
|
gsub(/^[\047\042]|[\047\042][[:space:]]*$/, "", line)
|
||||||
|
print line
|
||||||
|
exit
|
||||||
|
}
|
||||||
|
if (line ~ ("^" wanted ":[[:space:]]*")) {
|
||||||
|
sub("^" wanted ":[[:space:]]*", "", line)
|
||||||
|
gsub(/^[\047\042]|[\047\042][[:space:]]*$/, "", line)
|
||||||
|
print line
|
||||||
|
exit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
' "$COMPOSE_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
build_relationships()
|
||||||
|
{
|
||||||
|
count="$1"
|
||||||
|
base_id="$2"
|
||||||
|
key="$3"
|
||||||
|
relationships='['
|
||||||
|
number=1
|
||||||
|
while [ "$number" -le "$count" ]
|
||||||
|
do
|
||||||
|
if [ "$count" -eq 1 ]
|
||||||
|
then
|
||||||
|
server_id="$base_id"
|
||||||
|
else
|
||||||
|
server_id=$((base_id + number))
|
||||||
|
fi
|
||||||
|
bind_port=$((62030 + number))
|
||||||
|
peer_port=$((62040 + number))
|
||||||
|
if [ "$number" -gt 1 ]
|
||||||
|
then
|
||||||
|
relationships="${relationships},"
|
||||||
|
fi
|
||||||
|
relationships="${relationships}{\"link_id\":\"hdstack-${number}\",\"bind_host\":\"0.0.0.0\",\"bind_port\":${bind_port},\"peer_host\":\"freedmr\",\"peer_port\":${peer_port},\"peer_network_id\":${server_id},\"direct_source_server_id\":${server_id},\"key\":\"${key}\"}"
|
||||||
|
number=$((number + 1))
|
||||||
|
done
|
||||||
|
printf '%s]' "$relationships"
|
||||||
|
}
|
||||||
|
|
||||||
|
while [ "$#" -gt 0 ]
|
||||||
|
do
|
||||||
|
case "$1" in
|
||||||
|
--config-dir)
|
||||||
|
[ "$#" -ge 2 ] || fail "--config-dir requires a path"
|
||||||
|
CONFIG_DIR="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--dry-run)
|
||||||
|
DRY_RUN=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--yes)
|
||||||
|
ASSUME_YES=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--help|-h)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
usage >&2
|
||||||
|
fail "unknown argument: $1"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
FREEDMR_CONFIG="$CONFIG_DIR/freedmr.cfg"
|
||||||
|
COMPOSE_FILE="$CONFIG_DIR/docker-compose.yml"
|
||||||
|
|
||||||
|
[ -f "$FREEDMR_CONFIG" ] || fail "$FREEDMR_CONFIG was not found"
|
||||||
|
[ -f "$COMPOSE_FILE" ] || fail "$COMPOSE_FILE was not found"
|
||||||
|
[ -r "$FREEDMR_CONFIG" ] || fail "$FREEDMR_CONFIG is not readable"
|
||||||
|
[ -r "$COMPOSE_FILE" ] || fail "$COMPOSE_FILE is not readable"
|
||||||
|
[ -w "$FREEDMR_CONFIG" ] || fail "$FREEDMR_CONFIG is not writable"
|
||||||
|
[ -w "$COMPOSE_FILE" ] || fail "$COMPOSE_FILE is not writable"
|
||||||
|
|
||||||
|
grep -q '^\[GLOBAL\][[:space:]]*$' "$FREEDMR_CONFIG" || \
|
||||||
|
fail "$FREEDMR_CONFIG has no [GLOBAL] section"
|
||||||
|
grep -q '^services:[[:space:]]*$' "$COMPOSE_FILE" || \
|
||||||
|
fail "$COMPOSE_FILE has no top-level services mapping"
|
||||||
|
grep -q '^ freedmr:[[:space:]]*$' "$COMPOSE_FILE" || \
|
||||||
|
fail "$COMPOSE_FILE has no recognised freedmr service"
|
||||||
|
|
||||||
|
legacy_count=$(grep -Ec '^ D-APRS:[[:space:]]*$' "$COMPOSE_FILE" || true)
|
||||||
|
native_count=$(grep -Ec '^ data-gateway:[[:space:]]*$' "$COMPOSE_FILE" || true)
|
||||||
|
[ "$native_count" -eq 0 ] || \
|
||||||
|
fail "a native data-gateway service already exists; no files were changed"
|
||||||
|
[ "$legacy_count" -eq 1 ] || \
|
||||||
|
fail "expected exactly one legacy D-APRS service, found $legacy_count"
|
||||||
|
|
||||||
|
legacy_block=$(awk '
|
||||||
|
/^ D-APRS:[[:space:]]*$/ { active = 1 }
|
||||||
|
active && NR > 1 && (/^ [A-Za-z0-9_.-]+:[[:space:]]*$/ || /^[^[:space:]#][^:]*:[[:space:]]*$/) && $0 !~ /^ D-APRS:/ { exit }
|
||||||
|
active { print }
|
||||||
|
' "$COMPOSE_FILE")
|
||||||
|
printf '%s\n' "$legacy_block" | grep -q 'container_name:[[:space:]]*D-APRS' || \
|
||||||
|
fail "the D-APRS service is not the recognised legacy container shape"
|
||||||
|
printf '%s\n' "$legacy_block" | grep -q 'APRS_CALL' || \
|
||||||
|
fail "the D-APRS service has no recognised APRS_CALL setting"
|
||||||
|
printf '%s\n' "$legacy_block" | grep -q 'APRS_PASSCODE' || \
|
||||||
|
fail "the D-APRS service has no recognised APRS_PASSCODE setting"
|
||||||
|
|
||||||
|
HDSTACK_COUNT="${HDSTACK:-}"
|
||||||
|
if [ -z "$HDSTACK_COUNT" ]
|
||||||
|
then
|
||||||
|
HDSTACK_COUNT=$(compose_environment_value HDSTACK)
|
||||||
|
fi
|
||||||
|
if [ -z "$HDSTACK_COUNT" ]
|
||||||
|
then
|
||||||
|
HDSTACK_COUNT=$(prompt_value 'Number of active HDStack workers (1-5)' '1')
|
||||||
|
fi
|
||||||
|
if ! is_unsigned_integer "$HDSTACK_COUNT" || \
|
||||||
|
[ "$HDSTACK_COUNT" -lt 1 ] || [ "$HDSTACK_COUNT" -gt 5 ]
|
||||||
|
then
|
||||||
|
fail "HDSTACK must be an integer from 1 to 5"
|
||||||
|
fi
|
||||||
|
|
||||||
|
BASE_ID="${HDSTACK_BASEID:-}"
|
||||||
|
if [ -z "$BASE_ID" ]
|
||||||
|
then
|
||||||
|
BASE_ID=$(compose_environment_value HDSTACK_BASEID)
|
||||||
|
fi
|
||||||
|
if [ -z "$BASE_ID" ]
|
||||||
|
then
|
||||||
|
BASE_ID=$(config_value GLOBAL SERVER_ID)
|
||||||
|
fi
|
||||||
|
if ! is_unsigned_integer "$BASE_ID" || \
|
||||||
|
[ "$BASE_ID" -lt 1 ] || [ "$BASE_ID" -gt 4294967295 ]
|
||||||
|
then
|
||||||
|
fail "HDSTACK_BASEID or [GLOBAL] SERVER_ID must be a non-zero server ID"
|
||||||
|
fi
|
||||||
|
if [ $((BASE_ID + HDSTACK_COUNT)) -gt 4294967295 ]
|
||||||
|
then
|
||||||
|
fail "the base server ID plus worker IDs exceeds the supported range"
|
||||||
|
fi
|
||||||
|
|
||||||
|
APRS_CALL="${FREEDMR_GATEWAY_APRS_LOGIN_CALL:-}"
|
||||||
|
if [ -z "$APRS_CALL" ]
|
||||||
|
then
|
||||||
|
APRS_CALL=$(compose_environment_value APRS_CALL)
|
||||||
|
fi
|
||||||
|
if [ -z "$APRS_CALL" ]
|
||||||
|
then
|
||||||
|
APRS_CALL=$(prompt_value 'APRS-IS login callsign' '')
|
||||||
|
fi
|
||||||
|
case "$APRS_CALL" in
|
||||||
|
''|*[!A-Za-z0-9-]*) fail "APRS-IS login callsign contains invalid characters" ;;
|
||||||
|
esac
|
||||||
|
[ "${#APRS_CALL}" -le 9 ] || fail "APRS-IS login callsign must not exceed 9 characters"
|
||||||
|
|
||||||
|
APRS_PASSCODE="${FREEDMR_GATEWAY_APRS_PASSCODE:-}"
|
||||||
|
if [ -z "$APRS_PASSCODE" ]
|
||||||
|
then
|
||||||
|
APRS_PASSCODE=$(compose_environment_value APRS_PASSCODE)
|
||||||
|
fi
|
||||||
|
if [ -z "$APRS_PASSCODE" ]
|
||||||
|
then
|
||||||
|
APRS_PASSCODE=$(prompt_value 'APRS-IS passcode' '')
|
||||||
|
fi
|
||||||
|
is_unsigned_integer "$APRS_PASSCODE" || fail "APRS-IS passcode must be numeric"
|
||||||
|
|
||||||
|
FBP_KEY="${FREEDMR_GATEWAY_FBP_KEY:-}"
|
||||||
|
if [ -z "$FBP_KEY" ]
|
||||||
|
then
|
||||||
|
FBP_KEY=$(od -An -N10 -tx1 /dev/urandom | tr -d ' \n')
|
||||||
|
fi
|
||||||
|
case "$FBP_KEY" in
|
||||||
|
''|*[!A-Za-z0-9._-]*) fail "the Data Gateway FBP key contains invalid characters" ;;
|
||||||
|
esac
|
||||||
|
[ "${#FBP_KEY}" -le 20 ] || fail "the Data Gateway FBP key must not exceed 20 characters"
|
||||||
|
|
||||||
|
RELATIONSHIPS=$(build_relationships "$HDSTACK_COUNT" "$BASE_ID" "$FBP_KEY")
|
||||||
|
WORK_DIR=$(mktemp -d "$CONFIG_DIR/.data-gateway-upgrade.XXXXXX")
|
||||||
|
trap 'rm -rf "$WORK_DIR"' EXIT
|
||||||
|
PROPOSED_CONFIG="$WORK_DIR/freedmr.cfg"
|
||||||
|
PROPOSED_COMPOSE="$WORK_DIR/docker-compose.yml"
|
||||||
|
|
||||||
|
awk -v gateway_key="$FBP_KEY" '
|
||||||
|
function finish_section() {
|
||||||
|
if (section == "GLOBAL" && !global_value_seen)
|
||||||
|
print "DATA_GATEWAY: True"
|
||||||
|
if (section == "D-APRS" && !legacy_enabled_seen)
|
||||||
|
print "ENABLED: False"
|
||||||
|
}
|
||||||
|
function print_gateway() {
|
||||||
|
print "[DATA-GATEWAY]"
|
||||||
|
print "MODE: OPENBRIDGE"
|
||||||
|
print "ENABLED: True"
|
||||||
|
print "IP: 0.0.0.0"
|
||||||
|
print "PORT: 62041"
|
||||||
|
print "NETWORK_ID: 0"
|
||||||
|
print "PASSPHRASE: " gateway_key
|
||||||
|
print "TARGET_IP: freedmr-data-gateway"
|
||||||
|
print "TARGET_PORT: 62031"
|
||||||
|
print "USE_ACL: False"
|
||||||
|
print "SUB_ACL: PERMIT:ALL"
|
||||||
|
print "TGID_ACL: PERMIT:ALL"
|
||||||
|
print "RELAX_CHECKS: False"
|
||||||
|
print "ENHANCED_OBP: True"
|
||||||
|
print "PROTO_VER: 5"
|
||||||
|
gateway_seen = 1
|
||||||
|
}
|
||||||
|
/^\[[^]]+\][[:space:]]*$/ {
|
||||||
|
finish_section()
|
||||||
|
if ($0 ~ /^\[DATA-GATEWAY\]/) {
|
||||||
|
if (!gateway_seen)
|
||||||
|
print_gateway()
|
||||||
|
section = "DATA-GATEWAY-SKIP"
|
||||||
|
next
|
||||||
|
}
|
||||||
|
section = $0
|
||||||
|
gsub(/^\[|\][[:space:]]*$/, "", section)
|
||||||
|
global_value_seen = 0
|
||||||
|
legacy_enabled_seen = 0
|
||||||
|
print
|
||||||
|
next
|
||||||
|
}
|
||||||
|
section == "DATA-GATEWAY-SKIP" { next }
|
||||||
|
section == "GLOBAL" && /^[[:space:]]*DATA_GATEWAY[[:space:]]*:/ {
|
||||||
|
print "DATA_GATEWAY: True"
|
||||||
|
global_value_seen = 1
|
||||||
|
next
|
||||||
|
}
|
||||||
|
section == "D-APRS" && /^[[:space:]]*ENABLED[[:space:]]*:/ {
|
||||||
|
print "ENABLED: False"
|
||||||
|
legacy_enabled_seen = 1
|
||||||
|
next
|
||||||
|
}
|
||||||
|
{ print }
|
||||||
|
END {
|
||||||
|
finish_section()
|
||||||
|
if (!gateway_seen) {
|
||||||
|
print ""
|
||||||
|
print_gateway()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
' "$FREEDMR_CONFIG" > "$PROPOSED_CONFIG"
|
||||||
|
|
||||||
|
awk -v relationships="$RELATIONSHIPS" -v aprs_call="$APRS_CALL" -v aprs_passcode="$APRS_PASSCODE" '
|
||||||
|
function print_gateway() {
|
||||||
|
print " data-gateway:"
|
||||||
|
print " container_name: freedmr-data-gateway"
|
||||||
|
print " depends_on:"
|
||||||
|
print " - freedmr"
|
||||||
|
print " image: \047gitlab.hacknix.net:5050/freedmr/freedmr-data-gateway:latest\047"
|
||||||
|
print " restart: \042unless-stopped\042"
|
||||||
|
print " read_only: true"
|
||||||
|
print " tmpfs:"
|
||||||
|
print " - /tmp:size=16m,mode=1777"
|
||||||
|
print " cap_drop:"
|
||||||
|
print " - ALL"
|
||||||
|
print " security_opt:"
|
||||||
|
print " - no-new-privileges:true"
|
||||||
|
print " environment:"
|
||||||
|
print " FREEDMR_GATEWAY_INGRESS_MODE: fbp"
|
||||||
|
print " FREEDMR_GATEWAY_FBP_RELATIONSHIPS: \047" relationships "\047"
|
||||||
|
print " FREEDMR_GATEWAY_RADIOID_PATH: /data/subscriber_ids.json"
|
||||||
|
print " FREEDMR_GATEWAY_APRS_HOST: rotate.aprs2.net"
|
||||||
|
print " FREEDMR_GATEWAY_APRS_PORT: \04714580\047"
|
||||||
|
print " FREEDMR_GATEWAY_APRS_LOGIN_CALL: \047" aprs_call "\047"
|
||||||
|
print " FREEDMR_GATEWAY_APRS_PASSCODE: \047" aprs_passcode "\047"
|
||||||
|
print " FREEDMR_GATEWAY_LOG_LEVEL: INFO"
|
||||||
|
print " volumes:"
|
||||||
|
print " - \047/etc/freedmr/json/:/data/:ro\047"
|
||||||
|
print " networks:"
|
||||||
|
print " app_net:"
|
||||||
|
print " ipv4_address: 172.16.238.40"
|
||||||
|
print ""
|
||||||
|
}
|
||||||
|
{
|
||||||
|
if (skip_legacy) {
|
||||||
|
if ($0 ~ /^ [A-Za-z0-9_.-]+:[[:space:]]*$/ ||
|
||||||
|
$0 ~ /^[^[:space:]#][^:]*:[[:space:]]*$/) {
|
||||||
|
skip_legacy = 0
|
||||||
|
} else {
|
||||||
|
next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($0 ~ /^ D-APRS:[[:space:]]*$/) {
|
||||||
|
print_gateway()
|
||||||
|
skip_legacy = 1
|
||||||
|
next
|
||||||
|
}
|
||||||
|
print
|
||||||
|
}
|
||||||
|
' "$COMPOSE_FILE" > "$PROPOSED_COMPOSE"
|
||||||
|
|
||||||
|
for required in \
|
||||||
|
'DATA_GATEWAY: True' \
|
||||||
|
'[DATA-GATEWAY]' \
|
||||||
|
'PROTO_VER: 5' \
|
||||||
|
'PASSPHRASE:'
|
||||||
|
do
|
||||||
|
grep -Fq "$required" "$PROPOSED_CONFIG" || fail "generated FreeDMR configuration is incomplete"
|
||||||
|
done
|
||||||
|
|
||||||
|
grep -q '^ data-gateway:[[:space:]]*$' "$PROPOSED_COMPOSE" || \
|
||||||
|
fail "generated Compose file has no data-gateway service"
|
||||||
|
if grep -q '^ D-APRS:[[:space:]]*$' "$PROPOSED_COMPOSE"
|
||||||
|
then
|
||||||
|
fail "generated Compose file still contains the legacy D-APRS service"
|
||||||
|
fi
|
||||||
|
if grep -q '__FREEDMR_GATEWAY_' "$PROPOSED_COMPOSE"
|
||||||
|
then
|
||||||
|
fail "generated Compose file contains an unreplaced gateway placeholder"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! docker compose -f "$PROPOSED_COMPOSE" config --quiet >/dev/null
|
||||||
|
then
|
||||||
|
fail "Docker Compose rejected the generated configuration"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$DRY_RUN" -eq 1 ]
|
||||||
|
then
|
||||||
|
echo "Dry run successful; no files were changed."
|
||||||
|
echo "Configuration: $FREEDMR_CONFIG"
|
||||||
|
echo "Compose file: $COMPOSE_FILE"
|
||||||
|
echo "Workers: $HDSTACK_COUNT"
|
||||||
|
echo "Base ID: $BASE_ID"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$ASSUME_YES" -ne 1 ]
|
||||||
|
then
|
||||||
|
answer=$(prompt_value 'Back up both files and apply this migration? (y/N)' 'N')
|
||||||
|
case "$answer" in
|
||||||
|
y|Y|yes|YES) ;;
|
||||||
|
*) fail "migration was not confirmed" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
stamp=$(date -u +%Y%m%dT%H%M%SZ)
|
||||||
|
CONFIG_BACKUP="$FREEDMR_CONFIG.before-data-gateway.$stamp"
|
||||||
|
COMPOSE_BACKUP="$COMPOSE_FILE.before-data-gateway.$stamp"
|
||||||
|
[ ! -e "$CONFIG_BACKUP" ] || fail "backup already exists: $CONFIG_BACKUP"
|
||||||
|
[ ! -e "$COMPOSE_BACKUP" ] || fail "backup already exists: $COMPOSE_BACKUP"
|
||||||
|
cp -p "$FREEDMR_CONFIG" "$CONFIG_BACKUP"
|
||||||
|
cp -p "$COMPOSE_FILE" "$COMPOSE_BACKUP"
|
||||||
|
chmod --reference="$FREEDMR_CONFIG" "$PROPOSED_CONFIG"
|
||||||
|
chmod --reference="$COMPOSE_FILE" "$PROPOSED_COMPOSE"
|
||||||
|
if [ "$(id -u)" -eq 0 ]
|
||||||
|
then
|
||||||
|
chown --reference="$FREEDMR_CONFIG" "$PROPOSED_CONFIG"
|
||||||
|
chown --reference="$COMPOSE_FILE" "$PROPOSED_COMPOSE"
|
||||||
|
fi
|
||||||
|
mv "$PROPOSED_CONFIG" "$FREEDMR_CONFIG"
|
||||||
|
mv "$PROPOSED_COMPOSE" "$COMPOSE_FILE"
|
||||||
|
|
||||||
|
echo "FreeDMR Data Gateway migration complete."
|
||||||
|
echo "Backups:"
|
||||||
|
echo " $CONFIG_BACKUP"
|
||||||
|
echo " $COMPOSE_BACKUP"
|
||||||
|
echo "Review the files, then run:"
|
||||||
|
echo " cd $CONFIG_DIR"
|
||||||
|
echo " docker compose pull freedmr data-gateway"
|
||||||
|
echo " docker compose up -d --force-recreate freedmr data-gateway"
|
||||||
@ -0,0 +1,272 @@
|
|||||||
|
import configparser
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SCRIPT = ROOT / "docker-configs" / "upgrade-daprs-to-data-gateway.sh"
|
||||||
|
|
||||||
|
|
||||||
|
class DataGatewayUpgradeTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temporary = tempfile.TemporaryDirectory(dir=ROOT / "tests")
|
||||||
|
self.addCleanup(self.temporary.cleanup)
|
||||||
|
self.root = Path(self.temporary.name)
|
||||||
|
self.config_dir = self.root / "freedmr"
|
||||||
|
self.config_dir.mkdir()
|
||||||
|
self.config = self.config_dir / "freedmr.cfg"
|
||||||
|
self.compose = self.config_dir / "docker-compose.yml"
|
||||||
|
self.config.write_text(CONFIG, encoding="utf-8")
|
||||||
|
self.compose.write_text(COMPOSE, encoding="utf-8")
|
||||||
|
|
||||||
|
bin_dir = self.root / "bin"
|
||||||
|
bin_dir.mkdir()
|
||||||
|
docker = bin_dir / "docker"
|
||||||
|
docker.write_text(
|
||||||
|
"#!/bin/sh\n"
|
||||||
|
"[ \"$1\" = compose ] || exit 1\n"
|
||||||
|
"exit 0\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
docker.chmod(0o755)
|
||||||
|
self.environment = os.environ.copy()
|
||||||
|
self.environment.update(
|
||||||
|
{
|
||||||
|
"PATH": str(bin_dir) + os.pathsep + self.environment["PATH"],
|
||||||
|
"HDSTACK": "2",
|
||||||
|
"HDSTACK_BASEID": "23400",
|
||||||
|
"FREEDMR_GATEWAY_APRS_LOGIN_CALL": "M0ABC",
|
||||||
|
"FREEDMR_GATEWAY_APRS_PASSCODE": "12345",
|
||||||
|
"FREEDMR_GATEWAY_FBP_KEY": "private-key",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def run_upgrade(self, *arguments, environment=None):
|
||||||
|
return subprocess.run(
|
||||||
|
[str(SCRIPT), "--config-dir", str(self.config_dir), *arguments],
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env=environment or self.environment,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_migrates_legacy_service_and_builds_hdstack_relationships(self):
|
||||||
|
result = self.run_upgrade("--yes")
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
parser = configparser.ConfigParser()
|
||||||
|
parser.read(self.config)
|
||||||
|
self.assertTrue(parser.getboolean("GLOBAL", "DATA_GATEWAY"))
|
||||||
|
self.assertTrue(parser.getboolean("DATA-GATEWAY", "ENABLED"))
|
||||||
|
self.assertEqual(parser.get("DATA-GATEWAY", "MODE"), "OPENBRIDGE")
|
||||||
|
self.assertEqual(parser.getint("DATA-GATEWAY", "PROTO_VER"), 5)
|
||||||
|
self.assertEqual(parser.get("DATA-GATEWAY", "PASSPHRASE"), "private-key")
|
||||||
|
self.assertEqual(
|
||||||
|
parser.get("DATA-GATEWAY", "TARGET_IP"),
|
||||||
|
"freedmr-data-gateway",
|
||||||
|
)
|
||||||
|
self.assertFalse(parser.getboolean("D-APRS", "ENABLED"))
|
||||||
|
|
||||||
|
compose = self.compose.read_text(encoding="utf-8")
|
||||||
|
self.assertNotIn(" D-APRS:\n", compose)
|
||||||
|
self.assertIn(" data-gateway:\n", compose)
|
||||||
|
self.assertIn(" freedmrmonitor2:\n", compose)
|
||||||
|
relationships = self._relationships(compose)
|
||||||
|
self.assertEqual([item["link_id"] for item in relationships], ["hdstack-1", "hdstack-2"])
|
||||||
|
self.assertEqual([item["bind_port"] for item in relationships], [62031, 62032])
|
||||||
|
self.assertEqual([item["peer_port"] for item in relationships], [62041, 62042])
|
||||||
|
self.assertEqual(
|
||||||
|
[item["peer_network_id"] for item in relationships],
|
||||||
|
[23401, 23402],
|
||||||
|
)
|
||||||
|
self.assertTrue(all(item["key"] == "private-key" for item in relationships))
|
||||||
|
self.assertEqual(len(list(self.config_dir.glob("freedmr.cfg.before-data-gateway.*"))), 1)
|
||||||
|
self.assertEqual(len(list(self.config_dir.glob("docker-compose.yml.before-data-gateway.*"))), 1)
|
||||||
|
|
||||||
|
def test_single_worker_uses_main_server_id(self):
|
||||||
|
environment = self.environment.copy()
|
||||||
|
environment["HDSTACK"] = "1"
|
||||||
|
|
||||||
|
result = self.run_upgrade("--yes", environment=environment)
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
relationships = self._relationships(self.compose.read_text(encoding="utf-8"))
|
||||||
|
self.assertEqual(len(relationships), 1)
|
||||||
|
self.assertEqual(relationships[0]["peer_network_id"], 23400)
|
||||||
|
self.assertEqual(relationships[0]["direct_source_server_id"], 23400)
|
||||||
|
|
||||||
|
def test_reuses_legacy_credentials_and_explicit_compose_hdstack(self):
|
||||||
|
self.compose.write_text(
|
||||||
|
COMPOSE.replace(
|
||||||
|
" image: freedmr:test\n",
|
||||||
|
" image: freedmr:test\n"
|
||||||
|
" environment:\n"
|
||||||
|
" - HDSTACK=3\n"
|
||||||
|
" - HDSTACK_BASEID=24500\n",
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
environment = self.environment.copy()
|
||||||
|
for name in (
|
||||||
|
"HDSTACK",
|
||||||
|
"HDSTACK_BASEID",
|
||||||
|
"FREEDMR_GATEWAY_APRS_LOGIN_CALL",
|
||||||
|
"FREEDMR_GATEWAY_APRS_PASSCODE",
|
||||||
|
):
|
||||||
|
environment.pop(name)
|
||||||
|
|
||||||
|
result = self.run_upgrade("--yes", environment=environment)
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
compose = self.compose.read_text(encoding="utf-8")
|
||||||
|
self.assertIn("FREEDMR_GATEWAY_APRS_LOGIN_CALL: 'M0OLD'", compose)
|
||||||
|
self.assertIn("FREEDMR_GATEWAY_APRS_PASSCODE: '54321'", compose)
|
||||||
|
relationships = self._relationships(compose)
|
||||||
|
self.assertEqual(len(relationships), 3)
|
||||||
|
self.assertEqual(
|
||||||
|
[item["peer_network_id"] for item in relationships],
|
||||||
|
[24501, 24502, 24503],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_replaces_existing_disabled_gateway_section_once(self):
|
||||||
|
self.config.write_text(CONFIG_WITH_GATEWAY, encoding="utf-8")
|
||||||
|
|
||||||
|
result = self.run_upgrade("--yes")
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
updated = self.config.read_text(encoding="utf-8")
|
||||||
|
self.assertEqual(updated.count("[DATA-GATEWAY]"), 1)
|
||||||
|
self.assertNotIn("PASSPHRASE: old-key", updated)
|
||||||
|
self.assertIn("PASSPHRASE: private-key", updated)
|
||||||
|
self.assertIn("[SYSTEM]\nMODE: MASTER", updated)
|
||||||
|
|
||||||
|
def test_dry_run_validates_without_modifying_or_backing_up(self):
|
||||||
|
original_config = self.config.read_bytes()
|
||||||
|
original_compose = self.compose.read_bytes()
|
||||||
|
|
||||||
|
result = self.run_upgrade("--dry-run")
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertIn("Dry run successful", result.stdout)
|
||||||
|
self.assertEqual(self.config.read_bytes(), original_config)
|
||||||
|
self.assertEqual(self.compose.read_bytes(), original_compose)
|
||||||
|
self.assertEqual(list(self.config_dir.glob("*.before-data-gateway.*")), [])
|
||||||
|
|
||||||
|
def test_refuses_existing_native_service_without_changes(self):
|
||||||
|
self.compose.write_text(
|
||||||
|
COMPOSE.replace(" D-APRS:\n", " data-gateway:\n"),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
original_config = self.config.read_bytes()
|
||||||
|
original_compose = self.compose.read_bytes()
|
||||||
|
|
||||||
|
result = self.run_upgrade("--yes")
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 2)
|
||||||
|
self.assertIn("already exists", result.stderr)
|
||||||
|
self.assertEqual(self.config.read_bytes(), original_config)
|
||||||
|
self.assertEqual(self.compose.read_bytes(), original_compose)
|
||||||
|
|
||||||
|
def test_refuses_unrecognised_legacy_service_without_changes(self):
|
||||||
|
self.compose.write_text(
|
||||||
|
COMPOSE.replace("container_name: D-APRS", "container_name: unrelated"),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
original_config = self.config.read_bytes()
|
||||||
|
original_compose = self.compose.read_bytes()
|
||||||
|
|
||||||
|
result = self.run_upgrade("--yes")
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 2)
|
||||||
|
self.assertIn("not the recognised legacy container shape", result.stderr)
|
||||||
|
self.assertEqual(self.config.read_bytes(), original_config)
|
||||||
|
self.assertEqual(self.compose.read_bytes(), original_compose)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _relationships(compose):
|
||||||
|
match = re.search(
|
||||||
|
r"FREEDMR_GATEWAY_FBP_RELATIONSHIPS: '([^']+)'",
|
||||||
|
compose,
|
||||||
|
)
|
||||||
|
if match is None:
|
||||||
|
raise AssertionError("relationship JSON was not found")
|
||||||
|
return json.loads(match.group(1))
|
||||||
|
|
||||||
|
|
||||||
|
CONFIG = """\
|
||||||
|
[GLOBAL]
|
||||||
|
SERVER_ID: 23400
|
||||||
|
DATA_GATEWAY: False
|
||||||
|
|
||||||
|
[D-APRS]
|
||||||
|
MODE: MASTER
|
||||||
|
ENABLED: True
|
||||||
|
PORT: 52555
|
||||||
|
PASSPHRASE:
|
||||||
|
|
||||||
|
[SYSTEM]
|
||||||
|
MODE: MASTER
|
||||||
|
ENABLED: True
|
||||||
|
PORT: 54000
|
||||||
|
"""
|
||||||
|
|
||||||
|
CONFIG_WITH_GATEWAY = """\
|
||||||
|
[GLOBAL]
|
||||||
|
SERVER_ID: 23400
|
||||||
|
DATA_GATEWAY: False
|
||||||
|
|
||||||
|
[DATA-GATEWAY]
|
||||||
|
MODE: OPENBRIDGE
|
||||||
|
ENABLED: False
|
||||||
|
PASSPHRASE: old-key
|
||||||
|
PROTO_VER: 5
|
||||||
|
|
||||||
|
[D-APRS]
|
||||||
|
MODE: MASTER
|
||||||
|
ENABLED: True
|
||||||
|
|
||||||
|
[SYSTEM]
|
||||||
|
MODE: MASTER
|
||||||
|
"""
|
||||||
|
|
||||||
|
COMPOSE = """\
|
||||||
|
version: '2.4'
|
||||||
|
services:
|
||||||
|
freedmr:
|
||||||
|
container_name: freedmr
|
||||||
|
image: freedmr:test
|
||||||
|
networks:
|
||||||
|
app_net:
|
||||||
|
ipv4_address: 172.16.238.10
|
||||||
|
|
||||||
|
D-APRS:
|
||||||
|
container_name: D-APRS
|
||||||
|
environment:
|
||||||
|
- APRS_CALL=M0OLD
|
||||||
|
- APRS_PASSCODE=54321
|
||||||
|
image: legacy-daprs:test
|
||||||
|
restart: "unless-stopped"
|
||||||
|
networks:
|
||||||
|
app_net:
|
||||||
|
ipv4_address: 172.16.238.40
|
||||||
|
|
||||||
|
freedmrmonitor2:
|
||||||
|
container_name: freedmrmonitor2
|
||||||
|
image: monitor:test
|
||||||
|
networks:
|
||||||
|
app_net:
|
||||||
|
ipv4_address: 172.16.238.20
|
||||||
|
|
||||||
|
networks:
|
||||||
|
app_net:
|
||||||
|
driver: bridge
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Loading…
Reference in new issue