Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions scripts/apl-feed/config.sh
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,27 @@
# broken installation. systemctl marks the unit failed so the
# failure surfaces in `apl-feed status` and `systemctl status`.

# State writer/reader. config-sync publishes its own runtime state file (the
# same daemon-state pattern feed/mlat/diagnostics use) to record the server's
# ownership verdict and detect the unowned→owned claim edge. Defensive source:
# a missing lib (mid-update transient) degrades to no-op stubs rather than
# breaking the sync. BASH_SOURCE-relative so it resolves in both the source
# tree (scripts/apl-feed/.. -> scripts/lib) and the install layout.
_config_sync_lib_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/../lib"
if [[ -r "$_config_sync_lib_dir/state-writer.sh" ]]; then
# shellcheck source=../lib/state-writer.sh
source "$_config_sync_lib_dir/state-writer.sh"
else
airplanes_write_state() { return 1; }
fi
if [[ -r "$_config_sync_lib_dir/state-reader.sh" ]]; then
# shellcheck source=../lib/state-reader.sh
source "$_config_sync_lib_dir/state-reader.sh"
else
airplanes_read_state() { return 1; }
fi
unset _config_sync_lib_dir

# Legacy fallback tuple matches the migration in
# scripts/lib/update-migrations.sh:migrate_seed_feed_meta_json so a
# feeder whose sidecar is missing or corrupt converges to the server's
Expand Down Expand Up @@ -61,6 +82,12 @@ _config_sync_parse_opt_in() {
# where the unit env isn't present.
CONFIG_SYNC_LAST_SUCCESS_FILE="${AIRPLANES_CONFIG_SYNC_LAST_SUCCESS:-${STATE_DIRECTORY:-/var/lib/airplanes-config-sync}/config-sync-last-success}"

# Owned-state file (same StateDirectory, same override discipline as the
# sentinel). Holds the server's last ownership verdict so the unowned→owned
# claim edge can be detected across ticks. Persistent (not /run) so the edge
# fires only on an actual claim, not on every reboot.
CONFIG_SYNC_STATE_FILE="${AIRPLANES_CONFIG_SYNC_STATE:-${STATE_DIRECTORY:-/var/lib/airplanes-config-sync}/state}"

# Structured logger. Mirrors airplanes-diagnostics.sh's `log` so the two
# timers produce a uniform journal stream.
_config_sync_log() {
Expand Down Expand Up @@ -323,6 +350,51 @@ _config_sync_touch_sentinel() {
: > "$file" 2>/dev/null || true
}

# Nudge one diagnostics push (best-effort, non-blocking) so a just-claimed
# feeder's dashboard shows data within ~60s instead of waiting for the next
# 10-min diagnostics tick. Guards mirror the apply-side service-action skips:
# no host systemctl during chroot/--root or test runs, and respect the
# operator's --no-restart. The diagnostics oneshot self-gates on REPORT_STATUS,
# so a muted feeder makes this a harmless no-op.
_config_sync_trigger_diagnostics_push() {
local skip_restart="$1"
if (( skip_restart )) || [[ "${ROOT:-/}" != "/" ]]; then
return 0
fi
if ! command -v systemctl >/dev/null 2>&1; then
return 0
fi
systemctl start --no-block airplanes-diagnostics.service 2>/dev/null \
|| _config_sync_log warn "reason=diagnostics_trigger_failed"
return 0
}

# Record the server's ownership verdict to the config-sync state file and, on
# the unowned→owned edge (a fresh account claim), nudge a diagnostics push.
# Reads the prior verdict BEFORE writing the new one. Best-effort: a missing /
# unreadable prior reads as "not owned", so at worst one extra (rate-limited)
# push fires; a state-write failure is logged, not fatal.
_config_sync_record_owned() {
local now_owned="$1" skip_restart="$2"
local prior_owned='' write_ok=1
prior_owned="$(airplanes_read_state "$CONFIG_SYNC_STATE_FILE" owned 2>/dev/null || true)"
mkdir -p "$(dirname "$CONFIG_SYNC_STATE_FILE")" 2>/dev/null || true
if ! airplanes_write_state "$CONFIG_SYNC_STATE_FILE" \
service=airplanes-config-sync \
owned="$now_owned" \
decided_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"; then
write_ok=0
_config_sync_log warn "reason=state_write_failed"
fi
# Only fire the edge if the new verdict actually persisted. If the write
# failed we can't record that we fired, so firing would re-trigger on every
# tick — leave it to the regular diagnostics timer instead.
if (( write_ok )) && [[ "$now_owned" == "true" && "$prior_owned" != "true" ]]; then
_config_sync_trigger_diagnostics_push "$skip_restart"
fi
return 0
}

# Translate the server's `fields` response into per-key arguments for
# apl_feed_apply. Populates the caller-passed arrays:
# APL_APPLY_INCOMING_META_EDITED_AT, _EDITED_BY (global, by contract
Expand Down Expand Up @@ -749,10 +821,12 @@ apl_feed_config_sync() {
if _config_sync_apply_response "$response_file" "$skip_restart"; then
_config_sync_touch_sentinel
fi
_config_sync_record_owned true "$skip_restart"
;;
false)
_config_sync_log info "reason=unowned"
_config_sync_touch_sentinel
_config_sync_record_owned false "$skip_restart"
;;
*)
_config_sync_log warn "reason=malformed_response body=$body_preview"
Expand Down
122 changes: 122 additions & 0 deletions test/test_apl_feed_config_sync.bats
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ STUB

AIRPLANES_CONFIG_SYNC_LAST_SUCCESS="$ROOT_DIR/var/lib/airplanes-config-sync/config-sync-last-success"
export AIRPLANES_CONFIG_SYNC_LAST_SUCCESS

AIRPLANES_CONFIG_SYNC_STATE="$ROOT_DIR/var/lib/airplanes-config-sync/state"
export AIRPLANES_CONFIG_SYNC_STATE
}

