test: build and verify addon runtime

This commit is contained in:
Dennis Juhler Aagaard
2026-09-24 18:00:26 +02:00
parent 31aeeb5ed3
commit 1322859112
5 changed files with 398 additions and 5 deletions
+55
View File
@@ -0,0 +1,55 @@
# syntax=docker/dockerfile:1.7
FROM golang:1.27.1-bookworm@sha256:69a7b9788769bec032d238959b61854e9ae87f57be9029ec04e9885fabf99195 AS stelloauth-builder
ARG TARGETARCH
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates git patch \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src
RUN git clone --filter=blob:none https://github.com/tamcore/stelloauth.git . \
&& git checkout --detach 367d4f8c02a3b072c59142c49dffc129edc8548b \
&& test "$(git rev-parse HEAD)" = "367d4f8c02a3b072c59142c49dffc129edc8548b"
COPY patches/stelloauth-security.patch /tmp/stelloauth-security.patch
RUN git apply --check /tmp/stelloauth-security.patch \
&& git apply /tmp/stelloauth-security.patch \
&& go test ./... \
&& CGO_ENABLED=0 GOOS=linux GOARCH="$TARGETARCH" go build -trimpath -ldflags="-s -w" -o /out/stelloauth ./cmd/stelloauth
FROM cloakhq/cloakbrowser:0.5.10@sha256:2ed5b2d047cbdde22cde7ef1a796526c716aadaa5bccbe1db5ade49282b64a76
ARG BUILD_ARCH
ARG BUILD_DATE
ARG BUILD_DESCRIPTION="Local OAuth worker for Stellantis Vehicles using CloakBrowser"
ARG BUILD_NAME="Stelloauth"
ARG BUILD_REF
ARG BUILD_REPOSITORY="https://git.radixadm.dk/dennis/homeassistant-stelloauth-addon"
ARG BUILD_VERSION="0.1.0"
LABEL io.hass.name="$BUILD_NAME" \
io.hass.description="$BUILD_DESCRIPTION" \
io.hass.arch="$BUILD_ARCH" \
io.hass.type="addon" \
io.hass.version="$BUILD_VERSION" \
org.opencontainers.image.created="$BUILD_DATE" \
org.opencontainers.image.revision="$BUILD_REF" \
org.opencontainers.image.source="$BUILD_REPOSITORY" \
org.opencontainers.image.version="$BUILD_VERSION"
USER root
RUN apt-get update \
&& apt-get install -y --no-install-recommends patch \
&& rm -rf /var/lib/apt/lists/*
COPY patches/cloakserve-loopback.patch /tmp/cloakserve-loopback.patch
RUN patch --dry-run -p2 -d /usr/local/bin < /tmp/cloakserve-loopback.patch \
&& patch -p2 -d /usr/local/bin < /tmp/cloakserve-loopback.patch \
&& rm /tmp/cloakserve-loopback.patch \
&& apt-get purge -y --auto-remove patch \
&& rm -rf /var/lib/apt/lists/*
COPY --from=stelloauth-builder /out/stelloauth /usr/local/bin/stelloauth
COPY rootfs/ /
RUN chmod 0755 /usr/local/bin/stelloauth /usr/local/bin/addon-supervisor /usr/local/bin/cloakserve \
&& mkdir -p /data /tmp/cloakserve \
&& chmod 0700 /tmp/cloakserve
EXPOSE 8080
ENTRYPOINT []
CMD ["/usr/local/bin/addon-supervisor"]
@@ -31,6 +31,7 @@ CLOAK_COMMAND = [
"--data-dir=/tmp/cloakserve", "--data-dir=/tmp/cloakserve",
] ]
STELLOAUTH_COMMAND = ["/usr/local/bin/stelloauth"] STELLOAUTH_COMMAND = ["/usr/local/bin/stelloauth"]
SHUTDOWN_GRACE_SECONDS = 9.0
class ConfigError(RuntimeError): class ConfigError(RuntimeError):
@@ -297,7 +298,7 @@ class ProcessManager:
def _begin_shutdown(self) -> None: def _begin_shutdown(self) -> None:
if self._shutdown_deadline is None: if self._shutdown_deadline is None:
self._shutdown_deadline = self._monotonic() + 10.0 self._shutdown_deadline = self._monotonic() + SHUTDOWN_GRACE_SECONDS
if not self._term_sent: if not self._term_sent:
self._signal_groups(signal.SIGTERM) self._signal_groups(signal.SIGTERM)
self._term_sent = True self._term_sent = True
+46
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import re
from pathlib import Path from pathlib import Path
import yaml import yaml
@@ -54,3 +55,48 @@ def test_translations_cover_every_option() -> None:
for entry in translation["configuration"].values(): for entry in translation["configuration"].values():
assert set(entry) == {"name", "description"} assert set(entry) == {"name", "description"}
assert all(isinstance(value, str) and value.strip() for value in entry.values()) assert all(isinstance(value, str) and value.strip() for value in entry.values())
def test_dockerfile_uses_approved_pins_and_builds_patched_stelloauth() -> None:
dockerfile = (ROOT / "stelloauth/Dockerfile").read_text(encoding="utf-8")
assert (
"golang:1.27.1-bookworm@sha256:"
"69a7b9788769bec032d238959b61854e9ae87f57be9029ec04e9885fabf99195"
) in dockerfile
assert (
"cloakhq/cloakbrowser:0.5.10@sha256:"
"2ed5b2d047cbdde22cde7ef1a796526c716aadaa5bccbe1db5ade49282b64a76"
) in dockerfile
assert "367d4f8c02a3b072c59142c49dffc129edc8548b" in dockerfile
assert "go test ./..." in dockerfile
assert not re.search(r"^FROM\s+\S+:latest(?:\s|$)", dockerfile, re.MULTILINE)
def test_dockerfile_declares_home_assistant_runtime_contract() -> None:
dockerfile = (ROOT / "stelloauth/Dockerfile").read_text(encoding="utf-8")
for label in (
"io.hass.name",
"io.hass.description",
"io.hass.arch",
"io.hass.type",
"io.hass.version",
):
assert label in dockerfile
assert re.search(r"^EXPOSE 8080$", dockerfile, re.MULTILINE)
assert not re.search(r"^EXPOSE .*\b9222\b", dockerfile, re.MULTILINE)
assert "ENTRYPOINT []" in dockerfile
assert 'CMD ["/usr/local/bin/addon-supervisor"]' in dockerfile
def test_dockerfile_patches_parent_cloakserve_instead_of_copying_a_binary() -> None:
dockerfile = (ROOT / "stelloauth/Dockerfile").read_text(encoding="utf-8")
assert "COPY patches/cloakserve-loopback.patch" in dockerfile
for line in dockerfile.splitlines():
if line.lstrip().startswith("COPY "):
source = line.split()[1]
assert Path(source).name != "cloakserve"
def test_runtime_accepts_docker_port_unpublished_status() -> None:
runtime_test = (ROOT / "tests/test_runtime.sh").read_text(encoding="utf-8")
assert 'docker port "$container" 9222/tcp 2>/dev/null || true' in runtime_test
+290
View File
@@ -0,0 +1,290 @@
#!/usr/bin/env bash
set -Eeuo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
image="homeassistant-stelloauth-addon:test"
run_id="$(date +%s)-$$"
first_container="stelloauth-runtime-${run_id}-first"
second_container="stelloauth-runtime-${run_id}-second"
tmp_dir="$(mktemp -d)"
artifacts_dir="${repo_root}/artifacts"
options_file="${tmp_dir}/options.json"
sentinels=(
"sentinel-email@example.invalid"
"SENTINEL_PASSWORD_9a34"
"SENTINEL_COOKIE_7b21"
"SENTINEL_OAUTH_CODE_5c88"
"SENTINEL_ACCESS_TOKEN_1d62"
"SENTINEL_REFRESH_TOKEN_4e73"
)
cleanup() {
set +e
for container in "$first_container" "$second_container"; do
if docker container inspect "$container" >/dev/null 2>&1; then
if [ "$(docker inspect --format '{{.State.Running}}' "$container" 2>/dev/null)" = "true" ]; then
docker stop --time 10 "$container" >/dev/null 2>&1
fi
docker rm "$container" >/dev/null 2>&1
fi
done
rm -r "$tmp_dir"
}
trap cleanup EXIT
fail() {
printf 'runtime test failed: %s\n' "$*" >&2
exit 1
}
write_options() {
python3 - "$options_file" <<'PY'
import json
import pathlib
import sys
options = {
"queue_timeout": "60s",
"rate_limit_count": 5,
"rate_limit_duration": "1h",
}
pathlib.Path(sys.argv[1]).write_text(
json.dumps(options, separators=(",", ":")) + "\n",
encoding="utf-8",
)
PY
}
start_container() {
local container="$1"
docker run --detach \
--name "$container" \
--platform linux/amd64 \
--mount "type=bind,src=${options_file},dst=/data/options.json,readonly" \
--publish 127.0.0.1::8080 \
"$image" >/dev/null
}
host_port() {
local container="$1"
local mapping
mapping="$(docker port "$container" 8080/tcp)"
[ -n "$mapping" ] || fail "container 8080 has no host mapping"
printf '%s\n' "${mapping##*:}"
}
probe_root() {
local port="$1"
python3 - "$port" <<'PY'
import sys
import urllib.request
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
with opener.open(f"http://127.0.0.1:{sys.argv[1]}/", timeout=2) as response:
if response.status != 200:
raise SystemExit(f"root status {response.status}")
response.read()
PY
}
wait_ready() {
local container="$1"
local port="$2"
local deadline=$((SECONDS + 90))
while (( SECONDS < deadline )); do
if [ "$(docker inspect --format '{{.State.Running}}' "$container")" != "true" ]; then
docker logs "$container" >&2
fail "$container exited during readiness"
fi
if docker logs "$container" 2>&1 | grep -Fq "Stelloauth listening on 0.0.0.0:8080"; then
if probe_root "$port" >/dev/null 2>&1; then
return
fi
fi
sleep 1
done
docker logs "$container" >&2
fail "$container did not become ready within 90 seconds"
}
assert_loopback_cdp_listener() {
local container="$1"
local artifact="$2"
docker exec "$container" cat /proc/net/tcp > "$artifact"
docker exec -i "$container" python3 - <<'PY'
expected = f"0100007F:{9222:04X}"
wildcard = f"00000000:{9222:04X}"
if expected != "0100007F:2406":
raise SystemExit(f"unexpected 9222 hexadecimal encoding: {expected}")
listeners = set()
with open("/proc/net/tcp", encoding="ascii") as handle:
next(handle)
for line in handle:
fields = line.split()
if len(fields) >= 4 and fields[3] == "0A":
listeners.add(fields[1].upper())
if expected not in listeners:
raise SystemExit(f"missing loopback CDP listener {expected}: {sorted(listeners)}")
if wildcard in listeners:
raise SystemExit(f"wildcard CDP listener present: {wildcard}")
PY
}
probe_and_close_cdp() {
local container="$1"
docker exec -i "$container" python3 - <<'PY'
import json
import urllib.request
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
version_url = "http://127.0.0.1:9222/json/version?fingerprint=runtime-readiness"
close_url = "http://127.0.0.1:9222/fingerprint/runtime-readiness/close"
with opener.open(version_url, timeout=10) as response:
if response.status != 200:
raise SystemExit(f"CDP version status {response.status}")
document = json.load(response)
websocket_url = document.get("webSocketDebuggerUrl")
if not isinstance(websocket_url, str) or not websocket_url:
raise SystemExit("CDP response lacks webSocketDebuggerUrl")
request = urllib.request.Request(close_url, data=b"", method="POST")
with opener.open(request, timeout=10) as response:
if response.status != 200:
raise SystemExit(f"CDP close status {response.status}")
response.read()
PY
}
post_invalid_worker() {
local port="$1"
local response_artifact="$2"
python3 - "$port" "$response_artifact" <<'PY'
import json
import pathlib
import sys
import urllib.error
import urllib.request
port, artifact = sys.argv[1:]
body = {
"url": (
"https://example.invalid/am/oauth2/authorize"
"?redirect_uri=sentinel%3A%2F%2Fcallback"
"&code=SENTINEL_OAUTH_CODE_5c88"
"&access_token=SENTINEL_ACCESS_TOKEN_1d62"
"&refresh_token=SENTINEL_REFRESH_TOKEN_4e73"
"&cookie=SENTINEL_COOKIE_7b21"
),
"email": "sentinel-email@example.invalid",
"password": "SENTINEL_PASSWORD_9a34",
"cookie": "SENTINEL_COOKIE_7b21",
"oauth_code": "SENTINEL_OAUTH_CODE_5c88",
"access_token": "SENTINEL_ACCESS_TOKEN_1d62",
"refresh_token": "SENTINEL_REFRESH_TOKEN_4e73",
}
request = urllib.request.Request(
f"http://127.0.0.1:{port}/worker",
data=json.dumps(body, separators=(",", ":")).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
try:
with opener.open(request, timeout=10) as response:
status = response.status
response_body = response.read()
except urllib.error.HTTPError as error:
status = error.code
response_body = error.read()
if status != 400:
raise SystemExit(f"invalid worker status {status}, want 400")
pathlib.Path(artifact).write_bytes(response_body)
PY
}
assert_no_9222_mapping() {
local container="$1"
local mapping
mapping="$(docker port "$container" 9222/tcp 2>/dev/null || true)"
[ -z "$mapping" ] || fail "container 9222 is mapped: $mapping"
}
scan_logs() {
local container="$1"
local log_file="${tmp_dir}/${container}.log"
docker logs "$container" > "$log_file" 2>&1
for sentinel in "${sentinels[@]}"; do
if grep -Fq "$sentinel" "$log_file"; then
fail "$container logs contain sentinel $sentinel"
fi
done
if grep -Fq "worker OAuth request" "$log_file"; then
fail "$container began an OAuth flow for the rejected worker body"
fi
}
stop_and_assert() {
local container="$1"
local timing_artifact="$2"
local started_ns ended_ns elapsed state
started_ns="$(python3 -c 'import time; print(time.monotonic_ns())')"
docker stop --time 10 "$container" >/dev/null
ended_ns="$(python3 -c 'import time; print(time.monotonic_ns())')"
elapsed="$(python3 - "$started_ns" "$ended_ns" <<'PY'
import sys
print((int(sys.argv[2]) - int(sys.argv[1])) / 1_000_000_000)
PY
)"
printf 'seconds=%s\n' "$elapsed" > "$timing_artifact"
python3 - "$elapsed" <<'PY'
import sys
if float(sys.argv[1]) > 10.0:
raise SystemExit(f"container stop exceeded 10 seconds: {sys.argv[1]}")
PY
state="$(docker inspect --format '{{.State.Status}} {{.State.ExitCode}}' "$container")"
[ "$state" = "exited 0" ] || fail "$container state is $state, want exited 0"
}
mkdir -p "$artifacts_dir"
write_options
if [ "${SKIP_BUILD:-0}" != "1" ]; then
docker buildx build \
--platform linux/amd64 \
--build-arg BUILD_ARCH=amd64 \
--load \
--tag "$image" \
"$repo_root/stelloauth"
fi
start_container "$first_container"
first_port="$(host_port "$first_container")"
wait_ready "$first_container" "$first_port"
assert_no_9222_mapping "$first_container"
assert_loopback_cdp_listener "$first_container" "${artifacts_dir}/runtime-proc-net-tcp.txt"
probe_and_close_cdp "$first_container"
post_invalid_worker "$first_port" "${artifacts_dir}/runtime-invalid-worker-response.json"
scan_logs "$first_container"
stop_and_assert "$first_container" "${artifacts_dir}/runtime-first-stop.txt"
scan_logs "$first_container"
start_container "$second_container"
second_port="$(host_port "$second_container")"
wait_ready "$second_container" "$second_port"
assert_no_9222_mapping "$second_container"
assert_loopback_cdp_listener "$second_container" "${artifacts_dir}/runtime-restart-proc-net-tcp.txt"
probe_and_close_cdp "$second_container"
sleep 5
docker stats --no-stream "$second_container" > "${artifacts_dir}/runtime-docker-stats.txt"
docker top "$second_container" > "${artifacts_dir}/runtime-docker-top.txt"
docker image inspect "$image" --format '{{.Size}}' > "${artifacts_dir}/runtime-image-size-bytes.txt"
docker image inspect "$image" --format '{{json .Config.ExposedPorts}}' > "${artifacts_dir}/runtime-image-exposed-ports.json"
docker inspect "$second_container" --format '{{json .HostConfig.PortBindings}}' > "${artifacts_dir}/runtime-host-port-bindings.json"
stop_and_assert "$second_container" "${artifacts_dir}/runtime-second-stop.txt"
scan_logs "$second_container"
printf 'runtime acceptance PASS: root, CDP, loopback bind, invalid worker, redaction, stop, restart\n'
+5 -4
View File
@@ -669,7 +669,7 @@ def test_exited_leader_with_live_descendants_still_uses_deadline_and_sigkill(
monkeypatch.setattr(supervisor.signal, "signal", lambda *_args: None) monkeypatch.setattr(supervisor.signal, "signal", lambda *_args: None)
assert harness.manager.run() == 7 assert harness.manager.run() == 7
assert harness.clock.now == pytest.approx(10.25) assert harness.clock.now == pytest.approx(9.25)
assert ("signal", 1001, signal.SIGTERM) in harness.events assert ("signal", 1001, signal.SIGTERM) in harness.events
assert ("signal", 1001, signal.SIGKILL) in harness.events assert ("signal", 1001, signal.SIGKILL) in harness.events
assert ("signal", 1002, signal.SIGTERM) in harness.events assert ("signal", 1002, signal.SIGTERM) in harness.events
@@ -742,7 +742,7 @@ def test_group_disappearing_at_deadline_is_rechecked_before_sigkill(
deadline_checks = {1001: 0, 1002: 0} deadline_checks = {1001: 0, 1002: 0}
def group_alive(pgid: int) -> bool: def group_alive(pgid: int) -> bool:
if harness.clock.now < 10.0: if harness.clock.now < supervisor.SHUTDOWN_GRACE_SECONDS:
return True return True
deadline_checks[pgid] += 1 deadline_checks[pgid] += 1
return deadline_checks[pgid] == 1 return deadline_checks[pgid] == 1
@@ -849,7 +849,7 @@ def test_signal_while_starting_child_still_terminates_new_process_group(
assert [process.wait_calls for process in harness.processes] == [[None], [None]] assert [process.wait_calls for process in harness.processes] == [[None], [None]]
def test_shutdown_uses_one_ten_second_deadline_then_sigkills_remaining_groups( def test_shutdown_reserves_time_before_outer_ten_second_stop_deadline(
supervisor, tmp_path: Path, monkeypatch supervisor, tmp_path: Path, monkeypatch
) -> None: ) -> None:
harness = manager_harness(supervisor, tmp_path, ignores_term=(True, True)) harness = manager_harness(supervisor, tmp_path, ignores_term=(True, True))
@@ -866,7 +866,8 @@ def test_shutdown_uses_one_ten_second_deadline_then_sigkills_remaining_groups(
monkeypatch.setattr(supervisor.signal, "signal", lambda *_args: None) monkeypatch.setattr(supervisor.signal, "signal", lambda *_args: None)
assert harness.manager.run() == 0 assert harness.manager.run() == 0
assert harness.clock.now == pytest.approx(10.0) assert harness.clock.now < 10.0
assert harness.clock.now == pytest.approx(supervisor.SHUTDOWN_GRACE_SECONDS)
assert [ assert [
(event[1], event[2]) for event in harness.events if event[0] == "signal" (event[1], event[2]) for event in harness.events if event[0] == "signal"
] == [ ] == [