Skip to content

feat(pusher): harden native RollingUpdate (SCA-39 long-term rollout, DRAFT)#10280

Draft
Git-on-my-level wants to merge 3 commits into
mainfrom
sca-39/hardened-rolling-update
Draft

feat(pusher): harden native RollingUpdate (SCA-39 long-term rollout, DRAFT)#10280
Git-on-my-level wants to merge 3 commits into
mainfrom
sca-39/hardened-rolling-update

Conversation

@Git-on-my-level

@Git-on-my-level Git-on-my-level commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

SCA-39 — Harden pusher native RollingUpdate (long-term rollout strategy)

DRAFT — architecture review only. Implements the accepted PRIMARY from the
SCA-39 Phase-0 decision: harden the existing native Kubernetes RollingUpdate
for the pusher GKE workload. No Argo / Flagger / service mesh / Gateway API, and
no two-color (A2) experiment — those remain documented variants only.

Dependency / branch

  • Base: origin/main (241f1c4c4dc6). Not stacked on SCA-40 because SCA-40
    (c0590c68) was still in_progress with no published branch at start; this PR
    implements only the non-overlapping long-term pieces and must be rebased
    onto SCA-40's head if/when it lands (digest promotion / chart-only mode /
    REDIS_DB_HOST preflight / rollback evidence are SCA-40's and are not
    duplicated here). The quality-gate script explicitly asserts image.digest
    is absent
    and will need a lockstep update alongside SCA-40's digest field.

Changed surfaces

Runtime (Lane A)

  • backend/utils/readiness.py (new): reusable, dependency-free ReadinessGate
    (SERVING/DRAINING), thread-safe, idempotent begin_drain(); designed for
    pusher (wired) and the llm-gateway (planned).
  • backend/pusher/main.py: /health (liveness, 200 while alive), new /ready
    (503 when draining), new loopback-only POST /__internal/drain; lifespan
    shutdown calls begin_drain() first then drains background tasks.
  • backend/routers/pusher.py: rejects new WS (close 1001 before accept)
    during drain; in-flight sessions keep draining via their own bounded logic.
  • backend/utils/metrics.py: two low-cardinality gauges pusher_ready,
    pusher_drain_in_progress.

Helm/GKE contract (Lane B)

  • backend/charts/pusher/{dev,prod}_omi_pusher_values.yaml: readinessProbe
    /ready (liveness/startup stay /health); preStop now
    curl -sf -m 5 -X POST localhost:8080/__internal/drain || true; sleep 15
    (flip readiness early + NEG-convergence sleep); added
    backendConfig.connectionDraining.drainingTimeoutSec: 60.
  • backend/charts/pusher/templates/backendconfig.yaml: added
    connectionDraining.drainingTimeoutSec; healthCheck stays on /health
    (liveness semantics — routing it to /ready would defeat connectionDraining).

Quality gates + rollback contract (Lane D)

  • backend/scripts/verify_pusher_rollout_gate.py (new): fail-closed preflight
    (capacity headroom, image-identity, probe split, telemetry-present) + a
    read-only rollback contract (emits JSON; never executes). Missing telemetry
    ⇒ fail, not green.
  • backend/scripts/verify_pusher_rollout_budget.py: extended to also enforce the
    probe split, connectionDraining, drainingTimeoutSec ≤ grace, and the
    BackendConfig healthCheck invariant (the highest-value guard added from the
    refuter pass).

Docs (Lane E)

  • backend/docs/pusher_rolling_update_operations.md (new): honest availability
    contract, termination sequence, operator runbook, fail-closed gates, N/N-1
    compatibility checklist, dev qualification plan, SCA-40 relationship.

Honest availability contract (no overclaim)

In-flight WebSocket sessions are not migrated across a cutover over the
proxied ALB+NEG path. "Graceful drain" = zero new-connection rejection + a
bounded ~1–60s reconnect gap per affected session (backend-listen reconnects);
connectionDraining bounds how long the LB waits before cutting. No sub-second
or zero-session-impact cutover is claimed.

