Skip to content

Commit 58f6a0c

Browse files
authored
Merge pull request #1600 from grafana/feat/folder-conditions
fix: improve nested folder handling
2 parents df1a50e + 4e1214c commit 58f6a0c

9 files changed

+417
-69
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

api/v1beta1/zz_generated.deepcopy.go

+7
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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

+71-5
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
@@ -134,11 +203,8 @@ spec:
134203
description: Last time the folder was resynced
135204
format: date-time
136205
type: string
137-
parentFolderUID:
138-
description: |-
139-
UID of the parent folder where the folder is created.
140-
Will be empty if the folder is deployed at the root level
141-
type: string
206+
required:
207+
- conditions
142208
type: object
143209
type: object
144210
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-26
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,9 @@ import (
2020
"context"
2121
"errors"
2222
"fmt"
23-
"strings"
24-
"time"
2523

2624
kuberr "k8s.io/apimachinery/pkg/api/errors"
2725
"k8s.io/apimachinery/pkg/api/meta"
28-
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2926
"k8s.io/apimachinery/pkg/runtime"
3027
ctrl "sigs.k8s.io/controller-runtime"
3128

@@ -143,29 +140,7 @@ func (r *GrafanaAlertRuleGroupReconciler) Reconcile(ctx context.Context, req ctr
143140
applyErrors[fmt.Sprintf("%s/%s", grafana.Namespace, grafana.Name)] = err.Error()
144141
}
145142
}
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-
}
143+
condition := buildSynchronizedCondition("Alert Rule Group", conditionAlertGroupSynchronized, group.Generation, applyErrors, len(instances))
169144
meta.SetStatusCondition(&group.Status.Conditions, condition)
170145

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

controllers/grafanafolder_controller.go

+48-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
@@ -175,16 +180,35 @@ func (r *GrafanaFolderReconciler) Reconcile(ctx context.Context, req ctrl.Reques
175180
controllerLog.Error(err, "error getting grafana folder cr")
176181
return ctrl.Result{RequeueAfter: RequeueDelay}, err
177182
}
183+
defer func() {
184+
if err := r.UpdateStatus(ctx, folder); err != nil {
185+
r.Log.Error(err, "updating status")
186+
}
187+
}()
188+
189+
if folder.Spec.ParentFolderUID == string(folder.UID) {
190+
setInvalidSpec(&folder.Status.Conditions, folder.Generation, "CyclicParent", "The value of parentFolderUID must not be the uid of the current folder")
191+
meta.RemoveStatusCondition(&folder.Status.Conditions, conditionFolderSynchronized)
192+
return ctrl.Result{}, fmt.Errorf("cyclic folder reference")
193+
}
194+
removeInvalidSpec(&folder.Status.Conditions)
178195

179196
instances, err := r.GetMatchingFolderInstances(ctx, folder, r.Client)
180197
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
198+
setNoMatchingInstance(&folder.Status.Conditions, folder.Generation, "ErrFetchingInstances", fmt.Sprintf("error occurred during fetching of instances: %s", err.Error()))
199+
meta.RemoveStatusCondition(&folder.Status.Conditions, conditionFolderSynchronized)
200+
r.Log.Error(err, "could not find matching instances")
201+
return ctrl.Result{}, err
183202
}
184-
203+
if len(instances.Items) == 0 {
204+
setNoMatchingInstance(&folder.Status.Conditions, folder.Generation, "EmptyAPIReply", "Instances could not be fetched, reconciliation will be retried")
205+
meta.RemoveStatusCondition(&folder.Status.Conditions, conditionFolderSynchronized)
206+
return ctrl.Result{}, fmt.Errorf("no instances found")
207+
}
208+
removeNoMatchingInstance(&folder.Status.Conditions)
185209
controllerLog.Info("found matching Grafana instances for folder", "count", len(instances.Items))
186210

