From 932352d43a3c20282368acfda5c14a2eed7a737c Mon Sep 17 00:00:00 2001 From: Colin Seymour Date: Wed, 19 Aug 2026 17:14:15 +0100 Subject: [PATCH] Update integration tests and update dev environment (#152) --- .devcontainer/devcontainer-lock.json | 14 + .devcontainer/devcontainer.json | 14 +- .devcontainer/prepare-local-addon.sh | 32 ++ .devcontainer/run-integration-test.sh | 47 +++ .devcontainer/setup-ha.sh | 497 ++++++++++-------------- .github/workflows/integration-test.yaml | 39 +- config.json | 6 + 7 files changed, 333 insertions(+), 316 deletions(-) create mode 100644 .devcontainer/devcontainer-lock.json create mode 100755 .devcontainer/prepare-local-addon.sh create mode 100755 .devcontainer/run-integration-test.sh diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json new file mode 100644 index 0000000..a1d6baa --- /dev/null +++ b/.devcontainer/devcontainer-lock.json @@ -0,0 +1,14 @@ +{ + "features": { + "ghcr.io/eitsupi/devcontainer-features/jq-likes:2": { + "version": "2.1.1", + "resolved": "ghcr.io/eitsupi/devcontainer-features/jq-likes@sha256:6a8b044f9f424097ec6d212167836b26994a76a556cbcb202084be79fe8032bd", + "integrity": "sha256:6a8b044f9f424097ec6d212167836b26994a76a556cbcb202084be79fe8032bd" + }, + "ghcr.io/jungaretti/features/ripgrep:1": { + "version": "1.0.1", + "resolved": "ghcr.io/jungaretti/features/ripgrep@sha256:c0922b6f4a9184080c8435b6ef25983dee6c734733f0a749d852ad0d33de2253", + "integrity": "sha256:c0922b6f4a9184080c8435b6ef25983dee6c734733f0a749d852ad0d33de2253" + } + } +} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 8a1e3d8..f3acf6a 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,14 +1,20 @@ { "name": "HA Addon: TeslaMate", - "image": "ghcr.io/home-assistant/devcontainer:4-apps", + "image": "ghcr.io/home-assistant/devcontainer:6-apps", "overrideCommand": false, - "appPort": ["7123:8123", "7357:4357"], + "remoteUser": "vscode", + "appPort": ["7123:8123", "7180:80", "7357:4357"], "postStartCommand": "bash devcontainer_bootstrap", "runArgs": ["-e", "GIT_EDITOR=code --wait", "--privileged"], "workspaceFolder": "/mnt/supervisor/addons/local/${localWorkspaceFolderBasename}", "workspaceMount": "source=${localWorkspaceFolder},target=${containerWorkspaceFolder},type=bind,consistency=cached", "containerEnv": { - "WORKSPACE_DIRECTORY": "${containerWorkspaceFolder}" + "WORKSPACE_DIRECTORY": "${containerWorkspaceFolder}", + "SUPERVISOR_CHANNEL": "stable" + }, + "features": { + "ghcr.io/eitsupi/devcontainer-features/jq-likes:2": {}, + "ghcr.io/jungaretti/features/ripgrep:1": {} }, "customizations": { "vscode": { @@ -33,6 +39,6 @@ "mounts": [ "type=volume,target=/var/lib/docker", "type=volume,target=/var/lib/containerd", - "type=volume,target=/mnt/supervisor" + "type=volume,target=/mnt/supervisor", ] } \ No newline at end of file diff --git a/.devcontainer/prepare-local-addon.sh b/.devcontainer/prepare-local-addon.sh new file mode 100755 index 0000000..3efbcfd --- /dev/null +++ b/.devcontainer/prepare-local-addon.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Remove an add-on's registry image before the Supervisor starts so it must +# build the current checkout's Dockerfile. + +set -euo pipefail + +config="${1:-config.json}" + +if [ ! -f "$config" ]; then + echo "ERROR: Add-on config not found: ${config}" >&2 + exit 1 +fi + +if ! jq empty "$config"; then + echo "ERROR: Invalid add-on config: ${config}" >&2 + exit 1 +fi + +if jq -e 'has("image")' "$config" > /dev/null; then + temporary_config=$(mktemp "${config}.XXXXXX") + trap 'rm -f "$temporary_config"' EXIT + + jq 'del(.image)' "$config" > "$temporary_config" + mv "$temporary_config" "$config" +fi + +if jq -e 'has("image")' "$config" > /dev/null; then + echo "ERROR: Failed to remove image from ${config}" >&2 + exit 1 +fi + +echo "Registry image disabled; the Supervisor will build the current checkout" \ No newline at end of file diff --git a/.devcontainer/run-integration-test.sh b/.devcontainer/run-integration-test.sh new file mode 100755 index 0000000..a0d2220 --- /dev/null +++ b/.devcontainer/run-integration-test.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# run-integration-test.sh - Boot the Supervisor and run setup-ha.sh against it. +# +# Usage: bash .devcontainer/run-integration-test.sh [channel] +# +# The channel (stable, beta or dev) selects which Home Assistant release the +# Supervisor installs. It defaults to stable so that CI tests what users actually +# run; the devcontainer image itself defaults to dev, which means unreleased +# Home Assistant changes would otherwise land straight in CI. +# +# Set DEBUG=1 to stream the Supervisor log instead of capturing it. + +set -euo pipefail + +export SUPERVISOR_CHANNEL="${1:-stable}" +SUPERVISOR_LOG="/tmp/supervisor.log" + +bash .devcontainer/prepare-local-addon.sh + +# The v6 devcontainer exposes host AppArmor through OS Agent, but profiles +# applied to containers in its nested Docker daemon break the PostgreSQL image. +sudo systemctl stop haos-agent + +# supervisor_run hardcodes SUPERVISOR_DEV=1, which makes the Supervisor force its +# update channel to dev and so install nightly Home Assistant builds whatever +# channel is asked for. +sudo sed -i '/-e SUPERVISOR_DEV=1/d' /usr/bin/supervisor_run + +# The channel is otherwise only reachable through the Supervisor API, by which +# point it has already installed Home Assistant, so seed it before first boot. +sudo mkdir -p /mnt/supervisor +printf '{"channel": "%s"}\n' "${SUPERVISOR_CHANNEL}" | sudo tee /mnt/supervisor/updater.json > /dev/null + +echo "Home Assistant channel: ${SUPERVISOR_CHANNEL}" + +if [ -n "${DEBUG:-}" ]; then + supervisor_run 2>&1 | tee "${SUPERVISOR_LOG}" & +else + supervisor_run > "${SUPERVISOR_LOG}" 2>&1 & +fi + +if ! bash .devcontainer/setup-ha.sh; then + echo "::group::Supervisor log (last 300 lines, image pull progress omitted)" + grep -v -E "docker_image_pull_update|pull_progress" "${SUPERVISOR_LOG}" | tail -n 300 + echo "::endgroup::" + exit 1 +fi diff --git a/.devcontainer/setup-ha.sh b/.devcontainer/setup-ha.sh index 0a6db63..88fd1f3 100755 --- a/.devcontainer/setup-ha.sh +++ b/.devcontainer/setup-ha.sh @@ -1,15 +1,11 @@ #!/usr/bin/env bash -# setup-ha.sh - Idempotent Home Assistant setup for TeslaMate development +# setup-ha.sh - Idempotent Home Assistant setup for addon development # # This script: # 1. Completes HA onboarding (creates admin user) # 2. Adds the alexbelgium addon repository (for PostgreSQL) # 3. Installs and starts PostgreSQL 17 -# 4. Installs, configures, and starts the local TeslaMate addon -# -# MQTT and Grafana dashboard import are disabled so TeslaMate boots cleanly -# against PostgreSQL alone. Enable them in the addon configuration once a -# broker / Grafana instance is available. +# 4. Installs, configures, and starts the local addon # # Usage: bash .devcontainer/setup-ha.sh # DEBUG=1 bash .devcontainer/setup-ha.sh # verbose output + error details @@ -18,96 +14,53 @@ set -euo pipefail -HA_URL="http://localhost:8123" +# --- Addon-specific configuration --- +ADDON_NAME="$(jq -r '.name' config.json)" +ADDON_SLUG="local_$(jq -r '.slug' config.json)" +ADDON_PORT="$(jq -r '.ports | keys[0] | split("/") | .[0]' config.json)" +ADDON_READY_LOG="TeslaMateWeb.Endpoint" +TIMEZONE="Europe/London" + +# --- Home Assistant configuration --- +HA_HOST="localhost" +# Core binds port 80 under the Supervisor from 2026.8 onwards, and 8123 before +# that, so wait_for_ha resolves the port at runtime rather than assuming one. +HA_PORTS=(80 8123) +HA_URL="" +CLIENT_ID="" ALEXBELGIUM_REPO="https://github.com/alexbelgium/hassio-addons" ADMIN_USER="admin" ADMIN_PASS="pass" -CLIENT_ID="${HA_URL}/" +# --- PostgreSQL configuration --- POSTGRES_PASSWORD="homeassistant" POSTGRES_USER="postgres" -POSTGRES_DB="teslamate" +POSTGRES_DB="${ADDON_NAME,,}" POSTGRES_PORT=5432 # Addon slug = repo_hash + "_" + addon_slug_from_config # Repo hash: first 8 chars of sha1("https://github.com/alexbelgium/hassio-addons") POSTGRES_SLUG="db21ed7f_postgres_latest" -TESLAMATE_SLUG="local_teslamate" -TESLAMATE_PORT=4000 -TIMEZONE="Europe/London" - -# Path to the addon's config.json. Home Assistant mounts the local add-on store -# at /mnt/supervisor/addons/local on older Supervisor versions and at -# /mnt/supervisor/apps/local on newer ones. Derive the addon's directory name -# from the script's own location (so it is correct regardless of where the -# script is invoked from), then anchor ADDON_DIR to whichever canonical -# Supervisor path actually exists - that is the config.json the Supervisor reads. -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ADDON_DIR="$(dirname "$SCRIPT_DIR")" -ADDON_NAME="$(basename "$ADDON_DIR")" -for _local_base in /mnt/supervisor/addons/local /mnt/supervisor/apps/local; do - if [ -d "${_local_base}/${ADDON_NAME}" ]; then - ADDON_DIR="${_local_base}/${ADDON_NAME}" - break - fi -done -CONFIG_JSON="${ADDON_DIR}/config.json" - # --- Helpers --- - GREEN='\033[0;32m' YELLOW='\033[0;33m' RED='\033[0;31m' CYAN='\033[0;36m' -DIM='\033[2m' BOLD='\033[1m' NC='\033[0m' # No Color log() { echo -e "${CYAN}==>${NC} ${BOLD}$*${NC}"; } warn() { echo -e "${YELLOW}WARNING:${NC} $*" >&2; } -err() { group_end; echo -e "${RED}ERROR:${NC} $*" >&2; exit 1; } +err() { echo -e "${RED}ERROR:${NC} $*" >&2; exit 1; } ok() { echo -e "${CYAN}==>${NC} ${GREEN}$*${NC}"; } -debug() { - [ -n "$DEBUG" ] || return 0 - echo -e "${DIM}DEBUG:${NC} $*" >&2 -} - - -debug_response() { - [ -n "$DEBUG" ] || return 0 - local resp="$1" max=800 - if [ "${#resp}" -gt "$max" ]; then - debug "response (truncated): ${resp:0:$max}..." - else - debug "response: ${resp}" +# Parse JSON, aborting with the offending body instead of a bare jq parse error +jq_or_die() { + local json="$1" filter="$2" context="$3" + if [ -z "$json" ] || ! jq empty <<< "$json" 2>/dev/null; then + err "${context}: expected JSON, got: ${json}" fi -} - -# group_start()/group_end() wrap verbose output in GitHub Actions log groups so -# it is folded by default. They are no-ops unless DEBUG is enabled and the -# script is running inside GitHub Actions, so local and non-debug runs are -# unchanged. Markers go to stderr to stay ordered with debug() and err() output. -_group_open=0 -group_start() { - [ -n "$DEBUG" ] && [ "${GITHUB_ACTIONS:-}" = "true" ] || return 0 - echo "::group::$*" >&2 - _group_open=1 -} -group_end() { - [ "$_group_open" = "1" ] || return 0 - echo "::endgroup::" >&2 - _group_open=0 -} - -# run_step() runs a setup step, wrapping its output in a titled GitHub Actions -# log group (when enabled) so the verbose output folds under that heading. -run_step() { - local title="$1" - shift - group_start "$title" - "$@" - group_end + jq -r "$filter" <<< "$json" } # Run Supervisor API call via hassio_cli container @@ -115,67 +68,28 @@ run_step() { # is expanded by the inner shell (where the env var exists), not the outer bash. supervisor_api() { local method="$1" endpoint="$2" - debug "API ${method} ${endpoint}" - local resp - resp=$(docker exec hassio_cli sh -c \ - 'curl -s -X '"${method}"' -H "Authorization: Bearer $SUPERVISOR_TOKEN" -H "Content-Type: application/json" http://supervisor'"${endpoint}") - debug_response "$resp" - printf '%s\n' "$resp" + docker exec hassio_cli sh -c \ + 'curl -s -X '"${method}"' -H "Authorization: Bearer $SUPERVISOR_TOKEN" -H "Content-Type: application/json" http://supervisor'"${endpoint}" } supervisor_api_with_body() { local method="$1" endpoint="$2" body="$3" - debug "API ${method} ${endpoint} body=${body}" - local resp - resp=$(docker exec -e "BODY=${body}" hassio_cli sh -c \ - 'curl -s -X '"${method}"' -H "Authorization: Bearer $SUPERVISOR_TOKEN" -H "Content-Type: application/json" -d "$BODY" http://supervisor'"${endpoint}") - debug_response "$resp" - printf '%s\n' "$resp" + docker exec -e "BODY=${body}" hassio_cli sh -c \ + 'curl -s -X '"${method}"' -H "Authorization: Bearer $SUPERVISOR_TOKEN" -H "Content-Type: application/json" -d "$BODY" http://supervisor'"${endpoint}" } -# supervisor_api_post() performs a state-changing POST (optionally with a JSON -# body) and aborts via err() if the Supervisor returns {"result":"error",...}. -# These endpoints (install/start/options/reload) are synchronous, so without -# this check a failure is silently swallowed and the script then polls for a -# state that will never arrive. The response is emitted on stdout on success. -supervisor_api_post() { - local endpoint="$1" body="${2:-}" - local resp result - if [ -n "$body" ]; then - resp=$(supervisor_api_with_body POST "$endpoint" "$body") - else - resp=$(supervisor_api POST "$endpoint") - fi - result=$(printf '%s' "$resp" | jq -r '.result // empty' 2>/dev/null) - if [ "$result" = "error" ]; then - err "Supervisor API error on POST ${endpoint}: $(printf '%s' "$resp" | jq -r '.message // .' 2>/dev/null)" - fi - printf '%s\n' "$resp" -} - -# POST to the local HA API for an optional onboarding step. Failures are -# non-fatal; the request and any response/error are shown only when debugging. -ha_post_optional() { - local token="$1" endpoint="$2" data="$3" - debug "POST ${endpoint} ${data}" - local resp rc=0 - resp=$(curl -sS -X POST "${HA_URL}${endpoint}" \ - -H "Authorization: Bearer ${token}" \ - -H "Content-Type: application/json" \ - -d "$data" 2>&1) || rc=$? - if [ "$rc" -ne 0 ]; then - debug "${endpoint} failed (rc=${rc}): ${resp}" - elif [ -n "$resp" ]; then - debug "${endpoint} response: ${resp}" - fi -} - -# Query a specific jq field from a Supervisor API response -# Runs curl and jq inside hassio_cli to avoid docker exec stdout pipe issues +# Query a specific jq field from a Supervisor API response. +# Supervisor reports HTTP errors as plain text (e.g. "404: Not Found"), which is +# not JSON, so surface those and return nothing to let callers keep polling. supervisor_api_jq() { local endpoint="$1" jq_filter="$2" - docker exec -e "JQ_FILTER=${jq_filter}" hassio_cli sh -c \ - 'curl -s -H "Authorization: Bearer $SUPERVISOR_TOKEN" http://supervisor'"${endpoint}"' | jq -r "$JQ_FILTER"' + local response + response=$(supervisor_api GET "${endpoint}") + if [ -z "$response" ] || ! jq empty <<< "$response" 2>/dev/null; then + warn "Supervisor API ${endpoint} returned non-JSON: ${response}" + return 0 + fi + jq -r "$jq_filter" <<< "$response" } # Check if a jq expression matches (returns 0 if match found, 1 otherwise) @@ -187,45 +101,83 @@ supervisor_api_jq_test() { # --- Step 0: Wait for HA to be ready --- -wait_for_ha() { - log "Waiting for Home Assistant to be ready..." - local max_attempts=60 - local attempt=0 - local http_code - while [ "$attempt" -lt "$max_attempts" ]; do - # 200 = onboarding pending, 404 = already onboarded; both mean HA's HTTP - # server is up and serving. A connection failure yields 000. - http_code=$(curl -s -o /dev/null -w "%{http_code}" "${HA_URL}/api/onboarding" 2>/dev/null || true) - if [ "$http_code" = "200" ] || [ "$http_code" = "404" ]; then - ok "Home Assistant is ready" +# Print an addon's log to help diagnose a failure +dump_addon_logs() { + local slug="$1" + echo "::group::${slug} logs" + supervisor_api GET "/addons/${slug}/logs" || true + echo "::endgroup::" +} + +# Best-effort Home Assistant Core state from the Supervisor, for diagnostics +ha_core_state() { + local info + info=$(supervisor_api GET "/core/info" 2>/dev/null) || true + if [ -n "$info" ] && jq empty <<< "$info" 2>/dev/null; then + jq -r '.data.state // "unknown"' <<< "$info" + else + echo "unavailable" + fi +} + +# HA answers on its HTTP port long before the API is usable, and from 2026.8 the +# port itself moved from 8123 to 80 under the Supervisor. Older releases still use +# 8123, and during onboarding new releases answer on both - but 8123 only serves a +# redirect that Core tears down once onboarding completes. So port 80 is tried +# first, redirects are deliberately not followed, and a port only counts as ready +# when it serves the onboarding payload itself rather than merely valid JSON. +HA_PROBE="" + +ha_probe() { + local port="$1" raw status body + raw=$(curl -s --max-time 5 -w $'\n%{http_code}' "http://${HA_HOST}:${port}/api/onboarding" 2>/dev/null || true) + status=${raw##*$'\n'} + body=${raw%$'\n'*} + HA_PROBE="${HA_PROBE:+${HA_PROBE} }port=${port} http=${status:-none} body=${body:0:80}" + jq -e 'type == "array" and (.[0] | has("step"))' <<< "$body" > /dev/null 2>&1 +} + +# Sets HA_URL and CLIENT_ID on success. Must not run in a subshell. +ha_resolve_url() { + local port + HA_PROBE="" + for port in "${HA_PORTS[@]}"; do + if ha_probe "$port"; then + HA_URL="http://${HA_HOST}:${port}" + CLIENT_ID="${HA_URL}/" return 0 fi - debug "Attempt $((attempt + 1))/${max_attempts}: Home Assistant not ready yet" + done + return 1 +} + +wait_for_ha() { + log "Waiting for Home Assistant to be ready..." + local max_attempts=120 + local attempt=0 + while [ "$attempt" -lt "$max_attempts" ]; do + if ha_resolve_url; then + ok "Home Assistant is ready at ${HA_URL}" + return 0 + fi + if [ $((attempt % 6)) -eq 0 ]; then + log " attempt ${attempt}/${max_attempts}: core=$(ha_core_state) ${HA_PROBE}" + fi sleep 5 attempt=$((attempt + 1)) done - err "Home Assistant did not become ready in time" + err "Home Assistant did not become ready in time (core=$(ha_core_state) ${HA_PROBE})" } # --- Step 1: Complete onboarding --- complete_onboarding() { log "Checking onboarding status..." - # Capture the body and HTTP status separately. A configured instance returns - # 404 here because the onboarding endpoints are removed once onboarding - # completes, so don't use curl -f (which would abort under set -e). - local onboarding http_code - onboarding=$(curl -s -w $'\n%{http_code}' "${HA_URL}/api/onboarding") - http_code=$(printf '%s' "$onboarding" | tail -n1) - onboarding=$(printf '%s' "$onboarding" | sed '$d') - - if [ "$http_code" = "404" ]; then - ok "Onboarding already completed (endpoint returned 404)" - return 0 - fi + local onboarding + onboarding=$(curl -sfL "${HA_URL}/api/onboarding") local user_done - user_done=$(echo "$onboarding" | jq -r '.[] | select(.step == "user") | .done') + user_done=$(jq_or_die "$onboarding" '.[] | select(.step == "user") | .done' "Onboarding status") if [ "$user_done" = "true" ]; then ok "Onboarding already completed" @@ -234,13 +186,12 @@ complete_onboarding() { log "Creating admin user '${ADMIN_USER}'..." local auth_response - auth_response=$(curl -sf -X POST "${HA_URL}/api/onboarding/users" \ + auth_response=$(curl -sfL -X POST "${HA_URL}/api/onboarding/users" \ -H "Content-Type: application/json" \ -d "{\"client_id\":\"${CLIENT_ID}\",\"name\":\"Admin\",\"username\":\"${ADMIN_USER}\",\"password\":\"${ADMIN_PASS}\",\"language\":\"en\"}") - debug "users response: ${auth_response}" local auth_code - auth_code=$(echo "$auth_response" | jq -r '.auth_code') + auth_code=$(jq_or_die "$auth_response" '.auth_code' "Create admin user") if [ -z "$auth_code" ] || [ "$auth_code" = "null" ]; then err "Failed to create user: ${auth_response}" @@ -248,25 +199,34 @@ complete_onboarding() { # Exchange auth code for access token local token_response - token_response=$(curl -sf -X POST "${HA_URL}/auth/token" \ + token_response=$(curl -sfL -X POST "${HA_URL}/auth/token" \ --data-urlencode "grant_type=authorization_code" \ --data-urlencode "code=${auth_code}" \ --data-urlencode "client_id=${CLIENT_ID}") - debug "token response: ${token_response}" local access_token - access_token=$(echo "$token_response" | jq -r '.access_token') + access_token=$(jq_or_die "$token_response" '.access_token' "Token exchange") if [ -z "$access_token" ] || [ "$access_token" = "null" ]; then err "Failed to get access token: ${token_response}" fi - # Complete remaining onboarding steps (best-effort; failures are non-fatal) + # Complete remaining onboarding steps log "Completing onboarding steps..." - ha_post_optional "$access_token" "/api/onboarding/core_config" "{}" - ha_post_optional "$access_token" "/api/onboarding/analytics" "{}" - ha_post_optional "$access_token" "/api/onboarding/integration" \ - "{\"client_id\":\"${CLIENT_ID}\",\"redirect_uri\":\"${HA_URL}/\"}" + curl -sfL -X POST "${HA_URL}/api/onboarding/core_config" \ + -H "Authorization: Bearer ${access_token}" \ + -H "Content-Type: application/json" \ + -d "{}" > /dev/null 2>&1 || true + + curl -sfL -X POST "${HA_URL}/api/onboarding/analytics" \ + -H "Authorization: Bearer ${access_token}" \ + -H "Content-Type: application/json" \ + -d "{}" > /dev/null 2>&1 || true + + curl -sfL -X POST "${HA_URL}/api/onboarding/integration" \ + -H "Authorization: Bearer ${access_token}" \ + -H "Content-Type: application/json" \ + -d "{\"client_id\":\"${CLIENT_ID}\",\"redirect_uri\":\"${HA_URL}/\"}" > /dev/null 2>&1 || true ok "Onboarding complete" } @@ -282,7 +242,7 @@ add_addon_repo() { fi log "Adding addon repository: ${ALEXBELGIUM_REPO}" - supervisor_api_post "/store/repositories" \ + supervisor_api_with_body POST "/store/repositories" \ "{\"repository\":\"${ALEXBELGIUM_REPO}\"}" > /dev/null # Wait for the store to refresh by checking if the postgres addon is available @@ -294,7 +254,6 @@ add_addon_repo() { ok "Store refreshed successfully" return 0 fi - debug "Attempt $((attempt + 1))/${max_attempts}: postgres addon not in store yet" sleep 5 attempt=$((attempt + 1)) done @@ -318,7 +277,7 @@ install_postgres() { fi log "Installing PostgreSQL addon..." - supervisor_api_post "/addons/${postgres_slug}/install" > /dev/null + supervisor_api POST "/addons/${postgres_slug}/install" > /dev/null # Wait for installation to complete log "Waiting for PostgreSQL installation..." @@ -330,7 +289,6 @@ install_postgres() { ok "PostgreSQL addon installed (version: ${version})" return 0 fi - debug "Attempt $((attempt + 1))/${max_attempts}: PostgreSQL not installed yet" sleep 5 attempt=$((attempt + 1)) done @@ -349,7 +307,7 @@ configure_and_start_postgres() { --arg db "$POSTGRES_DB" \ '{options: {POSTGRES_PASSWORD: $pw, POSTGRES_DB: $db, env_vars: []}}') - supervisor_api_post "/addons/${postgres_slug}/options" \ + supervisor_api_with_body POST "/addons/${postgres_slug}/options" \ "${options_json}" > /dev/null # Check if already running @@ -362,7 +320,7 @@ configure_and_start_postgres() { fi log "Starting PostgreSQL addon..." - supervisor_api_post "/addons/${postgres_slug}/start" > /dev/null + supervisor_api POST "/addons/${postgres_slug}/start" > /dev/null # Wait for postgres to be ready log "Waiting for PostgreSQL to be ready..." @@ -372,105 +330,83 @@ configure_and_start_postgres() { state=$(supervisor_api_jq "/addons/${postgres_slug}/info" '.data.state') if [ "$state" = "started" ]; then ok "PostgreSQL addon is running" - # Give postgres time to initialize the database - sleep 10 + wait_for_postgres_ready "$postgres_slug" return 0 fi - debug "Attempt $((attempt + 1))/${max_attempts}: PostgreSQL state=${state}" sleep 5 attempt=$((attempt + 1)) done err "PostgreSQL addon did not start in time" } -# --- Step 5: Install and configure TeslaMate --- +wait_for_postgres_ready() { + local postgres_slug="$1" + # Supervisor renamed add-on containers from addon_* to app_*, so try both. + local containers=("app_${postgres_slug}" "addon_${postgres_slug}") -# Remove the "image" key from config.json so the Supervisor builds the addon -# image locally from the Dockerfile instead of pulling a pre-built image. -remove_addon_image() { - log "Ensuring TeslaMate addon builds locally..." - - if [ ! -f "$CONFIG_JSON" ]; then - warn "config.json not found at ${CONFIG_JSON} - skipping image removal" - return 0 - fi - - if ! jq -e 'has("image")' "$CONFIG_JSON" > /dev/null 2>&1; then - ok "config.json already has no 'image' key - addon will build locally" - return 0 - fi - - jq 'del(.image)' "$CONFIG_JSON" > "${CONFIG_JSON}.tmp" && mv "${CONFIG_JSON}.tmp" "$CONFIG_JSON" - ok "Removed 'image' key from config.json" - - # Reload the store so the Supervisor re-reads the local addon config, then - # confirm the store entry actually has no image before installing. The reload - # is eventually-consistent: if we install while the store still has the - # prebuilt image set, the Supervisor tries to PULL ghcr.io/...-{arch}:dev - # (which is never published) and fails with addon_unknown_error. - log "Reloading addon store..." - supervisor_api_post "/store/reload" > /dev/null - - log "Verifying TeslaMate will build locally..." - local attempt=0 image - while [ "$attempt" -lt 12 ]; do - image=$(supervisor_api_jq "/store/addons/${TESLAMATE_SLUG}" '.data.image // empty') - if [ -z "$image" ]; then - ok "TeslaMate store entry has no image - will build from Dockerfile" - return 0 - fi - debug "Attempt $((attempt + 1))/12: store still reports image=${image}" + log "Waiting for PostgreSQL database to accept connections..." + local max_attempts=60 + local attempt=0 + local container probe + while [ "$attempt" -lt "$max_attempts" ]; do + for container in "${containers[@]}"; do + if probe=$(docker exec -e "PGPASSWORD=${POSTGRES_PASSWORD}" "$container" \ + psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "SELECT 1" 2>&1); then + ok "PostgreSQL database is ready" + return 0 + fi + done sleep 5 attempt=$((attempt + 1)) done - err "Store still reports a prebuilt image for TeslaMate after reload; aborting to avoid pulling a non-existent image" + warn "Last psql output: ${probe}" + dump_addon_logs "$postgres_slug" + err "PostgreSQL database did not become ready in time" } -install_teslamate() { - log "Checking TeslaMate addon status..." +# --- Step 5: Install and configure addon --- + +install_addon() { + log "Checking ${ADDON_NAME} addon status..." local version - version=$(supervisor_api_jq "/addons/${TESLAMATE_SLUG}/info" '.data.version // empty') + version=$(supervisor_api_jq "/addons/${ADDON_SLUG}/info" '.data.version // empty') if [ -n "$version" ]; then local state - state=$(supervisor_api_jq "/addons/${TESLAMATE_SLUG}/info" '.data.state') - ok "TeslaMate addon already installed (version: ${version}, state: ${state})" + state=$(supervisor_api_jq "/addons/${ADDON_SLUG}/info" '.data.state') + ok "${ADDON_NAME} addon already installed (version: ${version}, state: ${state})" return 0 fi - log "Installing TeslaMate addon..." - supervisor_api_post "/addons/${TESLAMATE_SLUG}/install" > /dev/null + log "Installing ${ADDON_NAME} addon..." + supervisor_api POST "/addons/${ADDON_SLUG}/install" > /dev/null # Wait for installation to complete (the TeslaMate image is large) - log "Waiting for TeslaMate installation..." + log "Waiting for ${ADDON_NAME} installation..." local max_attempts=120 local attempt=0 while [ "$attempt" -lt "$max_attempts" ]; do - version=$(supervisor_api_jq "/addons/${TESLAMATE_SLUG}/info" '.data.version // empty') + version=$(supervisor_api_jq "/addons/${ADDON_SLUG}/info" '.data.version // empty') if [ -n "$version" ]; then - ok "TeslaMate addon installed (version: ${version})" + ok "${ADDON_NAME} addon installed (version: ${version})" return 0 fi - debug "Attempt $((attempt + 1))/${max_attempts}: TeslaMate not installed yet" sleep 5 attempt=$((attempt + 1)) done - err "TeslaMate addon installation timed out" + err "${ADDON_NAME} addon installation timed out" } -configure_and_start_teslamate() { +configure_and_start_addon() { local postgres_slug="$1" - # Derive the postgres hostname from its slug (underscores -> hyphens) + # Derive the postgres hostname from its slug (underscores → hyphens) local postgres_host postgres_host=$(echo "$postgres_slug" | tr '_' '-') - log "Configuring TeslaMate addon (db host: ${postgres_host})..." - - # Fetch the addon's current options - already populated with defaults - and overlay only - # the values we need to change. + log "Configuring ${ADDON_NAME} addon (db host: ${postgres_host})..." local current_options overrides options_json - current_options=$(supervisor_api_jq "/addons/${TESLAMATE_SLUG}/info" '.data.options') + current_options=$(supervisor_api_jq "/addons/${ADDON_SLUG}/info" '.data.options') overrides=$(jq -n \ --arg user "$POSTGRES_USER" \ --arg pass "$POSTGRES_PASSWORD" \ @@ -480,122 +416,89 @@ configure_and_start_teslamate() { --arg tz "$TIMEZONE" \ '{database_user: $user, database_pass: $pass, database_port: $port, database_host: $host, database_name: $db, database_ssl: false, disable_mqtt: true, grafana_import_dashboards: false, timezone: $tz}') options_json=$(jq -n --argjson cur "$current_options" --argjson ovr "$overrides" '{options: ($cur + $ovr)}') - debug "options payload: ${options_json}" - supervisor_api_post "/addons/${TESLAMATE_SLUG}/options" "${options_json}" > /dev/null + supervisor_api_with_body POST "/addons/${ADDON_SLUG}/options" \ + "${options_json}" > /dev/null # Check if already running local state - state=$(supervisor_api_jq "/addons/${TESLAMATE_SLUG}/info" '.data.state') + state=$(supervisor_api_jq "/addons/${ADDON_SLUG}/info" '.data.state') if [ "$state" = "started" ]; then - ok "TeslaMate addon already running" - wait_for_teslamate_ready + ok "${ADDON_NAME} addon already running" + wait_for_addon_ready return 0 fi - log "Starting TeslaMate addon..." - supervisor_api_post "/addons/${TESLAMATE_SLUG}/start" > /dev/null + log "Starting ${ADDON_NAME} addon..." + supervisor_api POST "/addons/${ADDON_SLUG}/start" > /dev/null # Wait for addon state to become "started" - log "Waiting for TeslaMate addon to start..." + log "Waiting for ${ADDON_NAME} addon to start..." local max_attempts=60 local attempt=0 while [ "$attempt" -lt "$max_attempts" ]; do - state=$(supervisor_api_jq "/addons/${TESLAMATE_SLUG}/info" '.data.state') + state=$(supervisor_api_jq "/addons/${ADDON_SLUG}/info" '.data.state') if [ "$state" = "started" ]; then break fi - if [ "$state" = "error" ]; then - dump_diagnostics 50 - err "TeslaMate addon failed to start (state: ${state})" - fi - debug "Attempt $((attempt + 1))/${max_attempts}: TeslaMate state=${state}" sleep 5 attempt=$((attempt + 1)) done if [ "$state" != "started" ]; then - dump_diagnostics 50 - err "TeslaMate addon did not start in time (state: ${state})" + dump_addon_logs "${ADDON_SLUG}" + err "${ADDON_NAME} addon did not start in time (state: ${state})" fi - wait_for_teslamate_ready - ok "TeslaMate addon started and ready" + wait_for_addon_ready + ok "${ADDON_NAME} addon started and ready" } -# dump_diagnostics() prints PostgreSQL and TeslaMate add-on logs (full lines) so -# a startup failure shows its real cause. PostgreSQL is reported first because -# TeslaMate halts if it cannot reach the database, so a stopped/crashed postgres -# addon is the most common root cause. Closes any open GitHub Actions log group -# first so the output is never hidden inside a collapsed fold. -dump_diagnostics() { - local lines="${1:-50}" - group_end - - local pg_state - pg_state=$(supervisor_api_jq "/addons/${POSTGRES_SLUG}/info" '.data.state' 2>/dev/null || true) - warn "PostgreSQL addon state: ${pg_state:-unknown}" - warn "Last ${lines} lines of PostgreSQL add-on logs:" - supervisor_api GET "/addons/${POSTGRES_SLUG}/logs" 2>/dev/null | tail -n "${lines}" >&2 || true - - warn "Last ${lines} lines of TeslaMate add-on logs:" - supervisor_api GET "/addons/${TESLAMATE_SLUG}/logs" 2>/dev/null | tail -n "${lines}" >&2 || true -} - -wait_for_teslamate_ready() { - log "Waiting for TeslaMate to be ready (listening on port ${TESLAMATE_PORT})..." +wait_for_addon_ready() { + log "Waiting for ${ADDON_NAME} to be ready (listening on port ${ADDON_PORT})..." local max_attempts=60 local attempt=0 - local logs state while [ "$attempt" -lt "$max_attempts" ]; do - logs=$(supervisor_api GET "/addons/${TESLAMATE_SLUG}/logs" 2>/dev/null || true) - if echo "$logs" | grep -q "TeslaMateWeb.Endpoint"; then - ok "TeslaMate is ready" + local logs + logs=$(supervisor_api GET "/addons/${ADDON_SLUG}/logs" 2>/dev/null || true) + if echo "$logs" | grep -qF "$ADDON_READY_LOG"; then + ok "${ADDON_NAME} is ready" return 0 fi - - # TeslaMate's s6 finish script halts the whole container if the service - # exits non-zero, so a crash during startup shows up as the add-on leaving - # the "started" state. Detect that and fail immediately with the real logs - # instead of polling uselessly until the timeout. - state=$(supervisor_api_jq "/addons/${TESLAMATE_SLUG}/info" '.data.state') - if [ "$state" = "error" ] || [ "$state" = "stopped" ]; then - dump_diagnostics 50 - err "TeslaMate crashed during startup (add-on state: ${state})" - fi - - debug "Attempt $((attempt + 1))/${max_attempts}: endpoint not up yet (state=${state})" sleep 5 attempt=$((attempt + 1)) done - dump_diagnostics 50 - err "TeslaMate did not become ready in time (never saw 'TeslaMateWeb.Endpoint' in logs)" + dump_addon_logs "${ADDON_SLUG}" + err "${ADDON_NAME} did not become ready in time (never saw '${ADDON_READY_LOG}' in logs)" } # --- Main --- main() { - log "Setting up Home Assistant for TeslaMate development" + log "Setting up Home Assistant for ${ADDON_NAME} development" echo "" - debug "Verbose debug output enabled" - debug "HA_URL=${HA_URL} POSTGRES_SLUG=${POSTGRES_SLUG} TESLAMATE_SLUG=${TESLAMATE_SLUG}" + wait_for_ha + complete_onboarding + add_addon_repo - run_step "Wait for Home Assistant" wait_for_ha - run_step "Complete onboarding" complete_onboarding - run_step "Add addon repository" add_addon_repo - - run_step "Install PostgreSQL" install_postgres "$POSTGRES_SLUG" - run_step "Configure and start PostgreSQL" configure_and_start_postgres "$POSTGRES_SLUG" - run_step "Prepare TeslaMate local build" remove_addon_image - run_step "Install TeslaMate" install_teslamate - run_step "Configure and start TeslaMate" configure_and_start_teslamate "$POSTGRES_SLUG" + install_postgres "$POSTGRES_SLUG" + configure_and_start_postgres "$POSTGRES_SLUG" + install_addon + configure_and_start_addon "$POSTGRES_SLUG" echo "" ok "Setup complete!" + local host_port + # Mirrors the appPort list in devcontainer.json. + case "${HA_URL##*:}" in + 80) host_port=7180 ;; + *) host_port=7123 ;; + esac log " Home Assistant: ${HA_URL} (user: ${ADMIN_USER}, pass: ${ADMIN_PASS})" + log " from the host: http://localhost:${host_port}" log " PostgreSQL: ${POSTGRES_SLUG} (user: ${POSTGRES_USER}, db: ${POSTGRES_DB})" - log " TeslaMate: open the TeslaMate panel via HA ingress (${HA_URL})" + log " ${ADDON_NAME}: accessible via Home Assistant ingress" } main "$@" diff --git a/.github/workflows/integration-test.yaml b/.github/workflows/integration-test.yaml index f5e8abf..f4da33e 100644 --- a/.github/workflows/integration-test.yaml +++ b/.github/workflows/integration-test.yaml @@ -17,6 +17,13 @@ on: - Dockerfile - rootfs/** - .devcontainer/** + # Home Assistant and the third-party add-on repository this test installs both + # move independently of this repo, so the test can break with no change here. + # Run it on a schedule to catch that on main rather than on an unrelated pull + # request that happens to touch one of the paths above. + schedule: + - cron: "17 6 * * *" + workflow_dispatch: jobs: integration: @@ -30,20 +37,22 @@ jobs: - name: Run integration test in devcontainer uses: devcontainers/ci@513af61f4de4f75d37e4438f184ba4358f0fc1ca # v0.3.1900000450 with: - env: | - DEBUG=${{ vars.DEBUG }} - GITHUB_ACTIONS=true - runCmd: | - if jq -e 'has("image")' config.json >/dev/null 2>&1; then - jq 'del(.image)' config.json > config.json.tmp && mv config.json.tmp config.json - fi + runCmd: bash .devcontainer/run-integration-test.sh stable - printf '#!/bin/sh\nexit 0\n' | sudo tee /usr/sbin/apparmor_parser > /dev/null && sudo chmod +x /usr/sbin/apparmor_parser - sudo sed -i 's|docker run --rm --privileged|docker run --rm --privileged -v /usr/sbin/apparmor_parser:/usr/sbin/apparmor_parser:ro|' /usr/bin/supervisor_run - if [ -n "${DEBUG:-}" ]; then - supervisor_run & - else - supervisor_run > /dev/null 2>&1 & - fi + # Early warning for Home Assistant changes that have not reached stable yet. + # Deliberately never runs on a pull request, so a broken beta cannot block a + # merge, and is non-blocking when it does run. + canary: + name: Integration test (beta) + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 30 + continue-on-error: true + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - bash .devcontainer/setup-ha.sh + - name: Run integration test in devcontainer + uses: devcontainers/ci@513af61f4de4f75d37e4438f184ba4358f0fc1ca # v0.3.1900000450 + with: + runCmd: bash .devcontainer/run-integration-test.sh beta diff --git a/config.json b/config.json index dc06062..8596895 100644 --- a/config.json +++ b/config.json @@ -13,6 +13,12 @@ "init": false, "panel_icon": "mdi:car-connected", "panel_title": "TeslaMate", + "ports": { + "4000/tcp": null + }, + "ports_description": { + "4000/tcp": "Web interface (not required for Home Assistant ingress)" + }, "options": { "database_user": null, "database_pass": null,