Tests

  • test_pusher_readiness_drain.py (6): serving/drain, loopback-only, idempotent,
    non-loopback 403, reset, real-route wiring + shutdown-drain.
  • test_verify_pusher_rollout_budget.py (+6) and test_verify_pusher_rollout_gate.py (6):
    good-chart passes; fail-closed regressions for probe routing, missing
    connectionDraining/healthCheck invariant, drain>grace, missing telemetry.
  • Verified: make setup; helm template dev+prod (liveness /health,
    readiness /ready, BackendConfig healthCheck /health, connectionDraining
    60 ≤ grace 120); 81 affected unit tests pass with the worktree venv;
    scripts/pr-preflight --suggest clean.

Known gaps / remaining

  • Build-once digest promotion (Phase-0 §7) is deferred to stack on SCA-40.
  • The live-scrape "missing telemetry = pause" gate is operator-discipline in this
    draft; the automated check is a static definition-presence scan only (tracked).
  • CI wiring of the new preflight gate as a required check is intentionally
    deferred (wiring it could itself rebuild); both scripts are runnable standalone.

Boundary (DRAFT)

No dev/prod deployment, no controller/CRD install, no production mutation, no
workflow dispatch. Production adoption is blocked on a healthy Pusher
baseline (SCA-36/SCA-40 repair) plus explicit approval — this PR is for
review only.

Review in cubic

@Git-on-my-level
Git-on-my-level marked this pull request as ready for review July 22, 2026 19:13
@Git-on-my-level
Git-on-my-level marked this pull request as draft July 22, 2026 19:15

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c5dc3f5562

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread backend/routers/pusher.py
Comment on lines +324 to +326
if not ReadinessGate.is_serving():
logger.info(f'Rejecting new WS for {uid}: pusher is draining')
await websocket.close(code=1001)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid tripping the pusher circuit during drain

When a new connection reaches a draining pod before the NEG/ALB has stopped routing to it, this closes the WebSocket before accept(). Starlette/Uvicorn turn a pre-accept close into a failed HTTP upgrade rather than a 1001 close frame, so backend-listen's connect_to_trigger_pusher() follows its exception path and records a circuit-breaker failure for each retry (backend/utils/pusher.py:153-159); enough normal sessions during a rollout can open the backend pod's pusher circuit even though other pusher pods are healthy. If the intent is to signal a graceful reconnect, accept then close with 1001, or teach the client not to count this drain response as a pusher failure.

Useful? React with 👍 / 👎.

Comment on lines +29 to +30
Stdlib-only: runs in the shared pre-push lane before backend dependencies are
installed.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Wire the new rollout gate into CI

This new quality gate says it runs in the shared pre-push lane, but rg verify_pusher_rollout_gate only finds the script, tests, and docs; it is not invoked from .github/checks-manifest.yaml, backend/testing/workflow_contracts.json, the pusher deploy workflows, or scripts/pre-push. That leaves the fail-closed telemetry/probe checks as an on-demand script, so rollouts can still merge/deploy with green required checks while this gate is never executed; please register it alongside verify_pusher_rollout_budget.py.

AGENTS.md reference: AGENTS.md:L143-L144

Useful? React with 👍 / 👎.

@Git-on-my-level
Git-on-my-level marked this pull request as ready for review July 22, 2026 19:24
SCA-39 long-term rollout strategy (DRAFT, architecture review only):
- split /ready (readiness) from /health (liveness) + loopback-only drain
  endpoint + reject new WS during drain (in-flight sessions keep draining)
- chart readinessProbe->/ready, BackendConfig connectionDraining (60s <= grace),
  preStop flips readiness before the NEG-convergence sleep
- fail-closed rollout preflight + read-only rollback contract; fail-closed on
  missing rollout-blocking telemetry
