diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index a273afd..35202ff 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -81,8 +81,11 @@ jobs: script/e2e/lib/configuration.sh script/e2e/lib/ownership.sh script/e2e/lib/migration.sh + script/e2e/lib/polling.sh + script/e2e/lib/target.sh script/e2e/lib/cleanup.sh script/e2e/scenarios/control-plane.sh + script/e2e/scenarios/lifecycle.sh ) missing_harness_files=() @@ -108,8 +111,9 @@ jobs: echo "- Requested ref: \`$CANDIDATE_REF\`" echo "- Resolved commit: \`$sha\`" echo "- Environment: \`migration-e2e\`" - echo "- Scenario: \`control-plane\`" - echo "- Complete control-plane harness present: \`$harness_present\`" + echo "- Candidate setup: build and install once" + echo "- Scenarios: \`control-plane\`, then \`lifecycle\`" + echo "- Complete E2E harness present: \`$harness_present\`" if (( ${#missing_harness_files[@]} > 0 )); then echo @@ -135,27 +139,29 @@ jobs: { echo "## E2E test not run" echo - echo "The selected revision does not contain the complete control-plane E2E harness." + echo "The selected revision does not contain the complete E2E harness." echo echo "The resolve job summary lists the missing files." echo echo "No E2E credentials were requested, and no migration services were contacted." } >>"$GITHUB_STEP_SUMMARY" - echo "::notice::The selected revision has an incomplete control-plane E2E harness. Nothing was run." + echo "::notice::The selected revision has an incomplete E2E harness. Nothing was run." e2e: - name: Control-plane scenario + name: Migration scenarios needs: resolve if: needs.resolve.outputs.harness_present == 'true' runs-on: ubuntu-latest - # This is the only job referencing the protected environment. Approval - # therefore covers candidate setup and the complete control-plane scenario. + # This is the only job referencing the protected environment. One approval + # therefore covers candidate setup and both scenarios. environment: name: migration-e2e - timeout-minutes: 30 + # The lifecycle polling budget is at most 90 minutes. Leave additional time + # for candidate setup, the control-plane scenario, evidence, and cleanup. + timeout-minutes: 120 env: # Source GHES @@ -253,8 +259,11 @@ jobs: script/e2e/lib/configuration.sh script/e2e/lib/ownership.sh script/e2e/lib/migration.sh + script/e2e/lib/polling.sh + script/e2e/lib/target.sh script/e2e/lib/cleanup.sh script/e2e/scenarios/control-plane.sh + script/e2e/scenarios/lifecycle.sh ) for file in "${harness_files[@]}"; do @@ -271,7 +280,7 @@ jobs: fi done - echo "All control-plane harness files passed Bash syntax validation." + echo "All E2E harness files passed Bash syntax validation." - name: Set up Go uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 @@ -301,6 +310,9 @@ jobs: exit 1 fi + # Keep the runner's existing GH_CONFIG_DIR for both installation and + # scenario execution. The harness isolates gh-elm configuration + # separately through GH_ELM_CONFIG_DIR. gh extension install . installed_version="$(gh elm --version)" @@ -311,6 +323,9 @@ jobs: exit 1 fi + # Control-plane runs first. If it or its cleanup fails, normal Actions + # step behavior prevents lifecycle from starting against an unsafe + # migration environment. - name: Run control-plane scenario id: control_plane env: @@ -320,12 +335,22 @@ jobs: shell: bash run: bash script/e2e/test-elm-ghes.sh - - name: Add E2E summary + - name: Run lifecycle scenario + id: lifecycle + env: + E2E_MODE: lifecycle + E2E_RUN_ID: actions-${{ github.run_id }}-${{ github.run_attempt }}-lifecycle + OUTDIR: ${{ github.workspace }}/elm-results/lifecycle + shell: bash + run: bash script/e2e/test-elm-ghes.sh + + - name: Add combined E2E summary if: always() env: CANDIDATE_SHA: ${{ needs.resolve.outputs.sha }} JOB_STATUS: ${{ job.status }} - SCENARIO_OUTDIR: ${{ github.workspace }}/elm-results/control-plane + CONTROL_PLANE_OUTDIR: ${{ github.workspace }}/elm-results/control-plane + LIFECYCLE_OUTDIR: ${{ github.workspace }}/elm-results/lifecycle shell: bash run: | { @@ -333,29 +358,39 @@ jobs: echo echo "- Candidate: \`$CANDIDATE_SHA\`" echo "- Environment: \`migration-e2e\`" - echo "- Scenario: \`control-plane\`" + echo "- Candidate setup: built and installed once" + echo "- Execution order: \`control-plane\`, then \`lifecycle\`" echo "- Job result: \`$JOB_STATUS\`" } >>"$GITHUB_STEP_SUMMARY" - if [[ -f "$SCENARIO_OUTDIR/evidence.md" ]]; then - { - echo - echo "### Control-plane scenario" - echo - } >>"$GITHUB_STEP_SUMMARY" + append_scenario_evidence() { + local scenario="$1" + local scenario_outdir="$2" - cat "$SCENARIO_OUTDIR/evidence.md" \ - >>"$GITHUB_STEP_SUMMARY" - else { echo - echo "### Control-plane scenario" + echo "### ${scenario} scenario" echo - echo "No evidence file was produced. The scenario may not have started." } >>"$GITHUB_STEP_SUMMARY" - fi - - name: Upload E2E evidence + if [[ -f "$scenario_outdir/evidence.md" ]]; then + cat "$scenario_outdir/evidence.md" \ + >>"$GITHUB_STEP_SUMMARY" + else + echo "No evidence file was produced. The scenario may not have started." \ + >>"$GITHUB_STEP_SUMMARY" + fi + } + + append_scenario_evidence \ + "Control-plane" \ + "$CONTROL_PLANE_OUTDIR" + + append_scenario_evidence \ + "Lifecycle" \ + "$LIFECYCLE_OUTDIR" + + - name: Upload combined E2E evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: diff --git a/script/e2e/lib/cleanup.sh b/script/e2e/lib/cleanup.sh index a579753..c701e7e 100644 --- a/script/e2e/lib/cleanup.sh +++ b/script/e2e/lib/cleanup.sh @@ -1,14 +1,14 @@ #!/usr/bin/env bash # -# Run-scoped cleanup and final result reporting for the gh-elm control-plane -# E2E harness. +# Run-scoped cleanup and final result reporting for the gh-elm E2E harness. # # Cleanup operates exclusively on migration IDs recorded by ownership.sh: # # - migrations already marked cleanup-complete are skipped; -# - other run-owned migrations are cancelled; +# - migrations that may have entered cutover must be successfully reverted; +# - other active migrations are cancelled; # - rejected cancellation operations are accepted only when status confirms -# that the migration is already terminal. +# that a non-cutover migration is already terminal. # # The cleanup function is installed as an EXIT trap by test-elm-ghes.sh. It # preserves the scenario's original exit status unless cleanup itself fails. @@ -52,6 +52,7 @@ cleanup_get_migration_status() { local -n response_ref="$2" local fetched_response local artifact_id + local invalid_status_file local status_file local temporary_file @@ -96,8 +97,12 @@ cleanup_get_migration_status() { cleanup_log \ "Migration $migration_id returned an unexpected status response during cleanup." - printf '%s\n' "$fetched_response" \ - >"$OUTDIR/cleanup-invalid-status-${artifact_id}.json" + invalid_status_file="$OUTDIR/cleanup-invalid-status-${artifact_id}.json" + + if ! printf '%s\n' "$fetched_response" >"$invalid_status_file"; then + cleanup_log \ + "Failed to save the invalid cleanup status response for migration $migration_id." + fi return 1 fi @@ -172,6 +177,156 @@ cleanup_migration_is_terminal() { return 1 } +cleanup_revert_cutover() { + local migration_id="$1" + local response + local artifact_id + local evidence_file + local invalid_evidence_file + local temporary_file + local source_unarchived + local cutover_terminated + local migration_terminated + + if ! migration_is_owned "$migration_id"; then + cleanup_log \ + "Refusing to revert cutover for unowned migration $migration_id." + + return 1 + fi + + if ! migration_started_cutover "$migration_id"; then + cleanup_log \ + "Refusing to revert cutover for migration $migration_id because this run did not record a cutover attempt." + + return 1 + fi + + if migration_cleanup_is_complete "$migration_id"; then + cleanup_log \ + "Refusing to revert cutover for migration $migration_id because cleanup is already complete." + + return 1 + fi + + artifact_id="$(migration_artifact_id "$migration_id")" + evidence_file="$OUTDIR/cleanup-revert-cutover-${artifact_id}.json" + invalid_evidence_file="$OUTDIR/cleanup-revert-cutover-invalid-${artifact_id}.json" + temporary_file="${evidence_file}.tmp" + + cleanup_log \ + "Attempting cutover revert for run-owned migration $migration_id." + + # revert-cutover is the canonical command in older candidates and a retained + # compatibility alias in newer candidates. Use the flag form because older + # candidates do not accept the migration ID positionally. + if ! response="$( + gh elm migration revert-cutover \ + --migration-id "$migration_id" \ + --json 2>>"$CLEANUP_LOG" + )"; then + cleanup_log \ + "Cutover revert command failed for migration $migration_id." + + return 1 + fi + + # A successful cleanup must confirm that the source repository was + # unarchived. Terminal migration status alone is not proof that cutover was + # safely reversed. + if ! jq -e ' + (type == "object") and + (.success | type == "boolean") and + .success == true and + (.unarchived_source_repository | type == "boolean") and + ( + (has("in_progress_cutover_terminated") | not) or + (.in_progress_cutover_terminated | type == "boolean") + ) and + ( + (has("in_progress_migration_terminated") | not) or + (.in_progress_migration_terminated | type == "boolean") + ) + ' >/dev/null <<<"$response" 2>>"$CLEANUP_LOG"; then + cleanup_log \ + "Cutover revert returned an invalid or unsuccessful response for migration $migration_id." + + if ! printf '%s\n' "$response" >"$invalid_evidence_file"; then + cleanup_log \ + "Failed to save the invalid cleanup revert response for migration $migration_id." + fi + + return 1 + fi + + # A false value means the source repository was already unarchived. Operation + # success is determined by the validated success field above. + if ! source_unarchived="$( + jq -r '.unarchived_source_repository' \ + <<<"$response" 2>>"$CLEANUP_LOG" + )"; then + cleanup_log \ + "Failed to read source restoration state for migration $migration_id." + + return 1 + fi + + # These optional fields default to false, matching the typed CLI response. + # The validation above rejects any present non-boolean value. + if ! cutover_terminated="$( + jq -r '.in_progress_cutover_terminated // false' \ + <<<"$response" 2>>"$CLEANUP_LOG" + )"; then + cleanup_log \ + "Failed to read cutover termination state for migration $migration_id." + + return 1 + fi + + if ! migration_terminated="$( + jq -r '.in_progress_migration_terminated // false' \ + <<<"$response" 2>>"$CLEANUP_LOG" + )"; then + cleanup_log \ + "Failed to read migration termination state for migration $migration_id." + + return 1 + fi + + # The remote revert has succeeded and explicitly confirmed that the source + # repository was restored. Mark cleanup complete before writing evidence so + # an evidence failure cannot cause a second revert attempt. + if ! mark_cleanup_complete "$migration_id"; then + cleanup_log \ + "Cutover was reverted, but migration $migration_id could not be marked cleanup-complete." + + return 1 + fi + + if ! printf '%s\n' "$response" >"$temporary_file"; then + rm -f "$temporary_file" + + cleanup_log \ + "Cutover was reverted, but cleanup evidence could not be saved for migration $migration_id." + + return 1 + fi + + if ! mv "$temporary_file" "$evidence_file"; then + rm -f "$temporary_file" + + cleanup_log \ + "Cutover was reverted, but cleanup evidence could not be finalized for migration $migration_id." + + return 1 + fi + + cleanup_log \ + "Cutover reverted successfully for migration $migration_id: source_unarchived=$source_unarchived, cutover_terminated=$cutover_terminated, migration_terminated=$migration_terminated." + + return 0 +} + cleanup_cancel_migration() { local migration_id="$1" @@ -182,6 +337,13 @@ cleanup_cancel_migration() { return 1 fi + if migration_started_cutover "$migration_id"; then + cleanup_log \ + "Refusing to use cancellation as cleanup for migration $migration_id because this run recorded a cutover attempt." + + return 1 + fi + if migration_cleanup_is_complete "$migration_id"; then cleanup_log \ "Refusing to cancel migration $migration_id because cleanup is already complete." @@ -230,6 +392,9 @@ cleanup_one_migration() { if migration_was_cancelled "$migration_id"; then cleanup_log \ "Skipping migration $migration_id: already cancelled." + elif migration_started_cutover "$migration_id"; then + cleanup_log \ + "Skipping migration $migration_id: cutover cleanup already complete." else cleanup_log \ "Skipping migration $migration_id: cleanup already complete." @@ -238,13 +403,32 @@ cleanup_one_migration() { return 0 fi + # initiate_cutover records this state before sending its API request. This + # closes the interruption window where the server accepts cutover but the + # client exits before observing success. + # + # Once cutover may have started, cancellation or terminal migration status + # cannot prove that the source repository was restored. Cleanup must therefore + # require a successful revert that explicitly confirms the source was + # unarchived. + if migration_started_cutover "$migration_id"; then + if cleanup_revert_cutover "$migration_id"; then + return 0 + fi + + cleanup_log \ + "Cutover cleanup did not complete successfully for migration $migration_id. Refusing to downgrade this failure to cancellation or terminal-state cleanup." + + return 1 + fi + if cleanup_cancel_migration "$migration_id"; then return 0 fi - # Cancellation can be rejected when the migration has already settled into a - # terminal state. In that case, terminal status is sufficient evidence that - # no further control-plane cleanup is required. + # Cancellation can be rejected when a migration that never entered cutover + # has already settled into a terminal state. Only non-cutover migrations may + # use terminal status as evidence that no further cleanup is required. if cleanup_migration_is_terminal "$migration_id"; then if ! mark_cleanup_complete "$migration_id"; then cleanup_log \ @@ -254,13 +438,13 @@ cleanup_one_migration() { fi cleanup_log \ - "Migration $migration_id is already terminal; no further cleanup is required." + "Non-cutover migration $migration_id is already terminal; no further cleanup is required." return 0 fi cleanup_log \ - "Failed to cancel or confirm a terminal state for migration $migration_id." + "Failed to cancel or confirm a terminal state for non-cutover migration $migration_id." return 1 } @@ -272,7 +456,7 @@ record_cleanup_result() { if ! record_result \ "Cleanup" \ "❌ fail" \ - "One or more run-owned migrations could not be cancelled or confirmed terminal. See cleanup.log."; then + "One or more run-owned migrations could not be safely reverted, cancelled, or confirmed terminal before cutover. See cleanup.log."; then cleanup_log \ "Failed to record the cleanup failure result." fi @@ -283,7 +467,7 @@ record_cleanup_result() { if ! record_result \ "Cleanup" \ "✅ pass" \ - "All run-owned migrations were cancelled or confirmed terminal."; then + "All run-owned migrations were safely reverted, cancelled, or confirmed terminal before cutover."; then cleanup_log \ "Failed to record the successful cleanup result." @@ -300,7 +484,7 @@ record_overall_result() { if ! record_result \ "Overall result" \ "✅ pass" \ - "The GHES control-plane E2E scenario completed successfully."; then + "The GHES $E2E_MODE E2E scenario completed successfully."; then cleanup_log \ "Failed to record the successful overall result." @@ -313,7 +497,7 @@ record_overall_result() { if ! record_result \ "Overall result" \ "❌ fail" \ - "The GHES control-plane E2E scenario failed. Run-scoped cleanup was attempted."; then + "The GHES $E2E_MODE E2E scenario failed. Run-scoped cleanup was attempted."; then cleanup_log \ "Failed to record the failed overall result." @@ -370,7 +554,7 @@ cleanup() { record_result \ "Overall result" \ "❌ fail" \ - "The GHES control-plane E2E scenario could not finalize its evidence. See cleanup.log." \ + "The GHES $E2E_MODE E2E scenario could not finalize its evidence. See cleanup.log." \ >/dev/null 2>&1 || true fi diff --git a/script/e2e/lib/common.sh b/script/e2e/lib/common.sh index 2f49d52..8bd068f 100644 --- a/script/e2e/lib/common.sh +++ b/script/e2e/lib/common.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # # Shared defaults, state, logging, and validation primitives for the gh-elm -# control-plane E2E harness. +# E2E harness. # # This file is sourced by script/e2e/test-elm-ghes.sh and is not intended to # be executed directly. @@ -20,6 +20,10 @@ fi OUTDIR="${OUTDIR:-./elm-results}" E2E_MODE="${E2E_MODE:-control-plane}" +E2E_POLL_INTERVAL_SECONDS="${E2E_POLL_INTERVAL_SECONDS:-10}" +E2E_STATE_TIMEOUT_SECONDS="${E2E_STATE_TIMEOUT_SECONDS:-900}" +E2E_CUTOVER_TIMEOUT_SECONDS="${E2E_CUTOVER_TIMEOUT_SECONDS:-1800}" + # --------------------------------------------------------------------------- # Evidence paths # @@ -97,3 +101,18 @@ require_command() { "Required command $name was not found on PATH." fi } + +require_positive_integer() { + local name="$1" + local value="${!name:-}" + + if [[ ! "$value" =~ ^[0-9]+$ ]] || ((10#$value <= 0)); then + fail \ + "Configuration" \ + "$name must be a positive integer, not ${value:-}." + fi + + # Normalize decimal-looking values such as 08 so subsequent Bash arithmetic + # cannot interpret them as octal. + printf -v "$name" '%d' "$((10#$value))" +} diff --git a/script/e2e/lib/configuration.sh b/script/e2e/lib/configuration.sh index 7836221..7860fee 100644 --- a/script/e2e/lib/configuration.sh +++ b/script/e2e/lib/configuration.sh @@ -1,7 +1,6 @@ #!/usr/bin/env bash # -# Configuration validation and runtime setup for the gh-elm control-plane E2E -# harness. +# Configuration validation and runtime setup for the gh-elm E2E harness. # # This file is sourced by script/e2e/test-elm-ghes.sh and is not intended to # be executed directly. @@ -88,6 +87,10 @@ validate_token() { validate_configuration() { local variable local dependency + local value + local normalized_value + local maximum_value + local lifecycle_polling_budget for variable in \ SOURCE_HOST \ @@ -106,15 +109,21 @@ validate_configuration() { gh \ jq \ sed \ - tr; do + tr \ + sleep \ + date; do require_command "$dependency" done - if [[ "$E2E_MODE" != "control-plane" ]]; then - fail \ - "Configuration" \ - "Unsupported E2E_MODE: $E2E_MODE. Expected control-plane." - fi + case "$E2E_MODE" in + control-plane | lifecycle) + ;; + *) + fail \ + "Configuration" \ + "Unsupported E2E_MODE: $E2E_MODE. Expected control-plane or lifecycle." + ;; + esac case "$TARGET_VISIBILITY" in private | internal) @@ -126,6 +135,83 @@ validate_configuration() { ;; esac + # Validate and bound timeout strings before using Bash arithmetic. Bash uses + # fixed-width signed integers, so evaluating an unbounded digits-only value + # could overflow before a later maximum-value check is reached. + for variable in \ + E2E_POLL_INTERVAL_SECONDS \ + E2E_STATE_TIMEOUT_SECONDS \ + E2E_CUTOVER_TIMEOUT_SECONDS; do + value="${!variable:-}" + + if [[ ! "$value" =~ ^[0-9]+$ ]]; then + fail \ + "Configuration" \ + "$variable must be a positive integer, not ${value:-}." + fi + + # Remove leading zeroes without first interpreting the value as an integer. + normalized_value="$( + printf '%s' "$value" | + sed -E 's/^0+//' + )" + + if [[ -z "$normalized_value" ]]; then + fail \ + "Configuration" \ + "$variable must be a positive integer, not $value." + fi + + case "$variable" in + E2E_POLL_INTERVAL_SECONDS) + maximum_value=300 + ;; + E2E_STATE_TIMEOUT_SECONDS | E2E_CUTOVER_TIMEOUT_SECONDS) + maximum_value=2700 + ;; + esac + + # Compare string lengths first. Arithmetic is safe only after proving that + # the normalized value contains no more digits than the small upper bound. + if ((${#normalized_value} > ${#maximum_value})) || + ((${#normalized_value} == ${#maximum_value} && + 10#$normalized_value > maximum_value)); then + fail \ + "Configuration" \ + "$variable must not exceed $maximum_value seconds." + fi + + # The value is now known to be small enough for safe decimal conversion. + printf -v "$variable" '%d' "$((10#$normalized_value))" + done + + if ((E2E_STATE_TIMEOUT_SECONDS < E2E_POLL_INTERVAL_SECONDS)); then + fail \ + "Configuration" \ + "E2E_STATE_TIMEOUT_SECONDS must be at least E2E_POLL_INTERVAL_SECONDS." + fi + + if ((E2E_CUTOVER_TIMEOUT_SECONDS < E2E_POLL_INTERVAL_SECONDS)); then + fail \ + "Configuration" \ + "E2E_CUTOVER_TIMEOUT_SECONDS must be at least E2E_POLL_INTERVAL_SECONDS." + fi + + # Lifecycle can use each timeout twice. Both values are already bounded at + # 2700 seconds, so this addition cannot overflow. Requiring their sum to be + # at most 2700 is equivalent to limiting the four lifecycle polling phases + # to an aggregate budget of 5400 seconds. + lifecycle_polling_budget=$(( + E2E_STATE_TIMEOUT_SECONDS + + E2E_CUTOVER_TIMEOUT_SECONDS + )) + + if ((lifecycle_polling_budget > 2700)); then + fail \ + "Configuration" \ + "E2E_STATE_TIMEOUT_SECONDS plus E2E_CUTOVER_TIMEOUT_SECONDS must not exceed 2700 seconds (5400 seconds across the four lifecycle polling phases)." + fi + validate_http_url SOURCE_HOST validate_http_url TARGET_HOST @@ -193,7 +279,7 @@ configure_runtime() { fi # Calculate the maximum run-ID length using the longest generated repository - # suffix. This ensures both generated repository names fit within the + # suffix. This ensures every generated repository name fits within the # repository-name limit. if ((${#primary_suffix} >= ${#pagination_suffix})); then longest_suffix_length="${#primary_suffix}" @@ -253,6 +339,8 @@ configure_runtime() { # Keep temporary gh-elm configuration outside the uploaded evidence # directory. GH_CONFIG_DIR deliberately remains unchanged so the candidate # installed by the workflow remains registered for subsequent gh elm calls. + # The workflow gives each scenario a unique E2E_RUN_ID, so their gh-elm + # configuration directories do not overlap. config_root="${RUNNER_TEMP:-/tmp}/gh-elm-e2e-$SAFE_RUN_ID" if [[ -e "$config_root" && ! -d "$config_root" ]]; then @@ -281,7 +369,10 @@ configure_runtime() { "Failed to restrict temporary gh-elm configuration directory permissions." fi - log "Configured runtime for the control-plane E2E scenario." + log "Configured runtime for E2E scenario $E2E_MODE." log "Primary target repository: $TARGET_ORG/$TARGET_REPO_PRIMARY" - log "Pagination target repository: $TARGET_ORG/$TARGET_REPO_PAGINATION" + + if [[ "$E2E_MODE" == "control-plane" ]]; then + log "Pagination target repository: $TARGET_ORG/$TARGET_REPO_PAGINATION" + fi } diff --git a/script/e2e/lib/evidence.sh b/script/e2e/lib/evidence.sh index 41f56f9..0ff9b5a 100644 --- a/script/e2e/lib/evidence.sh +++ b/script/e2e/lib/evidence.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # # Evidence initialization, structured metadata, and result recording for the -# gh-elm control-plane E2E harness. +# gh-elm E2E harness. # # This file is sourced by script/e2e/test-elm-ghes.sh and is not intended to # be executed directly. @@ -12,6 +12,8 @@ if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then fi initialize_harness() { + local polling_timeline_file + if [[ -z "${OUTDIR:-}" ]]; then printf 'OUTDIR must be set before initializing the E2E harness.\n' >&2 return 1 @@ -28,12 +30,25 @@ initialize_harness() { CLEANUP_LOG="$OUTDIR/cleanup.log" COMMAND_LOG="$OUTDIR/commands.log" METADATA_FILE="$OUTDIR/metadata.json" + polling_timeline_file="$OUTDIR/poll-timeline.ndjson" + + # Remove migration-specific polling snapshots left by a previous local + # invocation that reused the same output directory. Run-level evidence files + # are truncated separately below. + if ! rm -f -- \ + "$OUTDIR"/poll-*.json \ + "$OUTDIR"/poll-*.json.tmp; then + printf 'Failed to reset polling snapshots under: %s\n' \ + "$OUTDIR" >&2 + return 1 + fi if ! : >"$EVIDENCE_FILE" || ! : >"$RESULTS_FILE" || ! : >"$MIGRATIONS_FILE" || ! : >"$CLEANUP_LOG" || - ! : >"$COMMAND_LOG"; then + ! : >"$COMMAND_LOG" || + ! : >"$polling_timeline_file"; then printf 'Failed to initialize E2E evidence files under: %s\n' \ "$OUTDIR" >&2 return 1 @@ -108,22 +123,53 @@ write_evidence_header() { "Evidence paths are unavailable; initialize_harness must run first." fi - if [[ -z "${TARGET_REPO_PRIMARY:-}" || - -z "${TARGET_REPO_PAGINATION:-}" ]]; then + if [[ -z "${TARGET_REPO_PRIMARY:-}" ]]; then fail \ "Harness" \ "Runtime repository names are unavailable; configure_runtime must run before write_evidence_header." fi - target_repositories_json="$( - jq -cn \ - --arg primary "$TARGET_REPO_PRIMARY" \ - --arg pagination "$TARGET_REPO_PAGINATION" \ - '[$primary, $pagination]' - )" + case "$E2E_MODE" in + control-plane) + if [[ -z "${TARGET_REPO_PAGINATION:-}" ]]; then + fail \ + "Harness" \ + "The control-plane scenario requires a pagination target repository name." + fi + + if ! target_repositories_json="$( + jq -cn \ + --arg primary "$TARGET_REPO_PRIMARY" \ + --arg pagination "$TARGET_REPO_PAGINATION" \ + '[$primary, $pagination]' + )"; then + fail \ + "Evidence" \ + "Failed to construct control-plane target repository metadata." + fi + ;; + lifecycle) + if ! target_repositories_json="$( + jq -cn \ + --arg primary "$TARGET_REPO_PRIMARY" \ + '[$primary]' + )"; then + fail \ + "Evidence" \ + "Failed to construct lifecycle target repository metadata." + fi + ;; + *) + fail \ + "Configuration" \ + "Unsupported E2E_MODE: $E2E_MODE. Expected control-plane or lifecycle." + ;; + esac temporary_metadata_file="${METADATA_FILE}.tmp" + # Timeout values have already been validated and normalized as positive + # decimal integers by validate_configuration(). if ! jq -n \ --arg run_id "$E2E_RUN_ID" \ --arg scenario "$E2E_MODE" \ @@ -133,6 +179,9 @@ write_evidence_header() { --arg target_organization "$TARGET_ORG" \ --arg target_visibility "$TARGET_VISIBILITY" \ --argjson target_repositories "$target_repositories_json" \ + --argjson poll_interval_seconds "$E2E_POLL_INTERVAL_SECONDS" \ + --argjson state_timeout_seconds "$E2E_STATE_TIMEOUT_SECONDS" \ + --argjson cutover_timeout_seconds "$E2E_CUTOVER_TIMEOUT_SECONDS" \ '{ run_id: $run_id, scenario: $scenario, @@ -141,7 +190,12 @@ write_evidence_header() { target_host: $target_host, target_organization: $target_organization, target_visibility: $target_visibility, - target_repositories: $target_repositories + target_repositories: $target_repositories, + timeouts: { + poll_interval_seconds: $poll_interval_seconds, + state_timeout_seconds: $state_timeout_seconds, + cutover_timeout_seconds: $cutover_timeout_seconds + } }' >"$temporary_metadata_file"; then rm -f "$temporary_metadata_file" @@ -159,18 +213,33 @@ write_evidence_header() { fi if ! { - echo "# gh-elm GHES E2E evidence" - echo - echo "- Run ID: \`$E2E_RUN_ID\`" - echo "- Scenario: \`$E2E_MODE\`" - echo "- Source host: \`$SOURCE_HOST\`" - echo "- Source fixture: \`$SOURCE_ORG/$SOURCE_REPO\`" - echo "- Target host: \`$TARGET_HOST\`" - echo "- Target organization: \`$TARGET_ORG\`" - echo "- Target visibility: \`$TARGET_VISIBILITY\`" - echo "- Primary target: \`$TARGET_ORG/$TARGET_REPO_PRIMARY\`" - echo "- Pagination target: \`$TARGET_ORG/$TARGET_REPO_PAGINATION\`" - echo + printf '# gh-elm GHES E2E evidence\n\n' + printf -- '- Run ID: `%s`\n' "$E2E_RUN_ID" + printf -- '- Scenario: `%s`\n' "$E2E_MODE" + printf -- '- Source host: `%s`\n' "$SOURCE_HOST" + printf -- '- Source fixture: `%s/%s`\n' \ + "$SOURCE_ORG" \ + "$SOURCE_REPO" + printf -- '- Target host: `%s`\n' "$TARGET_HOST" + printf -- '- Target organization: `%s`\n' "$TARGET_ORG" + printf -- '- Target visibility: `%s`\n' "$TARGET_VISIBILITY" + printf -- '- Primary target: `%s/%s`\n' \ + "$TARGET_ORG" \ + "$TARGET_REPO_PRIMARY" + + if [[ "$E2E_MODE" == "control-plane" ]]; then + printf -- '- Pagination target: `%s/%s`\n' \ + "$TARGET_ORG" \ + "$TARGET_REPO_PAGINATION" + fi + + printf -- '- Poll interval: `%ss`\n' \ + "$E2E_POLL_INTERVAL_SECONDS" + printf -- '- State timeout: `%ss`\n' \ + "$E2E_STATE_TIMEOUT_SECONDS" + printf -- '- Cutover timeout: `%ss`\n' \ + "$E2E_CUTOVER_TIMEOUT_SECONDS" + printf '\n' } >>"$EVIDENCE_FILE"; then fail \ "Evidence" \ diff --git a/script/e2e/lib/migration.sh b/script/e2e/lib/migration.sh index 00dc560..bbe6264 100644 --- a/script/e2e/lib/migration.sh +++ b/script/e2e/lib/migration.sh @@ -1,13 +1,15 @@ #!/usr/bin/env bash # -# Source-side migration operations for the gh-elm control-plane E2E harness. +# Source-side migration operations for the gh-elm E2E harness. # # This module provides: # # - read-only migration-list preflight; # - migration creation and status validation; # - migration-list pagination validation; -# - explicit migration cancellation. +# - start, cancel, cutover, and revert operations. +# +# State polling and target-side operations live in polling.sh and target.sh. # # This file is sourced by script/e2e/test-elm-ghes.sh and is not intended to # be executed directly. @@ -82,13 +84,21 @@ run_preflight() { "Failed to save migration-list preflight evidence." fi - page_count="$( - jq -r '.migrations | length' <<<"$output" - )" + if ! page_count="$( + jq -er '.migrations | length' <<<"$output" + )"; then + fail \ + "Preflight" \ + "Failed to read the migration-list page count." + fi - total_count="$( - jq -r '.total_count' <<<"$output" - )" + if ! total_count="$( + jq -er '.total_count' <<<"$output" + )"; then + fail \ + "Preflight" \ + "Failed to read the total migration count." + fi record_result \ "Preflight" \ @@ -147,9 +157,9 @@ create_migration() { "Migration creation succeeded but returned no migration_id." fi - # Persist ownership immediately after obtaining the ID. No additional - # assertion may occur before this call because cleanup depends on the - # ownership record. + # Register ownership immediately after obtaining the ID. No evidence write + # or further assertion may occur before this call because the EXIT trap + # depends on the in-memory ownership record. remember_migration "$migration_id" LAST_MIGRATION_ID="$migration_id" @@ -157,7 +167,7 @@ create_migration() { temporary_file="${evidence_file}.tmp" # Save the creation response only after recording ownership. If evidence - # storage fails, the EXIT trap can still clean up the created migration. + # storage fails, the EXIT trap can still clean up the remote migration. if ! printf '%s\n' "$output" >"$temporary_file"; then rm -f "$temporary_file" @@ -201,8 +211,8 @@ get_migration_status() { return 1 fi - # Keep the latest status response as evidence. Write atomically so - # interruption cannot leave truncated JSON. + # Keep the latest response available even when a later polling assertion + # fails. Write atomically so interruption cannot leave truncated JSON. if ! printf '%s\n' "$output" >"$temporary_file"; then rm -f "$temporary_file" return 1 @@ -266,6 +276,15 @@ verify_status() { ( .migration.status | type == "string" and length > 0 + ) and + ( + .combined_state == null or + (.combined_state | type == "object") + ) and + ( + .combined_state == null or + .combined_state.status == null or + (.combined_state.status | type == "string") ) ' >/dev/null <<<"$output"; then fail \ @@ -273,13 +292,21 @@ verify_status() { "The status response did not match the run-owned migration." fi - migration_status="$( - jq -r '.migration.status' <<<"$output" - )" + if ! migration_status="$( + jq -er '.migration.status' <<<"$output" + )"; then + fail \ + "$result_key" \ + "Failed to read the migration status from the validated response." + fi - combined_status="$( + if ! combined_status="$( jq -r '.combined_state.status // empty' <<<"$output" - )" + )"; then + fail \ + "$result_key" \ + "Failed to read the combined migration status." + fi record_result \ "$result_key" \ @@ -382,9 +409,19 @@ verify_pagination() { break fi - next_cursor="$( - jq -r '.next_cursor // empty' <<<"$output" - )" + if ! next_cursor="$( + jq -er ' + if .next_cursor == null then + "" + else + .next_cursor + end + ' <<<"$output" + )"; then + fail \ + "List pagination" \ + "Failed to read the pagination cursor from page $page_number." + fi if [[ -z "$next_cursor" ]]; then break @@ -420,6 +457,43 @@ verify_pagination() { "Found both migrations after $page_number page(s) and $transitions cursor transition(s)." } +start_migration() { + local migration_id="$1" + local result_key="${2:-Start migration}" + + require_owned_migration "$migration_id" + + if migration_cleanup_is_complete "$migration_id"; then + fail \ + "$result_key" \ + "Refusing to start migration $migration_id because its cleanup is already complete." + fi + + if migration_started_cutover "$migration_id"; then + fail \ + "$result_key" \ + "Refusing to start migration $migration_id because this scenario already recorded a cutover attempt." + fi + + log "Starting migration $migration_id." + migration_command_log_section "Start migration: $migration_id" + + # Use the flag form for compatibility with candidates that do not accept a + # positional migration ID. + if ! gh elm migration start \ + --migration-id "$migration_id" \ + >>"$COMMAND_LOG" 2>&1; then + fail \ + "$result_key" \ + "Failed to start run-owned migration $migration_id; see commands.log." + fi + + record_result \ + "$result_key" \ + "✅ pass" \ + "Started migration $migration_id." +} + cancel_migration() { local migration_id="$1" local result_key="${2:-Cancel migration}" @@ -432,6 +506,12 @@ cancel_migration() { "Refusing to cancel migration $migration_id because its cleanup is already complete." fi + if migration_started_cutover "$migration_id"; then + fail \ + "$result_key" \ + "Refusing to cancel migration $migration_id because this scenario recorded a cutover attempt." + fi + log "Cancelling migration $migration_id." migration_command_log_section "Cancel migration: $migration_id" @@ -450,3 +530,164 @@ cancel_migration() { "✅ pass" \ "Cancelled run-owned migration $migration_id." } + +initiate_cutover() { + local migration_id="$1" + local result_key="${2:-Initiate cutover}" + + require_owned_migration "$migration_id" + + if migration_cleanup_is_complete "$migration_id"; then + fail \ + "$result_key" \ + "Refusing to initiate cutover for cleanup-complete migration $migration_id." + fi + + if migration_started_cutover "$migration_id"; then + fail \ + "$result_key" \ + "Refusing to initiate cutover for migration $migration_id more than once." + fi + + log "Initiating cutover for migration $migration_id." + migration_command_log_section "Initiate cutover: $migration_id" + + # Record that cutover may have started before making the request. If the + # process is interrupted after the server accepts the request but before the + # command returns, cleanup must still attempt a revert. + mark_cutover_started "$migration_id" + + # cutover-to-destination is the canonical command in older candidates and a + # retained compatibility alias in newer candidates. + if ! gh elm migration cutover-to-destination \ + --migration-id "$migration_id" \ + >>"$COMMAND_LOG" 2>&1; then + fail \ + "$result_key" \ + "Failed to initiate cutover for migration $migration_id; cleanup will attempt recovery." + fi + + record_result \ + "$result_key" \ + "✅ pass" \ + "Cutover initiated for migration $migration_id." +} + +revert_cutover() { + local migration_id="$1" + local result_key="${2:-Revert cutover}" + local artifact_id + local output + local evidence_file + local temporary_file + local source_unarchived + local cutover_terminated + local migration_terminated + + require_owned_migration "$migration_id" + + if ! migration_started_cutover "$migration_id"; then + fail \ + "$result_key" \ + "Refusing to revert cutover for migration $migration_id because this scenario did not initiate cutover." + fi + + if migration_cleanup_is_complete "$migration_id"; then + fail \ + "$result_key" \ + "Refusing to revert cutover for migration $migration_id because cleanup is already complete." + fi + + artifact_id="$(migration_artifact_id "$migration_id")" + evidence_file="$OUTDIR/revert-cutover-${artifact_id}.json" + temporary_file="${evidence_file}.tmp" + + log "Reverting cutover for migration $migration_id." + migration_command_log_section "Revert cutover: $migration_id" + + # revert-cutover is the canonical command in older candidates and a retained + # compatibility alias in newer candidates. + if ! output="$( + gh elm migration revert-cutover \ + --migration-id "$migration_id" \ + --json 2>>"$COMMAND_LOG" + )"; then + fail \ + "$result_key" \ + "Failed to revert cutover for migration $migration_id; cleanup will retry recovery." + fi + + # Validate the response before treating the migration as safely restored. + if ! jq -e ' + (type == "object") and + (.success | type == "boolean") and + .success == true and + (.unarchived_source_repository | type == "boolean") and + ( + (has("in_progress_cutover_terminated") | not) or + (.in_progress_cutover_terminated | type == "boolean") + ) and + ( + (has("in_progress_migration_terminated") | not) or + (.in_progress_migration_terminated | type == "boolean") + ) + ' >/dev/null <<<"$output"; then + printf '%s\n' "$output" \ + >"$OUTDIR/revert-cutover-invalid-${artifact_id}.json" + + fail \ + "$result_key" \ + "The revert-cutover response was invalid or did not report success." + fi + + if ! source_unarchived="$( + jq -r '.unarchived_source_repository' <<<"$output" + )"; then + fail \ + "$result_key" \ + "Failed to read the source restoration state from the revert response." + fi + + if ! cutover_terminated="$( + jq -r '.in_progress_cutover_terminated // false' <<<"$output" + )"; then + fail \ + "$result_key" \ + "Failed to read the cutover termination state from the revert response." + fi + + if ! migration_terminated="$( + jq -r '.in_progress_migration_terminated // false' <<<"$output" + )"; then + fail \ + "$result_key" \ + "Failed to read the migration termination state from the revert response." + fi + + # The remote revert has succeeded and the response confirms that the source + # repository was restored. Mark cleanup complete before writing evidence so + # an evidence failure cannot cause the EXIT trap to retry an already completed + # revert operation. + mark_cleanup_complete "$migration_id" + + if ! printf '%s\n' "$output" >"$temporary_file"; then + rm -f "$temporary_file" + + fail \ + "$result_key" \ + "Cutover was reverted, but its response evidence could not be saved." + fi + + if ! mv "$temporary_file" "$evidence_file"; then + rm -f "$temporary_file" + + fail \ + "$result_key" \ + "Cutover was reverted, but its response evidence could not be finalized." + fi + + record_result \ + "$result_key" \ + "✅ pass" \ + "Reverted cutover for migration $migration_id; source_unarchived=$source_unarchived, cutover_terminated=$cutover_terminated, migration_terminated=$migration_terminated." +} diff --git a/script/e2e/lib/ownership.sh b/script/e2e/lib/ownership.sh index 4959af2..9270e51 100644 --- a/script/e2e/lib/ownership.sh +++ b/script/e2e/lib/ownership.sh @@ -1,11 +1,12 @@ #!/usr/bin/env bash # -# Run-owned migration tracking for the gh-elm control-plane E2E harness. +# Run-owned migration tracking for the gh-elm E2E harness. # # Cleanup must operate only on migrations created by the current scenario. # This module records those migration IDs and tracks whether each migration: # # - was explicitly cancelled; +# - entered cutover and may require a revert; # - has completed all required cleanup. # # This file is sourced by script/e2e/test-elm-ghes.sh and is not intended to @@ -22,6 +23,7 @@ declare -a CREATED_MIGRATION_IDS=() # Associative sets keyed by source-side migration UUID. declare -A OWNED_MIGRATION_IDS=() declare -A CANCELLED_MIGRATION_IDS=() +declare -A CUTOVER_STARTED_MIGRATION_IDS=() declare -A CLEANUP_COMPLETE_MIGRATION_IDS=() validate_migration_id() { @@ -33,7 +35,8 @@ validate_migration_id() { "Refusing to use an empty migration ID." fi - if [[ "$migration_id" == *$'\n'* || "$migration_id" == *$'\r'* ]]; then + if [[ "$migration_id" == *$'\n'* || + "$migration_id" == *$'\r'* ]]; then fail \ "Migration ownership" \ "Migration IDs must not contain line breaks." @@ -45,6 +48,12 @@ validate_migration_id() { "Migration IDs must not contain whitespace." fi + if [[ "$migration_id" =~ [[:cntrl:]] ]]; then + fail \ + "Migration ownership" \ + "Migration IDs must not contain control characters." + fi + if [[ "$migration_id" == -* ]]; then fail \ "Migration ownership" \ @@ -55,7 +64,8 @@ validate_migration_id() { migration_is_owned() { local migration_id="$1" - [[ -n "${OWNED_MIGRATION_IDS[$migration_id]:-}" ]] + [[ -n "$migration_id" && + -n "${OWNED_MIGRATION_IDS[$migration_id]:-}" ]] } require_owned_migration() { @@ -107,17 +117,66 @@ mark_cancelled() { require_owned_migration "$migration_id" + if migration_started_cutover "$migration_id"; then + fail \ + "Migration ownership" \ + "Cannot mark migration $migration_id as cancelled because this scenario recorded a cutover attempt." + fi + + if migration_cleanup_is_complete "$migration_id"; then + fail \ + "Migration ownership" \ + "Cannot mark migration $migration_id as cancelled because cleanup is already complete." + fi + CANCELLED_MIGRATION_IDS["$migration_id"]=1 CLEANUP_COMPLETE_MIGRATION_IDS["$migration_id"]=1 log "Marked migration $migration_id as cancelled and cleanup-complete." } +mark_cutover_started() { + local migration_id="$1" + + require_owned_migration "$migration_id" + + if migration_was_cancelled "$migration_id"; then + fail \ + "Migration ownership" \ + "Cannot mark cutover as started for cancelled migration $migration_id." + fi + + if migration_cleanup_is_complete "$migration_id"; then + fail \ + "Migration ownership" \ + "Cannot mark cutover as started for cleanup-complete migration $migration_id." + fi + + if migration_started_cutover "$migration_id"; then + fail \ + "Migration ownership" \ + "Cutover was recorded more than once for migration $migration_id." + fi + + # Record this before the remote cutover request is sent. If the process is + # interrupted after the service accepts the request, cleanup will know that + # cancellation is insufficient and that a cutover revert must be attempted. + CUTOVER_STARTED_MIGRATION_IDS["$migration_id"]=1 + + log "Marked migration $migration_id as having started cutover." +} + mark_cleanup_complete() { local migration_id="$1" require_owned_migration "$migration_id" + if migration_cleanup_is_complete "$migration_id"; then + fail \ + "Migration ownership" \ + "Cleanup was recorded more than once for migration $migration_id." + fi + CLEANUP_COMPLETE_MIGRATION_IDS["$migration_id"]=1 log "Marked migration $migration_id as cleanup-complete." @@ -130,6 +189,13 @@ migration_was_cancelled() { [[ -n "${CANCELLED_MIGRATION_IDS[$migration_id]:-}" ]] } +migration_started_cutover() { + local migration_id="$1" + + migration_is_owned "$migration_id" && + [[ -n "${CUTOVER_STARTED_MIGRATION_IDS[$migration_id]:-}" ]] +} + migration_cleanup_is_complete() { local migration_id="$1" diff --git a/script/e2e/lib/polling.sh b/script/e2e/lib/polling.sh new file mode 100644 index 0000000..55fe8e8 --- /dev/null +++ b/script/e2e/lib/polling.sh @@ -0,0 +1,856 @@ +#!/usr/bin/env bash +# +# Polling and asynchronous state verification for the gh-elm E2E harness. +# +# This module provides: +# +# - target migration ID polling; +# - cutover-readiness polling; +# - cutover-completion polling; +# - post-revert state verification. +# +# Source-side commands and one-shot operations live in migration.sh. +# +# This file is sourced by script/e2e/test-elm-ghes.sh and is not intended to +# be executed directly. + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + printf 'This file must be sourced by script/e2e/test-elm-ghes.sh.\n' >&2 + exit 1 +fi + +# Avoid failing a polling operation because of one transient status error. +# The counter is reset after every valid response. +POLLING_MAX_CONSECUTIVE_STATUS_FAILURES=3 + +polling_now() { + date +%s +} + +polling_timestamp() { + date -u '+%Y-%m-%dT%H:%M:%SZ' +} + +polling_elapsed() { + local started_at="$1" + local now + + now="$(polling_now)" + + # Avoid a negative elapsed time if the system clock moves backward. + if ((now < started_at)); then + printf '0\n' + return 0 + fi + + printf '%d\n' "$((now - started_at))" +} + +polling_sleep() { + sleep "$E2E_POLL_INTERVAL_SECONDS" +} + +validate_polling_timeout() { + local timeout_seconds="$1" + local result_key="$2" + local description="$3" + + if [[ ! "$timeout_seconds" =~ ^[0-9]+$ ]] || + ((10#$timeout_seconds <= 0)); then + fail \ + "$result_key" \ + "$description must be a positive integer, not ${timeout_seconds:-}." + fi +} + +migration_status_from_response() { + local response="$1" + + jq -r '.migration.status // empty' <<<"$response" +} + +combined_status_from_response() { + local response="$1" + + jq -r '.combined_state.status // empty' <<<"$response" +} + +ready_for_cutover_from_response() { + local response="$1" + + jq -r ' + if .combined_state.ready_for_cutover == true then + "true" + else + "false" + end + ' <<<"$response" +} + +migration_state_is_failure() { + local status="$1" + + case "$status" in + failed | terminated | cancelled | canceled | aborted | expired) + return 0 + ;; + *) + return 1 + ;; + esac +} + +lifecycle_status_is() { + local migration_status="$1" + local combined_status="$2" + local expected_status="$3" + + # combined_state.status is authoritative whenever it is present. Fall back + # to migration.status only when the combined status is unavailable. + if [[ -n "$combined_status" ]]; then + [[ "$combined_status" == "$expected_status" ]] + else + [[ "$migration_status" == "$expected_status" ]] + fi +} + +polling_artifact_purpose() { + local purpose="$1" + local safe_purpose + + safe_purpose="$( + printf '%s' "$purpose" | + tr '[:upper:]' '[:lower:]' | + tr -c 'a-z0-9._-' '-' | + sed -E \ + -e 's/^-+//' \ + -e 's/-+$//' + )" + + if [[ -z "$safe_purpose" ]]; then + safe_purpose="poll" + fi + + printf '%s\n' "$safe_purpose" +} + +record_poll_snapshot() { + local migration_id="$1" + local purpose="$2" + local attempt="$3" + local response="$4" + local artifact_id + local safe_purpose + local first_file + local first_temporary_file + local latest_file + local latest_temporary_file + local timeline_file + local timeline_entry + local timestamp + + artifact_id="$(migration_artifact_id "$migration_id")" + safe_purpose="$(polling_artifact_purpose "$purpose")" + + first_file="$OUTDIR/poll-${safe_purpose}-${artifact_id}-first.json" + first_temporary_file="${first_file}.tmp" + latest_file="$OUTDIR/poll-${safe_purpose}-${artifact_id}-latest.json" + latest_temporary_file="${latest_file}.tmp" + timeline_file="$OUTDIR/poll-timeline.ndjson" + timestamp="$(polling_timestamp)" + + # Preserve the first state observed for each polling operation. Write it + # atomically so interruption cannot leave a truncated first-state artifact. + if [[ ! -e "$first_file" ]]; then + if ! printf '%s\n' "$response" >"$first_temporary_file"; then + rm -f "$first_temporary_file" + return 1 + fi + + if ! mv "$first_temporary_file" "$first_file"; then + rm -f "$first_temporary_file" + return 1 + fi + fi + + # Write the latest response atomically so termination cannot leave a + # partially written JSON artifact. + if ! printf '%s\n' "$response" >"$latest_temporary_file"; then + rm -f "$latest_temporary_file" + return 1 + fi + + if ! mv "$latest_temporary_file" "$latest_file"; then + rm -f "$latest_temporary_file" + return 1 + fi + + # Append a compact timeline entry containing only state information useful + # for diagnosis. Full first/latest API responses remain available separately. + if ! timeline_entry="$( + jq -c \ + --arg observed_at "$timestamp" \ + --arg purpose "$purpose" \ + --argjson attempt "$attempt" ' + { + observed_at: $observed_at, + purpose: $purpose, + attempt: $attempt, + migration_id: (.migration.migration_id // null), + migration_status: (.migration.status // null), + combined_status: (.combined_state.status // null), + ready_for_cutover: + (.combined_state.ready_for_cutover // false), + cutover_blockers: + (.combined_state.cutover_blockers // []) + } + ' <<<"$response" + )"; then + return 1 + fi + + if ! printf '%s\n' "$timeline_entry" >>"$timeline_file"; then + return 1 + fi +} + +validate_status_response() { + local response="$1" + local expected_migration_id="${2:-}" + + jq -e \ + --arg expected_migration_id "$expected_migration_id" ' + (.migration | type == "object") and + ( + .migration.migration_id | + type == "string" and length > 0 + ) and + ( + $expected_migration_id == "" or + .migration.migration_id == $expected_migration_id + ) and + ( + .migration.status | + type == "string" and length > 0 + ) and + ( + .combined_state == null or + (.combined_state | type == "object") + ) and + ( + .combined_state == null or + .combined_state.status == null or + (.combined_state.status | type == "string") + ) and + ( + .combined_state == null or + .combined_state.ready_for_cutover == null or + (.combined_state.ready_for_cutover | type == "boolean") + ) and + ( + .combined_state == null or + .combined_state.cutover_blockers == null or + (.combined_state.cutover_blockers | type == "array") + ) + ' >/dev/null <<<"$response" +} + +read_poll_status() { + local migration_id="$1" + local purpose="$2" + local attempt="$3" + + # Assign the fetched response to the variable named by the fourth argument. + # The fetched value deliberately uses a different local name so the nameref + # cannot resolve to a local variable in this function when callers pass a + # variable named "response". + local -n response_ref="$4" + local fetched_response + local artifact_id + local safe_purpose + local invalid_response_file + + if ! fetched_response="$(get_migration_status "$migration_id")"; then + printf 'Status request failed for migration %s during %s attempt %s.\n' \ + "$migration_id" \ + "$purpose" \ + "$attempt" >>"$COMMAND_LOG" + + return 1 + fi + + artifact_id="$(migration_artifact_id "$migration_id")" + safe_purpose="$(polling_artifact_purpose "$purpose")" + + if ! validate_status_response \ + "$fetched_response" \ + "$migration_id"; then + printf 'Unexpected status response for migration %s during %s attempt %s.\n' \ + "$migration_id" \ + "$purpose" \ + "$attempt" >>"$COMMAND_LOG" + + invalid_response_file="$OUTDIR/poll-invalid-${safe_purpose}-${artifact_id}-${attempt}.json" + + if ! printf '%s\n' "$fetched_response" >"$invalid_response_file"; then + printf 'Failed to save invalid status response for migration %s during %s attempt %s.\n' \ + "$migration_id" \ + "$purpose" \ + "$attempt" >>"$COMMAND_LOG" + fi + + return 1 + fi + + if ! record_poll_snapshot \ + "$migration_id" \ + "$purpose" \ + "$attempt" \ + "$fetched_response"; then + printf 'Failed to record polling evidence for migration %s during %s attempt %s.\n' \ + "$migration_id" \ + "$purpose" \ + "$attempt" >>"$COMMAND_LOG" + + return 1 + fi + + response_ref="$fetched_response" +} + +polling_handle_status_failure() { + local result_key="$1" + local migration_id="$2" + local purpose="$3" + local failure_count="$4" + + log \ + "Migration $migration_id $purpose status request failed ($failure_count/$POLLING_MAX_CONSECUTIVE_STATUS_FAILURES consecutive failures)." + + if ((failure_count >= POLLING_MAX_CONSECUTIVE_STATUS_FAILURES)); then + fail \ + "$result_key" \ + "Failed to retrieve a valid status for migration $migration_id during $purpose after $failure_count consecutive attempts." + fi +} + +fail_on_terminal_migration_state() { + local result_key="$1" + local migration_id="$2" + local expected_description="$3" + local migration_status="$4" + local combined_status="$5" + + # combined_state.status is authoritative whenever it is present. Do not + # diagnose a fallback migration.status failure if combined state reports a + # different, current lifecycle state. + if [[ -n "$combined_status" ]]; then + if migration_state_is_failure "$combined_status"; then + fail \ + "$result_key" \ + "Migration $migration_id entered terminal combined state $combined_status while waiting for $expected_description." + fi + + return 0 + fi + + if migration_state_is_failure "$migration_status"; then + fail \ + "$result_key" \ + "Migration $migration_id entered terminal state $migration_status while waiting for $expected_description." + fi +} + +resolve_target_migration_id() { + local migration_id="$1" + local result_key="${2:-Target migration ID}" + local started_at + local elapsed + local attempt=0 + local response + local status_response + local target_migration_id + local migration_status + local combined_status + local artifact_id + local evidence_file + local temporary_file + local consecutive_status_failures=0 + local lookup_succeeded + + require_owned_migration "$migration_id" + + artifact_id="$(migration_artifact_id "$migration_id")" + evidence_file="$OUTDIR/target-id-${artifact_id}.json" + temporary_file="${evidence_file}.tmp" + started_at="$(polling_now)" + + # Callers use command substitution to capture the numeric ID. Keep stdout + # reserved for that value and send progress/result logging to stderr. + log "Resolving the target migration ID for $migration_id." >&2 + + migration_command_log_section \ + "Resolve target migration ID: $migration_id" + + while true; do + attempt=$((attempt + 1)) + lookup_succeeded=0 + + # lookup-target-id is the canonical command in older candidates and a + # retained compatibility alias in newer candidates. + if response="$( + gh elm migration lookup-target-id \ + --migration-id "$migration_id" \ + --json 2>>"$COMMAND_LOG" + )"; then + lookup_succeeded=1 + + if target_migration_id="$( + jq -er \ + --arg migration_id "$migration_id" ' + select(.migration_id == $migration_id) | + .target_migration_id | + select(type == "number" and . > 0) + ' <<<"$response" + )"; then + # Save only a validated successful lookup response. Write atomically so + # interruption cannot leave a partial target-ID artifact. + if ! printf '%s\n' "$response" >"$temporary_file"; then + rm -f "$temporary_file" + + fail \ + "$result_key" \ + "Failed to save target migration ID evidence for migration $migration_id." >&2 + fi + + if ! mv "$temporary_file" "$evidence_file"; then + rm -f "$temporary_file" + + fail \ + "$result_key" \ + "Failed to finalize target migration ID evidence for migration $migration_id." >&2 + fi + + record_result \ + "$result_key" \ + "✅ pass" \ + "Resolved target migration ID $target_migration_id for migration $migration_id." >&2 + + printf '%s\n' "$target_migration_id" + return 0 + fi + fi + + # If the target ID is not available yet, inspect source-side state so + # terminal failures are reported promptly. + if read_poll_status \ + "$migration_id" \ + "target-id" \ + "$attempt" \ + status_response; then + consecutive_status_failures=0 + + migration_status="$( + migration_status_from_response "$status_response" + )" + + combined_status="$( + combined_status_from_response "$status_response" + )" + + log \ + "Migration $migration_id target-ID wait: migration=$migration_status combined=$combined_status" >&2 + + fail_on_terminal_migration_state \ + "$result_key" \ + "$migration_id" \ + "a target migration ID" \ + "$migration_status" \ + "$combined_status" + + # A completed migration proves that no ID will appear only when the + # lookup request itself succeeded. A transient lookup command failure + # must remain recoverable and continue polling. + if ((lookup_succeeded == 1)) && + lifecycle_status_is \ + "$migration_status" \ + "$combined_status" \ + "completed"; then + fail \ + "$result_key" \ + "Migration $migration_id completed without exposing a positive target migration ID." >&2 + fi + else + consecutive_status_failures=$((consecutive_status_failures + 1)) + + polling_handle_status_failure \ + "$result_key" \ + "$migration_id" \ + "target-ID resolution" \ + "$consecutive_status_failures" >&2 + fi + + elapsed="$(polling_elapsed "$started_at")" + + if ((elapsed >= E2E_STATE_TIMEOUT_SECONDS)); then + fail \ + "$result_key" \ + "Timed out after ${E2E_STATE_TIMEOUT_SECONDS}s waiting for migration $migration_id to expose a target migration ID. Last states: migration=${migration_status:-unknown}, combined=${combined_status:-unknown}." >&2 + fi + + polling_sleep + done +} + +wait_for_cutover_readiness() { + local migration_id="$1" + local timeout_seconds="$2" + local result_key="${3:-Wait for cutover readiness}" + local started_at + local elapsed + local attempt=0 + local response + local migration_status + local combined_status + local ready_for_cutover + local blockers + local consecutive_status_failures=0 + + require_owned_migration "$migration_id" + + validate_polling_timeout \ + "$timeout_seconds" \ + "$result_key" \ + "Cutover-readiness timeout" + + started_at="$(polling_now)" + + log \ + "Waiting up to ${timeout_seconds}s for migration $migration_id to become ready for cutover." + + migration_command_log_section \ + "Wait for cutover readiness: $migration_id" + + while true; do + attempt=$((attempt + 1)) + + if ! read_poll_status \ + "$migration_id" \ + "cutover-readiness" \ + "$attempt" \ + response; then + consecutive_status_failures=$((consecutive_status_failures + 1)) + + polling_handle_status_failure \ + "$result_key" \ + "$migration_id" \ + "cutover-readiness polling" \ + "$consecutive_status_failures" + else + consecutive_status_failures=0 + + migration_status="$( + migration_status_from_response "$response" + )" + + combined_status="$( + combined_status_from_response "$response" + )" + + ready_for_cutover="$( + ready_for_cutover_from_response "$response" + )" + + blockers="$( + jq -r ' + [ + .combined_state.cutover_blockers[]? + | select(type == "string" and length > 0) + ] | + if length == 0 then + "none" + else + join("; ") + end + ' <<<"$response" + )" + + log \ + "Migration $migration_id cutover-readiness wait: ready=$ready_for_cutover migration=$migration_status combined=$combined_status blockers=$blockers" + + # Evaluate authoritative terminal and completed states before accepting + # readiness. This prevents a stale ready_for_cutover value from masking a + # newer failed, terminated, or completed lifecycle state. + fail_on_terminal_migration_state \ + "$result_key" \ + "$migration_id" \ + "cutover readiness" \ + "$migration_status" \ + "$combined_status" + + if lifecycle_status_is \ + "$migration_status" \ + "$combined_status" \ + "completed"; then + fail \ + "$result_key" \ + "Migration $migration_id completed before the harness observed cutover readiness." + fi + + if [[ "$ready_for_cutover" == "true" || + "$combined_status" == "ready_for_cutover" ]]; then + record_result \ + "$result_key" \ + "✅ pass" \ + "Migration $migration_id became ready for cutover after $attempt status request(s)." + + return 0 + fi + fi + + elapsed="$(polling_elapsed "$started_at")" + + if ((elapsed >= timeout_seconds)); then + fail \ + "$result_key" \ + "Timed out after ${timeout_seconds}s waiting for cutover readiness. Last states: migration=${migration_status:-unknown}, combined=${combined_status:-unknown}. Blockers: ${blockers:-unknown}." + fi + + polling_sleep + done +} + +wait_for_cutover_completion() { + local migration_id="$1" + local timeout_seconds="$2" + local result_key="${3:-Wait for cutover completion}" + local started_at + local elapsed + local attempt=0 + local response + local migration_status + local combined_status + local consecutive_status_failures=0 + + require_owned_migration "$migration_id" + + if ! migration_started_cutover "$migration_id"; then + fail \ + "$result_key" \ + "Refusing to wait for cutover completion because this scenario did not initiate cutover for migration $migration_id." + fi + + validate_polling_timeout \ + "$timeout_seconds" \ + "$result_key" \ + "Cutover-completion timeout" + + started_at="$(polling_now)" + + log \ + "Waiting up to ${timeout_seconds}s for migration $migration_id to complete cutover." + + migration_command_log_section \ + "Wait for cutover completion: $migration_id" + + while true; do + attempt=$((attempt + 1)) + + if ! read_poll_status \ + "$migration_id" \ + "cutover-completion" \ + "$attempt" \ + response; then + consecutive_status_failures=$((consecutive_status_failures + 1)) + + polling_handle_status_failure \ + "$result_key" \ + "$migration_id" \ + "cutover-completion polling" \ + "$consecutive_status_failures" + else + consecutive_status_failures=0 + + migration_status="$( + migration_status_from_response "$response" + )" + + combined_status="$( + combined_status_from_response "$response" + )" + + log \ + "Migration $migration_id cutover-completion wait: migration=$migration_status combined=$combined_status" + + # combined_state.status is authoritative when present. A completed + # migration.status must not override a combined cutting_over or failed + # state. + if lifecycle_status_is \ + "$migration_status" \ + "$combined_status" \ + "completed"; then + record_result \ + "$result_key" \ + "✅ pass" \ + "Migration $migration_id completed cutover after $attempt status request(s)." + + return 0 + fi + + fail_on_terminal_migration_state \ + "$result_key" \ + "$migration_id" \ + "successful cutover completion" \ + "$migration_status" \ + "$combined_status" + fi + + elapsed="$(polling_elapsed "$started_at")" + + if ((elapsed >= timeout_seconds)); then + fail \ + "$result_key" \ + "Timed out after ${timeout_seconds}s waiting for cutover completion. Last states: migration=${migration_status:-unknown}, combined=${combined_status:-unknown}." + fi + + polling_sleep + done +} + +verify_reverted_state() { + local migration_id="$1" + local target_repo="$2" + local result_key="${3:-Verify reverted state}" + local started_at + local elapsed + local attempt=0 + local response + local migration_status + local combined_status + local consecutive_status_failures=0 + + require_owned_migration "$migration_id" + + if ! migration_cleanup_is_complete "$migration_id"; then + fail \ + "$result_key" \ + "Refusing to verify post-revert state before migration $migration_id has been marked cleanup-complete." + fi + + started_at="$(polling_now)" + + log "Verifying the post-revert state for migration $migration_id." + + migration_command_log_section \ + "Verify post-revert state: $migration_id" + + while true; do + attempt=$((attempt + 1)) + + if ! read_poll_status \ + "$migration_id" \ + "post-revert" \ + "$attempt" \ + response; then + consecutive_status_failures=$((consecutive_status_failures + 1)) + + polling_handle_status_failure \ + "$result_key" \ + "$migration_id" \ + "post-revert status verification" \ + "$consecutive_status_failures" + else + consecutive_status_failures=0 + + if ! jq -e \ + --arg migration_id "$migration_id" \ + --arg source_org "$SOURCE_ORG" \ + --arg source_repo "$SOURCE_REPO" \ + --arg target_org "$TARGET_ORG" \ + --arg target_repo "$target_repo" ' + .migration.migration_id == $migration_id and + ( + .migration.source_organization_login | + type == "string" and + ascii_downcase == ($source_org | ascii_downcase) + ) and + ( + .migration.source_repository_name | + type == "string" and + ascii_downcase == ($source_repo | ascii_downcase) + ) and + ( + .migration.target_organization_login | + type == "string" and + ascii_downcase == ($target_org | ascii_downcase) + ) and + ( + .migration.target_repository_name | + type == "string" and + ascii_downcase == ($target_repo | ascii_downcase) + ) + ' >/dev/null <<<"$response"; then + fail \ + "$result_key" \ + "The post-revert status response did not match the run-owned migration." + fi + + migration_status="$( + migration_status_from_response "$response" + )" + + combined_status="$( + combined_status_from_response "$response" + )" + + log \ + "Migration $migration_id post-revert wait: migration=$migration_status combined=$combined_status" + + # combined_state.status is authoritative whenever present. Only inspect + # migration.status when the combined status is unavailable. + if [[ -n "$combined_status" ]]; then + case "$combined_status" in + terminated | completed | cancelled | canceled) + record_result \ + "$result_key" \ + "✅ pass" \ + "Migration $migration_id has accessible post-revert combined status $combined_status after $attempt status request(s)." + + return 0 + ;; + failed | aborted | expired) + fail \ + "$result_key" \ + "Migration $migration_id entered unexpected post-revert combined state $combined_status." + ;; + esac + else + case "$migration_status" in + terminated | completed | cancelled | canceled) + record_result \ + "$result_key" \ + "✅ pass" \ + "Migration $migration_id has accessible post-revert status $migration_status after $attempt status request(s)." + + return 0 + ;; + failed | aborted | expired) + fail \ + "$result_key" \ + "Migration $migration_id entered unexpected post-revert state $migration_status." + ;; + esac + fi + fi + + elapsed="$(polling_elapsed "$started_at")" + + if ((elapsed >= E2E_STATE_TIMEOUT_SECONDS)); then + fail \ + "$result_key" \ + "Timed out after ${E2E_STATE_TIMEOUT_SECONDS}s waiting for an accessible terminal post-revert status. Last states: migration=${migration_status:-unknown}, combined=${combined_status:-unknown}." + fi + + polling_sleep + done +} diff --git a/script/e2e/lib/target.sh b/script/e2e/lib/target.sh new file mode 100644 index 0000000..7039f5c --- /dev/null +++ b/script/e2e/lib/target.sh @@ -0,0 +1,262 @@ +#!/usr/bin/env bash +# +# Target-side migration operations for the gh-elm E2E harness. +# +# This module validates target migration IDs and exercises APIs exposed by: +# +# gh elm target ... +# +# Target migration ID polling remains in polling.sh because it reads the +# source-side migration until the destination ID becomes available. +# +# This file is sourced by script/e2e/test-elm-ghes.sh and is not intended to +# be executed directly. + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + printf 'This file must be sourced by script/e2e/test-elm-ghes.sh.\n' >&2 + exit 1 +fi + +validate_target_migration_id_value() { + local target_migration_id="$1" + local normalized_id + + if [[ -z "$target_migration_id" ]]; then + return 1 + fi + + if [[ ! "$target_migration_id" =~ ^[0-9]+$ ]]; then + return 1 + fi + + # Strip leading zeroes before arithmetic evaluation so Bash does not + # interpret the value as octal. + normalized_id="$( + printf '%s' "$target_migration_id" | + sed -E 's/^0+//' + )" + + if [[ -z "$normalized_id" ]]; then + normalized_id="0" + fi + + ((10#$normalized_id > 0)) +} + +verify_target_migration_id() { + local target_migration_id="$1" + local result_key="${2:-Target migration ID validation}" + + if ! validate_target_migration_id_value "$target_migration_id"; then + fail \ + "$result_key" \ + "Target migration ID must be a positive integer, not ${target_migration_id:-}." + fi + + record_result \ + "$result_key" \ + "✅ pass" \ + "Target migration ID $target_migration_id is a positive integer." +} + +validate_repository_nwo() { + local repository_nwo="$1" + local owner + local repository + + if [[ -z "$repository_nwo" ]]; then + return 1 + fi + + if [[ "$repository_nwo" == *$'\n'* || + "$repository_nwo" == *$'\r'* || + "$repository_nwo" =~ [[:space:]] || + "$repository_nwo" =~ [[:cntrl:]] ]]; then + return 1 + fi + + if [[ "$repository_nwo" == -* ]]; then + return 1 + fi + + if [[ "$repository_nwo" != */* ]]; then + return 1 + fi + + owner="${repository_nwo%%/*}" + repository="${repository_nwo#*/}" + + # Reject owner/repository/extra and empty owner or repository components. + if [[ -z "$owner" || + -z "$repository" || + "$repository" == */* ]]; then + return 1 + fi + + if [[ "$owner" == "." || + "$owner" == ".." || + "$repository" == "." || + "$repository" == ".." ]]; then + return 1 + fi + + return 0 +} + +target_artifact_name() { + local value="$1" + local sanitized + + sanitized="$( + printf '%s' "$value" | + tr '[:upper:]' '[:lower:]' | + tr -c 'a-z0-9._-' '-' | + sed -E \ + -e 's/^-+//' \ + -e 's/-+$//' + )" + + if [[ -z "$sanitized" ]]; then + sanitized="target" + fi + + printf '%s\n' "$sanitized" +} + +verify_target_resources() { + local target_migration_id="$1" + local repository_nwo="$2" + local result_key="${3:-Target resources}" + local max_results=20 + local output + local resource_count + local artifact_repository + local artifact_file + local artifact_temporary_file + local parsed_file + local parsed_temporary_file + + if ! validate_target_migration_id_value "$target_migration_id"; then + fail \ + "$result_key" \ + "Cannot list resources with invalid target migration ID ${target_migration_id:-}." + fi + + if ! validate_repository_nwo "$repository_nwo"; then + fail \ + "$result_key" \ + "Repository must be in owner/repository format, not ${repository_nwo:-}." + fi + + if [[ -z "${OUTDIR:-}" || -z "${COMMAND_LOG:-}" ]]; then + fail \ + "Harness" \ + "Evidence paths are unavailable; initialize_harness must run before target API checks." + fi + + artifact_repository="$( + target_artifact_name "$repository_nwo" + )" + + artifact_file="$OUTDIR/target-resources-${target_migration_id}-${artifact_repository}.ndjson" + artifact_temporary_file="${artifact_file}.tmp" + + parsed_file="$OUTDIR/target-resources-${target_migration_id}-${artifact_repository}.json" + parsed_temporary_file="${parsed_file}.tmp" + + log \ + "Listing target resources for target migration $target_migration_id and repository $repository_nwo." + + migration_command_log_section \ + "Target resources: migration=$target_migration_id repository=$repository_nwo" + + # Use flags for compatibility with candidates that do not accept the target + # migration ID or repository as positional operands. Newer candidates retain + # these flags for backward compatibility. + if ! output="$( + gh elm target resources \ + --migration-id "$target_migration_id" \ + --repository "$repository_nwo" \ + --max-results "$max_results" \ + --json 2>>"$COMMAND_LOG" + )"; then + fail \ + "$result_key" \ + "Failed to list target resources for migration $target_migration_id and repository $repository_nwo; see commands.log." + fi + + # Preserve the command's exact NDJSON output. An empty response is valid and + # represents a migration for which no matching resource records were found. + if [[ -n "$output" ]]; then + if ! printf '%s\n' "$output" >"$artifact_temporary_file"; then + rm -f "$artifact_temporary_file" + + fail \ + "$result_key" \ + "Failed to save target resource evidence." + fi + else + if ! : >"$artifact_temporary_file"; then + rm -f "$artifact_temporary_file" + + fail \ + "$result_key" \ + "Failed to save empty target resource evidence." + fi + fi + + if ! mv "$artifact_temporary_file" "$artifact_file"; then + rm -f "$artifact_temporary_file" + + fail \ + "$result_key" \ + "Failed to finalize target resource evidence." + fi + + # Slurp the NDJSON into an array, validate that every element is an object, + # and emit the array itself. An empty input produces an empty array. + if ! jq -s -e ' + if type == "array" and + all(.[]; + type == "object" + ) + then + . + else + error("target resources output is not valid object NDJSON") + end + ' "$artifact_file" >"$parsed_temporary_file"; then + rm -f "$parsed_temporary_file" + + fail \ + "$result_key" \ + "The target resources command returned invalid newline-delimited JSON." + fi + + if ! mv "$parsed_temporary_file" "$parsed_file"; then + rm -f "$parsed_temporary_file" + + fail \ + "$result_key" \ + "Failed to finalize parsed target resource evidence." + fi + + if ! resource_count="$( + jq -er ' + if type == "array" then + length + else + error("parsed target resources are not an array") + end + ' "$parsed_file" + )"; then + fail \ + "$result_key" \ + "Failed to count target resource records." + fi + + record_result \ + "$result_key" \ + "✅ pass" \ + "Target resources returned valid JSON with $resource_count resource record(s), capped at $max_results." +} diff --git a/script/e2e/scenarios/lifecycle.sh b/script/e2e/scenarios/lifecycle.sh new file mode 100644 index 0000000..2960a2a --- /dev/null +++ b/script/e2e/scenarios/lifecycle.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# +# Full migration lifecycle scenario for the gh-elm E2E harness. +# +# This scenario exercises creation, start, target-ID resolution, target +# resources, cutover, cutover completion, revert, and post-revert status +# verification. +# +# Pause and resume are intentionally excluded because the protected GHES +# sandbox uses the database-backed work scheduler, which does not currently +# support those operations. +# +# This file is sourced by script/e2e/test-elm-ghes.sh and defines the required +# run_scenario function. It is not intended to be executed directly. + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + printf 'This file must be sourced by script/e2e/test-elm-ghes.sh.\n' >&2 + exit 1 +fi + +run_scenario() { + local migration_id + local target_migration_id + + log "Starting the lifecycle E2E scenario." + + create_migration \ + "$TARGET_REPO_PRIMARY" \ + "Create lifecycle migration" + migration_id="$LAST_MIGRATION_ID" + + verify_status \ + "$migration_id" \ + "$TARGET_REPO_PRIMARY" \ + "Initial migration status" + + start_migration \ + "$migration_id" \ + "Start migration" + + target_migration_id="$( + resolve_target_migration_id \ + "$migration_id" \ + "Target migration ID" + )" + + verify_target_migration_id \ + "$target_migration_id" \ + "Target migration ID validation" + + record_result \ + "Pause and resume" \ + "⏭️ skip" \ + "Not exercised because the GHES sandbox uses the database-backed work scheduler." + + wait_for_cutover_readiness \ + "$migration_id" \ + "$E2E_CUTOVER_TIMEOUT_SECONDS" \ + "Wait for cutover readiness" + + verify_target_resources \ + "$target_migration_id" \ + "$TARGET_ORG/$TARGET_REPO_PRIMARY" \ + "Target resources" + + initiate_cutover \ + "$migration_id" \ + "Initiate cutover" + + wait_for_cutover_completion \ + "$migration_id" \ + "$E2E_CUTOVER_TIMEOUT_SECONDS" \ + "Wait for cutover completion" + + revert_cutover \ + "$migration_id" \ + "Revert cutover" + + verify_reverted_state \ + "$migration_id" \ + "$TARGET_REPO_PRIMARY" \ + "Verify reverted state" + + log "Lifecycle assertions completed." +} diff --git a/script/e2e/test-elm-ghes.sh b/script/e2e/test-elm-ghes.sh index 891ff4a..be474bc 100755 --- a/script/e2e/test-elm-ghes.sh +++ b/script/e2e/test-elm-ghes.sh @@ -1,14 +1,21 @@ #!/usr/bin/env bash # -# Control-plane E2E entrypoint for the gh-elm CLI against a protected GHES -# migration environment. +# E2E scenario entrypoint for the gh-elm CLI against a protected GHES migration +# environment. # -# The workflow builds, installs, and verifies the candidate before invoking -# this script. The control-plane scenario exercises migration creation, status -# retrieval, list pagination, explicit cancellation, and run-owned cleanup. +# The workflow builds, installs, and verifies the candidate once before invoking +# this script sequentially for each supported scenario: # -# Shared functionality lives under script/e2e/lib/. The scenario implementation -# lives in script/e2e/scenarios/control-plane.sh. +# control-plane +# Exercises migration creation, status retrieval, list pagination, +# cancellation, and run-owned cleanup. +# +# lifecycle +# Exercises migration creation, start, target-side APIs, cutover, cutover +# completion, revert, and post-revert verification. +# +# Shared functionality lives under script/e2e/lib/. Each scenario defines a +# run_scenario function under script/e2e/scenarios/. set -Eeuo pipefail @@ -32,11 +39,41 @@ source "$SCRIPT_DIR/lib/ownership.sh" # shellcheck source=script/e2e/lib/migration.sh source "$SCRIPT_DIR/lib/migration.sh" +# shellcheck source=script/e2e/lib/polling.sh +source "$SCRIPT_DIR/lib/polling.sh" + +# shellcheck source=script/e2e/lib/target.sh +source "$SCRIPT_DIR/lib/target.sh" + # shellcheck source=script/e2e/lib/cleanup.sh source "$SCRIPT_DIR/lib/cleanup.sh" -# shellcheck source=script/e2e/scenarios/control-plane.sh -source "$SCRIPT_DIR/scenarios/control-plane.sh" +load_scenario() { + case "$E2E_MODE" in + control-plane) + # shellcheck source=script/e2e/scenarios/control-plane.sh + source "$SCRIPT_DIR/scenarios/control-plane.sh" + ;; + lifecycle) + # shellcheck source=script/e2e/scenarios/lifecycle.sh + source "$SCRIPT_DIR/scenarios/lifecycle.sh" + ;; + *) + # validate_configuration should reject unsupported modes before this + # function runs. Keep this guard so E2E_MODE can never be used to derive + # an arbitrary source path. + fail \ + "Configuration" \ + "Unsupported E2E_MODE: $E2E_MODE. Expected control-plane or lifecycle." + ;; + esac + + if ! declare -F run_scenario >/dev/null 2>&1; then + fail \ + "Harness" \ + "Scenario $E2E_MODE did not define the required run_scenario function." + fi +} main() { # Create evidence before validation or external operations so early failures @@ -52,25 +89,20 @@ main() { validate_configuration configure_runtime write_evidence_header + load_scenario record_result \ "Configuration" \ "✅ pass" \ - "Required control-plane E2E configuration is present." + "Required $E2E_MODE E2E configuration is present." - # The workflow has already built, installed, and verified gh elm for this - # job. Scenario execution uses that existing installation. + # The workflow has already built, installed, and verified gh elm once for + # this job. Scenario execution uses that existing installation. run_preflight - if ! declare -F run_scenario >/dev/null 2>&1; then - fail \ - "Harness" \ - "The control-plane scenario did not define the required run_scenario function." - fi - run_scenario "$@" - log "Control-plane E2E scenario assertions completed." + log "$E2E_MODE E2E scenario assertions completed." } main "$@"