Skip to content

Commit 1364555

Browse files
committed
fix: improve nested folder handling
This fixes some issues around the move logic. The underlying issue was the way in which folder existance was checked. As the search folder endpoint does _not_ return nested folders, moving between folders was not possible. This fix prefers matching by UIDs first. Note that the introduction of nested folders implies that they will only work with folders created by the operator as names no longer need to be unique and the only way to keep track is to use the UID.
1 parent df1a50e commit 1364555

7 files changed

+307
-48
lines changed

api/v1beta1/grafanafolder_types.go

+2-8
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,8 @@ type GrafanaFolderStatus struct {
6565
// The folder instanceSelector can't find matching grafana instances
6666
NoMatchingInstances bool `json:"NoMatchingInstances,omitempty"`
6767
// Last time the folder was resynced
68-
LastResync metav1.Time `json:"lastResync,omitempty"`
69-
// UID of the parent folder where the folder is created.
70-
// Will be empty if the folder is deployed at the root level
71-
ParentFolderUID string `json:"parentFolderUID,omitempty"`
68+
LastResync metav1.Time `json:"lastResync,omitempty"`
69+
Conditions []metav1.Condition `json:"conditions"`
7270
}
7371

7472
//+kubebuilder:object:root=true
@@ -118,10 +116,6 @@ func (in *GrafanaFolder) Unchanged() bool {
118116
return in.Hash() == in.Status.Hash
119117
}
120118

