diff --git a/runtime-overlay/manifest-inputs/managed_paths.json b/runtime-overlay/manifest-inputs/managed_paths.json index b8534f945c..6277bc3c4b 100644 --- a/runtime-overlay/manifest-inputs/managed_paths.json +++ b/runtime-overlay/manifest-inputs/managed_paths.json @@ -247,6 +247,11 @@ "link": "/usr/local/share/airplanes/airplanes-diagnostics.sh", "target": "/opt/airplanes-runtime/current/share/airplanes/airplanes-diagnostics.sh" }, + { + "mode": "symlink", + "link": "/usr/local/share/airplanes/airplanes-stats.sh", + "target": "/opt/airplanes-runtime/current/share/airplanes/airplanes-stats.sh" + }, { "mode": "symlink", "link": "/usr/local/share/airplanes/apl-feed", @@ -291,5 +296,15 @@ "mode": "symlink", "link": "/etc/systemd/system/airplanes-config-sync.timer", "target": "/opt/airplanes-runtime/current/systemd/airplanes-config-sync.timer" + }, + { + "mode": "symlink", + "link": "/etc/systemd/system/airplanes-stats.service", + "target": "/opt/airplanes-runtime/current/systemd/airplanes-stats.service" + }, + { + "mode": "symlink", + "link": "/etc/systemd/system/airplanes-stats.timer", + "target": "/opt/airplanes-runtime/current/systemd/airplanes-stats.timer" } ] diff --git a/runtime-overlay/manifest-inputs/systemd.json b/runtime-overlay/manifest-inputs/systemd.json index ea44a223ef..c4fa9ed05e 100644 --- a/runtime-overlay/manifest-inputs/systemd.json +++ b/runtime-overlay/manifest-inputs/systemd.json @@ -13,7 +13,8 @@ "airplanes-feed.service", "airplanes-mlat.service", "airplanes-diagnostics.timer", - "airplanes-config-sync.timer" + "airplanes-config-sync.timer", + "airplanes-stats.timer" ], "disable": [], "reload_or_restart": [ diff --git a/runtime-overlay/scripts/gates/exec-bit-check.sh b/runtime-overlay/scripts/gates/exec-bit-check.sh index d7329bb32c..ef98035b6c 100755 --- a/runtime-overlay/scripts/gates/exec-bit-check.sh +++ b/runtime-overlay/scripts/gates/exec-bit-check.sh @@ -212,21 +212,46 @@ if [[ -d "$systemd_dir" ]]; then raw="${raw:1}" done bin_path="${raw%% *}" - [[ "$bin_path" == /* ]] || continue - - rel="" - if [[ "$bin_path" == "$CURRENT_PREFIX"* ]]; then - rel="${bin_path#"$CURRENT_PREFIX"}" - elif [[ -n "${managed_link_to_release_local[$bin_path]:-}" ]]; then - rel="${managed_link_to_release_local[$bin_path]}" - else - # Host-installed binary; out of scope. - continue + + # (1) argv[0] exec-bit / shebang shape check, when the executable + # resolves into the release tree (directly under the release + # prefix or via a managed_paths symlink). A host binary + # (/usr/bin/..., /bin/...) or a path under a managed directory + # symlink resolves to nothing here and is left to the host — its + # shape is out of scope. + if [[ "$bin_path" == /* ]]; then + rel="" + if [[ "$bin_path" == "$CURRENT_PREFIX"* ]]; then + rel="${bin_path#"$CURRENT_PREFIX"}" + elif [[ -n "${managed_link_to_release_local[$bin_path]:-}" ]]; then + rel="${managed_link_to_release_local[$bin_path]}" + fi + if [[ -n "$rel" ]] && ! _assert_exec_file "$rel" "$unit_base Exec=$bin_path"; then + fail_count=$((fail_count + 1)) + fi fi - if ! _assert_exec_file "$rel" "$unit_base Exec=$bin_path"; then + # (2) Every absolute path in the Exec line that is a DIRECT child of + # the overlay-managed runtime dir must have a managed_paths + # symlink. Scan the whole line, not just argv[0], so an + # interpreter form (`/bin/bash /usr/local/share/airplanes/foo.sh`) + # is covered as well as the direct form. A missing managed link + # means the overlay ships a unit referencing a runtime script it + # doesn't manage — exactly the airplanes-stats gap: the + # .timer/.service were globbed into the overlay but + # airplanes-stats.sh had no managed_paths row and was never + # staged, so a feeder updating to it got a timer pointing at a + # missing ExecStart. Deeper paths (venv/bin/..., apl-feed/*, + # lib/*) are provided wholesale by a managed *directory* symlink + # and carry a further slash, so they're excluded by design. + read -ra _exec_tokens <<< "$raw" + for tok in "${_exec_tokens[@]}"; do + [[ "$tok" == /usr/local/share/airplanes/* \ + && "$tok" != /usr/local/share/airplanes/*/* ]] || continue + [[ -n "${managed_link_to_release_local[$tok]:-}" ]] && continue + echo "exec-bit-check: $unit_base Exec references unmanaged runtime script: $tok — add a managed_paths symlink for it" >&2 fail_count=$((fail_count + 1)) - fi + done done < <(grep -hE '^[[:space:]]*Exec(Start|StartPre|StartPost|Stop|StopPost|Reload)=' \ "$unit" 2>/dev/null || true) done diff --git a/runtime-overlay/scripts/lib/stage-feed.sh b/runtime-overlay/scripts/lib/stage-feed.sh index 42fd563e03..d5c97ef683 100644 --- a/runtime-overlay/scripts/lib/stage-feed.sh +++ b/runtime-overlay/scripts/lib/stage-feed.sh @@ -169,11 +169,24 @@ fetch_repo "$FEED_SRC" "$FEED_REPO" "$FEED_REF" FEED_SHA="$(git -C "$FEED_SRC" rev-parse HEAD)" # Daemon wrappers → share/airplanes/ +# +# Glob feed/scripts/airplanes-*.sh, mirroring the unit glob below, so a wrapper +# added in feed lands on the image automatically. The previous hand-listed +# allowlist (airplanes-feed/mlat/diagnostics.sh) silently dropped +# airplanes-stats.sh when feed added it — the overlay shipped airplanes-stats.timer +# (globbed) pointing at a script that wasn't staged. apl-feed.sh is NOT matched +# (apl-* prefix) and is staged to bin/ below; the runtime libs under +# scripts/lib/ stay an explicit curated subset (install/update-only libs are +# deliberately excluded — see below). install -d -m 0755 "$OUTPUT_DIR/share/airplanes" -for wrapper in airplanes-feed.sh airplanes-mlat.sh airplanes-diagnostics.sh; do - if [[ -f "$FEED_SRC/scripts/$wrapper" ]]; then - install -m 0755 "$FEED_SRC/scripts/$wrapper" "$OUTPUT_DIR/share/airplanes/$wrapper" - fi +shopt -s nullglob +feed_wrappers=("$FEED_SRC"/scripts/airplanes-*.sh) +shopt -u nullglob +if [[ ${#feed_wrappers[@]} -eq 0 ]]; then + die "no airplanes-*.sh daemon wrappers found in $FEED_SRC/scripts/" +fi +for wrapper in "${feed_wrappers[@]}"; do + install -m 0755 "$wrapper" "$OUTPUT_DIR/share/airplanes/$(basename "$wrapper")" done # apl-feed CLI entry point → bin/ diff --git a/test/runtime-overlay/test_stage_feed_unit_symmetry.bats b/test/runtime-overlay/test_stage_feed_unit_symmetry.bats index 6a4b9d7976..414d0a5797 100644 --- a/test/runtime-overlay/test_stage_feed_unit_symmetry.bats +++ b/test/runtime-overlay/test_stage_feed_unit_symmetry.bats @@ -68,6 +68,23 @@ _feed_units() { done | LC_ALL=C sort } +# Collect every airplanes-*.sh daemon wrapper feed ships from scripts/ — the +# top-level wrappers stage-feed.sh installs into share/airplanes/. Excludes +# apl-feed.sh (apl-* CLI entry point → bin/) and the curated scripts/lib/ +# runtime libs. Empty result in a populated checkout is structural breakage. +_feed_wrappers() { + local f + shopt -s nullglob + local -a wrappers=("$FEED_SRC"/scripts/airplanes-*.sh) + shopt -u nullglob + if (( ${#wrappers[@]} == 0 )); then + return 1 + fi + for f in "${wrappers[@]}"; do + basename "$f" + done | LC_ALL=C sort +} + # A unit "needs enabling" iff it carries an [Install] section that wires it # into a target at boot. Oneshot timer-driven services carry no [Install] — # the timer's `Unit=` directive activates them — and must stay out of the @@ -105,6 +122,32 @@ _unit_needs_enable() { fi } +@test "every feed airplanes-*.sh wrapper is symlinked by managed_paths.json" { + local wrappers_out + wrappers_out="$(_feed_wrappers)" \ + || { echo "no airplanes-*.sh wrappers in $FEED_SRC/scripts/ — feed restructure?" >&2; return 1; } + mapfile -t wrappers <<< "$wrappers_out" + + local missing=() + local wrapper expected_link expected_target + for wrapper in "${wrappers[@]}"; do + expected_link="/usr/local/share/airplanes/$wrapper" + expected_target="/opt/airplanes-runtime/current/share/airplanes/$wrapper" + if ! jq -e --arg link "$expected_link" --arg target "$expected_target" ' + any(.[]; .mode == "symlink" and .link == $link and .target == $target) + ' "$MANAGED_PATHS_JSON" >/dev/null; then + missing+=("$wrapper") + fi + done + + if (( ${#missing[@]} > 0 )); then + printf 'managed_paths.json missing symlink for feed wrapper: %s\n' "${missing[@]}" >&2 + echo "expected entry shape:" >&2 + echo ' { "mode": "symlink", "link": "/usr/local/share/airplanes/.sh", "target": "/opt/airplanes-runtime/current/share/airplanes/.sh" }' >&2 + return 1 + fi +} + @test "systemd.json enable matches feed units with [Install] sections" { local units_out units_out="$(_feed_units)" \