Skip to content

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

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

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

Conversation

@fseldow

@fseldow fseldow commented Jul 27, 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 #2830 (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 ✅ (name pack/unpack, per-pod write path, aggregation logic + ExecutorPodStatusReconciler.Reconcile).

Note on RBAC / merge order with #2832

This PR keeps the legacy config.ratify.deislabs.io ClusterRole rules untouched so its RBAC diff is limited to the new per-pod permissions. Removing those dead rules is split into #2832. If #2832 merges first, rebase this branch onto main and drop the reintroduced config.ratify.deislabs.io block from config/rbac/role.yaml (make manifests regenerates the cleaned-up result).

Refs #2830, #2832.

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

This PR implements a Gatekeeper-style per-pod status reporting model for the cluster-scoped Executor to make multi-replica deployments safe (eliminating concurrent writes to a shared status object) and to stop status-update feedback loops by generation-filtering the Executor watch.

Changes:

  • Add a new namespaced CRD ExecutorPodStatus plus helpers to encode/decode deterministic per-pod object names.
  • Update ExecutorReconciler to write per-pod ExecutorPodStatus (and fall back to direct Executor.status updates when POD_NAME is unavailable), and add GenerationChangedPredicate to break reconcile storms.
  • Add ExecutorPodStatusReconciler to aggregate per-pod objects into Executor.status.byPod[], plus RBAC/env wiring and unit tests.

Reviewed changes

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

Show a summary per file
File Description
internal/podstatus/name.go Adds reversible name packing/unpacking for per-pod status objects.
internal/podstatus/name_test.go Unit tests for name packing/unpacking behavior.
internal/pod/info.go Adds pod.Name() sourced from POD_NAME.
internal/manager/manager.go Wires pod identity into ExecutorReconciler and registers the aggregation controller.
internal/controller/executorpodstatus_controller.go New controller to aggregate per-pod status objects back into Executor.status.byPod.
internal/controller/executorpodstatus_controller_test.go Tests for per-pod write path and aggregation logic.
internal/controller/executor_controller.go Writes per-pod ExecutorPodStatus, adds generation predicate, and adds conflict-retry for direct status fallback.
internal/controller/executor_controller_retry_test.go Tests conflict-retry and error recording for direct Executor.status writes.
config/rbac/role.yaml Adds RBAC for pods and executorpodstatuses resources/subresources.
config/manager/manager.yaml Injects POD_NAME and RATIFY_NAMESPACE via the downward API.
config/crd/kustomization.yaml Includes the new executorpodstatuses CRD base.
config/crd/bases/config.ratify.dev_executors.yaml Extends Executor.status schema with byPod.
config/crd/bases/config.ratify.dev_executorpodstatuses.yaml New CRD definition for ExecutorPodStatus.
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 ExecutorStatus.ByPod field and kubebuilder list-map annotations.
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 +47 to +49
func PackName(podName, executorName string) string {
return encode(podName) + "-" + encode(executorName)
}
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 +79 to +81
if itemExecutor == executorName {
byPod = append(byPod, list.Items[i].Status)
}
Comment on lines +254 to +258
if len(msg) <= maxBriefErrorLength {
return msg
}
return msg[:maxBriefErrorLength] + "..."
}
@fseldow
fseldow force-pushed the fix/2797-per-pod-executor-status branch from c0323de to 2d40241 Compare August 6, 2026 06:56
@codecov

codecov Bot commented Aug 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 84.02062% with 31 lines in your changes missing coverage. Please review.
✅ Project coverage is 77.27%. Comparing base (6ead8b6) to head (8f43a3a).

Files with missing lines Patch % Lines
internal/manager/manager.go 0.00% 10 Missing ⚠️
internal/controller/executor_controller.go 91.58% 5 Missing and 4 partials ⚠️
...nternal/controller/executorpodstatus_controller.go 85.45% 6 Missing and 2 partials ⚠️
internal/podstatus/name.go 80.00% 2 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2831      +/-   ##
==========================================
+ Coverage   76.89%   77.27%   +0.37%     
==========================================
  Files          90       92       +2     
  Lines        4276     4457     +181     
==========================================
+ Hits         3288     3444     +156     
- Misses        831      849      +18     
- Partials      157      164       +7     

☔ 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.

@fseldow
fseldow force-pushed the fix/2797-per-pod-executor-status branch 4 times, most recently from 319b0ff to 8dc3bce Compare August 6, 2026 08:38
…i-replica

Fixes notaryproject#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.
- Sync the ratify-gatekeeper-provider Helm chart: add the ExecutorPodStatus
  CRD, pods + executorpodstatuses RBAC, and the POD_NAME downward-API env so
  the deployed manager can start its per-pod status controllers.
- Make per-pod status writes reliable: write the status subresource from
  the object CreateOrUpdate returns (a re-read through the informer cache
  can miss a just-created object and drop the status via a non-retried
  NotFound), and requeue the Executor after a failed build so a transient
  startup error recovers on its own despite the generation-filtered watch.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: ratify-contrib <noreply@github.com>
@fseldow
fseldow force-pushed the fix/2797-per-pod-executor-status branch from 8dc3bce to 8f43a3a Compare August 6, 2026 08:49
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