121-
func (in *GrafanaFolder) Moved() bool {
122-
return in.Spec.ParentFolderUID != in.Status.ParentFolderUID
123-
}
124-
125119
func (in *GrafanaFolder) IsAllowCrossNamespaceImport() bool {
126120
if in.Spec.AllowCrossNamespaceImport != nil {
127121
return *in.Spec.AllowCrossNamespaceImport

config/crd/bases/grafana.integreatly.org_grafanafolders.yaml

+71
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,75 @@ spec:
125125
description: The folder instanceSelector can't find matching grafana
126126
instances
127127
type: boolean
128+
conditions:
129+
items:
130+
description: "Condition contains details for one aspect of the current
131+
state of this API Resource.\n---\nThis struct is intended for
132+
direct use as an array at the field path .status.conditions. For
133+
example,\n\n\n\ttype FooStatus struct{\n\t // Represents the
134+
observations of a foo's current state.\n\t // Known .status.conditions.type
135+
are: \"Available\", \"Progressing\", and \"Degraded\"\n\t //
136+
+patchMergeKey=type\n\t // +patchStrategy=merge\n\t // +listType=map\n\t
137+
\ // +listMapKey=type\n\t Conditions []metav1.Condition `json:\"conditions,omitempty\"
138+
patchStrategy:\"merge\" patchMergeKey:\"type\" protobuf:\"bytes,1,rep,name=conditions\"`\n\n\n\t
139+
\ // other fields\n\t}"
140+
properties:
141+
lastTransitionTime:
142+
description: |-
143+
lastTransitionTime is the last time the condition transitioned from one status to another.
144+
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
145+
format: date-time
146+
type: string
147+
message:
148+
description: |-
149+
message is a human readable message indicating details about the transition.
150+
This may be an empty string.
151+
maxLength: 32768
152+
type: string
153+
observedGeneration:
154+
description: |-
155+
observedGeneration represents the .metadata.generation that the condition was set based upon.
156+
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
157+
with respect to the current state of the instance.
158+
format: int64
159+
minimum: 0
160+
type: integer
161+
reason:
162+
description: |-
163+
reason contains a programmatic identifier indicating the reason for the condition's last transition.
164+
Producers of specific condition types may define expected values and meanings for this field,
165+
and whether the values are considered a guaranteed API.
166+
The value should be a CamelCase string.
167+
This field may not be empty.
168+
maxLength: 1024
169+
minLength: 1
170+
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
171+
type: string
172+
status:
173+
description: status of the condition, one of True, False, Unknown.
174+
enum:
175+
- "True"
176+
- "False"
177+
- Unknown
178+
type: string
179+
type:
180+
description: |-
181+
type of condition in CamelCase or in foo.example.com/CamelCase.
182+
---
183+
Many .condition.type values are consistent across resources like Available, but because arbitrary conditions can be
184+
useful (see .node.status.conditions), the ability to deconflict is important.
185+
The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt)
186+
maxLength: 316
187+
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
188+
type: string
189+
required:
190+
- lastTransitionTime
191+
- message
192+
- reason
193+
- status
194+
- type
195+
type: object
196+
type: array
128197
hash:
129198
description: |-
130199
INSERT ADDITIONAL STATUS FIELD - define observed state of cluster
@@ -139,6 +208,8 @@ spec:
139208
UID of the parent folder where the folder is created.
140209
Will be empty if the folder is deployed at the root level
141210
type: string
211+
required:
212+
- conditions
142213
type: object
143214
type: object
144215
served: true

controllers/controller_shared.go

+47
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import (
44
"bytes"
55
"context"
66
"encoding/json"
7+
"fmt"
78
"slices"
9+
"strings"
810
"time"
911

1012
"github.com/grafana/grafana-operator/v5/api/v1beta1"
@@ -22,6 +24,7 @@ const grafanaFinalizer = "operator.grafana.com/finalizer"
2224
const (
2325
conditionNoMatchingInstance = "NoMatchingInstance"
2426
conditionNoMatchingFolder = "NoMatchingFolder"
27+
conditionInvalidSpec = "InvalidSpec"
2528
)
2629

2730
//+kubebuilder:rbac:groups=coordination.k8s.io,resources=leases,verbs=get;list;watch;create;update;patch;delete
@@ -143,6 +146,23 @@ func removeNoMatchingFolder(conditions *[]metav1.Condition) {
143146
meta.RemoveStatusCondition(conditions, conditionNoMatchingFolder)
144147
}
145148

149+
func setInvalidSpec(conditions *[]metav1.Condition, generation int64, reason, message string) {
150+
meta.SetStatusCondition(conditions, metav1.Condition{
151+
Type: conditionInvalidSpec,
152+
Status: "True",
153+
ObservedGeneration: generation,
154+
LastTransitionTime: metav1.Time{
155+
Time: time.Now(),
156+
},
157+
Reason: reason,
158+
Message: message,
159+
})
160+
}
161+
162+
func removeInvalidSpec(conditions *[]metav1.Condition) {
163+
meta.RemoveStatusCondition(conditions, conditionInvalidSpec)
164+
}
165+
146166
func ignoreStatusUpdates() predicate.Predicate {
147167
return predicate.Funcs{
148168
UpdateFunc: func(e event.UpdateEvent) bool {
@@ -151,3 +171,30 @@ func ignoreStatusUpdates() predicate.Predicate {
151171
},
152172
}
153173
}
174+
175+
func buildSynchronizedCondition(resource string, syncType string, generation int64, applyErrors map[string]string, total int) metav1.Condition {
176+
condition := metav1.Condition{
177+
Type: syncType,
178+
ObservedGeneration: generation,
179+
LastTransitionTime: metav1.Time{
180+
Time: time.Now(),
181+
},
182+
}
183+
184+
if len(applyErrors) == 0 {
185+
condition.Status = "True"
186+
condition.Reason = "ApplySuccessful"
187+
condition.Message = fmt.Sprintf("%s was successfully applied to %d instances", resource, total)
188+
} else {
189+
condition.Status = "False"
190+
condition.Reason = "ApplyFailed"
191+
192+
var sb strings.Builder
193+
for i, err := range applyErrors {
194+
sb.WriteString(fmt.Sprintf("\n- %s: %s", i, err))
195+
}
196+
197+
condition.Message = fmt.Sprintf("%s failed to be applied for %d out of %d instances. Errors:%s", resource, len(applyErrors), total, sb.String())
198+
}
199+
return condition
200+
}

controllers/grafanaalertrulegroup_controller.go

+1-23
Original file line numberDiff line numberDiff line change
@@ -143,29 +143,7 @@ func (r *GrafanaAlertRuleGroupReconciler) Reconcile(ctx context.Context, req ctr
143143
applyErrors[fmt.Sprintf("%s/%s", grafana.Namespace, grafana.Name)] = err.Error()
144144
}
145145
}
146-
condition := metav1.Condition{
147-
Type: conditionAlertGroupSynchronized,
148-
ObservedGeneration: group.Generation,
149-
LastTransitionTime: metav1.Time{
150-
Time: time.Now(),
151-
},
152-
}
153-
154-
if len(applyErrors) == 0 {
155-
condition.Status = "True"
156-
condition.Reason = "ApplySuccessful"
157-
condition.Message = fmt.Sprintf("Alert Rule Group was successfully applied to %d instances", len(instances))
158-
} else {
159-
condition.Status = "False"
160-
condition.Reason = "ApplyFailed"
161-
162-
var sb strings.Builder
163-
for i, err := range applyErrors {
164-
sb.WriteString(fmt.Sprintf("\n- %s: %s", i, err))
165-
}
166-
167-
condition.Message = fmt.Sprintf("Alert Rule Group failed to be applied for %d out of %d instances. Errors:%s", len(applyErrors), len(instances), sb.String())
168-
}
146+
condition := buildSynchronizedCondition("Alert Rule Group", conditionAlertGroupSynchronized, group.Generation, applyErrors, len(instances))
169147
meta.SetStatusCondition(&group.Status.Conditions, condition)
170148

171149
return ctrl.Result{RequeueAfter: group.Spec.ResyncPeriod.Duration}, nil

controllers/grafanafolder_controller.go

+44-17
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import (
3131
client2 "github.com/grafana/grafana-operator/v5/controllers/client"
3232
"github.com/grafana/grafana-operator/v5/controllers/metrics"
3333
kuberr "k8s.io/apimachinery/pkg/api/errors"
34+
"k8s.io/apimachinery/pkg/api/meta"
3435
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
3536

3637
"k8s.io/apimachinery/pkg/runtime"
@@ -41,6 +42,10 @@ import (
4142
grafanav1beta1 "github.com/grafana/grafana-operator/v5/api/v1beta1"
4243
)
4344

45+
const (
46+
conditionFolderSynchronized = "FolderSynchronized"
47+
)
48+
4449
// GrafanaFolderReconciler reconciles a GrafanaFolder object
4550
type GrafanaFolderReconciler struct {
4651
client.Client
@@ -176,15 +181,30 @@ func (r *GrafanaFolderReconciler) Reconcile(ctx context.Context, req ctrl.Reques
176181
return ctrl.Result{RequeueAfter: RequeueDelay}, err
177182
}
178183

184+
if folder.Spec.ParentFolderUID == string(folder.UID) {
185+
setInvalidSpec(&folder.Status.Conditions, folder.Generation, "CyclicParent", "The value of parentFolderUID must not be the uid of the current folder")
186+
meta.RemoveStatusCondition(&folder.Status.Conditions, conditionFolderSynchronized)
187+
r.UpdateStatus(ctx, folder)
188+
return ctrl.Result{}, fmt.Errorf("cyclic folder reference")
189+
}
190+
removeInvalidSpec(&folder.Status.Conditions)
191+
179192
instances, err := r.GetMatchingFolderInstances(ctx, folder, r.Client)
180193
if err != nil {
181-
controllerLog.Error(err, "could not find matching instances", "name", folder.Name, "namespace", folder.Namespace)
182-
return ctrl.Result{RequeueAfter: RequeueDelay}, err
194+
setNoMatchingInstance(&folder.Status.Conditions, folder.Generation, "ErrFetchingInstances", fmt.Sprintf("error occurred during fetching of instances: %s", err.Error()))
195+
meta.RemoveStatusCondition(&folder.Status.Conditions, conditionFolderSynchronized)
196+
r.Log.Error(err, "could not find matching instances")
197+
return ctrl.Result{}, err
183198
}
184-
199+
if len(instances.Items) == 0 {
200+
setNoMatchingInstance(&folder.Status.Conditions, folder.Generation, "EmptyAPIReply", "Instances could not be fetched, reconciliation will be retried")
201+
meta.RemoveStatusCondition(&folder.Status.Conditions, conditionFolderSynchronized)
202+
return ctrl.Result{}, fmt.Errorf("no instances found")
203+
}
204+
removeNoMatchingInstance(&folder.Status.Conditions)
185205
controllerLog.Info("found matching Grafana instances for folder", "count", len(instances.Items))
186206

187-
success := true
207+
applyErrors := make(map[string]string)
188208
for _, grafana := range instances.Items {
189209
// check if this is a cross namespace import
190210
if grafana.Namespace != folder.Namespace && !folder.IsAllowCrossNamespaceImport() {
@@ -200,12 +220,14 @@ func (r *GrafanaFolderReconciler) Reconcile(ctx context.Context, req ctrl.Reques
200220
err = r.onFolderCreated(ctx, &grafana, folder)
201221
if err != nil {
202222
controllerLog.Error(err, "error reconciling folder", "folder", folder.Name, "grafana", grafana.Name)
203-
success = false
223+
applyErrors[fmt.Sprintf("%s/%s", grafana.Namespace, grafana.Name)] = err.Error()
204224
}
205225
}
226+
condition := buildSynchronizedCondition("Folder", conditionFolderSynchronized, folder.Generation, applyErrors, len(instances.Items))
227+
meta.SetStatusCondition(&folder.Status.Conditions, condition)
206228

207-
if !success {
208-
return ctrl.Result{RequeueAfter: RequeueDelay}, nil
229+
if len(applyErrors) != 0 {
230+
return ctrl.Result{RequeueAfter: RequeueDelay}, r.UpdateStatus(ctx, folder)
209231
}
210232

211233
if folder.ResyncPeriodHasElapsed() {
@@ -295,13 +317,13 @@ func (r *GrafanaFolderReconciler) onFolderCreated(ctx context.Context, grafana *
295317
return err
296318
}
297319

298-
exists, remoteUID, err := r.Exists(grafanaClient, cr)
320+
exists, remoteUID, remoteParent, err := r.Exists(grafanaClient, cr)
299321
if err != nil {
300322
return err
301323
}
302324

303325
// always update after resync period has elapsed even if cr is unchanged.
304-
if exists && cr.Unchanged() && !cr.ResyncPeriodHasElapsed() && !cr.Moved() {
326+
if exists && cr.Unchanged() && !cr.ResyncPeriodHasElapsed() && cr.Spec.ParentFolderUID == remoteParent {
305327
return nil
306328
}
307329

@@ -328,7 +350,7 @@ func (r *GrafanaFolderReconciler) onFolderCreated(ctx context.Context, grafana *
328350
}
329351
}
330352

331-
if cr.Moved() {
353+
if cr.Spec.ParentFolderUID != remoteParent {
332354
_, err = grafanaClient.Folders.MoveFolder(remoteUID, &models.MoveFolderCommand{ //nolint
333355
ParentUID: cr.Spec.ParentFolderUID,
334356
})
@@ -374,30 +396,35 @@ func (r *GrafanaFolderReconciler) onFolderCreated(ctx context.Context, grafana *
374396

375397
func (r *GrafanaFolderReconciler) UpdateStatus(ctx context.Context, cr *grafanav1beta1.GrafanaFolder) error {
376398
cr.Status.Hash = cr.Hash()
377-
cr.Status.ParentFolderUID = cr.Spec.ParentFolderUID
378399
return r.Client.Status().Update(ctx, cr)
379400
}
380401

381-
func (r *GrafanaFolderReconciler) Exists(client *genapi.GrafanaHTTPAPI, cr *grafanav1beta1.GrafanaFolder) (bool, string, error) {
402+
// Check if the folder exists. Matches UID first and fall back to title. Title matching only works for non-nested folders
403+
func (r *GrafanaFolderReconciler) Exists(client *genapi.GrafanaHTTPAPI, cr *grafanav1beta1.GrafanaFolder) (bool, string, string, error) {
382404
title := cr.GetTitle()
383405
uid := string(cr.UID)
384406

407+
uidResp, err := client.Folders.GetFolderByUID(uid)
408+
if err == nil {
409+
return true, uidResp.Payload.UID, uidResp.Payload.ParentUID, nil
410+
}
411+
385412
page := int64(1)
386413
limit := int64(10000)
387414
for {
388-
params := folders.NewGetFoldersParams().WithPage(&page).WithLimit(&limit).WithParentUID(&cr.Status.ParentFolderUID)
415+
params := folders.NewGetFoldersParams().WithPage(&page).WithLimit(&limit)
389416

390417
foldersResp, err := client.Folders.GetFolders(params)
391418
if err != nil {
392-
return false, "", err
419+
return false, "", "", err
393420
}
394421
for _, folder := range foldersResp.Payload {
395-
if folder.UID == uid || strings.EqualFold(folder.Title, title) {
396-
return true, folder.UID, nil
422+
if strings.EqualFold(folder.Title, title) {
423+
return true, folder.UID, folder.ParentUID, nil
397424
}
398425
}
399426
if len(foldersResp.Payload) < int(limit) {
400-
return false, "", nil
427+
return false, "", "", nil
401428
}
402429
page++
403430
}

0 commit comments

Comments
 (0)