Skip to content

Commit fabf2d3

Browse files
authored
feat(drift-detection): add periodic drift detection to all controllers (#94) (#111)
Add a DriftCheckInterval constant (5 min) and checkDrift helper methods to all 14 CRD controllers so that resources deleted externally in AxonOps are automatically detected and recreated on the next reconcile cycle. - Add DriftCheckInterval = 5 * time.Minute to common package - Extract checkDrift method per controller (returns ctrl.Result, bool, error) - Drift check fires when Ready=True, ObservedGeneration matches, and synced ID is non-empty; sets DriftDetected condition and clears synced ID on mismatch - Failed drift-check API calls set DriftCheckFailed condition and requeue - AlertEndpoint and AlertRoute resolve their own API client inside checkDrift (since apiClient is resolved after the idempotency guard in those controllers) - AdaptiveRepair, DashboardTemplate, CommitlogArchive detect drift by settings comparison or empty-list check instead of ID match - Extract applyRoute helper in AlertRoute controller to keep Reconcile cyclomatic complexity under 30 - Update LogCollector idempotency test to use stateful mock server
1 parent 419c72e commit fabf2d3

16 files changed

Lines changed: 653 additions & 95 deletions

internal/controller/alerts/axonopsadaptiverepair_controller.go

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,13 +116,14 @@ func (r *AxonOpsAdaptiveRepairReconciler) Reconcile(ctx context.Context, req ctr
116116
return ctrl.Result{RequeueAfter: 30 * time.Second}, nil
117117
}
118118

119-
// Check if already synced and spec unchanged
119+
// Check if already synced with drift detection
120120
readyCond := meta.FindStatusCondition(repair.Status.Conditions, condTypeReady)
121121
if readyCond != nil && readyCond.Status == metav1.ConditionTrue &&
122122
repair.Status.ObservedGeneration == repair.Generation &&
123123
repair.Status.LastSyncTime != nil {
124-
log.Info("Adaptive repair already synced and spec unchanged, skipping API call")
125-
return ctrl.Result{}, nil
124+
if result, done, err := r.checkDrift(ctx, repair, apiClient); done {
125+
return result, err
126+
}
126127
}
127128

128129
// Get current settings from API
@@ -163,6 +164,36 @@ func (r *AxonOpsAdaptiveRepairReconciler) Reconcile(ctx context.Context, req ctr
163164
}
164165

165166
// handleDeletion handles cleanup when the CR is being deleted.
167+
// checkDrift verifies whether adaptive repair settings have drifted from the desired spec.
168+
// Returns (result, true, err) if the reconcile loop should return; (zero, false, nil) to continue.
169+
func (r *AxonOpsAdaptiveRepairReconciler) checkDrift(ctx context.Context, repair *alertsv1alpha1.AxonOpsAdaptiveRepair, apiClient *axonops.Client) (ctrl.Result, bool, error) {
170+
log := logf.FromContext(ctx)
171+
currentSettings, err := apiClient.GetAdaptiveRepair(ctx, repair.Spec.ClusterType, repair.Spec.ClusterName)
172+
if err != nil {
173+
log.Error(err, "Failed to perform drift check")
174+
r.setFailedCondition(ctx, repair, "DriftCheckFailed", common.SafeConditionMsg("Failed to check for drift", err))
175+
return ctrl.Result{RequeueAfter: common.DriftCheckInterval}, true, nil
176+
}
177+
desired := r.buildAdaptiveRepairSettings(repair)
178+
if r.settingsEqual(desired, *currentSettings) {
179+
log.Info("Adaptive repair already synced and spec unchanged, skipping API call")
180+
return ctrl.Result{RequeueAfter: common.DriftCheckInterval}, true, nil
181+
}
182+
log.Info("Drift detected: adaptive repair settings have changed externally")
183+
meta.SetStatusCondition(&repair.Status.Conditions, metav1.Condition{
184+
Type: "DriftDetected",
185+
Status: metav1.ConditionTrue,
186+
ObservedGeneration: repair.Generation,
187+
Reason: "SettingsChanged",
188+
Message: "Adaptive repair settings were changed externally; reapplying",
189+
})
190+
repair.Status.LastSyncTime = nil
191+
if statusErr := r.Status().Update(ctx, repair); statusErr != nil {
192+
return ctrl.Result{}, true, statusErr
193+
}
194+
return ctrl.Result{}, false, nil
195+
}
196+
166197
// Adaptive repair is a cluster-level singleton -- it cannot be "deleted" via the API.
167198
// The finalizer simply removes itself without making any API calls.
168199
func (r *AxonOpsAdaptiveRepairReconciler) handleDeletion(ctx context.Context, repair *alertsv1alpha1.AxonOpsAdaptiveRepair) error {

internal/controller/alerts/axonopsalertendpoint_controller.go

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -110,14 +110,14 @@ func (r *AxonOpsAlertEndpointReconciler) Reconcile(ctx context.Context, req ctrl
110110
return ctrl.Result{}, nil
111111
}
112112

113-
// Idempotency check: skip if already synced and generation unchanged
113+
// Idempotency check with drift detection
114114
readyCond := meta.FindStatusCondition(endpoint.Status.Conditions, condTypeReady)
115115
if readyCond != nil && readyCond.Status == metav1.ConditionTrue &&
116116
endpoint.Status.ObservedGeneration == endpoint.Generation &&
117117
endpoint.Status.SyncedIntegrationID != "" {
118-
log.Info("Endpoint already synced, skipping reconciliation",
119-
"integrationID", endpoint.Status.SyncedIntegrationID, "generation", endpoint.Generation)
120-
return ctrl.Result{}, nil
118+
if result, done, err := r.checkDrift(ctx, endpoint); done {
119+
return result, err
120+
}
121121
}
122122

123123
log.Info("Reconciling AxonOpsAlertEndpoint",
@@ -227,6 +227,53 @@ func (r *AxonOpsAlertEndpointReconciler) Reconcile(ctx context.Context, req ctrl
227227
return ctrl.Result{}, nil
228228
}
229229

230+
// checkDrift verifies whether the synced integration still exists in AxonOps.
231+
// Resolves its own API client since the main Reconcile path doesn't do so until later.
232+
// Returns (result, true, err) if the reconcile loop should return; (zero, false, nil) to continue.
233+
func (r *AxonOpsAlertEndpointReconciler) checkDrift(ctx context.Context, endpoint *alertsv1alpha1.AxonOpsAlertEndpoint) (ctrl.Result, bool, error) {
234+
log := logf.FromContext(ctx)
235+
apiClient, err := ResolveAPIClient(ctx, r.Client, endpoint.Namespace, endpoint.Spec.ConnectionRef)
236+
if err != nil {
237+
log.Error(err, "Failed to resolve API client for drift check")
238+
return ctrl.Result{RequeueAfter: common.DriftCheckInterval}, true, nil
239+
}
240+
integrations, err := apiClient.GetIntegrations(ctx, endpoint.Spec.ClusterType, endpoint.Spec.ClusterName)
241+
if err != nil {
242+
log.Error(err, "Failed to perform drift check")
243+
meta.SetStatusCondition(&endpoint.Status.Conditions, metav1.Condition{
244+
Type: "DriftCheckFailed",
245+
Status: metav1.ConditionTrue,
246+
ObservedGeneration: endpoint.Generation,
247+
Reason: "DriftCheckFailed",
248+
Message: common.SafeConditionMsg("Failed to check for drift", err),
249+
})
250+
if statusErr := r.Status().Update(ctx, endpoint); statusErr != nil {
251+
log.Error(statusErr, "Failed to update status")
252+
}
253+
return ctrl.Result{RequeueAfter: common.DriftCheckInterval}, true, nil
254+
}
255+
for _, def := range integrations.Definitions {
256+
if def.ID == endpoint.Status.SyncedIntegrationID {
257+
log.Info("Endpoint already synced, skipping reconciliation",
258+
"integrationID", endpoint.Status.SyncedIntegrationID, "generation", endpoint.Generation)
259+
return ctrl.Result{RequeueAfter: common.DriftCheckInterval}, true, nil
260+
}
261+
}
262+
log.Info("Drift detected: integration no longer exists in AxonOps", "integrationID", endpoint.Status.SyncedIntegrationID)
263+
meta.SetStatusCondition(&endpoint.Status.Conditions, metav1.Condition{
264+
Type: "DriftDetected",
265+
Status: metav1.ConditionTrue,
266+
ObservedGeneration: endpoint.Generation,
267+
Reason: "ResourceDeleted",
268+
Message: "Integration was deleted externally; recreating",
269+
})
270+
endpoint.Status.SyncedIntegrationID = ""
271+
if statusErr := r.Status().Update(ctx, endpoint); statusErr != nil {
272+
return ctrl.Result{}, true, statusErr
273+
}
274+
return ctrl.Result{}, false, nil
275+
}
276+
230277
// handleDeletion handles cleanup when the CR is being deleted
231278
func (r *AxonOpsAlertEndpointReconciler) handleDeletion(ctx context.Context, endpoint *alertsv1alpha1.AxonOpsAlertEndpoint) (ctrl.Result, error) {
232279
log := logf.FromContext(ctx)

internal/controller/alerts/axonopsalertroute_controller.go

Lines changed: 97 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -112,14 +112,14 @@ func (r *AxonOpsAlertRouteReconciler) Reconcile(ctx context.Context, req ctrl.Re
112112
return ctrl.Result{}, nil
113113
}
114114

115-
// Check idempotency: skip if already synced and generation unchanged
115+
// Check idempotency with drift detection
116116
readyCond := meta.FindStatusCondition(route.Status.Conditions, condTypeReady)
117117
if readyCond != nil && readyCond.Status == metav1.ConditionTrue &&
118118
route.Status.ObservedGeneration == route.Generation &&
119119
route.Status.IntegrationID != "" {
120-
log.Info("Route already synced, skipping reconciliation",
121-
"integrationID", route.Status.IntegrationID, "generation", route.Generation)
122-
return ctrl.Result{}, nil
120+
if result, done, err := r.checkDrift(ctx, route); done {
121+
return result, err
122+
}
123123
}
124124

125125
log.Info("Reconciling AxonOpsAlertRoute",
@@ -199,10 +199,42 @@ func (r *AxonOpsAlertRouteReconciler) Reconcile(ctx context.Context, req ctrl.Re
199199
return ctrl.Result{}, err
200200
}
201201

202-
// Check if route already exists
202+
// Set override and add route if needed
203203
routeExists := checkRouteExists(integrations, apiRouteType, route.Spec.Severity, integrationID)
204+
if result, stop := r.applyRoute(ctx, apiClient, route, apiRouteType, integrationID, routeExists); stop {
205+
return result, nil
206+
}
207+
208+
// Update status
209+
now := metav1.Now()
210+
if err := r.Get(ctx, req.NamespacedName, route); err != nil {
211+
return ctrl.Result{}, client.IgnoreNotFound(err)
212+
}
213+
route.Status.IntegrationID = integrationID
214+
route.Status.LastSyncTime = &now
215+
route.Status.ObservedGeneration = route.Generation
216+
meta.SetStatusCondition(&route.Status.Conditions, metav1.Condition{
217+
Type: condTypeReady,
218+
Status: metav1.ConditionTrue,
219+
Reason: "RouteSynced",
220+
Message: "Alert route successfully synced with AxonOps",
221+
ObservedGeneration: route.Generation,
222+
})
223+
224+
if err := r.Status().Update(ctx, route); err != nil {
225+
log.Error(err, "Failed to update status")
226+
return ctrl.Result{}, err
227+
}
228+
229+
log.Info("Successfully reconciled AxonOpsAlertRoute")
230+
return ctrl.Result{}, nil
231+
}
232+
233+
// applyRoute sets the integration override (if needed) and adds the route (if missing).
234+
// Returns (result, true) if the reconcile loop should stop.
235+
func (r *AxonOpsAlertRouteReconciler) applyRoute(ctx context.Context, apiClient *axonops.Client, route *alertsv1alpha1.AxonOpsAlertRoute, apiRouteType, integrationID string, routeExists bool) (ctrl.Result, bool) {
236+
log := logf.FromContext(ctx)
204237

205-
// Set override if non-global and enabled (defaults to true when nil)
206238
enableOverride := route.Spec.EnableOverride == nil || *route.Spec.EnableOverride
207239
if route.Spec.Type != "global" && enableOverride {
208240
log.Info("Setting integration override", "routeType", apiRouteType, "severity", route.Spec.Severity)
@@ -216,58 +248,78 @@ func (r *AxonOpsAlertRouteReconciler) Reconcile(ctx context.Context, req ctrl.Re
216248
Message: common.SafeConditionMsg("Failed to set override", err),
217249
ObservedGeneration: route.Generation,
218250
})
219-
if err := r.Status().Update(ctx, route); err != nil {
220-
log.Error(err, "Failed to update status")
251+
if statusErr := r.Status().Update(ctx, route); statusErr != nil {
252+
log.Error(statusErr, "Failed to update status")
221253
}
222-
return ctrl.Result{RequeueAfter: 30 * time.Second}, nil
254+
return ctrl.Result{RequeueAfter: 30 * time.Second}, true
223255
}
224256
}
225257

226-
// Add route if it doesn't exist
227-
if !routeExists {
228-
log.Info("Adding integration route", "integrationID", integrationID, "routeType", apiRouteType, "severity", route.Spec.Severity)
229-
if err := apiClient.AddIntegrationRoute(ctx, route.Spec.ClusterType, route.Spec.ClusterName,
230-
apiRouteType, route.Spec.Severity, integrationID); err != nil {
231-
log.Error(err, "Failed to add integration route")
232-
meta.SetStatusCondition(&route.Status.Conditions, metav1.Condition{
233-
Type: condTypeReady,
234-
Status: metav1.ConditionFalse,
235-
Reason: "RouteError",
236-
Message: common.SafeConditionMsg("Failed to add route", err),
237-
ObservedGeneration: route.Generation,
238-
})
239-
if err := r.Status().Update(ctx, route); err != nil {
240-
log.Error(err, "Failed to update status")
241-
}
242-
return ctrl.Result{RequeueAfter: 30 * time.Second}, nil
243-
}
244-
} else {
258+
if routeExists {
245259
log.Info("Route already exists, skipping creation")
260+
return ctrl.Result{}, false
246261
}
247262

248-
// Update status
249-
now := metav1.Now()
250-
if err := r.Get(ctx, req.NamespacedName, route); err != nil {
251-
return ctrl.Result{}, client.IgnoreNotFound(err)
263+
log.Info("Adding integration route", "integrationID", integrationID, "routeType", apiRouteType, "severity", route.Spec.Severity)
264+
if err := apiClient.AddIntegrationRoute(ctx, route.Spec.ClusterType, route.Spec.ClusterName,
265+
apiRouteType, route.Spec.Severity, integrationID); err != nil {
266+
log.Error(err, "Failed to add integration route")
267+
meta.SetStatusCondition(&route.Status.Conditions, metav1.Condition{
268+
Type: condTypeReady,
269+
Status: metav1.ConditionFalse,
270+
Reason: "RouteError",
271+
Message: common.SafeConditionMsg("Failed to add route", err),
272+
ObservedGeneration: route.Generation,
273+
})
274+
if statusErr := r.Status().Update(ctx, route); statusErr != nil {
275+
log.Error(statusErr, "Failed to update status")
276+
}
277+
return ctrl.Result{RequeueAfter: 30 * time.Second}, true
252278
}
253-
route.Status.IntegrationID = integrationID
254-
route.Status.LastSyncTime = &now
255-
route.Status.ObservedGeneration = route.Generation
279+
return ctrl.Result{}, false
280+
}
281+
282+
// checkDrift verifies whether the alert route still exists in AxonOps.
283+
// Returns (result, true, err) if the reconcile loop should return; (zero, false, nil) to continue.
284+
func (r *AxonOpsAlertRouteReconciler) checkDrift(ctx context.Context, route *alertsv1alpha1.AxonOpsAlertRoute) (ctrl.Result, bool, error) {
285+
log := logf.FromContext(ctx)
286+
apiClient, err := ResolveAPIClient(ctx, r.Client, route.Namespace, route.Spec.ConnectionRef)
287+
if err != nil {
288+
log.Error(err, "Failed to resolve API client for drift check")
289+
return ctrl.Result{RequeueAfter: common.DriftCheckInterval}, true, nil
290+
}
291+
integrations, err := apiClient.GetIntegrations(ctx, route.Spec.ClusterType, route.Spec.ClusterName)
292+
if err != nil {
293+
log.Error(err, "Failed to get integrations during drift check")
294+
meta.SetStatusCondition(&route.Status.Conditions, metav1.Condition{
295+
Type: "DriftCheckFailed",
296+
Status: metav1.ConditionTrue,
297+
ObservedGeneration: route.Generation,
298+
Reason: "APIError",
299+
Message: common.SafeConditionMsg("Failed to check for drift", err),
300+
})
301+
if statusErr := r.Status().Update(ctx, route); statusErr != nil {
302+
log.Error(statusErr, "Failed to update status after drift check failure")
303+
}
304+
return ctrl.Result{RequeueAfter: common.DriftCheckInterval}, true, nil
305+
}
306+
apiRouteType, _ := getAPIRouteType(route.Spec.Type)
307+
if checkRouteExists(integrations, apiRouteType, route.Spec.Severity, route.Status.IntegrationID) {
308+
return ctrl.Result{RequeueAfter: common.DriftCheckInterval}, true, nil
309+
}
310+
log.Info("Drift detected: alert route no longer exists in AxonOps, will recreate")
256311
meta.SetStatusCondition(&route.Status.Conditions, metav1.Condition{
257-
Type: condTypeReady,
312+
Type: "DriftDetected",
258313
Status: metav1.ConditionTrue,
259-
Reason: "RouteSynced",
260-
Message: "Alert route successfully synced with AxonOps",
261314
ObservedGeneration: route.Generation,
315+
Reason: "ResourceDeleted",
316+
Message: "Alert route was deleted externally; reapplying",
262317
})
263-
264-
if err := r.Status().Update(ctx, route); err != nil {
265-
log.Error(err, "Failed to update status")
266-
return ctrl.Result{}, err
318+
route.Status.IntegrationID = ""
319+
if statusErr := r.Status().Update(ctx, route); statusErr != nil {
320+
return ctrl.Result{}, true, statusErr
267321
}
268-
269-
log.Info("Successfully reconciled AxonOpsAlertRoute")
270-
return ctrl.Result{}, nil
322+
return ctrl.Result{}, false, nil
271323
}
272324

273325
// handleDeletion handles cleanup when the CR is being deleted

internal/controller/alerts/axonopscommitlogarchive_controller.go

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,9 @@ func (r *AxonOpsCommitlogArchiveReconciler) Reconcile(ctx context.Context, req c
115115
if readyCond != nil && readyCond.Status == metav1.ConditionTrue &&
116116
archive.Status.ObservedGeneration == archive.Generation &&
117117
archive.Status.LastSyncTime != nil {
118-
log.Info("Commitlog archive already synced and spec unchanged, skipping")
119-
return ctrl.Result{}, nil
118+
if result, done, err := r.checkDrift(ctx, archive, apiClient); done {
119+
return result, err
120+
}
120121
}
121122

122123
if err := r.validateConfig(archive); err != nil {
@@ -195,6 +196,35 @@ func (r *AxonOpsCommitlogArchiveReconciler) Reconcile(ctx context.Context, req c
195196
return ctrl.Result{}, nil
196197
}
197198

199+
// checkDrift verifies whether commitlog archive settings still exist in AxonOps.
200+
// Returns (result, true, err) if the reconcile loop should return; (zero, false, nil) to continue.
201+
func (r *AxonOpsCommitlogArchiveReconciler) checkDrift(ctx context.Context, archive *alertsv1alpha1.AxonOpsCommitlogArchive, apiClient *axonops.Client) (ctrl.Result, bool, error) {
202+
log := logf.FromContext(ctx)
203+
settings, err := apiClient.GetCommitlogArchiveSettings(ctx, archive.Spec.ClusterType, archive.Spec.ClusterName)
204+
if err != nil {
205+
log.Error(err, "Failed to perform drift check")
206+
r.setFailedCondition(ctx, archive, "DriftCheckFailed", common.SafeConditionMsg("Failed to check for drift", err))
207+
return ctrl.Result{RequeueAfter: common.DriftCheckInterval}, true, nil
208+
}
209+
if len(settings) > 0 {
210+
log.Info("Commitlog archive already synced and spec unchanged, skipping")
211+
return ctrl.Result{RequeueAfter: common.DriftCheckInterval}, true, nil
212+
}
213+
log.Info("Drift detected: commitlog archive settings no longer exist in AxonOps")
214+
meta.SetStatusCondition(&archive.Status.Conditions, metav1.Condition{
215+
Type: "DriftDetected",
216+
Status: metav1.ConditionTrue,
217+
ObservedGeneration: archive.Generation,
218+
Reason: "ResourceDeleted",
219+
Message: "Commitlog archive settings were removed externally; recreating",
220+
})
221+
archive.Status.LastSyncTime = nil
222+
if statusErr := r.Status().Update(ctx, archive); statusErr != nil {
223+
return ctrl.Result{}, true, statusErr
224+
}
225+
return ctrl.Result{}, false, nil
226+
}
227+
198228
func (r *AxonOpsCommitlogArchiveReconciler) validateConfig(archive *alertsv1alpha1.AxonOpsCommitlogArchive) error {
199229
switch archive.Spec.RemoteType {
200230
case "s3":

0 commit comments

Comments
 (0)