Skip to content

feat(controller): per-pod Executor status (Gatekeeper-style) for multi-replica safety - #2827

Closed
fseldow wants to merge 1 commit into
mainfrom
fix/2797-per-pod-executor-status
Closed

feat(controller): per-pod Executor status (Gatekeeper-style) for multi-replica safety#2827
fseldow wants to merge 1 commit into
mainfrom
fix/2797-per-pod-executor-status

Conversation

@fseldow

@fseldow fseldow commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Description

Fixes #2797. This is the full Gatekeeper-style per-pod status remediation (item 5 in the issue). It is an alternative to the minimal #2825 (predicate + retry only); maintainers can pick either direction.

Problem

With replicas > 1, every pod runs its own ExecutorReconciler and they all write the same Executor.status concurrently:

  • 409 write conflicts are silently swallowed → lost updates.
  • No GenerationChangedPredicate → each status write re-triggers reconcile → executor rebuild storms against providers (e.g. AKV), amplified ×N.
  • A single succeeded/error field is last-writer-wins → it flaps and can't express "2 of 5 replicas unhealthy".

Approach — Gatekeeper's per-pod *PodStatus pattern

  • New namespaced CRD ExecutorPodStatus. Each pod owns exactly one object per Executor. The name embeds the pod identity via a reversible base32 packing (PackName/UnpackName), so no two pods share an object → no write conflicts. An owner reference to the pod means the object is garbage-collected when the pod is deleted. (Namespaced, because a cluster-scoped object can't be owned by a namespaced pod — same as Gatekeeper.)
  • ExecutorReconciler writes its own ExecutorPodStatus (create-or-update + Status().Update with RetryOnConflict) instead of the shared status. Each pod still builds its in-memory executor, so the data plane keeps working on every replica.
  • New ExecutorPodStatusReconciler watches all ExecutorPodStatus objects and rebuilds Executor.status.byPod[] (full, idempotent rebuild; a deleted pod's entry just disappears — recovered from the reversible name even on delete events). Parent updates are retried on conflict, not swallowed. Top-level succeeded/error/briefError are derived from the aggregate (N/M replicas unhealthy).
  • Keep GenerationChangedPredicate on the Executor watch to break the feedback loop. Out-of-cluster usage (no POD_NAME) falls back to writing Executor.status directly with RetryOnConflict.
  • Inject POD_NAME via the downward API; add pods get/list/watch + executorpodstatuses RBAC; regenerate CRDs/RBAC/deepcopy.

No feedback loop

ExecutorPodStatus is written only by ExecutorReconciler; the aggregator writes only Executor.status; the Executor watch is generation-filtered → the aggregator's writes never re-trigger ExecutorReconciler, and the aggregator never writes ExecutorPodStatus. Stable.

Testing

  • go build ./..., go vet ./..., gofmt
  • make manifests generate (controller-gen v0.18.0) ✅ — CRDs/RBAC/deepcopy regenerated
  • New unit tests ✅:
    • internal/podstatus: name pack/unpack round-trip, DNS-1123 compliance, uniqueness.
    • internal/controller: per-pod write path (writes ExecutorPodStatus with owner ref, leaves Executor.status untouched), aggregation logic + ExecutorPodStatusReconciler.Reconcile.

Note: the existing ginkgo envtest suite was not run locally due to an unrelated /etc/hosts misconfiguration (envtest apiserver can't bind); it compiles and its direct-Reconcile assertions still hold via the out-of-cluster fallback path.

Refs #2825.

Copilot AI review requested due to automatic review settings July 26, 2026 08:30
@github-actions github-actions Bot added the v2 label Jul 26, 2026
@codecov

codecov Bot commented Jul 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 67.55319% with 61 lines in your changes missing coverage. Please review.
✅ Project coverage is 76.02%. Comparing base (0e5f47a) to head (c0323de).

Files with missing lines Patch % Lines
internal/controller/executor_controller.go 71.56% 18 Missing and 11 partials ⚠️
...nternal/controller/executorpodstatus_controller.go 69.09% 11 Missing and 6 partials ⚠️
internal/manager/manager.go 0.00% 9 Missing ⚠️
internal/podstatus/name.go 80.00% 2 Missing and 2 partials ⚠️
internal/pod/info.go 0.00% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2827      +/-   ##
==========================================
- Coverage   76.36%   76.02%   -0.34%     
==========================================
  Files          88       90       +2     
  Lines        3999     4176     +177     
==========================================
+ Hits         3054     3175     +121     
- Misses        799      837      +38     
- Partials      146      164      +18     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Implements Gatekeeper-style per-replica status reporting for multi-replica safety by introducing a per-pod ExecutorPodStatus CRD written by each Ratify pod, plus an aggregation controller that rebuilds Executor.status.byPod without concurrent-writer conflicts. This fits into the controller/CRD layer by separating “per-pod health reporting” from “parent CR aggregate status”, while also breaking the status-update feedback loop via a generation predicate.

Changes:

  • Add namespaced ExecutorPodStatus CRD + Go API types/deepcopies and a reversible (base32) (pod, executor) name pack/unpack helper.
  • Update ExecutorReconciler to write per-pod status objects (with pod ownerRef) and add GenerationChangedPredicate to stop status-write feedback loops; add an aggregation reconciler to compute Executor.status.byPod and derived top-level fields.
  • Update RBAC and manager deployment to support pod identity discovery (POD_NAME) and the new CRD permissions.

Reviewed changes

Copilot reviewed 15 out of 16 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
internal/podstatus/name.go Adds base32 pack/unpack helpers and label keys for per-pod status object identity.
internal/podstatus/name_test.go Unit tests for name pack/unpack, DNS-1123 character compliance, and uniqueness.
internal/pod/info.go Adds pod.Name() helper to read POD_NAME.
internal/manager/manager.go Wires pod identity into ExecutorReconciler and registers the new aggregation reconciler.
internal/controller/executorpodstatus_controller.go New controller that aggregates ExecutorPodStatus into Executor.status.byPod and derived status.
internal/controller/executorpodstatus_controller_test.go Unit tests for per-pod write path and aggregation behavior.
internal/controller/executor_controller.go Moves status writes to per-pod objects (with conflict retries), adds generation predicate, and adds helper utilities.
internal/controller/executor_controller_retry_test.go Unit tests for retry-on-conflict behavior in the direct (out-of-cluster) status path.
config/rbac/role.yaml Updates manager ClusterRole for pods + executorpodstatuses + executor status updates.
config/manager/manager.yaml Injects POD_NAME and RATIFY_NAMESPACE into the manager pod via downward API.
config/crd/kustomization.yaml Adds the new executorpodstatuses CRD to the CRD kustomization.
config/crd/bases/config.ratify.dev_executors.yaml Extends Executor.status schema with byPod entries.
config/crd/bases/config.ratify.dev_executorpodstatuses.yaml New CRD definition for namespaced ExecutorPodStatus with status subresource.
api/v2alpha1/zz_generated.deepcopy.go Regenerates deep-copies for new types and ExecutorStatus.ByPod.
api/v2alpha1/executorpodstatus_types.go Adds API types for ExecutorPodStatus and PodStatusEntry.
api/v2alpha1/executor_types.go Adds ByPod []PodStatusEntry to ExecutorStatus.
Files not reviewed (1)
  • api/v2alpha1/zz_generated.deepcopy.go: Generated file

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +68 to +71
var list configv2alpha1.ExecutorPodStatusList
if err := r.List(ctx, &list); err != nil {
return ctrl.Result{}, fmt.Errorf("failed to list ExecutorPodStatus objects: %w", err)
}
Comment on lines +41 to +49
// PackName returns a deterministic, DNS-1123-compliant object name that embeds
// both the pod name and the executor name. Because the name is unique per
// (pod, executor) pair, no two pods ever target the same ExecutorPodStatus
// object, which eliminates write conflicts. The name is reversible via
// UnpackName so aggregation can recover the executor name even from a delete
// event (where only the object name is available).
func PackName(podName, executorName string) string {
return encode(podName) + "-" + encode(executorName)
}
Comment on lines +68 to +76
env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: RATIFY_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
…i-replica

Fixes #2797.

When the provider is scaled beyond replicas: 1, every pod runs its own
ExecutorReconciler and they all write the same Executor.status
concurrently: 409 write conflicts are silently lost, a status-write
feedback loop amplifies reconciles xN, and a single succeeded/error field
flaps under last-writer-wins so you cannot tell '2 of 5 replicas are
unhealthy'.

This adopts Gatekeeper's per-pod *PodStatus pattern:

- New namespaced CRD ExecutorPodStatus. Each pod owns exactly one object
  per Executor; the name embeds the pod identity via a reversible base32
  packing (PackName/UnpackName), so no two pods share an object -> no
  write conflicts. The object carries an owner reference to the pod, so
  it is garbage-collected automatically when the pod goes away.
- ExecutorReconciler now writes its own ExecutorPodStatus (create-or-
  update + status update with RetryOnConflict) instead of the shared
  Executor.status. Each pod still builds its in-memory executor, so the
  data plane keeps working on every replica.
- New ExecutorPodStatusReconciler watches all ExecutorPodStatus objects
  and rebuilds Executor.status.byPod[] (full, idempotent rebuild; a
  deleted pod's entry simply disappears). Parent updates are retried on
  conflict, not swallowed. Top-level succeeded/error/briefError are
  derived from the aggregate (e.g. 'N/M replicas unhealthy').
- Keep GenerationChangedPredicate on the Executor watch to break the
  status feedback loop. Out-of-cluster usage (no POD_NAME) falls back to
  writing Executor.status directly with RetryOnConflict.
- Inject POD_NAME via the downward API; add pods get/list/watch and
  executorpodstatuses RBAC; regenerate CRDs, RBAC and deepcopy.
- Unit tests for name packing, the per-pod write path, and aggregation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@fseldow

fseldow commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Note on merge order with #2829: this PR intentionally keeps the legacy config.ratify.deislabs.io ClusterRole rules untouched so its RBAC diff is limited to the new per-pod permissions. The removal of those dead rules is split into #2829.

If #2829 merges first, please rebase this branch onto main and drop the reintroduced config.ratify.deislabs.io block from config/rbac/role.yaml (running make manifests regenerates the correct, cleaned-up result).

@fseldow

fseldow commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #2831, opened from a fork following the standard contribution flow. The branch pushed directly to the upstream repo has been removed.

@fseldow fseldow closed this Jul 27, 2026
@fseldow
fseldow deleted the fix/2797-per-pod-executor-status branch July 27, 2026 03:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Executor status: concurrent writes from multiple replicas (no leader election, no predicate)

3 participants