diff --git a/Cargo.lock b/Cargo.lock index 1f8d318..1090c37 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.22.4" +version = "0.22.5" dependencies = [ "anyhow", "async-stream", @@ -1686,7 +1686,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.22.4" +version = "0.22.5" dependencies = [ "anyhow", "base64", @@ -1698,7 +1698,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.22.4" +version = "0.22.5" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index ab7e23d..264e63f 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.22.4" +version = "0.22.5" edition = "2024" [dependencies] diff --git a/common/Cargo.toml b/common/Cargo.toml index bfcd507..e8bd4db 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.22.4" +version = "0.22.5" edition = "2024" build = "build.rs" diff --git a/scripts/install/server.sh b/scripts/install/server.sh index bf9f5dd..e1ba103 100755 --- a/scripts/install/server.sh +++ b/scripts/install/server.sh @@ -7,11 +7,43 @@ SCRIPT_REPO_ROOT=$(cd -- "$SCRIPT_DIR/../.." && pwd) DEFAULT_REPO_URL=ssh://git@scm.bstein.dev:2242/bstein/lesavka.git export TMPDIR=${TMPDIR:-/var/tmp} +persisted_env_value() { + local file=$1 + local key=$2 + [[ -r $file ]] || return 1 + awk -F= -v key="$key" ' + $0 ~ /^[[:space:]]*#/ { next } + $1 == key { + sub(/^[^=]*=/, "") + print + found=1 + exit + } + END { if (!found) exit 1 } + ' "$file" +} + +persisted_uvc_value() { + persisted_env_value /etc/lesavka/uvc.env "$1" +} + +uvc_env_value() { + local key=$1 + local default=$2 + if [[ -n ${!key+x} ]]; then + printf '%s\n' "${!key}" + return 0 + fi + persisted_uvc_value "$key" && return 0 + printf '%s\n' "$default" +} + REF=${LESAVKA_REF:-master} # fallback REPO_URL=${LESAVKA_REPO_URL:-} INSTALL_SOURCE=${LESAVKA_INSTALL_SOURCE:-auto} USER_HOME=$(getent passwd "$ORIG_USER" | cut -d: -f6) -INSTALL_UVC_CODEC=${LESAVKA_INSTALL_UVC_CODEC:-mjpeg} +PERSISTED_UVC_CODEC=$(persisted_uvc_value LESAVKA_UVC_CODEC || true) +INSTALL_UVC_CODEC=${LESAVKA_INSTALL_UVC_CODEC:-${PERSISTED_UVC_CODEC:-mjpeg}} INSTALL_CAM_CODEC=${LESAVKA_INSTALL_CAM_CODEC:-${LESAVKA_CAM_CODEC:-hevc}} INSTALL_UPLINK_AUDIO_CODEC=${LESAVKA_INSTALL_UPLINK_AUDIO_CODEC:-${LESAVKA_UPLINK_AUDIO_CODEC:-pcm}} INSTALL_UVC_FRAME_META=${LESAVKA_INSTALL_UVC_FRAME_META:-${LESAVKA_UVC_FRAME_META:-0}} @@ -238,19 +270,19 @@ render_uvc_env_file() { cat </dev/null [Unit] Description=lesavka gRPC relay After=network.target lesavka-core.service lesavka-uvc.service -Wants=lesavka-uvc.service StartLimitIntervalSec=30 StartLimitBurst=10 [Service] -ExecStartPre=/usr/local/bin/lesavka-core.sh --attach ExecStart=/usr/local/bin/lesavka-server TimeoutStopSec=10 KillSignal=SIGTERM @@ -1495,12 +1537,12 @@ if [[ -z ${LESAVKA_DISABLE_UVC:-} ]] && ! uvc_gadget_present; then GADGET_REBUILD_REASON="UVC function is missing from the live gadget" echo "⚠️ UVC function is missing from the live gadget; forcing a rebuild before server start." fi -if [[ "$UVC_ENV_CHANGED" == "1" ]] && is_attached_state "$UDC_STATE"; then +if [[ "$UVC_ENV_CHANGED" == "1" ]] && [[ "$HOST_GADGET_PROTECTED" == "1" ]]; then FORCE_GADGET_REBUILD=1 GADGET_REBUILD_REASON="UVC runtime settings changed while the host is attached" echo "⚠️ UVC runtime settings changed while the host is attached; forcing a gadget rebuild so the new descriptors take effect." fi -if [[ "$LIVE_UVC_DESCRIPTOR_MISMATCH" == "1" ]] && is_attached_state "$UDC_STATE"; then +if [[ "$LIVE_UVC_DESCRIPTOR_MISMATCH" == "1" ]] && [[ "$HOST_GADGET_PROTECTED" == "1" ]]; then FORCE_GADGET_REBUILD=1 GADGET_REBUILD_REASON="live UVC descriptor does not match requested codec ${INSTALL_UVC_CODEC}" echo "⚠️ live UVC descriptor does not match requested codec ${INSTALL_UVC_CODEC}; forcing a gadget rebuild so descriptors and runtime agree." @@ -1511,14 +1553,14 @@ if [[ -n ${LESAVKA_FORCE_GADGET_REBUILD:-} ]]; then GADGET_REBUILD_REASON="explicit LESAVKA_FORCE_GADGET_REBUILD request" echo "⚠️ explicit LESAVKA_FORCE_GADGET_REBUILD request; forcing a gadget rebuild before server start." fi -if [[ "$FORCE_GADGET_REBUILD" == "1" ]] && is_attached_state "$UDC_STATE" \ +if [[ "$FORCE_GADGET_REBUILD" == "1" ]] && [[ "$HOST_GADGET_PROTECTED" == "1" ]] \ && { [[ "$EXPLICIT_GADGET_REBUILD" != "1" ]] || [[ -z ${LESAVKA_ALLOW_GADGET_RESET:-} ]]; }; then - echo "⚠️ ${GADGET_REBUILD_REASON:-Gadget state} requires a rebuild, but UDC state is '$UDC_STATE' and attached-host hard reset was not explicitly allowed." >&2 + echo "⚠️ ${GADGET_REBUILD_REASON:-Gadget state} requires a rebuild, but UDC state is '$UDC_STATE' (bound=${LIVE_GADGET_BOUND}) and attached-host hard reset was not explicitly allowed." >&2 echo " Preserving the attached gadget to avoid wedging the Pi USB controller." >&2 echo " Run during a maintenance window with LESAVKA_ALLOW_GADGET_RESET=1 LESAVKA_FORCE_GADGET_REBUILD=1 if a hard rebuild is required." >&2 FORCE_GADGET_REBUILD=0 fi -if [[ "$FORCE_GADGET_REBUILD" == "1" ]] || ! is_attached_state "$UDC_STATE"; then +if [[ "$FORCE_GADGET_REBUILD" == "1" ]] || [[ "$HOST_GADGET_PROTECTED" != "1" ]]; then echo "⚠️ UDC state is '$UDC_STATE' - forcing a Lesavka gadget rebuild before server start." sudo systemctl stop lesavka-server >/dev/null 2>&1 || true sudo systemctl stop lesavka-uvc >/dev/null 2>&1 || true @@ -1539,7 +1581,7 @@ if [[ "$FORCE_GADGET_REBUILD" == "1" ]] || ! is_attached_state "$UDC_STATE"; the elif [[ -n ${LESAVKA_ALLOW_GADGET_RESET:-} ]]; then echo "✅ UDC state is '$UDC_STATE'; LESAVKA_ALLOW_GADGET_RESET permits recovery, but no hard gadget rebuild is needed." else - echo "⚠️ UDC state is '$UDC_STATE' - skipping lesavka-core restart." + echo "⚠️ UDC state is '$UDC_STATE' (bound=${LIVE_GADGET_BOUND}) - skipping lesavka-core restart." echo " Set LESAVKA_ALLOW_GADGET_RESET=1 LESAVKA_FORCE_GADGET_REBUILD=1 to force during a maintenance window." fi @@ -1579,7 +1621,16 @@ sudo rm -f /etc/systemd/system/lesavka-watchdog.timer \ echo "==> 6e. Systemd units - recovery ladder" echo "✅ recovery ladder unit files installed and timer enabled; activation waits for successful service verification." -if [[ "$UVC_ENV_CHANGED" == "1" ]] && systemctl is-active --quiet lesavka-uvc; then +CAN_TOUCH_UVC_SERVICE=1 +if [[ "$HOST_GADGET_PROTECTED" == "1" ]] \ + && { [[ "$EXPLICIT_GADGET_REBUILD" != "1" ]] || [[ -z ${LESAVKA_ALLOW_GADGET_RESET:-} ]]; }; then + CAN_TOUCH_UVC_SERVICE=0 +fi +if [[ "$CAN_TOUCH_UVC_SERVICE" != "1" ]] && systemctl is-active --quiet lesavka-uvc; then + echo "✅ lesavka-uvc already active; preserving it during attached-gadget version update." +elif [[ "$CAN_TOUCH_UVC_SERVICE" != "1" ]]; then + echo "⚠️ lesavka-uvc is inactive, but the host/gadget is protected; not starting it during a version-only install." >&2 +elif [[ "$UVC_ENV_CHANGED" == "1" ]] && systemctl is-active --quiet lesavka-uvc; then sudo systemctl restart lesavka-uvc echo "✅ lesavka-uvc restarted with the refreshed UVC runtime settings." elif systemctl is-active --quiet lesavka-uvc; then diff --git a/server/Cargo.toml b/server/Cargo.toml index bd85bd5..3b4e4cb 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.22.4" +version = "0.22.5" edition = "2024" autobins = false diff --git a/tests/installer/scripts/install/server_install_script_contract.rs b/tests/installer/scripts/install/server_install_script_contract.rs index 408ef9e..71a5940 100644 --- a/tests/installer/scripts/install/server_install_script_contract.rs +++ b/tests/installer/scripts/install/server_install_script_contract.rs @@ -61,7 +61,12 @@ fn server_install_pins_hdmi_camera_and_display_defaults() { SERVER_INSTALL .contains("${LESAVKA_INSTALL_UPLINK_AUDIO_CODEC:-${LESAVKA_UPLINK_AUDIO_CODEC:-pcm}}") ); - assert!(SERVER_INSTALL.contains("${LESAVKA_INSTALL_UVC_CODEC:-mjpeg}")); + assert!( + SERVER_INSTALL.contains( + "INSTALL_UVC_CODEC=${LESAVKA_INSTALL_UVC_CODEC:-${PERSISTED_UVC_CODEC:-mjpeg}}" + ), + "plain upgrade installs should preserve the already-installed UVC codec" + ); assert!( SERVER_INSTALL.contains("${LESAVKA_INSTALL_UVC_FRAME_META:-${LESAVKA_UVC_FRAME_META:-0}}") ); @@ -145,11 +150,11 @@ fn server_install_pins_hdmi_camera_and_display_defaults() { "installer should persist HEVC-specific calibration maps" ); assert!(SERVER_INSTALL.contains("${LESAVKA_INSTALL_SERVER_BIND_ADDR:-0.0.0.0:50051}")); - assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_MAXPACKET:-1024}")); - assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_INTERVAL:-333333}")); - assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_WIDTH:-1280}")); - assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_HEIGHT:-720}")); - assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_CONTROL_READ_ONLY:-0}")); + assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_MAXPACKET 1024")); + assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_INTERVAL 333333")); + assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_WIDTH 1280")); + assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_HEIGHT 720")); + assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_CONTROL_READ_ONLY 0")); assert!( !SERVER_INSTALL.contains("LESAVKA_UVC_CODEC=${LESAVKA_UVC_CODEC:-mjpeg}"), "install script should not let ambient LESAVKA_UVC_CODEC leak into persisted defaults" @@ -239,6 +244,12 @@ fn server_install_pins_hdmi_camera_and_display_defaults() { SERVER_INSTALL.contains("LIVE_UVC_FUNCTION_MISSING"), "installer should not rebuild a missing attached UVC function without explicit maintenance-window force" ); + assert!( + SERVER_INSTALL.contains("live_gadget_bound") + && SERVER_INSTALL.contains("HOST_GADGET_PROTECTED=1") + && SERVER_INSTALL.contains("bound=${LIVE_GADGET_BOUND}"), + "installer should protect a bound gadget even when the UDC state is ambiguous" + ); assert!( SERVER_INSTALL.contains("ATTACHED_UVC_RESTART_DEFERRED=1"), "attached UVC env changes that match the live descriptor should still defer service restarts" @@ -338,6 +349,12 @@ fn server_install_pins_hdmi_camera_and_display_defaults() { SERVER_INSTALL.contains("lesavka-uvc already active; runtime settings unchanged."), "install script should avoid unnecessary UVC restarts when nothing changed" ); + assert!( + SERVER_INSTALL.contains("CAN_TOUCH_UVC_SERVICE=0") + && SERVER_INSTALL.contains("preserving it during attached-gadget version update") + && SERVER_INSTALL.contains("not starting it during a version-only install"), + "ordinary attached-gadget version updates must not start or restart the UVC helper" + ); assert!( SERVER_INSTALL.contains("sudo systemctl start lesavka-uvc"), "install script should start the UVC helper so the host enumerates the UVC function" @@ -347,8 +364,9 @@ fn server_install_pins_hdmi_camera_and_display_defaults() { "install script should report when it starts the UVC helper for enumeration" ); assert!( - SERVER_INSTALL.contains("Wants=lesavka-uvc.service"), - "server unit should pull in the external UVC helper on UVC installs" + !SERVER_INSTALL.contains("Wants=lesavka-uvc.service") + && !SERVER_INSTALL.contains("ExecStartPre=/usr/local/bin/lesavka-core.sh --attach"), + "server restarts during ordinary installs must not implicitly start UVC or touch the gadget" ); assert!( !SERVER_INSTALL.contains("Environment=LESAVKA_ALLOW_GADGET_CYCLE=1"), diff --git a/tests/regression/install/install_preserves_codec_settings_contract.rs b/tests/regression/install/install_preserves_codec_settings_contract.rs index 577668e..d2a801e 100644 --- a/tests/regression/install/install_preserves_codec_settings_contract.rs +++ b/tests/regression/install/install_preserves_codec_settings_contract.rs @@ -18,11 +18,13 @@ const CLIENT_CAMERA: &str = include_str!(concat!( #[test] fn server_install_defaults_to_hevc_ingress_and_mjpeg_uvc_output() { for marker in [ - "INSTALL_UVC_CODEC=${LESAVKA_INSTALL_UVC_CODEC:-mjpeg}", + "PERSISTED_UVC_CODEC=$(persisted_uvc_value LESAVKA_UVC_CODEC || true)", + "INSTALL_UVC_CODEC=${LESAVKA_INSTALL_UVC_CODEC:-${PERSISTED_UVC_CODEC:-mjpeg}}", "INSTALL_CAM_CODEC=${LESAVKA_INSTALL_CAM_CODEC:-${LESAVKA_CAM_CODEC:-hevc}}", "printf 'LESAVKA_CAM_CODEC=%s\\n' \"${INSTALL_CAM_CODEC}\"", "printf 'LESAVKA_UVC_CODEC=%s\\n' \"${INSTALL_UVC_CODEC}\"", "\"LESAVKA_UVC_CODEC=${INSTALL_UVC_CODEC}\"", + "uvc_env_value LESAVKA_UVC_WIDTH 1280", ] { assert!( SERVER_INSTALL.contains(marker), diff --git a/tests/system/scripts/install/systemd_unit_env_contract.rs b/tests/system/scripts/install/systemd_unit_env_contract.rs index a940f16..f42ff38 100644 --- a/tests/system/scripts/install/systemd_unit_env_contract.rs +++ b/tests/system/scripts/install/systemd_unit_env_contract.rs @@ -16,10 +16,8 @@ fn generated_systemd_units_load_runtime_env_files_in_the_right_places() { for marker in [ "EnvironmentFile=-/etc/lesavka/server.env", "EnvironmentFile=-/etc/lesavka/uvc.env", - "ExecStartPre=/usr/local/bin/lesavka-core.sh --attach", "ExecStart=/usr/local/bin/lesavka-server", "ExecStart=/usr/local/bin/lesavka-uvc.sh", - "Wants=lesavka-uvc.service", "Requires=lesavka-core.service", ] { assert!( @@ -27,6 +25,15 @@ fn generated_systemd_units_load_runtime_env_files_in_the_right_places() { "generated units should preserve marker {marker}" ); } + for marker in [ + "ExecStartPre=/usr/local/bin/lesavka-core.sh --attach", + "Wants=lesavka-uvc.service", + ] { + assert!( + !SERVER_INSTALL.contains(marker), + "server restarts should not implicitly touch gadget/UVC setup through {marker}" + ); + } } #[test]