- operator/dev runbook + N/N-1 compatibility checklist + dev qualification plan

Defers build-once digest promotion to SCA-40 (owns digest/chart-only mode/
REDIS_DB_HOST preflight/rollback evidence); no deploy, no mutation, no CRDs.
Absorbs the durable long-term pieces from the superseded incident recovery
(#10278) into the hardened RollingUpdate contract — no Argo/Flagger/mesh/Gateway.

Exact-digest build-once promotion contract (incident-class artifact fix):
- chart renders repository@sha256:<hex> when image.digest is set; rejects
  malformed/ambiguous identity, and a digest + mutable tag, at render time
- rollout gate admits a digest only with tag dropped and pullPolicy IfNotPresent
  (the digest is the content address; Always only adds a registry round-trip);
  tag mode (chart delegates the tag to the deploy workflow) is unchanged
- rollback contract restore is digest-aware; stale SCA-40 ownership notes removed

Cross-resource shared-config migration guard (2026-07-22 incident root fix):
- new verify_shared_config_migration.py fails CLOSED before any mutation when a
  serving workload still references a ConfigMap/Secret source or key the proposed
  state removes or reclassifies — the exact partial transition that dropped prod
  Pusher to zero healthy pods while /health stayed green
- reads object/key NAMES only; never reads, prints, or stores values
- transition-aware: a key moving between objects is allowed only once no stale
  old-source reference remains

Discards the incident-only chart-only recovery CLI surface and its broad
normalize() (which could hide real chart policy drift), per SCA-39 objective.

Coverage: 4 new gate digest tests + 9 migration-guard fixtures incl. the
historical partial transition and fail-closed drift. End-to-end verified against
the real rendered dev Pusher chart (22 refs resolve; a depleted REDIS_DB_HOST
inventory is caught).

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c5dc3f5562

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +431 to +435
f"kubectl get deployment {release} " f"-o jsonpath='{{.spec.template.spec.containers[0].image}}'"
),
restore_command=(
f"helm upgrade --install {release} ./backend/charts/pusher "
f"--set image.tag=<prior-immutable-tag> "

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Emit a rollback command that restores the captured image

In rollback mode, the capture command reads the container .image field, which Kubernetes documents as the full image name, but the restore command only substitutes that captured value into image.tag. If an operator follows this contract during an incident, they either have to do undocumented parsing or can render an invalid image like gcr.io/.../pusher:gcr.io/.../pusher:<tag>; make the command capture just the tag/digest or have the restore command consume the full captured image, and use Helm's --set-string when setting tags as the deploy workflows already do.

Useful? React with 👍 / 👎.

@Git-on-my-level
Git-on-my-level force-pushed the sca-39/hardened-rolling-update branch from c5dc3f5 to 4dfdb5b Compare July 22, 2026 19:36
…ce digest pullPolicy

Independent review (reviewer + adversarial refuter) converged on one MAJOR finding: the shared-config migration guard scanned only explicit env.valueFrom refs and was blind to envFrom whole-object bulk loads. Pusher bulk-loads ALL ConfigMap keys via envFrom, so a key removed from that ConfigMap (the exact 2026-07-22 outage class) went undetected when it lacked an explicit ref.

- verify_shared_config_migration.py now scans envFrom sources: a bulk-loaded object must still exist, and (given a previous inventory) no key may be removed from it.
- Generalizes to every pod-template workload (Deployment/StatefulSet/DaemonSet/Job/CronJob/ReplicaSet) and scans initContainers.
- Chart render-enforces image.pullPolicy=IfNotPresent for a digest-pinned release (defense-in-depth matching the gate).

Coverage: +4 migration-guard tests (envFrom object/key removal, envFrom clean pass, StatefulSet+initContainer). 81 affected tests pass; real rendered dev Pusher now reports 22 per-key refs + 1 envFrom source.
@Git-on-my-level
Git-on-my-level marked this pull request as draft July 22, 2026 20:10
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.

1 participant