Skip to content

fix(cd): make prod data restore idempotent - #91

Open
rhanka wants to merge 1 commit into
mainfrom
fix/prod-data-restore-idempotent
Open

fix(cd): make prod data restore idempotent#91
rhanka wants to merge 1 commit into
mainfrom
fix/prod-data-restore-idempotent

Conversation

@rhanka

@rhanka rhanka commented Jul 4, 2026

Copy link
Copy Markdown
Member

Summary

  • use the current workflow/runtime manifests for data_only/rollback deploy jobs instead of old app tags
  • skip the Elasticsearch restore job when FORCE_RESTORE=false and the requested snapshot is already live with documents
  • avoid waiting for a restore job that was intentionally skipped

Production incident notes

  • cancelled stuck Release Prod run 28719768819 (data_only esdata_a8ba0f33_81f8aaf7)
  • deleted the obsolete Pending elasticsearch-restore Job
  • verified deces.matchid.io healthcheck remains OK

Tests

  • parsed .github/workflows/release-prod.yml as YAML
  • bash deploy/k8s/tests/prod-manifest.sh
  • live idempotence probe: current snapshot esdata_a8ba0f33_81f8aaf7 with 29,034,953 docs would skip restore

Summary by CodeRabbit

  • Bug Fixes
    • Improved production deployment behavior so the correct runtime version is used for data-only and rollback releases.
    • Made Elasticsearch restore handling more reliable by skipping repeat restores when the requested snapshot is already active and data is present.
    • Reduced unnecessary waiting during deploys when a restore has already been completed.

@coderabbitai

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Modifies the deploy-prod job in release-prod.yml to select checkout refs conditionally based on release_kind, passes FORCE_RESTORE/RELEASE_KIND/SNAPSHOT_NAME into the restore environment, adds a live Elasticsearch state check to skip redundant restores, and conditionally waits/logs the restore job.

Changes

Prod Deploy Restore Idempotency

Layer / File(s) Summary
Checkout ref selection
.github/workflows/release-prod.yml
Checkout ref for prod deploy now depends on release_kind, using the current sha for data_only/rollback instead of older prod-tag manifests.
Idempotent Elasticsearch restore application
.github/workflows/release-prod.yml
Environment now passes FORCE_RESTORE, RELEASE_KIND, and SNAPSHOT_NAME; workflow queries live Elasticsearch snapshot name and doc count to determine whether to skip applying the elasticsearch-restore job, and the wait/log step is conditioned on whether the restore was skipped.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Workflow
  participant BackendPod
  participant Elasticsearch
  participant RestoreJob as elasticsearch-restore job

  Workflow->>BackendPod: query current restore state
  BackendPod->>Elasticsearch: get snapshot_name and doc count
  Elasticsearch-->>BackendPod: snapshot_name, doc_count
  BackendPod-->>Workflow: return state

  alt snapshot matches, doc_count > 0, not FORCE_RESTORE
    Workflow->>Workflow: mark restore skipped
  else
    Workflow->>RestoreJob: apply restore overlay
  end

  alt restore not skipped
    Workflow->>RestoreJob: wait for completion, fetch logs
  else
    Workflow->>Workflow: print skip message
  end
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: making the production data restore flow idempotent.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/prod-data-restore-idempotent

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/release-prod.yml:
- Around line 431-435: The prod deploy job’s actions/checkout@v4 step is leaving
persisted Git credentials behind, which later shell/make/kubectl steps don’t
need. Update the checkout configuration in the release-prod workflow to disable
persisted credentials for this job, while keeping the existing ref logic intact.
Use the checkout step in the release context/job block as the location to apply
the credential-setting change.
- Around line 536-558: The pre-restore probe in the release workflow is too
brittle because the `backend_pod` lookup and `kubectl exec` checks can fail
under `set -e` and stop the job before the restore is applied. Update the
restore gate around `restore_already_current` so failures in the probe path (pod
missing, restarting, or exec error) are treated as “not already current” instead
of exiting, while still preserving the existing success path that compares
`live_snapshot` and `live_count` before deciding whether to skip
`elasticsearch-restore.job.yaml`.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4e983fab-6004-4f73-a04e-0d6b7433b743

