Skip to content

Commit 5530019

Browse files
levan-mclaude
andauthored
Drive rollback, promote, timeout, and abort from the checkpoint contract
Rewrite processRollbackSignal, processPromoteSignal, abortExperiment, and handleRollback to validate against the experiment's Checkpoint instead of revision ordering or revision annotations. Add APIReader as an uncached, direct-to-apiserver reader for ControllerRevision lookups so a stale cache NotFound is never mistaken for a permanently-lost baseline, and enforce ownership (namespace, label, controller-owner UID) on every read. Recover the nil-phase rollback path so an annotation naming an unresolvable ControllerRevision aborts as baseline_not_found instead of silently no-op'ing, and abort in-flight experiments with no checkpoint as baseline_missing. Short-circuit reconcileInstance after a rollback restores the spec so stale-spec rendering doesn't run before the restore's watch event triggers a fresh reconcile. Environment: Datadog workspace Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent dc49840 commit 5530019

10 files changed

Lines changed: 1472 additions & 1095 deletions

File tree

internal/controller/datadogagent/controller.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ package datadogagent
77

88
import (
99
"context"
10+
"fmt"
1011
"time"
1112

1213
"github.com/go-logr/logr"
@@ -101,6 +102,10 @@ type ReconcilerOptions struct {
101102
// ClusterProviderDetector supplies the detected cluster provider. Nil disables
102103
// provider detection (reconcile behaves as before: empty provider).
103104
ClusterProviderDetector ProviderReader
105+
// APIReader is an uncached, direct-to-apiserver reader used to validate
106+
// experiment rollback targets. A cached/informer-backed client's stale
107+
// NotFound could be mistaken for a permanently-lost baseline revision.
108+
APIReader client.Reader
104109
}
105110

106111
// Reconciler is the internal reconciler for Datadog Agent
@@ -128,6 +133,10 @@ func (r *Reconciler) initializeComponentRegistry() {
128133
func NewReconciler(options ReconcilerOptions, client client.Client, platformInfo kubernetes.PlatformInfo,
129134
scheme *runtime.Scheme, log logr.Logger, recorder record.EventRecorder, metricForwardersMgr datadog.MetricsForwardersManager,
130135
) (*Reconciler, error) {
136+
if options.APIReader == nil {
137+
return nil, fmt.Errorf("ReconcilerOptions.APIReader must not be nil")
138+
}
139+
131140
r := &Reconciler{
132141
options: options,
133142
client: client,

internal/controller/datadogagent/experiment.go

Lines changed: 270 additions & 251 deletions
Large diffs are not rendered by default.

internal/controller/datadogagent/experiment_integration_test.go

Lines changed: 566 additions & 285 deletions
Large diffs are not rendered by default.

internal/controller/datadogagent/experiment_test.go

Lines changed: 318 additions & 386 deletions
Large diffs are not rendered by default.

internal/controller/datadogagent/reconcile.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,12 +127,20 @@ func (r *Reconciler) reconcileInstance(ctx context.Context, logger logr.Logger,
127127
// Use user-submitted instance instead of defaulted instance
128128
rawInstance := instance.DeepCopy()
129129
rawInstance.Spec = rawSpec
130-
experimentErr := r.manageExperiment(ctx, rawInstance, newDDAStatus, now, revList)
130+
specUpdated, experimentErr := r.manageExperiment(ctx, rawInstance, newDDAStatus, now)
131131
instance.ResourceVersion = rawInstance.ResourceVersion
132132
if experimentErr != nil {
133133
return r.updateStatusIfNeeded(logger, instance, newDDAStatus, result, experimentErr, now)
134134
}
135135
syncExperimentConfigStrandedCondition(newDDAStatus, now)
136+
if specUpdated {
137+
// A rollback restored the DDA spec via Update. instance still holds
138+
// the pre-rollback defaulted spec, so rendering DDAI/dependencies
139+
// from it below would apply stale config. Publish the terminal
140+
// experiment status now and let the Update's watch event trigger a
141+
// fresh reconcile against the restored spec.
142+
return r.updateStatusIfNeeded(logger, instance, newDDAStatus, result, nil, now)
143+
}
136144
if err := r.manageRevision(ctx, instance, rawSpec, revList, newDDAStatus); err != nil {
137145
return r.updateStatusIfNeeded(logger, instance, newDDAStatus, result, err, now)
138146
}

internal/controller/datadogagent/revision.go

Lines changed: 51 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import (
1515
appsv1 "k8s.io/api/apps/v1"
1616
apierrors "k8s.io/apimachinery/pkg/api/errors"
1717
"k8s.io/apimachinery/pkg/runtime"
18-
"k8s.io/apimachinery/pkg/runtime/schema"
1918
"k8s.io/apimachinery/pkg/types"
2019
ctrl "sigs.k8s.io/controller-runtime"
2120
"sigs.k8s.io/controller-runtime/pkg/client"
@@ -40,19 +39,6 @@ func buildRevisionSnapshot(spec v2alpha1.DatadogAgentSpec, allAnnotations map[st
4039
return json.Marshal(snap)
4140
}
4241

43-
// skipRevisionBump returns true when the revision bump should be suppressed.
44-
// During experiment rollback the spec is restored to an older revision; bumping
45-
// its revision number to "latest" would make it appear newer than the experiment
46-
// revision, causing findRollbackTarget to return the experiment revision on the
47-
// next rollback attempt instead of the pre-experiment revision.
48-
func skipRevisionBump(newStatus *v2alpha1.DatadogAgentStatus) bool {
49-
if newStatus == nil || newStatus.Experiment == nil {
50-
return false
51-
}
52-
phase := newStatus.Experiment.Phase
53-
return phase == v2alpha1.ExperimentPhaseTerminated
54-
}
55-
5642
// manageRevision creates a ControllerRevision snapshot of the current spec and
5743
// garbage collects old revisions. Must be called after manageExperiment.
5844
//
@@ -61,16 +47,54 @@ func skipRevisionBump(newStatus *v2alpha1.DatadogAgentStatus) bool {
6147
// still used for labels, annotations, and object identity, which are
6248
// unaffected by defaulting.
6349
func (r *Reconciler) manageRevision(ctx context.Context, instance *v2alpha1.DatadogAgent, rawSpec v2alpha1.DatadogAgentSpec, revList []appsv1.ControllerRevision, newStatus *v2alpha1.DatadogAgentStatus) error {
64-
revName, err := r.ensureRevision(ctx, instance, rawSpec, revList, skipRevisionBump(newStatus))
50+
revName, err := r.ensureRevision(ctx, instance, rawSpec, revList)
6551
if err != nil {
6652
return err
6753
}
68-
if err := r.gcOldRevisions(ctx, map[string]bool{revName: true}, revList); err != nil {
54+
55+
pins := map[string]bool{revName: true}
56+
var checkpoint *v2alpha1.ExperimentCheckpoint
57+
var phase v2alpha1.ExperimentPhase
58+
if newStatus != nil && newStatus.Experiment != nil {
59+
checkpoint = newStatus.Experiment.Checkpoint
60+
phase = newStatus.Experiment.Phase
61+
}
62+
if checkpoint != nil {
63+
if !isTerminalPhase(phase) {
64+
pins[checkpoint.RollbackTargetRevision] = true
65+
}
66+
} else if pending := instance.GetAnnotations()[v2alpha1.AnnotationExperimentRollbackTargetRevision]; pending != "" {
67+
// A start or rollback signal is pending and status has not copied
68+
// the checkpoint yet; keep the nominated baseline alive so it isn't
69+
// GC'd out from under the in-flight signal.
70+
pins[pending] = true
71+
}
72+
73+
if err := r.gcOldRevisions(ctx, pins, revList); err != nil {
6974
ctrl.LoggerFrom(ctx).Error(err, "Failed to garbage collect old ControllerRevisions, will retry on next reconcile")
7075
}
7176
return nil
7277
}
7378

79+
// ownedByDDA reports whether rev is a ControllerRevision owned by dda: same
80+
// namespace, the agent-name label matches, and a controller owner reference
81+
// points at dda's UID. A revision left behind by a deleted-and-recreated DDA
82+
// (same name, new UID) or a foreign/spoofed object fails this check.
83+
func ownedByDDA(rev *appsv1.ControllerRevision, dda *v2alpha1.DatadogAgent) bool {
84+
if rev.Namespace != dda.GetNamespace() {
85+
return false
86+
}
87+
if rev.Labels[apicommon.DatadogAgentNameLabelKey] != dda.GetName() {
88+
return false
89+
}
90+
for _, ref := range rev.OwnerReferences {
91+
if ref.Controller != nil && *ref.Controller && ref.UID == dda.GetUID() {
92+
return true
93+
}
94+
}
95+
return false
96+
}
97+
7498
// publishCurrentRevisionBarrier ensures a ControllerRevision exists for the
7599
// current raw spec plus Datadog-owned annotations, then durably publishes the
76100
// resulting pointer to status.currentRevision (plus its freshness fields) via
@@ -88,7 +112,7 @@ func (r *Reconciler) publishCurrentRevisionBarrier(
88112
rawSpec v2alpha1.DatadogAgentSpec,
89113
revList []appsv1.ControllerRevision,
90114
) (revName string, annotationsHash string, err error) {
91-
revName, err = r.ensureRevision(ctx, instance, rawSpec, revList, false)
115+
revName, err = r.ensureRevision(ctx, instance, rawSpec, revList)
92116
if err != nil {
93117
return "", "", err
94118
}
@@ -144,11 +168,8 @@ func (r *Reconciler) listRevisions(ctx context.Context, instance *v2alpha1.Datad
144168
// mistaken for the current owner's history.
145169
owned := revList.Items[:0]
146170
for i := range revList.Items {
147-
for _, ref := range revList.Items[i].OwnerReferences {
148-
if ref.Controller != nil && *ref.Controller && ref.UID == instance.GetUID() {
149-
owned = append(owned, revList.Items[i])
150-
break
151-
}
171+
if ownedByDDA(&revList.Items[i], instance) {
172+
owned = append(owned, revList.Items[i])
152173
}
153174
}
154175
revList.Items = owned
@@ -161,14 +182,12 @@ func (r *Reconciler) listRevisions(ctx context.Context, instance *v2alpha1.Datad
161182
// rawSpec (not instance.Spec, which may carry in-memory defaults) is what
162183
// gets stored, so that revisions reflect only user-intended changes.
163184
//
164-
// The Revision field is a monotonic creation counter. If skipBump is true the
165-
// existing revision is returned as-is without bumping its Revision number.
185+
// The Revision field is a monotonic creation counter.
166186
func (r *Reconciler) ensureRevision(
167187
ctx context.Context,
168188
instance *v2alpha1.DatadogAgent,
169189
rawSpec v2alpha1.DatadogAgentSpec,
170190
revList []appsv1.ControllerRevision,
171-
skipBump bool,
172191
) (string, error) {
173192
logger := ctrl.LoggerFrom(ctx)
174193

@@ -219,17 +238,9 @@ func (r *Reconciler) ensureRevision(
219238
"object.name", matchingRev.Name,
220239
)
221240

222-
if revisionExperimentState(matchingRev) == experimentRevisionStateRolledBack && !skipBump {
223-
return r.recreateRevision(ctx, matchingRev, instance, gvks[0], labels, data, maxRevision)
224-
}
225-
226241
// Identical content already snapshotted. Bump Revision to max+1 if it
227242
// has been superseded (e.g. after a revert) so ordering stays correct.
228-
// Skip the bump during experiment rollback: bumping the pre-experiment
229-
// revision above the experiment revision would cause findRollbackTarget
230-
// to select the experiment revision as the rollback target on the next
231-
// stopped signal, reversing the rollback.
232-
if matchingRev.Revision < maxRevision && !skipBump {
243+
if matchingRev.Revision < maxRevision {
233244
objLogger.Info("Bumping ControllerRevision to latest")
234245
patch := fmt.Appendf(nil, `{"revision":%d}`, maxRevision+1)
235246
if err := r.client.Patch(ctx, matchingRev, client.RawPatch(types.MergePatchType, patch)); err != nil && !apierrors.IsConflict(err) {
@@ -272,46 +283,6 @@ func (r *Reconciler) ensureRevision(
272283
return rev.Name, nil
273284
}
274285

275-
// recreateRevision deletes a rolled-back ControllerRevision and creates a
276-
// fresh one with the same content but a new CreationTimestamp. This prevents
277-
// an immediate timeout when the same experiment spec is re-applied, since
278-
// CreationTimestamp is immutable in Kubernetes.
279-
//
280-
// Failure recovery:
281-
// - Delete fails: error returned, next reconcile retries.
282-
// - Delete succeeds, Create fails (or operator crashes): the revision is
283-
// gone, so the next reconcile's ensureRevision takes the normal "no
284-
// matching revision" path and creates a fresh one.
285-
func (r *Reconciler) recreateRevision(
286-
ctx context.Context,
287-
old *appsv1.ControllerRevision,
288-
instance *v2alpha1.DatadogAgent,
289-
gvk schema.GroupVersionKind,
290-
labels map[string]string,
291-
data runtime.RawExtension,
292-
maxRevision int64,
293-
) (string, error) {
294-
logger := ctrl.LoggerFrom(ctx).WithValues(
295-
"object.kind", "ControllerRevision",
296-
"object.namespace", old.Namespace,
297-
"object.name", old.Name,
298-
)
299-
logger.Info("Recreating rolled-back ControllerRevision with fresh timestamp")
300-
301-
if err := r.client.Delete(ctx, old); err != nil && !apierrors.IsNotFound(err) {
302-
return "", fmt.Errorf("failed to delete rolled-back ControllerRevision %s: %w", old.Name, err)
303-
}
304-
305-
fresh := controllerrevisions.NewControllerRevision(instance, gvk, labels, data, maxRevision+1, nil)
306-
if err := r.client.Create(ctx, fresh); err != nil {
307-
if apierrors.IsAlreadyExists(err) {
308-
return fresh.Name, nil
309-
}
310-
return "", fmt.Errorf("failed to recreate ControllerRevision %s: %w", fresh.Name, err)
311-
}
312-
return fresh.Name, nil
313-
}
314-
315286
// datadogAnnotations returns a copy of annotations filtered to only those
316287
// with `.datadoghq.com/` in the key, which are used for preview features.
317288
// Experiment signal annotations (experiment.datadoghq.com/) are excluded
@@ -332,16 +303,13 @@ func datadogAnnotations(all map[string]string) map[string]string {
332303
}
333304

334305
// gcOldRevisions deletes all but the pinned revisions and the single most
335-
// recent unpinned one (kept as "previous"). Stale experiment revisions
336-
// (marked with the rollback annotation) are kept here — they are handled by
337-
// ensureRevision which recreates them with a fresh timestamp when the same
338-
// spec is re-applied.
306+
// recent unpinned one (kept as "previous").
339307
//
340-
// pins is a set of revision names that must never be deleted. Today it only
341-
// carries status.currentRevision; later commits add the active experiment
342-
// checkpoint's rollback target and a pending start signal's rollback-target
343-
// annotation, once those states exist, without needing to reshape this
344-
// signature again.
308+
// pins is a set of revision names that must never be deleted: the caller
309+
// includes status.currentRevision, the active experiment checkpoint's
310+
// rollback target (while non-terminal), and a pending start/rollback
311+
// signal's rollback-target annotation before status has copied the
312+
// checkpoint.
345313
func (r *Reconciler) gcOldRevisions(
346314
ctx context.Context,
347315
pins map[string]bool,

0 commit comments

Comments
 (0)