diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..b899074 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,26 @@ +name: Tests + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + mqtt-shell: + name: MQTT shell behavior + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Check shell syntax + run: | + dash -n docker/mqtt.sh + dash -n docker/mqtt-summary.sh + dash -n tests/mqtt-summary-test.sh + + - name: Run MQTT summary tests + run: dash tests/mqtt-summary-test.sh diff --git a/docker/Dockerfile b/docker/Dockerfile index 05a5692..8e859a5 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -6,6 +6,7 @@ RUN apk add --no-cache mosquitto-clients curl jq # Add scripts COPY mqtt.sh /app/mqtt.sh +COPY mqtt-summary.sh /app/mqtt-summary.sh COPY entrypoint.sh /app/entrypoint.sh RUN chmod +x /app/mqtt.sh /app/entrypoint.sh diff --git a/docker/mqtt-summary.sh b/docker/mqtt-summary.sh new file mode 100644 index 0000000..f9a4be8 --- /dev/null +++ b/docker/mqtt-summary.sh @@ -0,0 +1,20 @@ +#!/bin/sh + +# Write an Orb summary to stdout while keeping the per-invocation startup +# banner out of normal logs. File descriptor 3 carries the real stdout out of +# the command substitution so only stderr is captured in ORB_ERRS. Tests may +# pass a stub Orb executable as the first argument. +emit_orb_summary() { + ( + ORB_ERRS=$("${1:-/app/orb}" summary 2>&1 >&3) + ORB_RC=$? + + if [ "$ORB_RC" -ne 0 ]; then + echo "orb summary failed (exit $ORB_RC):" >&2 + [ -n "$ORB_ERRS" ] && printf '%s\n' "$ORB_ERRS" >&2 + echo '{}' + elif [ "$DEBUG_MODE" = "true" ] && [ -n "$ORB_ERRS" ]; then + printf '%s\n' "$ORB_ERRS" >&2 + fi + ) 3>&1 +} diff --git a/docker/mqtt.sh b/docker/mqtt.sh index 4cdef70..59168b2 100644 --- a/docker/mqtt.sh +++ b/docker/mqtt.sh @@ -1,5 +1,7 @@ #!/bin/sh +. /app/mqtt-summary.sh + AUTH_HEADER="Authorization: Bearer ${SUPERVISOR_TOKEN}" CONFIG_PATH=/data/options.json @@ -116,26 +118,11 @@ while true; do # sleep first to avoid publishing zeroes on first iteration sleep "$MQTT_PAUSE" - # output a single line to mosquitto_pub. orb summary prints a startup - # banner to stderr on every invocation, which would flood the journal at - # MQTT_PAUSE cadence -- so capture stderr in a variable (fd 3 carries the - # real stdout out of the command substitution) and only replay it when - # orb fails (it's the only diagnostic we have then) or mqtt_debug is on. - { - ORB_ERRS=$(/app/orb summary 2>&1 >&3) - ORB_RC=$? - if [ "$ORB_RC" -ne 0 ]; then - echo "orb summary failed (exit $ORB_RC):" >&2 - [ -n "$ORB_ERRS" ] && printf '%s\n' "$ORB_ERRS" >&2 - echo '{}' - elif [ "$DEBUG_MODE" = "true" ] && [ -n "$ORB_ERRS" ]; then - printf '%s\n' "$ORB_ERRS" >&2 - fi - } 3>&1 | jq -c . + # output a single line to mosquitto_pub + emit_orb_summary | jq -c . done | mosquitto_pub -h "$MQTT_HOST" -p "$MQTT_PORT" -u "$MQTT_USER" -P "$MQTT_PASS" \ -t "$STATE_TOPIC" -l -r -k $((MQTT_PAUSE * 2)) echo "mosquitto_pub disconnected, reconnecting..." done - diff --git a/tests/mqtt-summary-test.sh b/tests/mqtt-summary-test.sh new file mode 100755 index 0000000..d29a232 --- /dev/null +++ b/tests/mqtt-summary-test.sh @@ -0,0 +1,98 @@ +#!/bin/sh + +set -eu + +TEST_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +. "$TEST_DIR/../docker/mqtt-summary.sh" + +TEST_TMP=$(mktemp -d "${TMPDIR:-/tmp}/mqtt-summary-test.XXXXXX") +trap 'rm -rf "$TEST_TMP"' EXIT HUP INT TERM + +TEST_NUMBER=0 + +orb_stub() { + if [ "$#" -ne 1 ] || [ "$1" != "summary" ]; then + printf 'unexpected orb arguments\n' >&2 + return 99 + fi + + [ -n "$STUB_STDOUT" ] && printf '%s\n' "$STUB_STDOUT" + [ -n "$STUB_STDERR" ] && printf '%s\n' "$STUB_STDERR" >&2 + return "$STUB_STATUS" +} + +fail() { + printf 'not ok %s - %s\n' "$TEST_NUMBER" "$1" + exit 1 +} + +assert_file() { + ASSERT_NAME=$1 + ASSERT_FILE=$2 + ASSERT_EXPECTED=$3 + EXPECTED_FILE="$TEST_TMP/expected" + + printf '%b' "$ASSERT_EXPECTED" >"$EXPECTED_FILE" + if ! cmp -s "$EXPECTED_FILE" "$ASSERT_FILE"; then + printf '%s mismatch:\n' "$ASSERT_NAME" >&2 + diff -u "$EXPECTED_FILE" "$ASSERT_FILE" >&2 || true + return 1 + fi +} + +run_case() { + CASE_NAME=$1 + DEBUG_MODE=$2 + STUB_STATUS=$3 + STUB_STDOUT=$4 + STUB_STDERR=$5 + EXPECTED_STDOUT=$6 + EXPECTED_STDERR=$7 + TEST_NUMBER=$((TEST_NUMBER + 1)) + + if ! emit_orb_summary orb_stub >"$TEST_TMP/stdout" 2>"$TEST_TMP/stderr"; then + fail "$CASE_NAME returned a non-zero status" + fi + + assert_file stdout "$TEST_TMP/stdout" "$EXPECTED_STDOUT" || fail "$CASE_NAME stdout" + assert_file stderr "$TEST_TMP/stderr" "$EXPECTED_STDERR" || fail "$CASE_NAME stderr" + printf 'ok %s - %s\n' "$TEST_NUMBER" "$CASE_NAME" +} + +printf '1..4\n' + +run_case \ + 'success suppresses stderr when debug is off' \ + false 0 \ + '{"orb_score":42}' \ + 'Starting Orb +Config directory: /data' \ + '{"orb_score":42}\n' \ + '' + +run_case \ + 'success replays stderr when debug is on' \ + true 0 \ + '{"orb_score":42}' \ + 'Starting Orb +Config directory: /data' \ + '{"orb_score":42}\n' \ + 'Starting Orb\nConfig directory: /data\n' + +run_case \ + 'failure logs diagnostics when debug is off' \ + false 7 \ + '' \ + 'summary unavailable +connection refused' \ + '{}\n' \ + 'orb summary failed (exit 7):\nsummary unavailable\nconnection refused\n' + +run_case \ + 'failure logs diagnostics when debug is on' \ + true 7 \ + '' \ + 'summary unavailable +connection refused' \ + '{}\n' \ + 'orb summary failed (exit 7):\nsummary unavailable\nconnection refused\n'