📥 Commits

Reviewing files that changed from the base of the PR and between be88e8f and 055f41d.

📒 Files selected for processing (1)
  • .github/workflows/release-prod.yml

Comment on lines 431 to +435
- uses: actions/checkout@v4
with:
ref: ${{ needs.release-context.outputs.app_ref }}
# data_only/rollback must use the current workflow/runtime manifests;
# old prod tags can predate Kubernetes scheduling fixes.
ref: ${{ needs.release-context.outputs.release_kind == 'app_and_data' && needs.release-context.outputs.app_ref || github.sha }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Disable persisted checkout credentials in the prod deploy job.

This job does not appear to need Git credentials after checkout; avoid leaving the Actions token in the local git config for later shell/make/kubectl steps.

Proposed fix
       - uses: actions/checkout@v4
         with:
+          persist-credentials: false
           # data_only/rollback must use the current workflow/runtime manifests;
           # old prod tags can predate Kubernetes scheduling fixes.
           ref: ${{ needs.release-context.outputs.release_kind == 'app_and_data' && needs.release-context.outputs.app_ref || github.sha }}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- uses: actions/checkout@v4
with:
ref: ${{ needs.release-context.outputs.app_ref }}
# data_only/rollback must use the current workflow/runtime manifests;
# old prod tags can predate Kubernetes scheduling fixes.
ref: ${{ needs.release-context.outputs.release_kind == 'app_and_data' && needs.release-context.outputs.app_ref || github.sha }}
- uses: actions/checkout@v4
with:
persist-credentials: false
# data_only/rollback must use the current workflow/runtime manifests;
# old prod tags can predate Kubernetes scheduling fixes.
ref: ${{ needs.release-context.outputs.release_kind == 'app_and_data' && needs.release-context.outputs.app_ref || github.sha }}
🧰 Tools
🪛 zizmor (1.26.1)

[warning] 431-435: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/release-prod.yml around lines 431 - 435, The prod deploy
job’s actions/checkout@v4 step is leaving persisted Git credentials behind,
which later shell/make/kubectl steps don’t need. Update the checkout
configuration in the release-prod workflow to disable persisted credentials for
this job, while keeping the existing ref logic intact. Use the checkout step in
the release context/job block as the location to apply the credential-setting
change.

Source: Linters/SAST tools

Comment on lines +536 to +558
restore_already_current=false
if [ "${FORCE_RESTORE:-false}" != "true" ]; then
backend_pod="$(
kubectl -n "$NAMESPACE" get pod -l app.kubernetes.io/name=deces-backend,app.kubernetes.io/part-of=matchid -o jsonpath='{.items[0].metadata.name}'
)"
backend_pod="${backend_pod# }"
live_count="$(
kubectl -n "$NAMESPACE" exec "$backend_pod" -c deces-backend -- node -e 'require("http").get("http://elasticsearch:9200/deces/_count", r => { let d = ""; r.on("data", c => d += c); r.on("end", () => { try { console.log(JSON.parse(d).count || 0); } catch (_) { console.log(0); } }); }).on("error", () => console.log(0));'
)"
live_count="${live_count# }"
live_snapshot="$(
kubectl -n "$NAMESPACE" exec "$backend_pod" -c deces-backend -- node -e 'require("http").get("http://elasticsearch:9200/.matchid-restore-state/_doc/deces", r => { let d = ""; r.on("data", c => d += c); r.on("end", () => { try { console.log(JSON.parse(d)._source?.snapshot_name || ""); } catch (_) { console.log(""); } }); }).on("error", () => console.log(""));'
)"
live_snapshot="${live_snapshot# }"
if [ "$live_snapshot" = "$SNAPSHOT_NAME" ] && [ "${live_count:-0}" -gt 0 ]; then
restore_already_current=true
echo "Elasticsearch snapshot $SNAPSHOT_NAME is already live ($live_count docs); skipping restore job."
fi
fi
echo "RESTORE_ALREADY_CURRENT=$restore_already_current" >> "$GITHUB_ENV"
if [ "$restore_already_current" != "true" ]; then
kubectl -n "$NAMESPACE" apply -f deploy/k8s/overlays/prod/elasticsearch-restore.job.yaml
fi

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Fail open when the pre-restore probe cannot run.

