Skip to content

Commit 0fe4acf

Browse files
committed
wip
1 parent eea7e94 commit 0fe4acf

File tree

1 file changed

+117
-213
lines changed

1 file changed

+117
-213
lines changed

controllers/serviceaccount_controller.go

Lines changed: 117 additions & 213 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ func (r *GrafanaServiceAccountReconciler) Reconcile(ctx context.Context, req ctr
163163
removeNoMatchingInstance(&cr.Status.Conditions)
164164

165165
// 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)
167167

168168
// 6. Update the resource status with current state and conditions
169169
applyErrors := map[string]string{}
@@ -215,6 +215,8 @@ func (r *GrafanaServiceAccountReconciler) finalize(ctx context.Context, cr *v1be
215215
return nil
216216
}
217217

218+
// lookupGrafana retrieves the Grafana instance referenced by the GrafanaServiceAccount
219+
// and validates that it's in a ready state for accepting API requests.
218220
func (r *GrafanaServiceAccountReconciler) lookupGrafana(
219221
ctx context.Context,
220222
cr *v1beta1.GrafanaServiceAccount,
@@ -237,30 +239,75 @@ func (r *GrafanaServiceAccountReconciler) lookupGrafana(
237239
return &grafana, nil
238240
}
239241

240-
// reconcile performs the core reconciliation logic for active resources.
242+
// reconcileWithInstance performs the core reconciliation logic for active resources.
241243
//
242244
// It orchestrates the complete synchronization process:
243245
// 1. Ensures the service account exists in Grafana (creates if missing)
244246
// 2. Updates service account properties to match the spec
245247
// 3. Manages the lifecycle of authentication tokens and their secrets
246-
func (r *GrafanaServiceAccountReconciler) reconcile(
248+
func (r *GrafanaServiceAccountReconciler) reconcileWithInstance(
247249
ctx context.Context,
248250
gClient *genapi.GrafanaHTTPAPI,
249251
cr *v1beta1.GrafanaServiceAccount,
250252
) error {
251-
err := r.ensureAccount(ctx, gClient, cr)
253+
err := r.upsertAccount(ctx, gClient, cr)
252254
if err != nil {
253-
return fmt.Errorf("ensuring account exists: %w", err)
255+
return fmt.Errorf("upserting service account: %w", err)
254256
}
255257

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)
257296
if err != nil {
258-
return fmt.Errorf("updating account properties: %w", err)
297+
return err
259298
}
260299

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)
262304
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)
264311
}
265312

266313
return nil
@@ -307,206 +354,6 @@ func buildUpdateFormIfNeeded(spec *v1beta1.GrafanaServiceAccountSpec, status *v1
307354
return form
308355
}
309356

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-
510357
// removeOutdatedTokens removes tokens that are not in the desired spec or have mismatched expiration times.
511358
// These tokens will be recreated later with the correct configuration.
512359
func (r *GrafanaServiceAccountReconciler) removeOutdatedTokens(
@@ -764,13 +611,62 @@ func (r *GrafanaServiceAccountReconciler) pruneAndIndexSecrets(
764611
return filtered, nil
765612
}
766613

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(
770616
ctx context.Context,
771617
gClient *genapi.GrafanaHTTPAPI,
772618
cr *v1beta1.GrafanaServiceAccount,
773619
) 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+
774670
create, err := gClient.ServiceAccounts.CreateServiceAccount(
775671
service_accounts.
776672
NewCreateServiceAccountParamsWithContext(ctx).
@@ -784,7 +680,15 @@ func (r *GrafanaServiceAccountReconciler) createAccount(
784680
return fmt.Errorf("creating service account: %w", err)
785681
}
786682

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
788692
}
789693

790694
func (r *GrafanaServiceAccountReconciler) removeAccountSecrets(

0 commit comments

Comments
 (0)