#!/usr/bin/env bash set -euo pipefail # Observe a Docker-managed soak from the host without joining its compose project. # The verifier binary must be copied byte-for-byte from the running runtime image; # validation-contract.json rejects a different binary SHA-256. usage() { cat <<'USAGE' Usage: observe_remote231_verification_only.sh \ --runs-root \ --state-root \ --rpki-bin \ --work-root \ --webhook-url-file <0600_file> \ [--target-runs 20] [--delay-secs 30] [--poll-secs 5] \ [--verification-timeout-secs 480] [--normal-interval-secs 600] \ [--normal-run-safety-secs 60] [--verification-object-workers 4] \ [--verification-child-path ] This is a host-side, finite observer. On its first start it records the latest already-terminal normal run and observes only later terminal runs. For each one it waits DELAY seconds, then uses a non-blocking state lock. It never waits for or blocks the Docker soak. Successful normal runs are verified with a copied host-side rpki binary; failed, stale, or lock-busy runs are recorded as skipped. The verification timeout is additionally bounded by the source run completion time plus NORMAL_INTERVAL_SECS minus NORMAL_RUN_SAFETY_SECS. This preserves a normal-run safety window even after an observer restart or delayed scan. Only compact JSON results are retained under WORK_ROOT/results/. The large verification output and any retained scratch DB are always deleted afterwards. When --verification-child-path is supplied, it replaces PATH only for the verification binary. Passing a directory without rsync (for example /nonexistent) is an adversarial acceptance check: an accidental SystemRsync fallback must fail instead of accessing the network. USAGE } RUNS_ROOT="" STATE_ROOT="" RPKI_BIN="" WORK_ROOT="" WEBHOOK_URL_FILE="" TARGET_RUNS=20 DELAY_SECS=30 POLL_SECS=5 VERIFICATION_TIMEOUT_SECS=480 NORMAL_INTERVAL_SECS=600 NORMAL_RUN_SAFETY_SECS=60 VERIFICATION_OBJECT_WORKERS=4 VERIFICATION_CHILD_PATH="" while [[ $# -gt 0 ]]; do case "$1" in --runs-root) RUNS_ROOT="${2:?missing value}"; shift 2 ;; --state-root) STATE_ROOT="${2:?missing value}"; shift 2 ;; --rpki-bin) RPKI_BIN="${2:?missing value}"; shift 2 ;; --work-root) WORK_ROOT="${2:?missing value}"; shift 2 ;; --webhook-url-file) WEBHOOK_URL_FILE="${2:?missing value}"; shift 2 ;; --target-runs) TARGET_RUNS="${2:?missing value}"; shift 2 ;; --delay-secs) DELAY_SECS="${2:?missing value}"; shift 2 ;; --poll-secs) POLL_SECS="${2:?missing value}"; shift 2 ;; --verification-timeout-secs) VERIFICATION_TIMEOUT_SECS="${2:?missing value}"; shift 2 ;; --normal-interval-secs) NORMAL_INTERVAL_SECS="${2:?missing value}"; shift 2 ;; --normal-run-safety-secs) NORMAL_RUN_SAFETY_SECS="${2:?missing value}"; shift 2 ;; --verification-object-workers) VERIFICATION_OBJECT_WORKERS="${2:?missing value}"; shift 2 ;; --verification-child-path) VERIFICATION_CHILD_PATH="${2:?missing value}"; shift 2 ;; -h|--help) usage; exit 0 ;; *) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;; esac done for value_name in RUNS_ROOT STATE_ROOT RPKI_BIN WORK_ROOT WEBHOOK_URL_FILE; do [[ -n "${!value_name}" ]] || { echo "$value_name is required" >&2; exit 2; } done for integer_name in TARGET_RUNS DELAY_SECS POLL_SECS VERIFICATION_TIMEOUT_SECS NORMAL_INTERVAL_SECS VERIFICATION_OBJECT_WORKERS; do [[ "${!integer_name}" =~ ^[0-9]+$ ]] && [[ "${!integer_name}" != 0 ]] || { echo "$integer_name must be a positive integer" >&2 exit 2 } done [[ "$NORMAL_RUN_SAFETY_SECS" =~ ^[0-9]+$ ]] || { echo "NORMAL_RUN_SAFETY_SECS must be a non-negative integer" >&2 exit 2 } (( NORMAL_RUN_SAFETY_SECS < NORMAL_INTERVAL_SECS )) || { echo "NORMAL_RUN_SAFETY_SECS must be smaller than NORMAL_INTERVAL_SECS" >&2 exit 2 } [[ -d "$RUNS_ROOT" ]] || { echo "runs root is not a directory: $RUNS_ROOT" >&2; exit 2; } [[ -d "$STATE_ROOT" ]] || { echo "state root is not a directory: $STATE_ROOT" >&2; exit 2; } [[ -x "$RPKI_BIN" ]] || { echo "rpki binary is not executable: $RPKI_BIN" >&2; exit 2; } [[ -f "$WEBHOOK_URL_FILE" ]] || { echo "webhook URL file is absent: $WEBHOOK_URL_FILE" >&2; exit 2; } for required_command in curl flock ionice jq nice python3 sha256sum timeout; do command -v "$required_command" >/dev/null || { echo "missing required command: $required_command" >&2 exit 2 } done mkdir -p "$WORK_ROOT/results" "$WORK_ROOT/scratch" STATE_FILE="$WORK_ROOT/observer-state.json" OBSERVER_LOCK="$WORK_ROOT/observer.lock" RESULTS_DIR="$WORK_ROOT/results" SCRATCH_ROOT="$WORK_ROOT/scratch" WEBHOOK_URL="$(tr -d '\r\n' < "$WEBHOOK_URL_FILE")" [[ "$WEBHOOK_URL" == https://open.feishu.cn/open-apis/bot/v2/hook/* ]] || { echo "webhook URL does not have the expected Feishu incoming-webhook form" >&2 exit 2 } now_utc() { date -u +%Y-%m-%dT%H:%M:%SZ; } now_ms() { python3 -c 'import time; print(time.time_ns() // 1_000_000)'; } remaining_verification_window_secs() { local completed_at="$1" python3 - "$completed_at" "$NORMAL_INTERVAL_SECS" "$NORMAL_RUN_SAFETY_SECS" <<'PY' import datetime import sys import time completed = datetime.datetime.fromisoformat(sys.argv[1].replace("Z", "+00:00")) interval = int(sys.argv[2]) safety = int(sys.argv[3]) print(int(completed.timestamp()) + interval - safety - int(time.time())) PY } atomic_replace() { local destination="$1" local temporary="${destination}.tmp.$$" cat > "$temporary" mv "$temporary" "$destination" } terminal_run_ids() { local run_dir meta for run_dir in "$RUNS_ROOT"/run_[0-9]*; do [[ -d "$run_dir" ]] || continue meta="$run_dir/run-meta.json" [[ -f "$meta" ]] || continue jq -e '(.completed_at_rfc3339_utc? // .completedAt? // null) != null' "$meta" >/dev/null 2>&1 || continue basename "$run_dir" done | sort -t_ -k2,2n } run_number() { local run_id="$1" printf '%d\n' "$((10#${run_id#run_}))" } latest_terminal_run_id() { terminal_run_ids | tail -n 1 } result_path() { printf '%s/%s.json\n' "$RESULTS_DIR" "$1" } read_json_or_empty() { local path="$1" if [[ -f "$path" ]] && jq -e . "$path" >/dev/null 2>&1; then jq -c . "$path" else printf '{}' fi } update_cumulative() { local observed normal_success passes mismatches errors skipped pending latest local -a result_files shopt -s nullglob result_files=("$RESULTS_DIR"/run_*.json) shopt -u nullglob observed="${#result_files[@]}" if (( observed == 0 )); then normal_success=0 passes=0 mismatches=0 errors=0 skipped=0 pending=0 else normal_success="$(jq -r 'select(.normalRun.status == "success") | 1' "${result_files[@]}" | wc -l)" passes="$(jq -r 'select(.verification.status == "pass") | 1' "${result_files[@]}" | wc -l)" mismatches="$(jq -r 'select(.verification.status == "mismatch") | 1' "${result_files[@]}" | wc -l)" errors="$(jq -r 'select(.verification.status == "error") | 1' "${result_files[@]}" | wc -l)" skipped="$(jq -r 'select(.verification.status | startswith("skipped_")) | 1' "${result_files[@]}" | wc -l)" pending="$(jq -r 'select(.notification.status != "sent") | 1' "${result_files[@]}" | wc -l)" fi latest="$(find "$RESULTS_DIR" -maxdepth 1 -type f -name 'run_*.json' -printf '%f\n' | sed 's/.json$//' | sort -t_ -k2,2n | tail -n 1)" jq -n \ --arg updated_at "$(now_utc)" \ --arg initial_run_id "$(jq -r '.initialLatestTerminalRunId' "$STATE_FILE")" \ --arg latest_run_id "$latest" \ --argjson target_runs "$TARGET_RUNS" \ --argjson observed_runs "$observed" \ --argjson normal_success_runs "$normal_success" \ --argjson verification_passes "$passes" \ --argjson verification_mismatches "$mismatches" \ --argjson verification_errors "$errors" \ --argjson verification_skipped "$skipped" \ --argjson notification_pending "$pending" \ '{schemaVersion: 1, updatedAt: $updated_at, initialLatestTerminalRunId: $initial_run_id, latestProcessedRunId: $latest_run_id, targetRuns: $target_runs, observedRuns: $observed_runs, normalSuccessRuns: $normal_success_runs, verificationPasses: $verification_passes, verificationMismatches: $verification_mismatches, verificationErrors: $verification_errors, verificationSkipped: $verification_skipped, notificationPending: $notification_pending}' \ | atomic_replace "$RESULTS_DIR/cumulative.json" } notify_result() { local path="$1" run_id status observed target passes mismatches errors skipped analysis_available top_phase message payload response run_id="$(jq -r '.runId' "$path")" status="$(jq -r '.verification.status' "$path")" observed="$(jq -r '.observedRuns' "$RESULTS_DIR/cumulative.json")" target="$(jq -r '.targetRuns' "$RESULTS_DIR/cumulative.json")" passes="$(jq -r '.verificationPasses' "$RESULTS_DIR/cumulative.json")" mismatches="$(jq -r '.verificationMismatches' "$RESULTS_DIR/cumulative.json")" errors="$(jq -r '.verificationErrors' "$RESULTS_DIR/cumulative.json")" skipped="$(jq -r '.verificationSkipped' "$RESULTS_DIR/cumulative.json")" analysis_available="$(jq -r '.verification.analysis.available // false' "$path")" top_phase="$(jq -r '(.verification.analysis.phaseTotals // [])[0] | if . == null then "n/a" else "\(.name)=\(.totalMs)ms" end' "$path")" message="From codex: #137 远端231 verification-only 旁路复核\n${run_id}: ${status}\n" message+="CCR state digest match=$(jq -r 'if .verification.comparison | has("stateDigestMatch") then (.verification.comparison.stateDigestMatch | tostring) else "n/a" end' "$path"); fallback replay match=$(jq -r 'if .verification.comparison.fallbackReplaySelection | has("matches") then (.verification.comparison.fallbackReplaySelection.matches | tostring) else "n/a" end' "$path"); verificationProcessMs=$(jq -r '.verification.timing.verificationProcessMs // .verification.wallMs // "n/a"' "$path"); preValidationPreparationMs=$(jq -r '.verification.timing.preValidationPreparationMs // "n/a"' "$path"); lockWaitMs=$(jq -r '.verification.lockWaitMs // "n/a"' "$path")\n" message+="analysisAvailable=${analysis_available}; topPhase=${top_phase}\n" message+="累计 ${observed}/${target}: pass=${passes}, mismatch=${mismatches}, error=${errors}, skipped=${skipped}\n" message+="结果: ${path}" payload="$(jq -n --arg text "$message" '{msg_type: "text", content: {text: $text}}')" if response="$(curl --fail-with-body --silent --show-error --connect-timeout 10 --max-time 30 \ -H 'Content-Type: application/json' --data "$payload" "$WEBHOOK_URL" 2>&1)"; then if jq -e '((.code // .StatusCode // 0) == 0)' <<< "$response" >/dev/null 2>&1; then jq --arg sent_at "$(now_utc)" --arg response "$response" \ '.notification = {status: "sent", sentAt: $sent_at, response: $response}' "$path" \ | atomic_replace "$path" return 0 fi response="Feishu rejected payload: $response" fi jq --arg attempted_at "$(now_utc)" --arg error "$response" \ '.notification = {status: "pending", lastAttemptAt: $attempted_at, lastError: $error}' "$path" \ | atomic_replace "$path" return 1 } deliver_pending_notifications() { local path for path in "$RESULTS_DIR"/run_*.json; do [[ -f "$path" ]] || continue [[ "$(jq -r '.notification.status // "pending"' "$path")" == "sent" ]] && continue update_cumulative notify_result "$path" || true done update_cumulative } write_result() { local run_id="$1" normal_json="$2" verification_json="$3" result result="$(result_path "$run_id")" jq -n \ --arg recorded_at "$(now_utc)" \ --arg run_id "$run_id" \ --argjson normal_run "$normal_json" \ --argjson verification "$verification_json" \ '{schemaVersion: 1, recordedAt: $recorded_at, runId: $run_id, normalRun: $normal_run, verification: $verification, notification: {status: "pending"}}' \ | atomic_replace "$result" update_cumulative notify_result "$result" || true update_cumulative } normal_run_summary() { local run_dir="$1" if [[ ! -f "$run_dir/run-meta.json" ]]; then printf '{"status":"invalid-meta"}' return fi jq -cn --slurpfile meta "$run_dir/run-meta.json" --slurpfile summary "$run_dir/run-summary.json" \ '{status: ($meta[0].status // "unknown"), syncMode: ($meta[0].sync_mode // $meta[0].syncMode // "unknown"), runIndex: ($meta[0].run_index // $meta[0].runIndex // null), completedAt: ($meta[0].completed_at_rfc3339_utc // $meta[0].completedAt // null), wallMs: ($summary[0].wall_ms // $summary[0].wallMs // null), exitCode: ($summary[0].exit_code // $summary[0].exitCode // $meta[0].daemon_exit_code // null)}' 2>/dev/null \ || printf '{"status":"invalid-meta"}' } process_terminal_run() { local run_id="$1" run_dir="$RUNS_ROOT/$run_id" normal_json status temporary_dir state_lock_fd lock_started_ms lock_acquired_ms local binding_run_id binary_sha source_sha run_started_ms run_finished_ms exit_code comparison_json comparison_state_digest_match fallback_replay_comparison_json fallback_replay_selection_match verification_meta_json stage_timing_json analysis_json analysis_summary_json error_tail max_rss_kb verification_status verification_json timeout_secs completed_at local -a verification_child_exec local observer_started_ms delay_finished_ms preparation_started_ms binding_started_ms binding_finished_ms binary_check_started_ms binary_check_finished_ms window_started_ms window_finished_ms scratch_cleanup_started_ms scratch_cleanup_finished_ms post_validation_started_ms post_validation_collection_finished_ms result_cleanup_started_ms result_cleanup_finished_ms normal_json="$(normal_run_summary "$run_dir")" status="$(jq -r '.status' <<< "$normal_json")" observer_started_ms="$(now_ms)" sleep "$DELAY_SECS" delay_finished_ms="$(now_ms)" if [[ "$status" != "success" ]]; then write_result "$run_id" "$normal_json" '{"status":"skipped_normal_run_not_success"}' return fi preparation_started_ms="$delay_finished_ms" mkdir -p "$STATE_ROOT/locks" exec {state_lock_fd}>"$STATE_ROOT/locks/state-execution.lock" lock_started_ms="$(now_ms)" if ! flock -n "$state_lock_fd"; then exec {state_lock_fd}>&- write_result "$run_id" "$normal_json" '{"status":"skipped_state_lock_busy"}' return fi lock_acquired_ms="$(now_ms)" binding_started_ms="$(now_ms)" binding_run_id="$(jq -r '.currentRunId // empty' "$STATE_ROOT/meta/current-state-binding.json" 2>/dev/null || true)" binding_finished_ms="$(now_ms)" if [[ "$binding_run_id" != "$run_id" ]]; then flock -u "$state_lock_fd" exec {state_lock_fd}>&- verification_json="$(jq -n --arg bound_run "$binding_run_id" --argjson lock_wait_ms "$((lock_acquired_ms - lock_started_ms))" \ '{status: "skipped_source_no_longer_current", boundRunId: $bound_run, lockWaitMs: $lock_wait_ms}')" write_result "$run_id" "$normal_json" "$verification_json" return fi binary_check_started_ms="$(now_ms)" source_sha="$(jq -r '.binarySha256 // empty' "$run_dir/validation-contract.json" 2>/dev/null || true)" binary_sha="$(sha256sum "$RPKI_BIN" | awk '{print $1}')" binary_check_finished_ms="$(now_ms)" if [[ -z "$source_sha" || "$source_sha" != "$binary_sha" ]]; then flock -u "$state_lock_fd" exec {state_lock_fd}>&- verification_json="$(jq -n --arg expected "$source_sha" --arg actual "$binary_sha" --argjson lock_wait_ms "$((lock_acquired_ms - lock_started_ms))" \ '{status: "error_binary_sha256_mismatch", expectedBinarySha256: $expected, actualBinarySha256: $actual, lockWaitMs: $lock_wait_ms}')" write_result "$run_id" "$normal_json" "$verification_json" return fi window_started_ms="$(now_ms)" completed_at="$(jq -r '.completedAt // empty' <<< "$normal_json")" timeout_secs="$(remaining_verification_window_secs "$completed_at")" window_finished_ms="$(now_ms)" if (( timeout_secs <= 0 )); then flock -u "$state_lock_fd" exec {state_lock_fd}>&- verification_json="$(jq -n --arg completed_at "$completed_at" --argjson lock_wait_ms "$((lock_acquired_ms - lock_started_ms))" \ '{status: "skipped_insufficient_interval", sourceCompletedAt: $completed_at, lockWaitMs: $lock_wait_ms}')" write_result "$run_id" "$normal_json" "$verification_json" return fi if (( timeout_secs > VERIFICATION_TIMEOUT_SECS )); then timeout_secs="$VERIFICATION_TIMEOUT_SECS" fi temporary_dir="$SCRATCH_ROOT/$run_id" scratch_cleanup_started_ms="$(now_ms)" rm -rf "$temporary_dir" scratch_cleanup_finished_ms="$(now_ms)" run_started_ms="$(now_ms)" verification_child_exec=() if [[ -n "$VERIFICATION_CHILD_PATH" ]]; then verification_child_exec=(/usr/bin/env "PATH=$VERIFICATION_CHILD_PATH") fi set +e RPKI_STATE_EXECUTION_LOCK_HELD=1 /usr/bin/time -v -o "$temporary_dir.process-time" -- \ /usr/bin/timeout --foreground --signal=TERM --kill-after=30s "$timeout_secs" \ /usr/bin/ionice -c2 -n0 "${verification_child_exec[@]}" "$RPKI_BIN" \ --verification-only --verification-source-run "$run_dir" \ --verification-source-state "$STATE_ROOT" --verification-out "$temporary_dir" \ --analyze --analysis-out "$temporary_dir/analyze" \ --parallel-phase2-object-workers "$VERIFICATION_OBJECT_WORKERS" \ >"$temporary_dir.stdout" 2>"$temporary_dir.stderr" exit_code=$? set -e run_finished_ms="$(now_ms)" post_validation_started_ms="$run_finished_ms" flock -u "$state_lock_fd" exec {state_lock_fd}>&- comparison_json="$(read_json_or_empty "$temporary_dir/compare/ccr-state-digest.json")" fallback_replay_comparison_json="$(read_json_or_empty "$temporary_dir/compare/fallback-replay-selection.json")" verification_meta_json="$(read_json_or_empty "$temporary_dir/verification-meta.json")" stage_timing_json="$(read_json_or_empty "$temporary_dir/stage-timing.json")" analysis_json="$(read_json_or_empty "$temporary_dir/analyze/timing.json")" analysis_summary_json="$(jq -cn --argjson stage "$stage_timing_json" --argjson analysis "$analysis_json" ' {enabled: true, available: (($analysis | type) == "object" and ($analysis | length) > 0), stageTiming: { totalMs: ($stage.total_ms // null), validationMs: ($stage.validation_ms // null), repoSyncMsTotal: ($stage.repo_sync_ms_total // null), publicationPointRepoSyncMsTotal: ($stage.publication_point_repo_sync_ms_total // null), rrdpDownloadMsTotal: ($stage.rrdp_download_ms_total // null), rsyncDownloadMsTotal: ($stage.rsync_download_ms_total // null), reportBuildMs: ($stage.report_build_ms // null), reportWriteMs: ($stage.report_write_ms // null), ccrBuildMs: ($stage.ccr_build_ms // null), ccrWriteMs: ($stage.ccr_write_ms // null), cirTotalMs: ($stage.cir_total_ms // null) }, phaseTotals: (($analysis.phases // {}) | to_entries | map({name: .key, count: (.value.count // 0), totalMs: (((.value.total_nanos // 0) / 1000000) | floor)}) | sort_by(-.totalMs) | .[0:20]), topPublicationPoints: (($analysis.top_publication_points // [])[0:10]), topPublicationPointSteps: (($analysis.top_publication_point_steps // [])[0:10]), topRrdpRepos: (($analysis.top_rrdp_repos // [])[0:10]), topRrdpRepoSteps: (($analysis.top_rrdp_repo_steps // [])[0:10])}')" error_tail="$(tail -n 20 "$temporary_dir.stderr" 2>/dev/null || true)" max_rss_kb="$(awk -F: '/Maximum resident set size/{gsub(/^[[:space:]]+/, "", $2); print $2}' "$temporary_dir.process-time" 2>/dev/null || true)" comparison_state_digest_match="$(jq -r 'if has("stateDigestMatch") then (.stateDigestMatch | tostring) else "missing" end' <<< "$comparison_json")" fallback_replay_selection_match="$(jq -r 'if has("matches") then (.matches | tostring) else "missing" end' <<< "$fallback_replay_comparison_json")" if [[ "$comparison_state_digest_match" == "true" && "$fallback_replay_selection_match" == "true" && "$exit_code" -eq 0 ]]; then verification_status="pass" elif [[ "$comparison_state_digest_match" == "false" || "$fallback_replay_selection_match" == "false" ]]; then verification_status="mismatch" else verification_status="error" fi post_validation_collection_finished_ms="$(now_ms)" result_cleanup_started_ms="$(now_ms)" rm -rf "$temporary_dir" "$temporary_dir.stdout" "$temporary_dir.stderr" "$temporary_dir.process-time" result_cleanup_finished_ms="$(now_ms)" verification_json="$(jq -n \ --arg status "$verification_status" \ --arg verification_child_path "$VERIFICATION_CHILD_PATH" \ --argjson exit_code "$exit_code" \ --argjson wall_ms "$((run_finished_ms - run_started_ms))" \ --argjson lock_wait_ms "$((lock_acquired_ms - lock_started_ms))" \ --argjson timeout_limit_secs "$timeout_secs" \ --argjson object_workers "$VERIFICATION_OBJECT_WORKERS" \ --arg max_rss_kb "${max_rss_kb:-}" \ --arg error_tail "$error_tail" \ --argjson comparison "$comparison_json" \ --argjson fallback_replay_comparison "$fallback_replay_comparison_json" \ --argjson meta "$verification_meta_json" \ --argjson analysis "$analysis_summary_json" \ --argjson post_run_delay_ms "$((delay_finished_ms - observer_started_ms))" \ --argjson observer_to_verification_start_ms "$((run_started_ms - observer_started_ms))" \ --argjson pre_validation_preparation_ms "$((run_started_ms - preparation_started_ms))" \ --argjson binding_check_ms "$((binding_finished_ms - binding_started_ms))" \ --argjson binary_contract_check_ms "$((binary_check_finished_ms - binary_check_started_ms))" \ --argjson interval_window_calculation_ms "$((window_finished_ms - window_started_ms))" \ --argjson scratch_preparation_cleanup_ms "$((scratch_cleanup_finished_ms - scratch_cleanup_started_ms))" \ --argjson verification_process_ms "$((run_finished_ms - run_started_ms))" \ --argjson post_validation_collection_ms "$((post_validation_collection_finished_ms - post_validation_started_ms))" \ --argjson result_cleanup_ms "$((result_cleanup_finished_ms - result_cleanup_started_ms))" \ '{status: $status, exitCode: $exit_code, wallMs: $wall_ms, lockWaitMs: $lock_wait_ms, timeoutLimitSecs: $timeout_limit_secs, objectWorkers: $object_workers, verificationChildPath: (if $verification_child_path == "" then null else $verification_child_path end), timing: {postRunDelayMs: $post_run_delay_ms, observerToVerificationStartMs: $observer_to_verification_start_ms, preValidationPreparationMs: $pre_validation_preparation_ms, preparationBreakdownMs: {stateLockWaitMs: $lock_wait_ms, bindingCheckMs: $binding_check_ms, binaryContractCheckMs: $binary_contract_check_ms, intervalWindowCalculationMs: $interval_window_calculation_ms, scratchPreparationCleanupMs: $scratch_preparation_cleanup_ms}, verificationProcessMs: $verification_process_ms, postValidationCollectionMs: $post_validation_collection_ms, resultCleanupMs: $result_cleanup_ms}, maxRssKb: (if $max_rss_kb == "" then null else ($max_rss_kb | tonumber) end), comparison: {stateDigestMatch: ($comparison | if has("stateDigestMatch") then .stateDigestMatch else null end), mismatchedStates: ($comparison.mismatchedStates // []), states: (($comparison.states // []) | map({name, sourcePresent, verificationPresent, sourceHashHex, verificationHashHex, matches})), fallbackReplaySelection: { matches: ($fallback_replay_comparison | if has("matches") then .matches else null end), expectedCount: (($fallback_replay_comparison.expected // []) | length), actualCount: (($fallback_replay_comparison.actual // []) | length), missing: ($fallback_replay_comparison.missing // []), unexpected: ($fallback_replay_comparison.unexpected // []) }}, verificationMeta: {status: ($meta.status // null), wallMs: ($meta.wallMs // null), repositoryObjectsChecked: ($meta.repositoryObjectsChecked // null), repositoryBytesChecked: ($meta.repositoryBytesChecked // null)}, analysis: $analysis, errorTail: $error_tail}')" write_result "$run_id" "$normal_json" "$verification_json" } exec {observer_lock_fd}>"$OBSERVER_LOCK" if ! flock -n "$observer_lock_fd"; then echo "another observer already owns $OBSERVER_LOCK" >&2 exit 3 fi find "$SCRATCH_ROOT" -mindepth 1 -maxdepth 1 -exec rm -rf {} + if [[ ! -f "$STATE_FILE" ]]; then initial_run_id="$(latest_terminal_run_id)" [[ -n "$initial_run_id" ]] || { echo "no terminal normal run found under $RUNS_ROOT" >&2; exit 2; } jq -n \ --arg started_at "$(now_utc)" \ --arg initial_run_id "$initial_run_id" \ --arg runs_root "$RUNS_ROOT" \ --arg state_root "$STATE_ROOT" \ --arg rpki_sha256 "$(sha256sum "$RPKI_BIN" | awk '{print $1}')" \ --argjson target_runs "$TARGET_RUNS" \ --argjson delay_secs "$DELAY_SECS" \ --argjson verification_timeout_secs "$VERIFICATION_TIMEOUT_SECS" \ --argjson normal_interval_secs "$NORMAL_INTERVAL_SECS" \ --argjson normal_run_safety_secs "$NORMAL_RUN_SAFETY_SECS" \ --argjson verification_object_workers "$VERIFICATION_OBJECT_WORKERS" \ --arg verification_child_path "$VERIFICATION_CHILD_PATH" \ '{schemaVersion: 1, startedAt: $started_at, initialLatestTerminalRunId: $initial_run_id, runsRoot: $runs_root, stateRoot: $state_root, verifierBinarySha256: $rpki_sha256, targetRuns: $target_runs, delaySecs: $delay_secs, verificationTimeoutSecs: $verification_timeout_secs, normalIntervalSecs: $normal_interval_secs, normalRunSafetySecs: $normal_run_safety_secs, verificationObjectWorkers: $verification_object_workers, verificationChildPath: (if $verification_child_path == "" then null else $verification_child_path end), verificationAnalyze: true}' \ | atomic_replace "$STATE_FILE" update_cumulative fi # Preserve the original baseline run while recording the live configuration after # an observer restart. This is particularly important for the normal-run safety # window, whose length depends on the soak interval. jq --arg updated_at "$(now_utc)" \ --argjson target_runs "$TARGET_RUNS" \ --argjson delay_secs "$DELAY_SECS" \ --argjson verification_timeout_secs "$VERIFICATION_TIMEOUT_SECS" \ --argjson normal_interval_secs "$NORMAL_INTERVAL_SECS" \ --argjson normal_run_safety_secs "$NORMAL_RUN_SAFETY_SECS" \ --argjson verification_object_workers "$VERIFICATION_OBJECT_WORKERS" \ --arg verification_child_path "$VERIFICATION_CHILD_PATH" \ '.updatedAt = $updated_at | .targetRuns = $target_runs | .delaySecs = $delay_secs | .verificationTimeoutSecs = $verification_timeout_secs | .normalIntervalSecs = $normal_interval_secs | .normalRunSafetySecs = $normal_run_safety_secs | .verificationObjectWorkers = $verification_object_workers | .verificationChildPath = (if $verification_child_path == "" then null else $verification_child_path end) | .verificationAnalyze = true' "$STATE_FILE" \ | atomic_replace "$STATE_FILE" initial_run_number="$(run_number "$(jq -r '.initialLatestTerminalRunId' "$STATE_FILE")")" while true; do deliver_pending_notifications observed_count="$(find "$RESULTS_DIR" -maxdepth 1 -type f -name 'run_*.json' -printf '%f\n' | wc -l)" pending_count="$(jq -r '.notificationPending' "$RESULTS_DIR/cumulative.json")" if (( observed_count >= TARGET_RUNS && pending_count == 0 )); then break fi if (( observed_count < TARGET_RUNS )); then while IFS= read -r run_id; do [[ -n "$run_id" ]] || continue (( $(run_number "$run_id") > initial_run_number )) || continue [[ -f "$(result_path "$run_id")" ]] && continue process_terminal_run "$run_id" observed_count="$(find "$RESULTS_DIR" -maxdepth 1 -type f -name 'run_*.json' -printf '%f\n' | wc -l)" (( observed_count < TARGET_RUNS )) || break done < <(terminal_run_ids) fi sleep "$POLL_SECS" done update_cumulative echo "completed $(jq -r '.observedRuns' "$RESULTS_DIR/cumulative.json") observed runs; results: $RESULTS_DIR/cumulative.json"