Lines 538-548 make backend pod lookup/exec a hard gate under set -e; if the pod is missing, restarting, or exec fails, the workflow exits before applying the restore job. Treat probe failures as “not already current” and let elasticsearch-restore.job.yaml perform its own idempotent checks.

Proposed fix
             restore_already_current=false
             if [ "${FORCE_RESTORE:-false}" != "true" ]; then
               backend_pod="$(
                 kubectl -n "$NAMESPACE" get pod                   -l app.kubernetes.io/name=deces-backend,app.kubernetes.io/part-of=matchid                   -o jsonpath='{.items[0].metadata.name}'
-              )"
-              backend_pod="${backend_pod# }"
-              live_count="$(
-                kubectl -n "$NAMESPACE" exec "$backend_pod" -c deces-backend --                   node -e 'require("http").get("http://elasticsearch:9200/deces/_count", r => { let d = ""; r.on("data", c => d += c); r.on("end", () => { try { console.log(JSON.parse(d).count || 0); } catch (_) { console.log(0); } }); }).on("error", () => console.log(0));'
-              )"
-              live_count="${live_count# }"
-              live_snapshot="$(
-                kubectl -n "$NAMESPACE" exec "$backend_pod" -c deces-backend --                   node -e 'require("http").get("http://elasticsearch:9200/.matchid-restore-state/_doc/deces", r => { let d = ""; r.on("data", c => d += c); r.on("end", () => { try { console.log(JSON.parse(d)._source?.snapshot_name || ""); } catch (_) { console.log(""); } }); }).on("error", () => console.log(""));'
-              )"
-              live_snapshot="${live_snapshot# }"
+              )" || true
+              backend_pod="$(printf '%s' "$backend_pod" | tr -d '[:space:]')"
+              live_count=0
+              live_snapshot=""
+              if [ -n "$backend_pod" ]; then
+                live_count="$(
+                  kubectl -n "$NAMESPACE" exec "$backend_pod" -c deces-backend --                   node -e 'require("http").get("http://elasticsearch:9200/deces/_count", r => { let d = ""; r.on("data", c => d += c); r.on("end", () => { try { console.log(JSON.parse(d).count || 0); } catch (_) { console.log(0); } }); }).on("error", () => console.log(0));'
+                )" || true
+                live_count="$(printf '%s' "$live_count" | tr -d '[:space:]')"
+                case "$live_count" in ''|*[!0-9]*) live_count=0 ;; esac
+                live_snapshot="$(
+                  kubectl -n "$NAMESPACE" exec "$backend_pod" -c deces-backend --                   node -e 'require("http").get("http://elasticsearch:9200/.matchid-restore-state/_doc/deces", r => { let d = ""; r.on("data", c => d += c); r.on("end", () => { try { console.log(JSON.parse(d)._source?.snapshot_name || ""); } catch (_) { console.log(""); } }); }).on("error", () => console.log(""));'
+                )" || true
+                live_snapshot="$(printf '%s' "$live_snapshot" | tr -d '\r\n')"
+              else
+                echo "No backend pod available for pre-restore probe; applying restore job."
+              fi
               if [ "$live_snapshot" = "$SNAPSHOT_NAME" ] && [ "${live_count:-0}" -gt 0 ]; then
                 restore_already_current=true
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
restore_already_current=false
if [ "${FORCE_RESTORE:-false}" != "true" ]; then
backend_pod="$(
kubectl -n "$NAMESPACE" get pod -l app.kubernetes.io/name=deces-backend,app.kubernetes.io/part-of=matchid -o jsonpath='{.items[0].metadata.name}'
)"
backend_pod="${backend_pod# }"
live_count="$(
kubectl -n "$NAMESPACE" exec "$backend_pod" -c deces-backend -- node -e 'require("http").get("http://elasticsearch:9200/deces/_count", r => { let d = ""; r.on("data", c => d += c); r.on("end", () => { try { console.log(JSON.parse(d).count || 0); } catch (_) { console.log(0); } }); }).on("error", () => console.log(0));'
)"
live_count="${live_count# }"
live_snapshot="$(
kubectl -n "$NAMESPACE" exec "$backend_pod" -c deces-backend -- node -e 'require("http").get("http://elasticsearch:9200/.matchid-restore-state/_doc/deces", r => { let d = ""; r.on("data", c => d += c); r.on("end", () => { try { console.log(JSON.parse(d)._source?.snapshot_name || ""); } catch (_) { console.log(""); } }); }).on("error", () => console.log(""));'
)"
live_snapshot="${live_snapshot# }"
if [ "$live_snapshot" = "$SNAPSHOT_NAME" ] && [ "${live_count:-0}" -gt 0 ]; then
restore_already_current=true
echo "Elasticsearch snapshot $SNAPSHOT_NAME is already live ($live_count docs); skipping restore job."
fi
fi
echo "RESTORE_ALREADY_CURRENT=$restore_already_current" >> "$GITHUB_ENV"
if [ "$restore_already_current" != "true" ]; then
kubectl -n "$NAMESPACE" apply -f deploy/k8s/overlays/prod/elasticsearch-restore.job.yaml
fi
restore_already_current=false
if [ "${FORCE_RESTORE:-false}" != "true" ]; then
backend_pod="$(
kubectl -n "$NAMESPACE" get pod -l app.kubernetes.io/name=deces-backend,app.kubernetes.io/part-of=matchid -o jsonpath='{.items[0].metadata.name}'
)" || true
backend_pod="$(printf '%s' "$backend_pod" | tr -d '[:space:]')"
live_count=0
live_snapshot=""
if [ -n "$backend_pod" ]; then
live_count="$(
kubectl -n "$NAMESPACE" exec "$backend_pod" -c deces-backend -- node -e 'require("http").get("http://elasticsearch:9200/deces/_count", r => { let d = ""; r.on("data", c => d += c); r.on("end", () => { try { console.log(JSON.parse(d).count || 0); } catch (_) { console.log(0); } }); }).on("error", () => console.log(0));'
)" || true
live_count="$(printf '%s' "$live_count" | tr -d '[:space:]')"
case "$live_count" in ''|*[!0-9]*) live_count=0 ;; esac
live_snapshot="$(
kubectl -n "$NAMESPACE" exec "$backend_pod" -c deces-backend -- node -e 'require("http").get("http://elasticsearch:9200/.matchid-restore-state/_doc/deces", r => { let d = ""; r.on("data", c => d += c); r.on("end", () => { try { console.log(JSON.parse(d)._source?.snapshot_name || ""); } catch (_) { console.log(""); } }); }).on("error", () => console.log(""));'
)" || true
live_snapshot="$(printf '%s' "$live_snapshot" | tr -d '\r\n')"
else
echo "No backend pod available for pre-restore probe; applying restore job."
fi
if [ "$live_snapshot" = "$SNAPSHOT_NAME" ] && [ "${live_count:-0}" -gt 0 ]; then
restore_already_current=true
echo "Elasticsearch snapshot $SNAPSHOT_NAME is already live ($live_count docs); skipping restore job."
fi
fi
echo "RESTORE_ALREADY_CURRENT=$restore_already_current" >> "$GITHUB_ENV"
if [ "$restore_already_current" != "true" ]; then
kubectl -n "$NAMESPACE" apply -f deploy/k8s/overlays/prod/elasticsearch-restore.job.yaml
fi
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/release-prod.yml around lines 536 - 558, The pre-restore
probe in the release workflow is too brittle because the `backend_pod` lookup
and `kubectl exec` checks can fail under `set -e` and stop the job before the
restore is applied. Update the restore gate around `restore_already_current` so
failures in the probe path (pod missing, restarting, or exec error) are treated
as “not already current” instead of exiting, while still preserving the existing
success path that compares `live_snapshot` and `live_count` before deciding
whether to skip `elasticsearch-restore.job.yaml`.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants