Update work-controller to emit logs on ManifestWork apply (#1658)

* chore(work): vendor the ManifestWorkApplyLatency spoke-work feature gate

Pull in the new ManifestWorkApplyLatency spoke-work feature gate from
open-cluster-management.io/api and re-vendor.

Uses a temporary replace directive so the gate resolves before the upstream
api change is tagged. This replace MUST be dropped before merge: bump the api
require to the merged revision and re-run go mod vendor.

Signed-off-by: Ramesh Krishna <ramekris3163@gmail.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(work): emit apply-latency logs from the manifestwork reconciler

Add structured apply-latency logging to the spoke work agent, behind the
ManifestWorkApplyLatency feature gate (alpha, off by default), to measure
hub->spoke ManifestWork propagation latency.

On the first apply of a generation the reconciler emits, once per generation,
a rollup line at reconcile start (mw_spoke_apply, the propagation instant) and
a rollup line at reconcile end (mw_spoke_apply_result, with applied/failed/
read-only counts and outcome), plus a per-resource line (mw_resource_spoke_apply)
carrying each resource's outcome (read-only manifests report outcome=read_only,
observed but not applied). On a later reconcile of the same generation, a
resource re-emits (mw_resource_spoke_sync, with prev_outcome) only when its
outcome changed, so steady-state resyncs stay quiet.

The once-per-generation guard reads the persisted WorkApplied ObservedGeneration
and the per-resource change check reads the persisted ManifestApplied condition,
so both are restart-safe with no in-memory state. Timestamps mark the attempt,
not eventual success, so the delta reflects one-way propagation.

New apply_log.go holds the emitters and helpers; the reconciler threads the
ManifestWork identity, the gate flag, and the first-apply flag down to the
per-resource emit, and records each result's resolved strategy for the counts.
Covered by unit tests for the emitters and helpers and reconcile-level tests
for the gate, the once-per-generation dedup, and the gate-off path.

Signed-off-by: Ramesh Krishna <ramekris3163@gmail.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: adding json structured logs

Signed-off-by: suvaanshkumar <suvaanshkumar@gmail.com>

* feat: update ocm to emit json logs instead

Signed-off-by: Jeffrey Wong <jeffreywong0417@gmail.com>

* feat: revert temp changes used for testing

Signed-off-by: Jeffrey Wong <jeffreywong0417@gmail.com>

* feat: go mod tidy + vendor

Signed-off-by: Jeffrey Wong <jeffreywong0417@gmail.com>

* feat: updating flag and addressing review comments

Signed-off-by: suvaanshkumar <suvaanshkumar@gmail.com>

---------

