From 81af2208ca209483c257bf241c64c054c7da240a Mon Sep 17 00:00:00 2001 From: darken Date: Thu, 18 Jun 2026 15:19:54 +0200 Subject: [PATCH] feat(apl-feed): add --json output to claim rotate Adds a --json flag to claim rotate that emits a single schema-v1 result object on stdout (result rotated|error, the accepted version on success, a machine error code plus human detail on failure), mirroring claim status --json. Progress and retry notices move to stderr so stdout carries only the terminal result, letting the on-device webconfig drive rotation and report outcomes reliably. The default human output is unchanged. --- scripts/apl-feed/claim.sh | 95 +++++++++++++++++++++++++++++++------ test/test_apl_feed_cli.bats | 34 +++++++++++++ 2 files changed, 115 insertions(+), 14 deletions(-) diff --git a/scripts/apl-feed/claim.sh b/scripts/apl-feed/claim.sh index e2c2e1f..5356751 100644 --- a/scripts/apl-feed/claim.sh +++ b/scripts/apl-feed/claim.sh @@ -266,6 +266,37 @@ claim_rotate_abort() { return 1 } +# _claim_rotate_emit_json +# Renders one terminal rotation outcome as a stable schema-v1 object on +# stdout (the on-device webconfig parses this). result is "rotated" or +# "error"; version is the accepted secret version on success; error is a +# machine code and detail a human string on failure. The retry loop's +# INFO progress lines stay on stderr and are never emitted here. +# +# Contract for consumers: JSON is emitted for outcomes reached once the +# rotation request is under way. A precondition or local-IO failure +# (no active secret, corrupt pending file, write failure) still exits +# non-zero via die() with the reason on stderr and no JSON on stdout, so +# a consumer must treat "non-zero exit with unparseable stdout" as a +# generic rotation failure rather than assuming a result object. +_claim_rotate_emit_json() { + jq -nc \ + --arg result "$1" \ + --arg version "${2:-}" \ + --arg error "${3:-}" \ + --arg detail "${4:-}" \ + ' + def nullempty: if . == "" then null else . end; + def numberish: if . == "" then null else (tonumber? // null) end; + { + schema_version: 1, + result: $result, + version: ($version | numberish), + error: ($error | nullempty), + detail: ($detail | nullempty), + }' +} + claim_rotate() { if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then usage_claim_rotate @@ -277,9 +308,12 @@ claim_rotate() { return $? fi - local opt_rc + local opt_rc json=0 while [[ $# -gt 0 ]]; do - case "$1" in -h|--help) usage_claim_rotate; exit 0 ;; esac + case "$1" in + -h|--help) usage_claim_rotate; exit 0 ;; + --json) json=1; shift; continue ;; + esac if parse_common_option "$@"; then opt_rc=0; else opt_rc=$?; fi case "$opt_rc" in 1) shift ;; @@ -300,7 +334,7 @@ claim_rotate() { if [[ -f "$pending" ]]; then next="$(read_secret_file "$pending")" - echo "Resuming pending rotation." + echo "Resuming pending rotation." >&2 else next="$(generate_secret)" validate_secret "$next" || die "generated invalid secret" @@ -330,11 +364,19 @@ claim_rotate() { case "$curl_rc" in 0) ;; 6|7|28) - echo "ERROR: curl rc=$curl_rc (DNS/connect/timeout) - pending rotation left in place" >&2 + if (( json )); then + _claim_rotate_emit_json error "" network "curl rc=$curl_rc (DNS/connect/timeout); pending rotation left in place" + else + echo "ERROR: curl rc=$curl_rc (DNS/connect/timeout) - pending rotation left in place" >&2 + fi return 2 ;; *) - echo "ERROR: curl rc=$curl_rc - pending rotation left in place" >&2 + if (( json )); then + _claim_rotate_emit_json error "" network "curl rc=$curl_rc; pending rotation left in place" + else + echo "ERROR: curl rc=$curl_rc - pending rotation left in place" >&2 + fi return 2 ;; esac @@ -350,7 +392,11 @@ claim_rotate() { chmod 640 "$pending" mv "$pending" "$final" write_version_file "$version" - echo "Rotation complete (v$version)." + if (( json )); then + _claim_rotate_emit_json rotated "$version" "" "" + else + echo "Rotation complete (v$version)." + fi return 0 ;; 409) @@ -359,10 +405,18 @@ claim_rotate() { chmod 640 "$pending" mv "$pending" "$final" write_version_file "$accepted_version" - echo "Rotation finalized (v$accepted_version) after a previous transient failure." + if (( json )); then + _claim_rotate_emit_json rotated "$accepted_version" "" "finalized after a previous transient failure" + else + echo "Rotation finalized (v$accepted_version) after a previous transient failure." + fi return 0 fi - echo "ERROR: 409 ${error:-rotation_rejected} - pending rotation left in place. Run 'apl-feed status' or 'apl-feed claim rotate --abort'." >&2 + if (( json )); then + _claim_rotate_emit_json error "" "${error:-rotation_rejected}" "pending rotation left in place; run 'apl-feed claim rotate --abort' to cancel it" + else + echo "ERROR: 409 ${error:-rotation_rejected} - pending rotation left in place. Run 'apl-feed status' or 'apl-feed claim rotate --abort'." >&2 + fi return 1 ;; 423) @@ -374,7 +428,11 @@ claim_rotate() { echo "INFO: 423 reset_locked until $reset_until; sleeping ${sleep_for}s" >&2 ;; *) - echo "ERROR: 423 ${error:-blocked} - pending rotation left in place ($preview)" >&2 + if (( json )); then + _claim_rotate_emit_json error "" "${error:-blocked}" "pending rotation left in place ($preview)" + else + echo "ERROR: 423 ${error:-blocked} - pending rotation left in place ($preview)" >&2 + fi return 1 ;; esac @@ -391,14 +449,22 @@ claim_rotate() { echo "INFO: $status server error; backing off ${sleep_for}s" >&2 ;; *) - echo "ERROR: unexpected status $status - pending rotation left in place ($preview)" >&2 + if (( json )); then + _claim_rotate_emit_json error "" unexpected_status "status $status; pending rotation left in place ($preview)" + else + echo "ERROR: unexpected status $status - pending rotation left in place ($preview)" >&2 + fi return 1 ;; esac now="$(date +%s)" if (( now + sleep_for > deadline )); then - echo "ERROR: exceeded --max-retry-time=${MAX_RETRY_TIME}s on status $status; pending rotation left in place" >&2 + if (( json )); then + _claim_rotate_emit_json error "" deadline_exceeded "exceeded --max-retry-time=${MAX_RETRY_TIME}s on status $status; pending rotation left in place" + else + echo "ERROR: exceeded --max-retry-time=${MAX_RETRY_TIME}s on status $status; pending rotation left in place" >&2 + fi return 3 fi sleep "$sleep_for" @@ -750,12 +816,13 @@ USAGE usage_claim_rotate() { cat <<'USAGE' Usage: - apl-feed claim rotate + apl-feed claim rotate [--json] apl-feed claim rotate --abort Rotates the claim secret with airplanes.live, replacing the local secret -on success. --abort cancels a pending (interrupted) rotation, keeping the -current secret as long as the server still accepts it. +on success. --json emits a single machine-readable result object on stdout +instead of the human summary. --abort cancels a pending (interrupted) +rotation, keeping the current secret as long as the server still accepts it. USAGE } diff --git a/test/test_apl_feed_cli.bats b/test/test_apl_feed_cli.bats index 7a14b9a..8f5dad0 100644 --- a/test/test_apl_feed_cli.bats +++ b/test/test_apl_feed_cli.bats @@ -388,6 +388,40 @@ EOF [ ! -f "$ROOT_DIR/etc/airplanes/feeder-claim-secret.pending" ] } +@test "claim rotate --json emits a rotated result object on 200" { + echo "ABCDEFGHIJKLMNOP" > "$ROOT_DIR/etc/airplanes/feeder-claim-secret" + chmod 600 "$ROOT_DIR/etc/airplanes/feeder-claim-secret" + start_fixed_server 200 '{"version": 2}' + + run "$SCRIPT" claim rotate --json --root "$ROOT_DIR" --website-url "$(mock_url)" + + [ "$status" -eq 0 ] + # stdout is a single machine-readable object the webconfig can parse. + [ "$(jq -r '.schema_version' <<< "$output")" = "1" ] + [ "$(jq -r '.result' <<< "$output")" = "rotated" ] + [ "$(jq -r '.version' <<< "$output")" = "2" ] + [ "$(jq -r '.error' <<< "$output")" = "null" ] + # No human summary leaked onto stdout. + [[ ! "$output" =~ "Rotation complete" ]] + [ ! -f "$ROOT_DIR/etc/airplanes/feeder-claim-secret.pending" ] +} + +@test "claim rotate --json emits an error object and leaves pending on a terminal 409" { + echo "ABCDEFGHIJKLMNOP" > "$ROOT_DIR/etc/airplanes/feeder-claim-secret" + chmod 600 "$ROOT_DIR/etc/airplanes/feeder-claim-secret" + # 409 for both /secret and the /status finalize-probe → terminal reject. + start_fixed_server 409 '{"error": "rotation_rejected"}' + + run "$SCRIPT" claim rotate --json --root "$ROOT_DIR" --website-url "$(mock_url)" + + [ "$status" -eq 1 ] + [ "$(jq -r '.schema_version' <<< "$output")" = "1" ] + [ "$(jq -r '.result' <<< "$output")" = "error" ] + [ "$(jq -r '.error' <<< "$output")" = "rotation_rejected" ] + # The interrupted rotation is preserved so a retry can resume it. + [ -f "$ROOT_DIR/etc/airplanes/feeder-claim-secret.pending" ] +} + @test "backup writes mode 0600 JSON and restore reads it" { local backup_file="$ROOT_DIR/backup.json" echo "ABCDEFGHIJKLMNOP" > "$ROOT_DIR/etc/airplanes/feeder-claim-secret"