diff --git a/api/v1alpha1/grafanaorganization_types.go b/api/v1alpha1/grafanaorganization_types.go index e050c286..a8f6ad6a 100644 --- a/api/v1alpha1/grafanaorganization_types.go +++ b/api/v1alpha1/grafanaorganization_types.go @@ -29,12 +29,24 @@ const ( // GrafanaOrganizationSpec defines the desired state of GrafanaOrganization type GrafanaOrganizationSpec struct { // DisplayName is the name displayed when viewing the organization in Grafana. It can be different from the actual org's name. + // +kubebuilder:example="Giant Swarm" + // +kubebuilder:validation:MinLength=1 DisplayName string `json:"displayName"` // Access rules defines user permissions for interacting with the organization in Grafana. RBAC *RBAC `json:"rbac,omitempty"` + + // Tenants is a list of tenants that are associated with the Grafana organization. + // +kubebuilder:example={"giantswarm"} + Tenants []TenantID `json:"tenants"` } +// TenantID is a unique identifier for a tenant. It must be lowercase. +// +kubebuilder:validation:Pattern="^[a-z]*$" +// +kubebuilder:validation:MinLength=1 +// +kubebuilder:validation:MaxLength=63 +type TenantID string + // RBAC defines the RoleBasedAccessControl configuration for the Grafana organization. // Each fields represents the mapping to a Grafana role: // @@ -62,6 +74,7 @@ type RBAC struct { // GrafanaOrganizationStatus defines the observed state of GrafanaOrganization type GrafanaOrganizationStatus struct { // OrgID is the actual organisation ID in grafana. + // +optional OrgID int64 `json:"orgID"` // DataSources is a list of grafana data sources that are available to the Grafana organization. diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index ca1a92cd..1cfc51aa 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -106,6 +106,11 @@ func (in *GrafanaOrganizationSpec) DeepCopyInto(out *GrafanaOrganizationSpec) { *out = new(RBAC) (*in).DeepCopyInto(*out) } + if in.Tenants != nil { + in, out := &in.Tenants, &out.Tenants + *out = make([]TenantID, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaOrganizationSpec. diff --git a/config/crd/observability.giantswarm.io_grafanaorganizations.yaml b/config/crd/observability.giantswarm.io_grafanaorganizations.yaml index 08c7673e..fc11f7e2 100644 --- a/config/crd/observability.giantswarm.io_grafanaorganizations.yaml +++ b/config/crd/observability.giantswarm.io_grafanaorganizations.yaml @@ -50,6 +50,8 @@ spec: displayName: description: DisplayName is the name displayed when viewing the organization in Grafana. It can be different from the actual org's name. + example: Giant Swarm + minLength: 1 type: string rbac: description: Access rules defines user permissions for interacting @@ -76,8 +78,24 @@ spec: required: - admins type: object + tenants: + description: Tenants is a list of tenants that are associated with + the Grafana organization. + example: + - giantswarm + items: + description: TenantID is a unique identifier for a tenant. It must + be lowercase. + maxLength: 63 + minLength: 1 + pattern: ^[a-z]*$ + type: string + minItems: 1 + type: array + uniqueItems: true required: - displayName + - tenants type: object status: description: GrafanaOrganizationStatus defines the observed state of GrafanaOrganization @@ -104,8 +122,6 @@ spec: description: OrgID is the actual organisation ID in grafana. format: int64 type: integer - required: - - orgID type: object type: object served: true diff --git a/internal/controller/grafanaorganization_controller.go b/internal/controller/grafanaorganization_controller.go index c04c1d80..ec96851e 100644 --- a/internal/controller/grafanaorganization_controller.go +++ b/internal/controller/grafanaorganization_controller.go @@ -224,14 +224,23 @@ func (r GrafanaOrganizationReconciler) configureSharedOrg(ctx context.Context) e return nil } +func newOrganization(grafanaOrganization *v1alpha1.GrafanaOrganization) *grafana.Organization { + tenantIDs := make([]string, len(grafanaOrganization.Spec.Tenants)) + for i, tenant := range grafanaOrganization.Spec.Tenants { + tenantIDs[i] = string(tenant) + } + + return &grafana.Organization{ + ID: grafanaOrganization.Status.OrgID, + Name: grafanaOrganization.Spec.DisplayName, + TenantIDs: tenantIDs, + } +} + func (r GrafanaOrganizationReconciler) configureOrganization(ctx context.Context, grafanaOrganization *v1alpha1.GrafanaOrganization) (err error) { logger := log.FromContext(ctx) // Create or update organization in Grafana - var organization = &grafana.Organization{ - ID: grafanaOrganization.Status.OrgID, - Name: grafanaOrganization.Spec.DisplayName, - TenantID: grafanaOrganization.Name, - } + var organization = newOrganization(grafanaOrganization) if organization.ID == 0 { // if the CR doesn't have an orgID, create the organization in Grafana @@ -265,13 +274,9 @@ func (r GrafanaOrganizationReconciler) configureDatasources(ctx context.Context, logger.Info("configuring data sources") // Create or update organization in Grafana - var organization = grafana.Organization{ - ID: grafanaOrganization.Status.OrgID, - Name: grafanaOrganization.Spec.DisplayName, - TenantID: grafanaOrganization.Name, - } + var organization = newOrganization(grafanaOrganization) - datasources, err := grafana.ConfigureDefaultDatasources(ctx, r.GrafanaAPI, organization) + datasources, err := grafana.ConfigureDefaultDatasources(ctx, r.GrafanaAPI, *organization) if err != nil { return errors.WithStack(err) } diff --git a/internal/controller/predicates/predicates_test.go b/internal/controller/predicates/predicates_test.go new file mode 100644 index 00000000..44c6ec44 --- /dev/null +++ b/internal/controller/predicates/predicates_test.go @@ -0,0 +1,210 @@ +package predicates + +import ( + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/event" +) + +func TestIsGrafanaPod(t *testing.T) { + tests := []struct { + name string + pod *corev1.Pod + expected bool + }{ + { + name: "nil pod", + pod: nil, + expected: false, + }, + { + name: "non-Grafana pod", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "non-grafana-pod", + Namespace: "default", + }, + }, + expected: false, + }, + { + name: "Grafana pod with correct labels", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "grafana-pod", + Namespace: "monitoring", + Labels: map[string]string{ + "app.kubernetes.io/instance": "grafana", + }, + }, + }, + expected: true, + }, + { + name: "Grafana pod with incorrect namespace", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "grafana-pod", + Namespace: "default", + Labels: map[string]string{ + "app.kubernetes.io/instance": "grafana", + }, + }, + }, + expected: false, + }, + { + name: "Grafana pod with incorrect label", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "grafana-pod", + Namespace: "monitoring", + Labels: map[string]string{ + "app.kubernetes.io/instance": "not-grafana", + }, + }, + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isGrafanaPod(tt.pod) + if result != tt.expected { + t.Errorf("expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestGrafanaPodRecreatedPredicate_Update(t *testing.T) { + tests := []struct { + name string + oldPod *corev1.Pod + newPod *corev1.Pod + expected bool + }{ + { + name: "nil old object", + oldPod: nil, + newPod: &corev1.Pod{}, + expected: false, + }, + { + name: "nil new object", + oldPod: &corev1.Pod{}, + newPod: nil, + expected: false, + }, + { + name: "non-Grafana pod", + oldPod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "non-grafana-pod", + Namespace: "default", + }, + }, + newPod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "non-grafana-pod", + Namespace: "default", + }, + }, + expected: false, + }, + { + name: "Grafana pod not ready to ready", + oldPod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "grafana-pod", + Namespace: "monitoring", + Labels: map[string]string{ + "app.kubernetes.io/instance": "grafana", + }, + }, + Status: corev1.PodStatus{ + Conditions: []corev1.PodCondition{ + { + Type: corev1.PodReady, + Status: corev1.ConditionFalse, + }, + }, + }, + }, + newPod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "grafana-pod", + Namespace: "monitoring", + Labels: map[string]string{ + "app.kubernetes.io/instance": "grafana", + }, + }, + Status: corev1.PodStatus{ + Conditions: []corev1.PodCondition{ + { + Type: corev1.PodReady, + Status: corev1.ConditionTrue, + }, + }, + }, + }, + expected: true, + }, + { + name: "Grafana pod ready to not ready", + oldPod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "grafana-pod", + Namespace: "monitoring", + Labels: map[string]string{ + "app.kubernetes.io/instance": "grafana", + }, + }, + Status: corev1.PodStatus{ + Conditions: []corev1.PodCondition{ + { + Type: corev1.PodReady, + Status: corev1.ConditionTrue, + }, + }, + }, + }, + newPod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "grafana-pod", + Namespace: "monitoring", + Labels: map[string]string{ + "app.kubernetes.io/instance": "grafana", + }, + }, + Status: corev1.PodStatus{ + Conditions: []corev1.PodCondition{ + { + Type: corev1.PodReady, + Status: corev1.ConditionFalse, + }, + }, + }, + }, + expected: false, + }, + } + + predicate := GrafanaPodRecreatedPredicate{} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e := event.UpdateEvent{ + ObjectOld: tt.oldPod, + ObjectNew: tt.newPod, + } + result := predicate.Update(e) + if result != tt.expected { + t.Errorf("expected %v, got %v", tt.expected, result) + } + }) + } +} diff --git a/pkg/grafana/grafana.go b/pkg/grafana/grafana.go index 5c3e0b30..af7b8ff0 100644 --- a/pkg/grafana/grafana.go +++ b/pkg/grafana/grafana.go @@ -18,9 +18,9 @@ const ( ) var SharedOrg = Organization{ - ID: 1, - Name: "Shared Org", - TenantID: "giantswarm", + ID: 1, + Name: "Shared Org", + TenantIDs: []string{"giantswarm"}, } // We need to use a custom name for now until we can replace the existing datasources. diff --git a/pkg/grafana/types.go b/pkg/grafana/types.go index 595bbea0..6ded75be 100644 --- a/pkg/grafana/types.go +++ b/pkg/grafana/types.go @@ -1,9 +1,11 @@ package grafana +import "strings" + type Organization struct { - ID int64 - Name string - TenantID string + ID int64 + Name string + TenantIDs []string } type Datasource struct { @@ -33,12 +35,12 @@ func (d Datasource) buildJSONData() map[string]interface{} { } func (d Datasource) buildSecureJSONData(organization Organization) map[string]string { - tenant := organization.TenantID + tenantIDs := organization.TenantIDs if d.Type != "loki" { // We do not support multi-tenancy for Mimir yet - tenant = "anonymous" + tenantIDs = []string{"anonymous"} } return map[string]string{ - "httpHeaderValue1": tenant, + "httpHeaderValue1": strings.Join(tenantIDs, "|"), } }