teardown() {
Expand Down Expand Up @@ -731,3 +734,122 @@ EOF
[ "$status" -eq 0 ]
[[ "$output" != *"/var/lib/airplanes"* ]]
}

# --- Diagnostics push on the unowned→owned claim edge ---------------------

@test "200 owned records owned=true and does not start diagnostics under --root" {
seed_feed_env
seed_feed_meta MLAT_USER "2026-05-10T00:00:00Z"
set_canned 200 '{
"schema_version": 1,
"server_time": "2026-05-14T12:00:00Z",
"owned": true,
"fields": {
"position": {"value": {"lat": 47.0, "lon": 8.0}, "edited_at": "2026-05-10T10:00:00Z", "edited_by": "feeder"},
"alt": {"value": 120, "edited_at": "2026-05-10T10:00:00Z", "edited_by": "feeder"},
"mlat_user": {"value": "alice", "edited_at": "2026-05-10T10:00:00Z", "edited_by": "feeder"},
"mlat_enabled": {"value": true, "edited_at": "2026-05-10T10:00:00Z", "edited_by": "feeder"},
"mlat_private": {"value": false, "edited_at": "2026-05-10T10:00:00Z", "edited_by": "feeder"}
}
}'

run_sync --no-restart

[ "$SYNC_RC" -eq 0 ]
grep -qx 'owned=true' "$AIRPLANES_CONFIG_SYNC_STATE"
# The state was absent, so this is an unowned→owned edge — but the trigger
# must still be suppressed because the sync ran with --root.
if [ -f "$SYSTEMCTL_LOG" ]; then
! grep -F 'airplanes-diagnostics.service' "$SYSTEMCTL_LOG"
fi
}

@test "200 unowned records owned=false in the config-sync state file" {
seed_feed_env
set_canned 200 '{"schema_version":1,"server_time":"2026-05-14T12:00:00Z","owned":false}'

run_sync

[ "$SYNC_RC" -eq 0 ]
grep -qx 'owned=false' "$AIRPLANES_CONFIG_SYNC_STATE"
}

# The edge trigger itself is host-root-only, so exercise it by calling the
# helper directly with ROOT=/ and the setup() systemctl stub on PATH.
_source_config_sh() {
# shellcheck source=../scripts/apl-feed/config.sh
source "$REPO_ROOT/scripts/apl-feed/config.sh"
ROOT="/"
WEBSITE_HOST=""
CONFIG_SYNC_STATE_FILE="$ROOT_DIR/state"
}

@test "edge false->true triggers one diagnostics push" {
_source_config_sh
_config_sync_record_owned false 0
: > "$SYSTEMCTL_LOG"
_config_sync_record_owned true 0
grep -F 'start --no-block airplanes-diagnostics.service' "$SYSTEMCTL_LOG"
grep -qx 'owned=true' "$CONFIG_SYNC_STATE_FILE"
}

@test "no edge true->true does not re-trigger" {
_source_config_sh
_config_sync_record_owned true 0
: > "$SYSTEMCTL_LOG"
_config_sync_record_owned true 0
if [ -f "$SYSTEMCTL_LOG" ]; then
! grep -F 'airplanes-diagnostics.service' "$SYSTEMCTL_LOG"
fi
}

@test "owned=false does not trigger" {
_source_config_sh
: > "$SYSTEMCTL_LOG"
_config_sync_record_owned false 0
if [ -f "$SYSTEMCTL_LOG" ]; then
! grep -F 'airplanes-diagnostics.service' "$SYSTEMCTL_LOG"
fi
}

@test "missing prior state makes the first owned=true an edge" {
_source_config_sh
[ ! -f "$CONFIG_SYNC_STATE_FILE" ]
: > "$SYSTEMCTL_LOG"
_config_sync_record_owned true 0
grep -F 'start --no-block airplanes-diagnostics.service' "$SYSTEMCTL_LOG"
}

@test "--no-restart suppresses the edge trigger" {
_source_config_sh
_config_sync_record_owned false 0
: > "$SYSTEMCTL_LOG"
_config_sync_record_owned true 1
if [ -f "$SYSTEMCTL_LOG" ]; then
! grep -F 'airplanes-diagnostics.service' "$SYSTEMCTL_LOG"
fi
}

@test "non-host root suppresses the edge trigger" {
_source_config_sh
ROOT="$ROOT_DIR"
_config_sync_record_owned false 0
: > "$SYSTEMCTL_LOG"
_config_sync_record_owned true 0
if [ -f "$SYSTEMCTL_LOG" ]; then
! grep -F 'airplanes-diagnostics.service' "$SYSTEMCTL_LOG"
fi
}

@test "systemctl trigger failure is non-fatal and still records owned=true" {
_source_config_sh
cat > "$STUB_DIR/systemctl" <<'STUB'
#!/usr/bin/env bash
exit 1
STUB
chmod +x "$STUB_DIR/systemctl"
_config_sync_record_owned false 0
run _config_sync_record_owned true 0
[ "$status" -eq 0 ]
grep -qx 'owned=true' "$CONFIG_SYNC_STATE_FILE"
}