Signed-off-by: Ramesh Krishna <ramekris3163@gmail.com>
Signed-off-by: suvaanshkumar <suvaanshkumar@gmail.com>
Signed-off-by: Jeffrey Wong <jeffreywong0417@gmail.com>
Co-authored-by: Ramesh Krishna <ramekris3163@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: suvaanshkumar <suvaanshkumar@gmail.com>
This commit is contained in:
Jeffrey
2026-08-24 23:42:53 +00:00
committed by GitHub
co-authored by Claude Opus 4.8 Ramesh Krishna suvaanshkumar
parent b8dd057783
commit d4b1c465cc
9 changed files with 973 additions and 9 deletions
+3 -3
View File
@@ -12,6 +12,7 @@ require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc
github.com/evanphx/json-patch v5.9.11+incompatible
github.com/ghodss/yaml v1.0.0
github.com/go-logr/logr v1.4.3
github.com/google/cel-go v0.29.2
github.com/google/go-cmp v0.7.0
github.com/itchyny/gojq v0.12.19
@@ -26,6 +27,7 @@ require (
github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1
github.com/valyala/fasttemplate v1.2.2
go.uber.org/zap v1.27.0
google.golang.org/grpc v1.83.0
gopkg.in/yaml.v2 v2.4.0
helm.sh/helm/v3 v3.21.1
@@ -40,7 +42,7 @@ require (
k8s.io/kubectl v0.35.4
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2
open-cluster-management.io/addon-framework v1.3.0
open-cluster-management.io/api v1.3.1-0.20260709055002-403378b57558
open-cluster-management.io/api v1.3.1-0.20260824155636-3ff9ad2f868d
open-cluster-management.io/sdk-go v1.3.1-0.20260713072928-c812bc0c3b06
sigs.k8s.io/about-api v0.0.0-20250131010323-518069c31c03
sigs.k8s.io/cluster-inventory-api v0.1.3
@@ -93,7 +95,6 @@ require (
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-logr/zapr v1.3.0 // indirect
github.com/go-openapi/jsonpointer v0.22.5 // indirect
@@ -166,7 +167,6 @@ require (
go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.54.0 // indirect
+2 -2
View File
@@ -612,8 +612,8 @@ k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0x
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
open-cluster-management.io/addon-framework v1.3.0 h1:rqW8Dl2Gcbac+8C4mQhOtbbmXb+JbIp3jPwP6AA5DBw=
open-cluster-management.io/addon-framework v1.3.0/go.mod h1:UP3lXnEKduMF+6CTPJKzsIahrO6Hd55MSPhDYo1OwaM=
open-cluster-management.io/api v1.3.1-0.20260709055002-403378b57558 h1:Ny12QGibGK2KAZ9KM0E89+jeqmYtQQi3fpi1dOLG5SA=
open-cluster-management.io/api v1.3.1-0.20260709055002-403378b57558/go.mod h1:/qLKuMbMS1+MpirTjaw4BoS3INKG8jV3s5J7iju48tg=
open-cluster-management.io/api v1.3.1-0.20260824155636-3ff9ad2f868d h1:ggO+UQBCE/hNxjTVdw4CUu2YWN/llXRJ7v+lHBrOXeo=
open-cluster-management.io/api v1.3.1-0.20260824155636-3ff9ad2f868d/go.mod h1:/qLKuMbMS1+MpirTjaw4BoS3INKG8jV3s5J7iju48tg=
open-cluster-management.io/sdk-go v1.3.1-0.20260713072928-c812bc0c3b06 h1:gpk3LkPj0zMI8lt0A619JQ5KzB5RjxQob5eiF7zeZ28=
open-cluster-management.io/sdk-go v1.3.1-0.20260713072928-c812bc0c3b06/go.mod h1:j2M5g9Shhgvh9Y79/T7ZI1iyokZKaZ7ricRa9yHrzd8=
sigs.k8s.io/about-api v0.0.0-20250131010323-518069c31c03 h1:1ShFiMjGQOR/8jTBkmZrk1gORxnvMwm1nOy2/DbHg4U=
@@ -0,0 +1,280 @@
package manifestcontroller
import (
"context"
"os"
"sync"
"time"
"github.com/go-logr/logr"
"go.uber.org/zap/zapcore"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/component-base/logs/json"
workapiv1 "open-cluster-management.io/api/work/v1"
)
// applyLogSink is the logger used for the apply-log lines below.
//
// These lines are a machine-consumed telemetry stream, not operator-facing prose: a log
// pipeline joins them to the hub's write-time line on {mw_namespace, mw_name, generation} to
// derive hub->spoke propagation latency, which requires every field to arrive as a first-class
// attribute. A collector can only do that if the whole line is JSON, and klog's format is
// process-global — so the agent's default text output would force every consumer to re-parse
// these fields out of a formatted string.
//
// This sink therefore emits the apply-log lines as JSON regardless of the process-wide
// logging format, while every other line in the agent keeps that format. It is built from
// component-base's own JSON logger, so the encoding is identical to what
// --logging-format=json produces (ts / caller / msg plus the structured key-values) rather
// than a format private to this package. Writes are serialised by zapcore.Lock, so a JSON
// line can never interleave with concurrent klog output on the same stream.
//
// Emission stays gated behind the ManifestWorkApplyLogs feature gate; when the gate is off
// nothing is written here at all. Overridden in tests to capture the emitted key-values.
var applyLogSink = newApplyLogSink()
func newApplyLogSink() logr.Logger {
// verbosity 0: these lines are unconditional Info once the feature gate admits them,
// so they must not be filtered by -v. nil errorStream keeps everything on stdout.
logger, _ := json.NewJSONLogger(0, zapcore.Lock(zapcore.AddSync(os.Stdout)), nil, nil)
return logger
}
// Flow discriminators for the spoke apply-log lines. Gated behind the
// ManifestWorkApplyLogs feature gate; paired with the hub webhook line (mw_hub_apply) for
// hub->spoke propagation-latency measurement in Datadog.
const (
flowSpokeApply = "mw_spoke_apply" // rollup start, once per generation (latency join key)
flowResourceSpokeApply = "mw_resource_spoke_apply" // per resource apply attempt, first apply of a generation (drill-down)
flowResourceSpokeSync = "mw_resource_spoke_sync" // per resource, later reconcile where the outcome changed
flowSpokeApplyResult = "mw_spoke_apply_result" // rollup end, once per generation (outcome)
)
// applyLogTimeFormat is RFC3339 with millisecond precision, UTC.
const applyLogTimeFormat = "2006-01-02T15:04:05.000Z07:00"
// Per-resource outcome values. read_only marks a resource tracked by a read-only manifest, which
// the agent observes but never writes, so it is neither applied nor failed.
const (
outcomeApplied = "applied"
outcomeFailed = "failed"
outcomeReadOnly = "read_only"
)
// workMeta carries the ManifestWork identity down to the per-resource apply emit, which runs
// deep in applyOneManifest where the ManifestWork object itself is no longer in scope.
type workMeta struct {
name string
namespace string
generation int64
labels map[string]string
}
// applyCounts is the per-generation apply tally carried by the rollup end line.
type applyCounts struct {
applied int
failed int
readOnly int
}
// emittedRollups records the newest generation whose rollup pair this agent process has already
// emitted, keyed by ManifestWork name (the work informer is scoped to the agent's own cluster
// namespace, so the name is unique within a process).
//
// It closes a retry window the persisted guard cannot see. priorAppliedGeneration reads
// WorkApplied.ObservedGeneration, but that value is written by sync *after* reconcile returns.
// When the write fails — a concurrent spec update conflicting on resourceVersion is the common
// case — the controller requeues and reconcile runs again against a status that still names the
// previous generation, so the persisted guard admits the same generation a second time and emits
// a duplicate mw_spoke_apply. That line is the key the propagation-latency join groups on, so a
// duplicate leaves the join with two candidate timestamps for one propagation event.
//
// This layers on top of the persisted guard rather than replacing it: a restarted agent starts
// with an empty ledger and falls back to the status it reads from the API server, which is the
// restart-safe behaviour D4 chose the persisted signal for.
var emittedRollups = newRollupGenerations()
// rollupGenerations is the process-local half of the once-per-generation guard.
type rollupGenerations struct {
mu sync.Mutex
seen map[string]int64
}
func newRollupGenerations() *rollupGenerations {
return &rollupGenerations{seen: map[string]int64{}}
}
// admit reports whether this process has yet to emit the rollup pair for this generation,
// recording it when so. Because it records, callers must place it last in a short-circuiting
// condition, so a work rejected by an earlier check is not marked as emitted.
func (r *rollupGenerations) admit(name string, generation int64) bool {
r.mu.Lock()
defer r.mu.Unlock()
if seen, ok := r.seen[name]; ok && seen >= generation {
return false
}
r.seen[name] = generation
return true
}
// forget drops a ManifestWork's entry once the work is gone, so the ledger tracks live works
// rather than every work the process has ever reconciled.
func (r *rollupGenerations) forget(name string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.seen, name)
}
// priorAppliedGeneration returns the ObservedGeneration of the WorkApplied condition, or -1
// when the condition is absent. This persisted value is the restart-safe signal used to emit
// the rollup lines exactly once per generation (a new agent pod reads the same status).
func priorAppliedGeneration(mw *workapiv1.ManifestWork) int64 {
if c := meta.FindStatusCondition(mw.Status.Conditions, workapiv1.WorkApplied); c != nil {
return c.ObservedGeneration
}
return -1
}
// applyOutcome maps an apply error to the per-resource outcome value.
func applyOutcome(err error) string {
if err != nil {
return outcomeFailed
}
return outcomeApplied
}
// priorManifestOutcome returns the outcome recorded by the resource's last-persisted
// ManifestApplied condition ("applied"/"failed"), or "" when unknown. It is the restart-safe
// signal used to detect a per-resource outcome change on a later reconcile.
func priorManifestOutcome(mc *workapiv1.ManifestCondition) string {
if mc == nil {
return ""
}
c := meta.FindStatusCondition(mc.Conditions, workapiv1.ManifestApplied)
if c == nil {
return ""
}
if c.Status == metav1.ConditionTrue {
return outcomeApplied
}
return outcomeFailed
}
// countApplyResults tallies results by strategy: read-only manifests apply nothing (counted
// separately), otherwise a non-nil error is a failure and success is an apply.
func countApplyResults(results []applyResult) (applied, failed, readOnly int) {
for _, r := range results {
switch {
case r.strategy == workapiv1.UpdateStrategyTypeReadOnly:
readOnly++
case r.Error != nil:
failed++
default:
applied++
}
}
return applied, failed, readOnly
}
// rollupOutcome maps the applying-manifest counts to the work-level outcome value. A work with no
// applying manifests (all read-only) reports read_only.
func rollupOutcome(applied, failed int) string {
switch {
case applied == 0 && failed == 0:
return outcomeReadOnly
case failed == 0:
return outcomeApplied
case applied == 0:
return outcomeFailed
default:
return "partial"
}
}
// emitResourceApply logs one per-resource apply-log line. It is a noop when the feature is
// disabled. Otherwise it emits:
// - flowResourceSpokeApply, on the first apply of a generation (firstApply), for every resource —
// read-only manifests get outcome=read_only (observed, not applied), others applied/failed; or
// - flowResourceSpokeSync, on a later reconcile of the same generation, only when a non-read-only
// resource's outcome changed vs prevOutcome (its last-persisted ManifestApplied result).
//
// Read-only manifests never emit a sync line (their outcome cannot change), and a steady-state resync
// with no outcome change emits nothing — this bounds the per-resource stream to the propagation event
// plus genuine recoveries/regressions.
func emitResourceApply(ctx context.Context, logApply, firstApply bool, wm workMeta, om orderedManifest,
strategy workapiv1.UpdateStrategyType, result applyResult, prevOutcome string) {
if !logApply {
return
}
readOnly := strategy == workapiv1.UpdateStrategyTypeReadOnly
outcome := applyOutcome(result.Error)
if readOnly {
outcome = outcomeReadOnly
}
flow := ""
switch {
case firstApply:
flow = flowResourceSpokeApply
case !readOnly && prevOutcome != "" && prevOutcome != outcome:
flow = flowResourceSpokeSync
default:
return
}
now := time.Now().UTC()
resourceVersion := ""
if result.Error == nil && result.Result != nil {
if accessor, err := meta.Accessor(result.Result); err == nil {
resourceVersion = accessor.GetResourceVersion()
}
}
kv := []any{
"flow", flow,
"mw_name", wm.name,
"mw_namespace", wm.namespace,
"generation", wm.generation,
"applied_kind", om.resourceMeta.Kind,
"applied_name", om.resourceMeta.Name,
"applied_namespace", om.resourceMeta.Namespace,
"applied_resource_version", resourceVersion,
"ts_utc", now.Format(applyLogTimeFormat),
"ts_epoch_ms", now.UnixMilli(),
"outcome", outcome,
"labels", wm.labels,
}
if flow == flowResourceSpokeSync {
kv = append(kv, "prev_outcome", prevOutcome)
}
applyLogSink.Info("manifestwork resource applied", kv...)
}
// emitApplyRollup logs one work-level rollup line. With counts == nil it is the start line
// (mw_spoke_apply, no outcome, stamped before the apply loop); with counts != nil it is the end
// line (mw_spoke_apply_result, carrying applied/failed/outcome, stamped after the apply loop).
func emitApplyRollup(ctx context.Context, wm workMeta, flow string, resourceCount int, counts *applyCounts) {
now := time.Now().UTC()
kv := []any{
"flow", flow,
"mw_name", wm.name,
"mw_namespace", wm.namespace,
"generation", wm.generation,
"resource_count", resourceCount,
"ts_utc", now.Format(applyLogTimeFormat),
"ts_epoch_ms", now.UnixMilli(),
"labels", wm.labels,
}
if counts != nil {
kv = append(kv,
"applied_count", counts.applied,
"failed_count", counts.failed,
"read_only_count", counts.readOnly,
"outcome", rollupOutcome(counts.applied, counts.failed),
)
}
applyLogSink.Info("manifestwork apply", kv...)
}
@@ -0,0 +1,466 @@
package manifestcontroller
import (
"context"
"errors"
"fmt"
"os"
"testing"
"github.com/go-logr/logr"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
ocmfeature "open-cluster-management.io/api/feature"
workapiv1 "open-cluster-management.io/api/work/v1"
testingcommon "open-cluster-management.io/ocm/pkg/common/testing"
"open-cluster-management.io/ocm/pkg/features"
"open-cluster-management.io/ocm/pkg/work/spoke/spoketesting"
)
// TestMain registers the spoke-work feature gates so reconcile's
// SpokeMutableFeatureGate.Enabled(ManifestWorkApplyLogs) check does not panic in tests.
func TestMain(m *testing.M) {
utilruntime.Must(features.SpokeMutableFeatureGate.Add(ocmfeature.DefaultSpokeWorkFeatureGates))
os.Exit(m.Run())
}
// logEntry is a single captured structured log line.
type logEntry struct {
msg string
kv map[string]any
}
// captureSink is a logr.LogSink that records Info lines and their key/value pairs
// so tests can assert on the structured attributes the emitters produce.
type captureSink struct {
entries *[]logEntry
}
func (s *captureSink) Init(logr.RuntimeInfo) {}
func (s *captureSink) Enabled(int) bool { return true }
func (s *captureSink) Error(error, string, ...any) {}
func (s *captureSink) WithValues(...any) logr.LogSink { return s }
func (s *captureSink) WithName(string) logr.LogSink { return s }
func (s *captureSink) Info(_ int, msg string, kv ...any) {
m := map[string]any{}
for i := 0; i+1 < len(kv); i += 2 {
key, ok := kv[i].(string)
if !ok {
continue
}
m[key] = kv[i+1]
}
*s.entries = append(*s.entries, logEntry{msg: msg, kv: m})
}
// captureContext swaps the package-level apply-log sink for a capturing one and returns a
// context plus the slice the emitted entries land in. The sink is package-level rather than
// context-carried because these lines are emitted as JSON independently of the process-wide
// klog format, so tests assert against the sink instead of a context logger.
func captureContext() (context.Context, *[]logEntry) {
entries := &[]logEntry{}
applyLogSink = logr.New(&captureSink{entries: entries})
return context.Background(), entries
}
func TestApplyOutcome(t *testing.T) {
if got := applyOutcome(nil); got != "applied" {
t.Errorf("applyOutcome(nil) = %q, want applied", got)
}
if got := applyOutcome(errors.New("boom")); got != "failed" {
t.Errorf("applyOutcome(err) = %q, want failed", got)
}
}
func TestRollupOutcome(t *testing.T) {
cases := []struct {
applied, failed int
want string
}{
{3, 0, "applied"},
{0, 2, "failed"},
{2, 1, "partial"},
{0, 0, "read_only"},
}
for _, c := range cases {
if got := rollupOutcome(c.applied, c.failed); got != c.want {
t.Errorf("rollupOutcome(%d,%d) = %q, want %q", c.applied, c.failed, got, c.want)
}
}
}
func TestCountApplyResults(t *testing.T) {
results := []applyResult{
{Error: nil}, // applied
{Error: errors.New("x")}, // failed
{Error: nil}, // applied
{strategy: workapiv1.UpdateStrategyTypeReadOnly}, // read-only, counted separately
}
applied, failed, readOnly := countApplyResults(results)
if applied != 2 || failed != 1 || readOnly != 1 {
t.Errorf("countApplyResults = (%d,%d,%d), want (2,1,1)", applied, failed, readOnly)
}
}
func TestPriorAppliedGeneration(t *testing.T) {
empty := &workapiv1.ManifestWork{}
if got := priorAppliedGeneration(empty); got != -1 {
t.Errorf("priorAppliedGeneration(no condition) = %d, want -1", got)
}
mw := &workapiv1.ManifestWork{}
mw.Status.Conditions = []metav1.Condition{{
Type: workapiv1.WorkApplied,
Status: metav1.ConditionTrue,
ObservedGeneration: 5,
}}
if got := priorAppliedGeneration(mw); got != 5 {
t.Errorf("priorAppliedGeneration = %d, want 5", got)
}
}
func testWorkMeta() workMeta {
return workMeta{
name: "demo-mw",
namespace: "cluster1",
generation: 7,
labels: map[string]string{"example.com/team": "platform"},
}
}
func testManifest() orderedManifest {
return orderedManifest{
resourceMeta: workapiv1.ManifestResourceMeta{
Kind: "ConfigMap",
Name: "cm1",
Namespace: "default",
},
}
}
func TestEmitResourceApply_GateOff(t *testing.T) {
ctx, entries := captureContext()
// logApply=false: never emits, even on a first apply.
emitResourceApply(ctx, false, true, testWorkMeta(), testManifest(),
workapiv1.UpdateStrategyTypeUpdate, applyResult{}, "")
if len(*entries) != 0 {
t.Errorf("gate off: expected no log, got %d", len(*entries))
}
}
func TestEmitResourceApply_ReadOnlyFirstApply(t *testing.T) {
ctx, entries := captureContext()
emitResourceApply(ctx, true, true, testWorkMeta(), testManifest(),
workapiv1.UpdateStrategyTypeReadOnly, applyResult{Result: &unstructured.Unstructured{}}, "")
if len(*entries) != 1 {
t.Fatalf("read-only first apply: expected 1 log, got %d", len(*entries))
}
kv := (*entries)[0].kv
assertKV(t, kv, "flow", flowResourceSpokeApply)
assertKV(t, kv, "outcome", outcomeReadOnly)
if _, ok := kv["prev_outcome"]; ok {
t.Error("read-only apply line must not carry prev_outcome")
}
}
func TestEmitResourceApply_ReadOnlyNoSyncOnResync(t *testing.T) {
ctx, entries := captureContext()
// firstApply=false with a differing prevOutcome: a read-only manifest must NOT emit a sync line
// (its outcome cannot change).
emitResourceApply(ctx, true, false, testWorkMeta(), testManifest(),
workapiv1.UpdateStrategyTypeReadOnly, applyResult{Result: &unstructured.Unstructured{}}, "applied")
if len(*entries) != 0 {
t.Errorf("read-only resync: expected no log, got %d", len(*entries))
}
}
func TestEmitResourceApply_FirstApplySuccess(t *testing.T) {
ctx, entries := captureContext()
obj := &unstructured.Unstructured{}
obj.SetResourceVersion("123")
emitResourceApply(ctx, true, true, testWorkMeta(), testManifest(),
workapiv1.UpdateStrategyTypeUpdate, applyResult{Result: obj}, "")
if len(*entries) != 1 {
t.Fatalf("expected 1 log, got %d", len(*entries))
}
kv := (*entries)[0].kv
assertKV(t, kv, "flow", flowResourceSpokeApply)
assertKV(t, kv, "mw_name", "demo-mw")
assertKV(t, kv, "mw_namespace", "cluster1")
assertKV(t, kv, "generation", int64(7))
assertKV(t, kv, "applied_kind", "ConfigMap")
assertKV(t, kv, "applied_name", "cm1")
assertKV(t, kv, "applied_namespace", "default")
assertKV(t, kv, "applied_resource_version", "123")
assertKV(t, kv, "outcome", "applied")
if _, ok := kv["prev_outcome"]; ok {
t.Error("first-apply line must not carry prev_outcome")
}
if _, ok := kv["ts_epoch_ms"]; !ok {
t.Error("missing ts_epoch_ms")
}
}
func TestEmitResourceApply_FirstApplyFailure(t *testing.T) {
ctx, entries := captureContext()
emitResourceApply(ctx, true, true, testWorkMeta(), testManifest(),
workapiv1.UpdateStrategyTypeServerSideApply, applyResult{Error: errors.New("denied")}, "")
if len(*entries) != 1 {
t.Fatalf("expected 1 log, got %d", len(*entries))
}
kv := (*entries)[0].kv
assertKV(t, kv, "flow", flowResourceSpokeApply)
assertKV(t, kv, "outcome", "failed")
assertKV(t, kv, "applied_resource_version", "")
}
// Resync (firstApply=false) where the resource's outcome changed emits the sync flow.
func TestEmitResourceApply_SyncOutcomeChanged(t *testing.T) {
ctx, entries := captureContext()
obj := &unstructured.Unstructured{}
obj.SetResourceVersion("456")
emitResourceApply(ctx, true, false, testWorkMeta(), testManifest(),
workapiv1.UpdateStrategyTypeUpdate, applyResult{Result: obj}, "failed")
if len(*entries) != 1 {
t.Fatalf("expected 1 log, got %d", len(*entries))
}
kv := (*entries)[0].kv
assertKV(t, kv, "flow", flowResourceSpokeSync)
assertKV(t, kv, "outcome", "applied")
assertKV(t, kv, "prev_outcome", "failed")
}
// Resync where the outcome is unchanged emits nothing.
func TestEmitResourceApply_SyncNoChange(t *testing.T) {
ctx, entries := captureContext()
obj := &unstructured.Unstructured{}
emitResourceApply(ctx, true, false, testWorkMeta(), testManifest(),
workapiv1.UpdateStrategyTypeUpdate, applyResult{Result: obj}, "applied")
if len(*entries) != 0 {
t.Errorf("unchanged resync: expected no log, got %d", len(*entries))
}
}
// Resync with no known prior outcome emits nothing (cannot determine a change).
func TestEmitResourceApply_SyncUnknownPrev(t *testing.T) {
ctx, entries := captureContext()
emitResourceApply(ctx, true, false, testWorkMeta(), testManifest(),
workapiv1.UpdateStrategyTypeUpdate, applyResult{}, "")
if len(*entries) != 0 {
t.Errorf("unknown prev: expected no log, got %d", len(*entries))
}
}
func TestPriorManifestOutcome(t *testing.T) {
if got := priorManifestOutcome(nil); got != "" {
t.Errorf("nil condition = %q, want empty", got)
}
applied := &workapiv1.ManifestCondition{Conditions: []metav1.Condition{{
Type: workapiv1.ManifestApplied, Status: metav1.ConditionTrue,
}}}
if got := priorManifestOutcome(applied); got != "applied" {
t.Errorf("applied condition = %q, want applied", got)
}
failed := &workapiv1.ManifestCondition{Conditions: []metav1.Condition{{
Type: workapiv1.ManifestApplied, Status: metav1.ConditionFalse,
}}}
if got := priorManifestOutcome(failed); got != "failed" {
t.Errorf("failed condition = %q, want failed", got)
}
}
func TestEmitApplyRollup_Start(t *testing.T) {
ctx, entries := captureContext()
emitApplyRollup(ctx, testWorkMeta(), flowSpokeApply, 3, nil)
if len(*entries) != 1 {
t.Fatalf("expected 1 log, got %d", len(*entries))
}
kv := (*entries)[0].kv
assertKV(t, kv, "flow", flowSpokeApply)
assertKV(t, kv, "generation", int64(7))
assertKV(t, kv, "resource_count", 3)
if _, ok := kv["outcome"]; ok {
t.Error("start line must not carry outcome")
}
if _, ok := kv["applied_count"]; ok {
t.Error("start line must not carry applied_count")
}
}
func TestEmitApplyRollup_End(t *testing.T) {
ctx, entries := captureContext()
emitApplyRollup(ctx, testWorkMeta(), flowSpokeApplyResult, 6,
&applyCounts{applied: 2, failed: 1, readOnly: 3})
if len(*entries) != 1 {
t.Fatalf("expected 1 log, got %d", len(*entries))
}
kv := (*entries)[0].kv
assertKV(t, kv, "flow", flowSpokeApplyResult)
assertKV(t, kv, "resource_count", 6)
assertKV(t, kv, "applied_count", 2)
assertKV(t, kv, "failed_count", 1)
assertKV(t, kv, "read_only_count", 3)
assertKV(t, kv, "outcome", "partial")
}
func assertKV(t *testing.T, kv map[string]any, key string, want any) {
t.Helper()
got, ok := kv[key]
if !ok {
t.Errorf("missing key %q", key)
return
}
if got != want {
t.Errorf("key %q = %v (%T), want %v (%T)", key, got, got, want, want)
}
}
func flowCounts(entries *[]logEntry) map[string]int {
counts := map[string]int{}
for _, e := range *entries {
if f, ok := e.kv["flow"].(string); ok {
counts[f]++
}
}
return counts
}
// resetRollupState gives a test a clean process-local emit ledger, so generation state does not
// leak between reconcile-level tests in either direction.
func resetRollupState(t *testing.T) {
t.Helper()
emittedRollups = newRollupGenerations()
t.Cleanup(func() { emittedRollups = newRollupGenerations() })
}
func setApplyLogsGate(t *testing.T, enabled bool) {
t.Helper()
if err := features.SpokeMutableFeatureGate.Set(
fmt.Sprintf("%s=%t", ocmfeature.ManifestWorkApplyLogs, enabled)); err != nil {
t.Fatal(err)
}
}
// TestReconcileEmitsApplyLogLines drives a full reconcile with the gate on for a new
// generation and asserts all three lines fire exactly once.
func TestReconcileEmitsApplyLogLines(t *testing.T) {
setApplyLogsGate(t, true)
defer setApplyLogsGate(t, false)
resetRollupState(t)
work, workKey := newTestCase("emit").
withWorkManifest(testingcommon.NewUnstructured("v1", "Secret", "ns1", "test")).
newManifestWork()
controller := newController(t, work, nil, spoketesting.NewFakeRestMapper()).
withKubeObject().withUnstructuredObject()
syncContext := testingcommon.NewFakeSyncContext(t, workKey)
ctx, entries := captureContext()
if err := controller.toController().sync(ctx, syncContext, work.Name); err != nil {
t.Fatalf("sync: %v", err)
}
got := flowCounts(entries)
if got[flowSpokeApply] != 1 || got[flowResourceSpokeApply] != 1 || got[flowSpokeApplyResult] != 1 {
t.Errorf("flow counts = %v, want each of mw_spoke_apply/mw_resource_spoke_apply/mw_spoke_apply_result == 1", got)
}
}
// TestReconcileDedupsRollupBySameGeneration verifies that when the generation has already been
// applied (WorkApplied.ObservedGeneration == generation), no apply-log lines are emitted —
// the once-per-generation guard suppresses a resync.
func TestReconcileDedupsRollupBySameGeneration(t *testing.T) {
setApplyLogsGate(t, true)
defer setApplyLogsGate(t, false)
resetRollupState(t)
work, workKey := newTestCase("dedup").
withWorkManifest(testingcommon.NewUnstructured("v1", "Secret", "ns1", "test")).
withExistingWorkCondition(newCondition(workapiv1.WorkApplied, "True", "", "", 0, nil)).
newManifestWork()
controller := newController(t, work, nil, spoketesting.NewFakeRestMapper()).
withKubeObject().withUnstructuredObject()
syncContext := testingcommon.NewFakeSyncContext(t, workKey)
ctx, entries := captureContext()
if err := controller.toController().sync(ctx, syncContext, work.Name); err != nil {
t.Fatalf("sync: %v", err)
}
if got := flowCounts(entries); len(got) != 0 {
t.Errorf("flow counts = %v, want none (generation already applied)", got)
}
}
// TestReconcileRollupSurvivesUnpersistedStatus covers the retry window the persisted guard cannot
// see. sync emits the rollup pair and then writes WorkApplied.ObservedGeneration; when that write
// fails the controller requeues and reconciles the same generation again. Driving sync twice
// against a lister that never observes the status update is that exact sequence, and the latency
// join key must still be emitted once.
func TestReconcileRollupSurvivesUnpersistedStatus(t *testing.T) {
setApplyLogsGate(t, true)
defer setApplyLogsGate(t, false)
resetRollupState(t)
work, workKey := newTestCase("retry").
withWorkManifest(testingcommon.NewUnstructured("v1", "Secret", "ns1", "test")).
newManifestWork()
// Seed the AppliedManifestWork the controller would otherwise create on the first sync. The
// fake lister is not fed by that create, so without it the second sync fails on AlreadyExists
// before ever reaching reconcile. The name mirrors applyAppliedManifestWork's
// "<hubHash>-<workName>", and the test controller's hubHash is empty.
appliedWork := &workapiv1.AppliedManifestWork{
ObjectMeta: metav1.ObjectMeta{
Name: "-" + work.Name,
Finalizers: []string{workapiv1.AppliedManifestWorkFinalizer},
},
Spec: workapiv1.AppliedManifestWorkSpec{ManifestWorkName: work.Name},
}
controller := newController(t, work, appliedWork, spoketesting.NewFakeRestMapper()).
withKubeObject().withUnstructuredObject()
syncContext := testingcommon.NewFakeSyncContext(t, workKey)
ctx, entries := captureContext()
for i := range 2 {
if err := controller.toController().sync(ctx, syncContext, work.Name); err != nil {
t.Fatalf("sync %d: %v", i+1, err)
}
}
got := flowCounts(entries)
if got[flowSpokeApply] != 1 || got[flowSpokeApplyResult] != 1 {
t.Errorf("flow counts = %v, want mw_spoke_apply and mw_spoke_apply_result == 1 across both syncs", got)
}
}
// TestReconcileNoEmitWhenGateOff confirms a full reconcile emits no apply-log lines with the
// gate disabled.
func TestReconcileNoEmitWhenGateOff(t *testing.T) {
setApplyLogsGate(t, false)
resetRollupState(t)
work, workKey := newTestCase("gate-off").
withWorkManifest(testingcommon.NewUnstructured("v1", "Secret", "ns1", "test")).
newManifestWork()
controller := newController(t, work, nil, spoketesting.NewFakeRestMapper()).
withKubeObject().withUnstructuredObject()
syncContext := testingcommon.NewFakeSyncContext(t, workKey)
ctx, entries := captureContext()
if err := controller.toController().sync(ctx, syncContext, work.Name); err != nil {
t.Fatalf("sync: %v", err)
}
if got := flowCounts(entries); len(got) != 0 {
t.Errorf("flow counts = %v, want none (gate off)", got)
}
}
@@ -132,6 +132,8 @@ func (m *ManifestWorkController) sync(ctx context.Context, controllerContext fac
oldManifestWork, err := m.manifestWorkLister.Get(manifestWorkName)
if apierrors.IsNotFound(err) {
// work not found, could have been deleted, do nothing.
// Drop any apply-log emit state so the ledger tracks live works only.
emittedRollups.forget(manifestWorkName)
return nil
}
if err != nil {
@@ -17,11 +17,13 @@ import (
"k8s.io/client-go/util/retry"
"k8s.io/klog/v2"
ocmfeature "open-cluster-management.io/api/feature"
workapiv1 "open-cluster-management.io/api/work/v1"
"open-cluster-management.io/sdk-go/pkg/basecontroller/events"
"open-cluster-management.io/sdk-go/pkg/basecontroller/factory"
commonhelper "open-cluster-management.io/ocm/pkg/common/helpers"
"open-cluster-management.io/ocm/pkg/features"
"open-cluster-management.io/ocm/pkg/work/helper"
"open-cluster-management.io/ocm/pkg/work/spoke/apply"
"open-cluster-management.io/ocm/pkg/work/spoke/auth"
@@ -33,6 +35,9 @@ type applyResult struct {
Error error
resourceMeta workapiv1.ManifestResourceMeta
// strategy is the resolved update strategy for this manifest, used to classify the apply-log
// rollup counts (read-only manifests apply nothing and are counted separately).
strategy workapiv1.UpdateStrategyType
}
// resourceApplyOrder defines the priority rank for applying resources by kind.
@@ -112,12 +117,36 @@ func (m *manifestworkReconciler) reconcile(
// We creat a ownerref instead of controller ref since multiple controller can declare the ownership of a manifests
owner := helper.NewAppliedManifestWorkOwner(appliedManifestWork)
// Emit apply logs behind the ManifestWorkApplyLogs gate. The per-resource
// line is emitted deep in applyOneManifest; the two work-level rollup lines are emitted here, once
// per generation, gated on the persisted WorkApplied.ObservedGeneration (restart-safe).
wm := workMeta{
name: manifestWork.Name,
namespace: manifestWork.Namespace,
generation: manifestWork.Generation,
labels: manifestWork.Labels,
}
logApply := features.SpokeMutableFeatureGate.Enabled(ocmfeature.ManifestWorkApplyLogs)
// emittedRollups.admit records, so it stays last: the checks before it decide whether this
// generation is a candidate at all, and a work they reject must not be marked as emitted.
emitRollup := logApply &&
len(manifestWork.Spec.Workload.Manifests) > 0 &&
priorAppliedGeneration(manifestWork) < manifestWork.Generation &&
emittedRollups.admit(manifestWork.Name, manifestWork.Generation)
if emitRollup {
emitApplyRollup(ctx, wm, flowSpokeApply, len(manifestWork.Spec.Workload.Manifests), nil)
}
var errs []error
// Apply resources on spoke cluster.
resourceResults := make([]applyResult, len(manifestWork.Spec.Workload.Manifests))
// A line is emitted per resource apply attempt from inside this retry loop, not once after the
// loop settles, so the timestamp is when the hub change first reached this cluster — that is what
// the hub->spoke latency is calculated from. One line per manifestwork resource apply attempt,
// plus the work-level line at the end of a successful apply.
err := retry.RetryOnConflict(retry.DefaultBackoff, func() error {
resourceResults = m.applyManifests(
ctx, manifestWork.Spec.Workload.Manifests, manifestWork.Spec, manifestWork.Status, controllerContext.Recorder(), *owner, resourceResults)
ctx, manifestWork.Spec.Workload.Manifests, manifestWork.Spec, manifestWork.Status, controllerContext.Recorder(), *owner, wm, logApply, emitRollup, resourceResults)
for _, result := range resourceResults {
if apierrors.IsConflict(result.Error) {
@@ -191,6 +220,13 @@ func (m *manifestworkReconciler) reconcile(
)
}
// Work-level rollup end line, paired with the start line above (same guard).
if emitRollup {
applied, failed, readOnly := countApplyResults(resourceResults)
emitApplyRollup(ctx, wm, flowSpokeApplyResult, len(resourceResults),
&applyCounts{applied: applied, failed: failed, readOnly: readOnly})
}
return manifestWork, appliedManifestWork, resourceResults, err
}
@@ -244,6 +280,9 @@ func (m *manifestworkReconciler) applyManifests(
workStatus workapiv1.ManifestWorkStatus,
recorder events.Recorder,
owner metav1.OwnerReference,
wm workMeta,
logApply bool,
firstApply bool,
existingResults []applyResult) []applyResult {
ordered := m.parseAndSortManifests(manifests)
@@ -256,7 +295,7 @@ func (m *manifestworkReconciler) applyManifests(
if om.err != nil {
existingResults[om.specIndex] = applyResult{Error: om.err, resourceMeta: om.resourceMeta}
} else {
existingResults[om.specIndex] = m.applyOneManifest(ctx, om, workSpec, workStatus, recorder, owner)
existingResults[om.specIndex] = m.applyOneManifest(ctx, om, workSpec, workStatus, recorder, owner, wm, logApply, firstApply)
}
}
@@ -269,7 +308,10 @@ func (m *manifestworkReconciler) applyOneManifest(
workSpec workapiv1.ManifestWorkSpec,
workStatus workapiv1.ManifestWorkStatus,
recorder events.Recorder,
owner metav1.OwnerReference) applyResult {
owner metav1.OwnerReference,
wm workMeta,
logApply bool,
firstApply bool) applyResult {
logger := klog.FromContext(ctx)
result := applyResult{resourceMeta: om.resourceMeta}
@@ -309,9 +351,15 @@ func (m *manifestworkReconciler) applyOneManifest(
strategy = *option.UpdateStrategy
}
result.strategy = strategy.Type
applier := m.appliers.GetApplier(strategy.Type)
result.Result, result.Error = applier.Apply(ctx, om.gvr, om.obj, requiredOwner, option, recorder)
// Per-resource apply-log line. On the first apply of a generation it emits the
// apply flow; on a later reconcile it emits the sync flow only when the outcome changed vs the
// last-persisted ManifestApplied condition. Noop when gated off or read-only.
emitResourceApply(ctx, logApply, firstApply, wm, om, strategy.Type, result, priorManifestOutcome(manifestCondition))
return result
}
+159
View File
@@ -0,0 +1,159 @@
/*
Copyright 2020 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package json
import (
"io"
"sync/atomic"
"time"
"github.com/go-logr/logr"
"github.com/go-logr/zapr"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"k8s.io/component-base/featuregate"
logsapi "k8s.io/component-base/logs/api/v1"
)
var (
// timeNow stubbed out for testing
timeNow = time.Now
)
type runtime struct {
v uint32
}
func (r *runtime) ZapV() zapcore.Level {
// zap levels are inverted: everything with a verbosity >= threshold gets logged.
return -zapcore.Level(atomic.LoadUint32(&r.v))
}
// Enabled implements the zapcore.LevelEnabler interface.
func (r *runtime) Enabled(level zapcore.Level) bool {
return level >= r.ZapV()
}
func (r *runtime) SetVerbosityLevel(v uint32) error {
atomic.StoreUint32(&r.v, v)
return nil
}
var _ zapcore.LevelEnabler = &runtime{}
// NewJSONLogger creates a new json logr.Logger and its associated
// control interface. The separate error stream is optional and may be nil.
// The encoder config is also optional.
func NewJSONLogger(v logsapi.VerbosityLevel, infoStream, errorStream zapcore.WriteSyncer, encoderConfig *zapcore.EncoderConfig) (logr.Logger, logsapi.RuntimeControl) {
r := &runtime{v: uint32(v)}
if encoderConfig == nil {
encoderConfig = &zapcore.EncoderConfig{
MessageKey: "msg",
CallerKey: "caller",
NameKey: "logger",
TimeKey: "ts",
EncodeTime: epochMillisTimeEncoder,
EncodeDuration: zapcore.StringDurationEncoder,
EncodeCaller: zapcore.ShortCallerEncoder,
}
}
encoder := zapcore.NewJSONEncoder(*encoderConfig)
var core zapcore.Core
if errorStream == nil {
core = zapcore.NewCore(encoder, infoStream, r)
} else {
highPriority := zap.LevelEnablerFunc(func(lvl zapcore.Level) bool {
return lvl >= zapcore.ErrorLevel && r.Enabled(lvl)
})
lowPriority := zap.LevelEnablerFunc(func(lvl zapcore.Level) bool {
return lvl < zapcore.ErrorLevel && r.Enabled(lvl)
})
core = zapcore.NewTee(
zapcore.NewCore(encoder, errorStream, highPriority),
zapcore.NewCore(encoder, infoStream, lowPriority),
)
}
l := zap.New(core, zap.WithCaller(true))
return zapr.NewLoggerWithOptions(l, zapr.LogInfoLevel("v"), zapr.ErrorKey("err")),
logsapi.RuntimeControl{
SetVerbosityLevel: r.SetVerbosityLevel,
Flush: func() {
_ = l.Sync()
},
}
}
func epochMillisTimeEncoder(_ time.Time, enc zapcore.PrimitiveArrayEncoder) {
nanos := timeNow().UnixNano()
millis := float64(nanos) / float64(time.Millisecond)
enc.AppendFloat64(millis)
}
// Factory produces JSON logger instances.
type Factory struct{}
var _ logsapi.LogFormatFactory = Factory{}
func (f Factory) Feature() featuregate.Feature {
return logsapi.LoggingBetaOptions
}
func (f Factory) Create(c logsapi.LoggingConfiguration, o logsapi.LoggingOptions) (logr.Logger, logsapi.RuntimeControl) {
// We intentionally avoid all os.File.Sync calls. Output is unbuffered,
// therefore we don't need to flush, and calling the underlying fsync
// would just slow down writing.
//
// The assumption is that logging only needs to ensure that data gets
// written to the output stream before the process terminates, but
// doesn't need to worry about data not being written because of a
// system crash or powerloss.
stderr := zapcore.Lock(AddNopSync(o.ErrorStream))
if c.Options.JSON.SplitStream {
stdout := zapcore.Lock(AddNopSync(o.InfoStream))
size := c.Options.JSON.InfoBufferSize.Value()
if size > 0 {
// Prevent integer overflow.
if size > 2*1024*1024*1024 {
size = 2 * 1024 * 1024 * 1024
}
stdout = &zapcore.BufferedWriteSyncer{
WS: stdout,
Size: int(size),
}
}
// stdout for info messages, stderr for errors.
return NewJSONLogger(c.Verbosity, stdout, stderr, nil)
}
// Write info messages and errors to stderr to prevent mixing with normal program output.
return NewJSONLogger(c.Verbosity, stderr, nil, nil)
}
// AddNoSync adds a NOP Sync implementation.
func AddNopSync(writer io.Writer) zapcore.WriteSyncer {
return nopSync{Writer: writer}
}
type nopSync struct {
io.Writer
}
func (f nopSync) Sync() error {
return nil
}
+2 -1
View File
@@ -1841,6 +1841,7 @@ k8s.io/component-base/featuregate
k8s.io/component-base/logs
k8s.io/component-base/logs/api/v1
k8s.io/component-base/logs/internal/setverbositylevel
k8s.io/component-base/logs/json
k8s.io/component-base/logs/klogflags
k8s.io/component-base/metrics
k8s.io/component-base/metrics/features
@@ -1951,7 +1952,7 @@ open-cluster-management.io/addon-framework/pkg/agent/v1alpha1
open-cluster-management.io/addon-framework/pkg/assets
open-cluster-management.io/addon-framework/pkg/index
open-cluster-management.io/addon-framework/pkg/utils
# open-cluster-management.io/api v1.3.1-0.20260709055002-403378b57558
# open-cluster-management.io/api v1.3.1-0.20260824155636-3ff9ad2f868d
## explicit; go 1.26.0
open-cluster-management.io/api/addon/v1alpha1
open-cluster-management.io/api/addon/v1beta1
+8
View File
@@ -77,6 +77,13 @@ const (
// is not a scalar value.
RawFeedbackJsonString featuregate.Feature = "RawFeedbackJsonString"
// ManifestWorkApplyLogs makes the work agent in the spoke cluster emit structured logs on the apply of the manifestwork and its resources
// Log lines are written with three types of logs - mw_spoke_apply at reconcile start, mw_resource_spoke_apply per applied resource, and
// mw_spoke_apply_result at reconcile.
// The lines carry the manifestwork name/namespace/generation, the applied resource identity, an apply
// timestamp, and the manifestwork labels. Read-only applies emit an observation log instead.
ManifestWorkApplyLogs featuregate.Feature = "ManifestWorkApplyLogs"
// ResourceCleanup will start gc controller to clean up resources in cluster ns after cluster is deleted.
ResourceCleanup featuregate.Feature = "ResourceCleanup"
@@ -154,4 +161,5 @@ var DefaultHubWorkFeatureGates = map[featuregate.Feature]featuregate.FeatureSpec
var DefaultSpokeWorkFeatureGates = map[featuregate.Feature]featuregate.FeatureSpec{
ExecutorValidatingCaches: {Default: false, PreRelease: featuregate.Alpha},
RawFeedbackJsonString: {Default: false, PreRelease: featuregate.Alpha},
ManifestWorkApplyLogs: {Default: false, PreRelease: featuregate.Alpha},
}