413 lines
13 KiB
Bash
Executable File
413 lines
13 KiB
Bash
Executable File
#!/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 create --name "$container" \
|
|
--platform linux/amd64 \
|
|
--publish 127.0.0.1::8080 \
|
|
"$image" >/dev/null
|
|
docker cp "$options_file" "$container:/data/options.json"
|
|
docker start "$container" >/dev/null
|
|
}
|
|
|
|
assert_8080_loopback_mapping() {
|
|
local container="$1"
|
|
local mapping
|
|
mapping="$(docker port "$container" 8080/tcp)"
|
|
case "$mapping" in
|
|
127.0.0.1:[0-9]*) ;;
|
|
*) fail "$container 8080 mapping is '$mapping', want 127.0.0.1:<port>" ;;
|
|
esac
|
|
}
|
|
|
|
probe_root() {
|
|
local container="$1"
|
|
docker exec -i "$container" python3 - <<'PY'
|
|
import urllib.request
|
|
|
|
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
|
with opener.open("http://127.0.0.1:8080/", timeout=2) as response:
|
|
if response.status != 200:
|
|
raise SystemExit(f"root status {response.status}")
|
|
response.read()
|
|
PY
|
|
}
|
|
|
|
wait_ready() {
|
|
local container="$1"
|
|
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 "$container" >/dev/null 2>&1; then
|
|
return
|
|
fi
|
|
fi
|
|
sleep 1
|
|
done
|
|
docker logs "$container" >&2
|
|
fail "$container did not become ready within 90 seconds"
|
|
}
|
|
|
|
normalize_process_file() {
|
|
local top_file="$1"
|
|
python3 - "$top_file" <<'PY'
|
|
import pathlib
|
|
import sys
|
|
|
|
commands = []
|
|
for raw_line in pathlib.Path(sys.argv[1]).read_text(encoding="utf-8").splitlines()[1:]:
|
|
fields = raw_line.split(maxsplit=1)
|
|
command = " ".join(fields[1].split()) if len(fields) == 2 else ""
|
|
tokens = command.split()
|
|
while tokens and (
|
|
tokens[0] == "/run/rosetta/rosetta"
|
|
or pathlib.Path(tokens[0]).name.startswith("qemu-")
|
|
):
|
|
tokens = tokens[1:]
|
|
|
|
python_prefixes = (
|
|
[],
|
|
["python3"],
|
|
["/usr/local/bin/python3"],
|
|
["/usr/local/bin/python3", "python3"],
|
|
)
|
|
supervisor_commands = [
|
|
[*prefix, "/usr/local/bin/addon-supervisor"]
|
|
for prefix in python_prefixes
|
|
]
|
|
cloak_commands = [
|
|
[
|
|
*prefix,
|
|
"/usr/local/bin/cloakserve",
|
|
"--headless=true",
|
|
"--idle-timeout=30",
|
|
"--data-dir=/tmp/cloakserve",
|
|
]
|
|
for prefix in python_prefixes
|
|
]
|
|
stelloauth_commands = (
|
|
["/usr/local/bin/stelloauth"],
|
|
["/usr/local/bin/stelloauth", "/usr/local/bin/stelloauth"],
|
|
)
|
|
|
|
if tokens in supervisor_commands:
|
|
commands.append("addon-supervisor")
|
|
elif tokens in cloak_commands:
|
|
commands.append("cloakserve")
|
|
elif tokens in stelloauth_commands:
|
|
commands.append("stelloauth")
|
|
elif command:
|
|
commands.append(f"unexpected:{command}")
|
|
|
|
print("\n".join(sorted(commands)))
|
|
PY
|
|
}
|
|
|
|
normalized_process_commands() {
|
|
local container="$1"
|
|
local top_file="${tmp_dir}/${container}-processes.txt"
|
|
docker top "$container" -eo pid,args > "$top_file"
|
|
normalize_process_file "$top_file"
|
|
}
|
|
|
|
capture_process_baseline() {
|
|
local container="$1"
|
|
local expected current
|
|
local deadline=$((SECONDS + 10))
|
|
expected=$'addon-supervisor\ncloakserve\nstelloauth'
|
|
while (( SECONDS < deadline )); do
|
|
current="$(normalized_process_commands "$container")"
|
|
if [ "$current" = "$expected" ]; then
|
|
printf '%s\n' "$current"
|
|
return
|
|
fi
|
|
sleep 0.25
|
|
done
|
|
printf 'expected startup process baseline:\n%s\ncurrent process commands:\n%s\n' \
|
|
"$expected" "$current" >&2
|
|
docker top "$container" >&2
|
|
fail "$container did not reach the expected startup process baseline"
|
|
}
|
|
|
|
assert_processes_return_to_baseline() {
|
|
local container="$1"
|
|
local baseline="$2"
|
|
local current
|
|
local deadline=$((SECONDS + 10))
|
|
while (( SECONDS < deadline )); do
|
|
current="$(normalized_process_commands "$container")"
|
|
if [ "$current" = "$baseline" ]; then
|
|
return
|
|
fi
|
|
sleep 0.25
|
|
done
|
|
printf 'expected process baseline after CDP close:\n%s\ncurrent process commands:\n%s\n' \
|
|
"$baseline" "$current" >&2
|
|
docker top "$container" >&2
|
|
fail "$container retained browser or profile processes after CDP close"
|
|
}
|
|
|
|
assert_loopback_cdp_listener() {
|
|
local container="$1"
|
|
local tcp_artifact="$2"
|
|
local tcp6_artifact="$3"
|
|
docker exec "$container" cat /proc/net/tcp > "$tcp_artifact"
|
|
docker exec "$container" cat /proc/net/tcp6 > "$tcp6_artifact"
|
|
docker exec -i "$container" python3 - <<'PY'
|
|
expected = f"0100007F:{9222:04X}"
|
|
if expected != "0100007F:2406":
|
|
raise SystemExit(f"unexpected 9222 hexadecimal encoding: {expected}")
|
|
|
|
listeners = {"/proc/net/tcp": set(), "/proc/net/tcp6": set()}
|
|
for table in ("/proc/net/tcp", "/proc/net/tcp6"):
|
|
with open(table, encoding="ascii") as handle:
|
|
next(handle)
|
|
for line in handle:
|
|
fields = line.split()
|
|
if len(fields) < 4 or fields[3] != "0A":
|
|
continue
|
|
local_address = fields[1].upper()
|
|
if local_address.rsplit(":", 1)[-1] != "2406":
|
|
continue
|
|
listeners[table].add(local_address)
|
|
if table == "/proc/net/tcp6":
|
|
raise SystemExit(f"IPv6 CDP listener present: {local_address}")
|
|
|
|
if listeners["/proc/net/tcp"] != {expected}:
|
|
raise SystemExit(
|
|
f"IPv4 CDP listeners = {sorted(listeners['/proc/net/tcp'])}, want [{expected}]"
|
|
)
|
|
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 container="$1"
|
|
local response_artifact="$2"
|
|
docker exec -i "$container" python3 - > "$response_artifact" <<'PY'
|
|
import json
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
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(
|
|
"http://127.0.0.1:8080/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")
|
|
sys.stdout.buffer.write(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}} {{.State.Pid}}' "$container")"
|
|
[ "$state" = "exited 0 0" ] || fail "$container state is $state, want exited 0 with PID 0"
|
|
}
|
|
|
|
if [ "${1:-}" = "--normalize-processes" ]; then
|
|
[ "$#" -eq 2 ] || fail "--normalize-processes requires one docker top file"
|
|
normalize_process_file "$2"
|
|
exit
|
|
fi
|
|
[ "$#" -eq 0 ] || fail "unexpected runtime test arguments"
|
|
|
|
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"
|
|
assert_8080_loopback_mapping "$first_container"
|
|
wait_ready "$first_container"
|
|
first_baseline="$(capture_process_baseline "$first_container")"
|
|
assert_no_9222_mapping "$first_container"
|
|
assert_loopback_cdp_listener \
|
|
"$first_container" \
|
|
"${artifacts_dir}/runtime-proc-net-tcp.txt" \
|
|
"${artifacts_dir}/runtime-proc-net-tcp6.txt"
|
|
probe_and_close_cdp "$first_container"
|
|
assert_processes_return_to_baseline "$first_container" "$first_baseline"
|
|
post_invalid_worker "$first_container" "${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"
|
|
assert_8080_loopback_mapping "$second_container"
|
|
wait_ready "$second_container"
|
|
second_baseline="$(capture_process_baseline "$second_container")"
|
|
assert_no_9222_mapping "$second_container"
|
|
assert_loopback_cdp_listener \
|
|
"$second_container" \
|
|
"${artifacts_dir}/runtime-restart-proc-net-tcp.txt" \
|
|
"${artifacts_dir}/runtime-restart-proc-net-tcp6.txt"
|
|
probe_and_close_cdp "$second_container"
|
|
assert_processes_return_to_baseline "$second_container" "$second_baseline"
|
|
|
|
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'
|