@@ -163,7 +163,7 @@ func (r *GrafanaServiceAccountReconciler) Reconcile(ctx context.Context, req ctr
163
163
removeNoMatchingInstance (& cr .Status .Conditions )
164
164
165
165
// 5. For active resources - reconcile the actual state with the desired state (creates, updates, removes as needed)
166
- err = r .reconcile (ctx , gClient , cr )
166
+ err = r .reconcileWithInstance (ctx , gClient , cr )
167
167
168
168
// 6. Update the resource status with current state and conditions
169
169
applyErrors := map [string ]string {}
@@ -215,6 +215,8 @@ func (r *GrafanaServiceAccountReconciler) finalize(ctx context.Context, cr *v1be
215
215
return nil
216
216
}
217
217
218
+ // lookupGrafana retrieves the Grafana instance referenced by the GrafanaServiceAccount
219
+ // and validates that it's in a ready state for accepting API requests.
218
220
func (r * GrafanaServiceAccountReconciler ) lookupGrafana (
219
221
ctx context.Context ,
220
222
cr * v1beta1.GrafanaServiceAccount ,
@@ -237,30 +239,75 @@ func (r *GrafanaServiceAccountReconciler) lookupGrafana(
237
239
return & grafana , nil
238
240
}
239
241
240
- // reconcile performs the core reconciliation logic for active resources.
242
+ // reconcileWithInstance performs the core reconciliation logic for active resources.
241
243
//
242
244
// It orchestrates the complete synchronization process:
243
245
// 1. Ensures the service account exists in Grafana (creates if missing)
244
246
// 2. Updates service account properties to match the spec
245
247
// 3. Manages the lifecycle of authentication tokens and their secrets
246
- func (r * GrafanaServiceAccountReconciler ) reconcile (
248
+ func (r * GrafanaServiceAccountReconciler ) reconcileWithInstance (
247
249
ctx context.Context ,
248
250
gClient * genapi.GrafanaHTTPAPI ,
249
251
cr * v1beta1.GrafanaServiceAccount ,
250
252
) error {
251
- err := r .ensureAccount (ctx , gClient , cr )
253
+ err := r .upsertAccount (ctx , gClient , cr )
252
254
if err != nil {
253
- return fmt .Errorf ("ensuring account exists : %w" , err )
255
+ return fmt .Errorf ("upserting service account : %w" , err )
254
256
}
255
257
256
- err = r .reconcileAccount (ctx , gClient , cr )
258
+ updateForm := buildUpdateFormIfNeeded (& cr .Spec , cr .Status .Account )
259
+ if updateForm != nil {
260
+ update , err := gClient .ServiceAccounts .UpdateServiceAccount (
261
+ service_accounts .
262
+ NewUpdateServiceAccountParamsWithContext (ctx ).
263
+ WithServiceAccountID (cr .Status .Account .ID ).
264
+ WithBody (updateForm ),
265
+ )
266
+ if err != nil {
267
+ return fmt .Errorf ("updating service account: %w" , err )
268
+ }
269
+
270
+ cr .Status .Account .IsDisabled = update .Payload .Serviceaccount .IsDisabled
271
+ cr .Status .Account .Role = update .Payload .Serviceaccount .Role
272
+ cr .Status .Account .Name = update .Payload .Serviceaccount .Name
273
+ }
274
+
275
+ // Ensure tokens are always sorted for stable ordering
276
+ defer func () {
277
+ sort .Slice (cr .Status .Account .Tokens , func (i , j int ) bool {
278
+ return cr .Status .Account .Tokens [i ].Name < cr .Status .Account .Tokens [j ].Name
279
+ })
280
+ }()
281
+
282
+ // Phase 1: Prune orphaned secrets and index valid ones
283
+ secretsByTokenName , err := r .pruneAndIndexSecrets (ctx , cr )
284
+ if err != nil {
285
+ return err
286
+ }
287
+
288
+ // Phase 2: Remove outdated tokens (will be recreated with correct configuration)
289
+ err = r .removeOutdatedTokens (ctx , gClient , cr )
290
+ if err != nil {
291
+ return err
292
+ }
293
+
294
+ // Phase 3: Validate existing tokens and restore their secret references
295
+ err = r .validateAndRestoreTokenSecrets (ctx , gClient , cr , secretsByTokenName )
257
296
if err != nil {
258
- return fmt . Errorf ( "updating account properties: %w" , err )
297
+ return err
259
298
}
260
299
261
- err = r .reconcileTokens (ctx , gClient , cr )
300
+ // Phase 4: Provision missing tokens
301
+ tokensToCreate := r .determineMissingTokens (cr )
302
+
303
+ err = r .provisionTokens (ctx , gClient , cr , tokensToCreate , secretsByTokenName )
262
304
if err != nil {
263
- return fmt .Errorf ("managing tokens: %w" , err )
305
+ return err
306
+ }
307
+
308
+ if len (cr .Status .Account .Tokens ) != 0 {
309
+ // Grafana's create token API doesn't return expiration, requiring a separate fetch
310
+ return r .populateTokenExpirations (ctx , gClient , cr )
264
311
}
265
312
266
313
return nil
@@ -307,206 +354,6 @@ func buildUpdateFormIfNeeded(spec *v1beta1.GrafanaServiceAccountSpec, status *v1
307
354
return form
308
355
}
309
356
310
- // ensureAccount guarantees that a service account exists in Grafana with the desired name.
311
- // If found, it syncs the account status including tokens. If not found, it creates a new account.
312
- func (r * GrafanaServiceAccountReconciler ) ensureAccount (
313
- ctx context.Context ,
314
- gClient * genapi.GrafanaHTTPAPI ,
315
- cr * v1beta1.GrafanaServiceAccount ,
316
- ) error {
317
- // If we have an ID in status, try to fetch the account directly
318
- if cr .Status .Account != nil && cr .Status .Account .ID > 0 {
319
- retrieve , err := gClient .ServiceAccounts .RetrieveServiceAccountWithParams (
320
- service_accounts .
321
- NewRetrieveServiceAccountParamsWithContext (ctx ).
322
- WithServiceAccountID (cr .Status .Account .ID ),
323
- )
324
- if err == nil {
325
- return r .populateStatusFromGrafana (ctx , gClient , cr , retrieve .Payload )
326
- }
327
-
328
- // ATM, service_accounts.RetrieveServiceAccountNotFound doesn't have Is, Unwrap, Unwrap.
329
- // So, we cannot rely only on errors.Is().
330
- _ , notFound := err .(* service_accounts.RetrieveServiceAccountNotFound ) // nolint:errorlint
331
- if ! notFound && ! errors .Is (err , service_accounts .NewRetrieveServiceAccountNotFound ()) {
332
- return fmt .Errorf ("retrieving service account by ID %d: %w" , cr .Status .Account .ID , err )
333
- }
334
-
335
- // Service account not found, clear the status and create a new one
336
- cr .Status .Account = nil
337
- }
338
-
339
- // TODO: Handle edge case where service account was created but status update failed
340
- //
341
- // Problem: If createAccount succeeds but saving the ID to status fails (e.g., network issue,
342
- // controller crash), the next reconciliation will try to create a duplicate and fail.
343
- //
344
- // Solution: Search for existing service account by login before creating.
345
- // Grafana generates login as: sa-<orgId>-<normalized-name>
346
- // where normalized-name is: name in lowercase, spaces replaced with dashes, double dashes with single.
347
- // See: https://github.com/grafana/grafana/blob/v11.3.0/pkg/services/serviceaccounts/database/store.go#L52-L57
348
- //
349
- // Implementation would require searching the service account using a reproduced Grafana login.
350
-
351
- // No ID in status or account not found, create a new one
352
- if err := r .createAccount (ctx , gClient , cr ); err != nil {
353
- return fmt .Errorf ("creating service account: %w" , err )
354
- }
355
-
356
- return nil
357
- }
358
-
359
- // populateStatusFromGrafana updates the CR status to reflect the current state in Grafana.
360
- // This includes the account properties and tokens as they exist in Grafana, regardless of
361
- // how they got there (operator-managed or manual changes).
362
- //
363
- // Note: This does not populate secret references as they cannot be reliably matched
364
- // with manually created tokens or external modifications.
365
- func (r * GrafanaServiceAccountReconciler ) populateStatusFromGrafana (
366
- ctx context.Context ,
367
- gClient * genapi.GrafanaHTTPAPI ,
368
- cr * v1beta1.GrafanaServiceAccount ,
369
- sa * models.ServiceAccountDTO ,
370
- ) error {
371
- // Preserve existing ID if it was already set (IDs should be immutable)
372
- if cr .Status .Account != nil && cr .Status .Account .ID != 0 && cr .Status .Account .ID != sa .ID {
373
- return fmt .Errorf ("service account ID mismatch detected, preserving existing ID: %d" , cr .Status .Account .ID )
374
- }
375
-
376
- cr .Status .Account = & v1beta1.GrafanaServiceAccountInfo {
377
- ID : sa .ID ,
378
- Role : sa .Role ,
379
- IsDisabled : sa .IsDisabled ,
380
- Name : sa .Name ,
381
- Login : sa .Login ,
382
- }
383
-
384
- // Load existing tokens from Grafana
385
- tokenList , err := gClient .ServiceAccounts .ListTokensWithParams (
386
- service_accounts .
387
- NewListTokensParamsWithContext (ctx ).
388
- WithServiceAccountID (sa .ID ),
389
- )
390
- if err != nil {
391
- return fmt .Errorf ("listing tokens: %w" , err )
392
- }
393
-
394
- cr .Status .Account .Tokens = make ([]v1beta1.GrafanaServiceAccountTokenStatus , 0 , len (tokenList .Payload ))
395
- for _ , token := range tokenList .Payload {
396
- if token != nil {
397
- cr .Status .Account .Tokens = append (cr .Status .Account .Tokens , v1beta1.GrafanaServiceAccountTokenStatus {
398
- ID : token .ID ,
399
- Name : token .Name ,
400
- Expires : convertGrafanaExpiration (token .Expiration ),
401
- })
402
- }
403
- }
404
-
405
- return nil
406
- }
407
-
408
- // reconcileAccount ensures the service account properties in Grafana match the desired spec.
409
- // It compares the current state (status) with desired state (spec) and updates if needed.
410
- func (r * GrafanaServiceAccountReconciler ) reconcileAccount (
411
- ctx context.Context ,
412
- gClient * genapi.GrafanaHTTPAPI ,
413
- cr * v1beta1.GrafanaServiceAccount ,
414
- ) error {
415
- if cr .Status .Account == nil {
416
- return fmt .Errorf ("service account status is not initialized" )
417
- }
418
-
419
- updateForm := buildUpdateFormIfNeeded (& cr .Spec , cr .Status .Account )
420
- if updateForm == nil {
421
- // No changes needed
422
- return nil
423
- }
424
-
425
- update , err := gClient .ServiceAccounts .UpdateServiceAccount (
426
- service_accounts .
427
- NewUpdateServiceAccountParamsWithContext (ctx ).
428
- WithServiceAccountID (cr .Status .Account .ID ).
429
- WithBody (updateForm ),
430
- )
431
- if err != nil {
432
- return fmt .Errorf ("updating service account: %w" , err )
433
- }
434
-
435
- cr .Status .Account .IsDisabled = update .Payload .Serviceaccount .IsDisabled
436
- cr .Status .Account .Role = update .Payload .Serviceaccount .Role
437
- cr .Status .Account .Name = update .Payload .Serviceaccount .Name
438
-
439
- return nil
440
- }
441
-
442
- // reconcileTokens ensures that service account tokens in Grafana match the desired spec.
443
- // It orchestrates a multi-phase process to maintain consistency between Grafana tokens and Kubernetes secrets.
444
- //
445
- // The reconciliation process consists of 5 phases:
446
- // 1. Prune orphaned secrets and index valid ones by token name
447
- // 2. Remove outdated tokens (not in spec or with wrong expiration)
448
- // 3. Validate and restore secret references for existing tokens
449
- // 4. Provision missing tokens with their secrets
450
- // 5. Populate expiration times (workaround for Grafana API limitation)
451
- //
452
- // Tokens in Grafana are immutable (cannot be updated), so any mismatch requires recreation.
453
- // The function handles cases where either Grafana or Kubernetes resources may have been modified externally.
454
- func (r * GrafanaServiceAccountReconciler ) reconcileTokens (
455
- ctx context.Context ,
456
- gClient * genapi.GrafanaHTTPAPI ,
457
- cr * v1beta1.GrafanaServiceAccount ,
458
- ) error {
459
- if cr .Status .Account == nil {
460
- return fmt .Errorf ("service account status is not initialized" )
461
- }
462
-
463
- // Ensure tokens are always sorted for stable ordering
464
- defer func () {
465
- sort .Slice (cr .Status .Account .Tokens , func (i , j int ) bool {
466
- return cr .Status .Account .Tokens [i ].Name < cr .Status .Account .Tokens [j ].Name
467
- })
468
- }()
469
-
470
- // Phase 1: Prune orphaned secrets and index valid ones
471
- secretsByTokenName , err := r .pruneAndIndexSecrets (ctx , cr )
472
- if err != nil {
473
- return err
474
- }
475
-
476
- // Phase 2: Remove outdated tokens (will be recreated with correct configuration)
477
- err = r .removeOutdatedTokens (ctx , gClient , cr )
478
- if err != nil {
479
- return err
480
- }
481
-
482
- // Phase 3: Validate existing tokens and restore their secret references
483
- err = r .validateAndRestoreTokenSecrets (ctx , gClient , cr , secretsByTokenName )
484
- if err != nil {
485
- return err
486
- }
487
-
488
- // Phase 4: Provision missing tokens
489
- tokensToCreate := r .determineMissingTokens (cr )
490
-
491
- err = r .provisionTokens (ctx , gClient , cr , tokensToCreate , secretsByTokenName )
492
- if err != nil {
493
- return err
494
- }
495
-
496
- if len (cr .Status .Account .Tokens ) == 0 {
497
- return nil
498
- }
499
-
500
- // Phase 5: Fetch and populate expiration times
501
- // Grafana's create token API doesn't return expiration, requiring a separate fetch
502
- err = r .populateTokenExpirations (ctx , gClient , cr )
503
- if err != nil {
504
- return err
505
- }
506
-
507
- return nil
508
- }
509
-
510
357
// removeOutdatedTokens removes tokens that are not in the desired spec or have mismatched expiration times.
511
358
// These tokens will be recreated later with the correct configuration.
512
359
func (r * GrafanaServiceAccountReconciler ) removeOutdatedTokens (
@@ -764,13 +611,62 @@ func (r *GrafanaServiceAccountReconciler) pruneAndIndexSecrets(
764
611
return filtered , nil
765
612
}
766
613
767
- // createAccount creates a new service account in Grafana and all related resources such as tokens and secrets.
768
- // This operation isn't atomic, so can succeed partially.
769
- func (r * GrafanaServiceAccountReconciler ) createAccount (
614
+ // upsertAccount ensures a service account exists in Grafana.
615
+ func (r * GrafanaServiceAccountReconciler ) upsertAccount (
770
616
ctx context.Context ,
771
617
gClient * genapi.GrafanaHTTPAPI ,
772
618
cr * v1beta1.GrafanaServiceAccount ,
773
619
) error {
620
+ if cr .Status .Account != nil {
621
+ retrieve , err := gClient .ServiceAccounts .RetrieveServiceAccountWithParams (
622
+ service_accounts .
623
+ NewRetrieveServiceAccountParamsWithContext (ctx ).
624
+ WithServiceAccountID (cr .Status .Account .ID ),
625
+ )
626
+ if err == nil {
627
+ cr .Status .Account = & v1beta1.GrafanaServiceAccountInfo {
628
+ ID : retrieve .Payload .ID ,
629
+ Role : retrieve .Payload .Role ,
630
+ IsDisabled : retrieve .Payload .IsDisabled ,
631
+ Name : retrieve .Payload .Name ,
632
+ Login : retrieve .Payload .Login ,
633
+ }
634
+
635
+ // Load existing tokens from Grafana
636
+ tokenList , err := gClient .ServiceAccounts .ListTokensWithParams (
637
+ service_accounts .
638
+ NewListTokensParamsWithContext (ctx ).
639
+ WithServiceAccountID (cr .Status .Account .ID ),
640
+ )
641
+ if err != nil {
642
+ return fmt .Errorf ("listing tokens: %w" , err )
643
+ }
644
+
645
+ cr .Status .Account .Tokens = make ([]v1beta1.GrafanaServiceAccountTokenStatus , 0 , len (tokenList .Payload ))
646
+ for _ , token := range tokenList .Payload {
647
+ if token != nil {
648
+ cr .Status .Account .Tokens = append (cr .Status .Account .Tokens , v1beta1.GrafanaServiceAccountTokenStatus {
649
+ ID : token .ID ,
650
+ Name : token .Name ,
651
+ Expires : convertGrafanaExpiration (token .Expiration ),
652
+ })
653
+ }
654
+ }
655
+
656
+ return nil
657
+ }
658
+
659
+ // ATM, service_accounts.RetrieveServiceAccountNotFound doesn't have Is, Unwrap, Unwrap.
660
+ // So, we cannot rely only on errors.Is().
661
+ _ , notFound := err .(* service_accounts.RetrieveServiceAccountNotFound ) // nolint:errorlint
662
+ if ! notFound && ! errors .Is (err , service_accounts .NewRetrieveServiceAccountNotFound ()) {
663
+ return fmt .Errorf ("retrieving service account by ID %d: %w" , cr .Status .Account .ID , err )
664
+ }
665
+
666
+ // Service account not found, clear the status and create a new one
667
+ cr .Status .Account = nil
668
+ }
669
+
774
670
create , err := gClient .ServiceAccounts .CreateServiceAccount (
775
671
service_accounts .
776
672
NewCreateServiceAccountParamsWithContext (ctx ).
@@ -784,7 +680,15 @@ func (r *GrafanaServiceAccountReconciler) createAccount(
784
680
return fmt .Errorf ("creating service account: %w" , err )
785
681
}
786
682
787
- return r .populateStatusFromGrafana (ctx , gClient , cr , create .Payload )
683
+ cr .Status .Account = & v1beta1.GrafanaServiceAccountInfo {
684
+ ID : create .Payload .ID ,
685
+ Role : create .Payload .Role ,
686
+ IsDisabled : create .Payload .IsDisabled ,
687
+ Name : create .Payload .Name ,
688
+ Login : create .Payload .Login ,
689
+ }
690
+
691
+ return nil
788
692
}
789
693
790
694
func (r * GrafanaServiceAccountReconciler ) removeAccountSecrets (
0 commit comments