347 lines
11 KiB
Bash
Executable File
347 lines
11 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -Eeuo pipefail
|
|
|
|
MODE="${1:-quick}"
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
|
export PYTHONPATH="${REPO_ROOT}/src${PYTHONPATH:+:${PYTHONPATH}}"
|
|
ARTIFACT_DIR="${ARTIFACT_DIR:-${REPO_ROOT}/artifacts}"
|
|
mkdir -p "${ARTIFACT_DIR}"
|
|
LOG_FILE="${ARTIFACT_DIR}/preflight-${MODE}.log"
|
|
STATE_FILE="${ARTIFACT_DIR}/five-reboots.jsonl"
|
|
SOAK_FILE="${ARTIFACT_DIR}/soak-30m.json"
|
|
FAILURES=0
|
|
COUCH_USER="couch"
|
|
RUNTIME_CONFIG="/home/couch/.config/couchd/runtime.json"
|
|
|
|
record() {
|
|
local status="$1"
|
|
shift
|
|
printf '%s %s\n' "${status}" "$*" | tee -a "${LOG_FILE}"
|
|
if [[ "${status}" == "FAIL" ]]; then
|
|
FAILURES=$((FAILURES + 1))
|
|
fi
|
|
}
|
|
|
|
run_as_couch() {
|
|
if [[ "${EUID}" -eq 0 ]]; then
|
|
runuser -u "${COUCH_USER}" -- "$@"
|
|
elif [[ "$(id -un)" == "${COUCH_USER}" ]]; then
|
|
"$@"
|
|
else
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
runtime_field() {
|
|
local field="$1"
|
|
python3 - <<'PY' "${RUNTIME_CONFIG}" "${field}"
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
path = Path(sys.argv[1])
|
|
field = sys.argv[2]
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
value = data.get(field)
|
|
if value is None:
|
|
raise SystemExit(1)
|
|
print(value)
|
|
PY
|
|
}
|
|
|
|
controller_snapshot() {
|
|
local address
|
|
address="$(runtime_field xbox_bluetooth_address 2>/dev/null || true)"
|
|
if [[ -z "${address}" ]]; then
|
|
printf '{"address": null, "paired": false, "trusted": false, "connected": false}'
|
|
return 0
|
|
fi
|
|
local output
|
|
output="$(bluetoothctl info "${address}" 2>/dev/null || true)"
|
|
python3 - <<'PY' "${address}" "${output}"
|
|
import json
|
|
import sys
|
|
|
|
address = sys.argv[1]
|
|
text = sys.argv[2]
|
|
print(json.dumps({
|
|
"address": address,
|
|
"paired": "Paired: yes" in text,
|
|
"trusted": "Trusted: yes" in text,
|
|
"connected": "Connected: yes" in text,
|
|
}))
|
|
PY
|
|
}
|
|
|
|
status_json() {
|
|
python3 -m couchd --config "${RUNTIME_CONFIG}" status
|
|
}
|
|
|
|
finish() {
|
|
if [[ "${FAILURES}" -gt 0 ]]; then
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
quick_mode() {
|
|
: >"${LOG_FILE}"
|
|
if id "${COUCH_USER}" >/dev/null 2>&1; then
|
|
record PASS "couch user exists"
|
|
else
|
|
record FAIL "couch user missing"
|
|
fi
|
|
|
|
if [[ "$(stat -c '%a' /home/jackmhny 2>/dev/null || true)" == "700" ]]; then
|
|
record PASS "/home/jackmhny mode is 0700"
|
|
else
|
|
record FAIL "/home/jackmhny mode is not 0700"
|
|
fi
|
|
|
|
if run_as_couch bash -lc 'test ! -r /home/jackmhny && test ! -x /home/jackmhny'; then
|
|
record PASS "couch cannot read or traverse /home/jackmhny"
|
|
else
|
|
record FAIL "couch can read or traverse /home/jackmhny or couch access test could not run"
|
|
fi
|
|
|
|
if id -nG "${COUCH_USER}" 2>/dev/null | tr ' ' '\n' | grep -Eq '^(sudo|adm)$'; then
|
|
record FAIL "couch is still in sudo/adm"
|
|
else
|
|
record PASS "couch is not in sudo/adm"
|
|
fi
|
|
|
|
grep -Fq 'autologin-user=couch' /etc/lightdm/lightdm.conf.d/50-couchos.conf 2>/dev/null && \
|
|
grep -Fq 'autologin-session=xfce' /etc/lightdm/lightdm.conf.d/50-couchos.conf 2>/dev/null \
|
|
&& record PASS "LightDM autologin is set to couch/XFCE" \
|
|
|| record FAIL "LightDM autologin is not set to couch/XFCE"
|
|
|
|
grep -Fq 'HandleLidSwitchExternalPower=ignore' /etc/systemd/logind.conf.d/50-couchos.conf 2>/dev/null \
|
|
&& record PASS "logind ignores lid close on AC" \
|
|
|| record FAIL "logind lid policy on AC is not configured"
|
|
|
|
grep -Fq 'use_compositing" type="bool" value="false' /home/couch/.config/xfce4/xfconf/xfce-perchannel-xml/xfwm4.xml 2>/dev/null \
|
|
&& record PASS "XFCE compositing disabled" \
|
|
|| record FAIL "XFCE compositing not disabled"
|
|
|
|
grep -Fq 'do-not-disturb" type="bool" value="true' /home/couch/.config/xfce4/xfconf/xfce-perchannel-xml/xfce4-notifyd.xml 2>/dev/null \
|
|
&& record PASS "notifications DND enabled" \
|
|
|| record FAIL "notifications DND not enabled"
|
|
|
|
grep -Fq 'dpms-enabled" type="bool" value="false' /home/couch/.config/xfce4/xfconf/xfce-perchannel-xml/xfce4-power-manager.xml 2>/dev/null \
|
|
&& record PASS "power blanking/suspend disabled" \
|
|
|| record FAIL "power blanking/suspend not disabled"
|
|
|
|
if [[ -f "${RUNTIME_CONFIG}" ]]; then
|
|
record PASS "runtime config present"
|
|
else
|
|
record FAIL "runtime config missing"
|
|
fi
|
|
|
|
if [[ -f /home/couch/.config/couchd/token && "$(stat -c '%a' /home/couch/.config/couchd/token)" == "600" ]]; then
|
|
if [[ "$(tr -d '\r\n' </home/couch/.config/couchd/token)" == "replace-with-long-random-token" ]]; then
|
|
record FAIL "token still uses the template placeholder"
|
|
else
|
|
record PASS "token mode is 0600 and not placeholder"
|
|
fi
|
|
else
|
|
record FAIL "token missing or not 0600"
|
|
fi
|
|
|
|
local bind_address
|
|
bind_address="$(runtime_field bind_address 2>/dev/null || true)"
|
|
if [[ "${bind_address}" == "100.64.0.15" ]]; then
|
|
record PASS "runtime bind address is 100.64.0.15"
|
|
else
|
|
record FAIL "runtime bind address is not 100.64.0.15"
|
|
fi
|
|
|
|
if ss -ltn sport = :8765 2>/dev/null | awk '{print $4}' | grep -Fxq '100.64.0.15:8765'; then
|
|
record PASS "couchd listens only on 100.64.0.15:8765"
|
|
else
|
|
record FAIL "couchd listener is not exactly 100.64.0.15:8765"
|
|
fi
|
|
|
|
local controller
|
|
controller="$(controller_snapshot)"
|
|
if python3 - <<'PY' "${controller}"
|
|
import json
|
|
import sys
|
|
|
|
snapshot = json.loads(sys.argv[1])
|
|
if snapshot["address"] and snapshot["paired"] and snapshot["trusted"] and snapshot["connected"]:
|
|
raise SystemExit(0)
|
|
raise SystemExit(1)
|
|
PY
|
|
then
|
|
record PASS "controller is paired/trusted/connected"
|
|
else
|
|
record FAIL "controller is not paired/trusted/connected"
|
|
fi
|
|
|
|
local status_payload
|
|
if status_payload="$(status_json 2>/dev/null)"; then
|
|
record PASS "couchd status command succeeded"
|
|
if python3 - <<'PY' "${status_payload}"
|
|
import json
|
|
import sys
|
|
|
|
payload = json.loads(sys.argv[1])
|
|
mode = payload.get("display", {}).get("mode")
|
|
name = payload.get("display", {}).get("name")
|
|
if name == "HDMI-1" and mode == "1920x1080@60.00":
|
|
raise SystemExit(0)
|
|
raise SystemExit(1)
|
|
PY
|
|
then
|
|
record PASS "HDMI-1 active mode is 1920x1080@60.00"
|
|
else
|
|
record FAIL "HDMI-1 active mode is not 1920x1080@60.00"
|
|
fi
|
|
else
|
|
record FAIL "couchd status command failed"
|
|
fi
|
|
|
|
if pactl info 2>/dev/null | grep -Eiq '^Default Sink: .*hdmi'; then
|
|
record PASS "default audio sink is HDMI"
|
|
else
|
|
record FAIL "default audio sink is not HDMI"
|
|
fi
|
|
|
|
local couch_uid
|
|
couch_uid="$(id -u "${COUCH_USER}" 2>/dev/null || true)"
|
|
if [[ -n "${couch_uid}" ]] && XDG_RUNTIME_DIR="/run/user/${couch_uid}" run_as_couch systemctl --user is-active couchd.service >/dev/null 2>&1; then
|
|
record PASS "couchd user service is active"
|
|
else
|
|
record FAIL "couchd user service is not active"
|
|
fi
|
|
if [[ -f /home/couch/.config/autostart/couchd.desktop && -f /home/couch/.config/autostart/couch-display-audio.desktop ]]; then
|
|
record PASS "XFCE autostart entries exist for couch session units"
|
|
else
|
|
record FAIL "XFCE autostart entries for couch session units are missing"
|
|
fi
|
|
|
|
finish
|
|
}
|
|
|
|
five_reboots_mode() {
|
|
: >"${LOG_FILE}"
|
|
local boot_id
|
|
boot_id="$(cat /proc/sys/kernel/random/boot_id)"
|
|
local controller
|
|
controller="$(controller_snapshot)"
|
|
local status_payload
|
|
status_payload="$(status_json 2>/dev/null || printf '{}')"
|
|
|
|
python3 - <<'PY' "${STATE_FILE}" "${boot_id}" "${controller}" "${status_payload}"
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
path = Path(sys.argv[1])
|
|
boot_id = sys.argv[2]
|
|
controller = json.loads(sys.argv[3])
|
|
status_payload = json.loads(sys.argv[4])
|
|
existing = []
|
|
if path.exists():
|
|
existing = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
|
|
if not any(item.get("boot_id") == boot_id for item in existing):
|
|
existing.append({
|
|
"boot_id": boot_id,
|
|
"timestamp": __import__("datetime").datetime.now().isoformat(timespec="seconds"),
|
|
"controller": controller,
|
|
"display_mode": status_payload.get("display", {}).get("mode"),
|
|
"operator_login_observed": "SKIP",
|
|
"operator_fps_observed": "SKIP",
|
|
})
|
|
path.write_text("\n".join(json.dumps(item, sort_keys=True) for item in existing) + ("\n" if existing else ""), encoding="utf-8")
|
|
print(len(existing))
|
|
PY
|
|
local count
|
|
count="$(python3 - <<'PY' "${STATE_FILE}"
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
path = Path(sys.argv[1])
|
|
print(len([json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]))
|
|
PY
|
|
)"
|
|
record PASS "recorded reboot observation for boot ${boot_id} (${count}/5 unique boots)"
|
|
record SKIP "operator_login_observed remains SKIP until human session confirmation is recorded"
|
|
record SKIP "operator_fps_observed remains SKIP until human 60 FPS confirmation is recorded"
|
|
finish
|
|
}
|
|
|
|
soak_30m_start() {
|
|
: >"${LOG_FILE}"
|
|
python3 - <<'PY' "${SOAK_FILE}" "$(cat /proc/sys/kernel/random/boot_id)" "$(controller_snapshot)"
|
|
import json
|
|
import sys
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
Path(sys.argv[1]).write_text(json.dumps({
|
|
"start_boot_id": sys.argv[2],
|
|
"started_at": datetime.now().isoformat(timespec="seconds"),
|
|
"controller_start": json.loads(sys.argv[3]),
|
|
"operator_fps_observed": "SKIP",
|
|
}, sort_keys=True) + "\n", encoding="utf-8")
|
|
PY
|
|
record PASS "30-minute soak started"
|
|
record SKIP "operator_fps_observed remains SKIP until a human records it after the session"
|
|
finish
|
|
}
|
|
|
|
soak_30m_finish() {
|
|
: >"${LOG_FILE}"
|
|
if [[ ! -f "${SOAK_FILE}" ]]; then
|
|
record FAIL "soak-30m-start was not run"
|
|
finish
|
|
fi
|
|
python3 - <<'PY' "${SOAK_FILE}" "$(cat /proc/sys/kernel/random/boot_id)" "$(controller_snapshot)" "${ARTIFACT_DIR}/soak-30m-finish.json"
|
|
import json
|
|
import sys
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
start = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
|
finished_at = datetime.now()
|
|
started_at = datetime.fromisoformat(start["started_at"])
|
|
elapsed = int((finished_at - started_at).total_seconds())
|
|
payload = {
|
|
**start,
|
|
"finish_boot_id": sys.argv[2],
|
|
"finished_at": finished_at.isoformat(timespec="seconds"),
|
|
"elapsed_seconds": elapsed,
|
|
"controller_finish": json.loads(sys.argv[3]),
|
|
}
|
|
Path(sys.argv[4]).write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
print(elapsed)
|
|
PY
|
|
local elapsed
|
|
elapsed="$(python3 - <<'PY' "${ARTIFACT_DIR}/soak-30m-finish.json"
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
print(json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))["elapsed_seconds"])
|
|
PY
|
|
)"
|
|
if [[ "${elapsed}" -ge 1800 ]]; then
|
|
record PASS "30-minute soak elapsed ${elapsed} seconds"
|
|
else
|
|
record FAIL "30-minute soak only ran ${elapsed} seconds"
|
|
fi
|
|
record SKIP "operator_fps_observed remains SKIP until a human records the observed FPS stability"
|
|
finish
|
|
}
|
|
|
|
case "${MODE}" in
|
|
quick) quick_mode ;;
|
|
five-reboots) five_reboots_mode ;;
|
|
soak-30m-start) soak_30m_start ;;
|
|
soak-30m-finish) soak_30m_finish ;;
|
|
*)
|
|
printf 'unknown mode: %s\n' "${MODE}" >&2
|
|
exit 1
|
|
;;
|
|
esac
|