187-
success := true
211+
applyErrors := make(map[string]string)
188212
for _, grafana := range instances.Items {
189213
// check if this is a cross namespace import
190214
if grafana.Namespace != folder.Namespace && !folder.IsAllowCrossNamespaceImport() {
@@ -200,18 +224,20 @@ func (r *GrafanaFolderReconciler) Reconcile(ctx context.Context, req ctrl.Reques
200224
err = r.onFolderCreated(ctx, &grafana, folder)
201225
if err != nil {
202226
controllerLog.Error(err, "error reconciling folder", "folder", folder.Name, "grafana", grafana.Name)
203-
success = false
227+
applyErrors[fmt.Sprintf("%s/%s", grafana.Namespace, grafana.Name)] = err.Error()
204228
}
205229
}
230+
condition := buildSynchronizedCondition("Folder", conditionFolderSynchronized, folder.Generation, applyErrors, len(instances.Items))
231+
meta.SetStatusCondition(&folder.Status.Conditions, condition)
206232

207-
if !success {
233+
if len(applyErrors) != 0 {
208234
return ctrl.Result{RequeueAfter: RequeueDelay}, nil
209235
}
210236

211237
if folder.ResyncPeriodHasElapsed() {
212238
folder.Status.LastResync = metav1.Time{Time: time.Now()}
213239
}
214-
return ctrl.Result{RequeueAfter: folder.GetResyncPeriod()}, r.UpdateStatus(ctx, folder)
240+
return ctrl.Result{RequeueAfter: folder.GetResyncPeriod()}, nil
215241
}
216242

217243
// SetupWithManager sets up the controller with the Manager.
@@ -295,13 +321,13 @@ func (r *GrafanaFolderReconciler) onFolderCreated(ctx context.Context, grafana *
295321
return err
296322
}
297323

298-
exists, remoteUID, err := r.Exists(grafanaClient, cr)
324+
exists, remoteUID, remoteParent, err := r.Exists(grafanaClient, cr)
299325
if err != nil {
300326
return err
301327
}
302328

303329
// always update after resync period has elapsed even if cr is unchanged.
304-
if exists && cr.Unchanged() && !cr.ResyncPeriodHasElapsed() && !cr.Moved() {
330+
if exists && cr.Unchanged() && !cr.ResyncPeriodHasElapsed() && cr.Spec.ParentFolderUID == remoteParent {
305331
return nil
306332
}
307333

@@ -328,7 +354,7 @@ func (r *GrafanaFolderReconciler) onFolderCreated(ctx context.Context, grafana *
328354
}
329355
}
330356

331-
if cr.Moved() {
357+
if cr.Spec.ParentFolderUID != remoteParent {
332358
_, err = grafanaClient.Folders.MoveFolder(remoteUID, &models.MoveFolderCommand{ //nolint
333359
ParentUID: cr.Spec.ParentFolderUID,
334360
})
@@ -374,30 +400,35 @@ func (r *GrafanaFolderReconciler) onFolderCreated(ctx context.Context, grafana *
374400

375401
func (r *GrafanaFolderReconciler) UpdateStatus(ctx context.Context, cr *grafanav1beta1.GrafanaFolder) error {
376402
cr.Status.Hash = cr.Hash()
377-
cr.Status.ParentFolderUID = cr.Spec.ParentFolderUID
378403
return r.Client.Status().Update(ctx, cr)
379404
}
380405

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

411+
uidResp, err := client.Folders.GetFolderByUID(uid)
412+
if err == nil {
413+
return true, uidResp.Payload.UID, uidResp.Payload.ParentUID, nil
414+
}
415+
385416
page := int64(1)
386417
limit := int64(10000)
387418
for {
388-
params := folders.NewGetFoldersParams().WithPage(&page).WithLimit(&limit).WithParentUID(&cr.Status.ParentFolderUID)
419+
params := folders.NewGetFoldersParams().WithPage(&page).WithLimit(&limit)
389420

390421
foldersResp, err := client.Folders.GetFolders(params)
391422
if err != nil {
392-
return false, "", err
423+
return false, "", "", err
393424
}
394425
for _, folder := range foldersResp.Payload {
395-
if folder.UID == uid || strings.EqualFold(folder.Title, title) {
396-
return true, folder.UID, nil
426+
if strings.EqualFold(folder.Title, title) {
427+
return true, folder.UID, folder.ParentUID, nil
397428
}
398429
}
399430
if len(foldersResp.Payload) < int(limit) {
400-
return false, "", nil
431+
return false, "", "", nil
401432
}
402433
page++
403434
}

0 commit comments

Comments
 (0)