Merge branch 'main' into kristina/static-transform-refIds

This commit is contained in:
Kristina Durivage
2025-09-26 14:50:14 -05:00
133 changed files with 7122 additions and 5556 deletions
+24 -1
View File
@@ -14,6 +14,7 @@ import (
advisorv0alpha1 "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1"
"github.com/grafana/grafana/apps/advisor/pkg/app/checks"
"github.com/grafana/grafana/pkg/services/contexthandler"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
)
var retryAnnotationPollingInterval = 1 * time.Second
@@ -81,7 +82,11 @@ func processCheck(ctx context.Context, log logging.Logger, client resource.Clien
}
return fmt.Errorf("error running steps: %w", err)
}
// Wait for the item to be persisted before patching the object
err = waitForItem(ctx, log, client, obj)
if err != nil {
return err
}
report := &advisorv0alpha1.CheckReport{
Failures: failures,
Count: int64(len(items)),
@@ -264,6 +269,24 @@ func retryAnnotationChanged(oldObj, newObj resource.Object) bool {
oldAnnotations[checks.RetryAnnotation] != newAnnotations[checks.RetryAnnotation]
}
func waitForItem(ctx context.Context, log logging.Logger, client resource.Client, obj resource.Object) error {
_, err := client.Get(ctx, resource.Identifier{
Namespace: obj.GetNamespace(),
Name: obj.GetName(),
})
retries := 0
for err != nil && k8serrors.IsNotFound(err) && retries < 5 {
log.Debug("Waiting for item to be persisted", "check", obj.GetName(), "retries", retries)
time.Sleep(retryAnnotationPollingInterval)
retries++
_, err = client.Get(ctx, resource.Identifier{
Namespace: obj.GetNamespace(),
Name: obj.GetName(),
})
}
return err
}
// waitForRetryAnnotation waits for the retry annotation to match the item to retry
func waitForRetryAnnotation(ctx context.Context, log logging.Logger, client resource.Client, obj resource.Object, itemToRetry string) error {
currentObj, err := client.Get(ctx, resource.Identifier{
+12 -10
View File
@@ -3,14 +3,16 @@
# https://docs.tilt.dev/api.html#api.version_settings
version_settings(constraint='>=0.22.2')
custom_build(
'grafana-iam-operator',
command='docker build -t $EXPECTED_REF -f cmd/operator/Dockerfile .',
deps=[
'cmd/operator',
'pkg',
],
disable_push=True,
)
k8s_yaml([filename for filename in listdir('local/yamls') if filename.lower().endswith(('.yaml', '.yml'))])
# Port forward Grafana to localhost:3000
k8s_resource('grafana', port_forwards=['3000:3000'])
# Port forward Jaeger UI to localhost:16686
k8s_resource('jaeger-agent', port_forwards=['16686:16686'])
# Port forward Pyroscope UI to localhost:4040
k8s_resource('pyroscope', port_forwards=['4040:4040'])
# Port forward Alloy UI to localhost:12345
k8s_resource('alloy', port_forwards=['12345:12345'])
+1 -1
View File
@@ -204,7 +204,7 @@ require (
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
github.com/googleapis/gax-go/v2 v2.14.2 // indirect
github.com/gorilla/mux v1.8.1 // indirect
github.com/grafana/alerting v0.0.0-20250915130141-a8ee25091876 // indirect
github.com/grafana/alerting v0.0.0-20250925200825-7a889aa4934d // indirect
github.com/grafana/authlib/types v0.0.0-20250917093142-83a502239781 // indirect
github.com/grafana/dataplane/sdata v0.0.9 // indirect
github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 // indirect
+2 -2
View File
@@ -721,8 +721,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/grafana/alerting v0.0.0-20250915130141-a8ee25091876 h1:BzoGpzARwRCNOHcqQdYPAFp2LS1pqnkLWhIuDdq1zho=
github.com/grafana/alerting v0.0.0-20250915130141-a8ee25091876/go.mod h1:T5sitas9VhVj8/S9LeRLy6H75kTBdh/sCCqHo7gaQI8=
github.com/grafana/alerting v0.0.0-20250925200825-7a889aa4934d h1:zzEty7HgfXbQ/RiBCJFMqaZiJlqiXuz/Zbc6/H6ksuM=
github.com/grafana/alerting v0.0.0-20250925200825-7a889aa4934d/go.mod h1:T5sitas9VhVj8/S9LeRLy6H75kTBdh/sCCqHo7gaQI8=
github.com/grafana/authlib v0.0.0-20250924100039-ea07223cdb6c h1:8GIMe1KclDdfogaeRsiU69Ev2zTF9kmjqjQqqZMzerc=
github.com/grafana/authlib v0.0.0-20250924100039-ea07223cdb6c/go.mod h1:C6CmTG6vfiqebjJswKsc6zes+1F/OtTCi6aAtL5Um6A=
github.com/grafana/authlib/types v0.0.0-20250917093142-83a502239781 h1:jymmOFIWnW26DeUjFgYEoltI170KeT5r1rI8a/dUf0E=
+122
View File
@@ -0,0 +1,122 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: alloy-config
namespace: default
data:
config.alloy: |
// Pyroscope configuration to receive profiles
pyroscope.write "default" {
endpoint {
url = "http://pyroscope.default.svc.cluster.local:4040"
}
}
// Scrape CPU profiles from the IAM operator
pyroscope.scrape "iam_operator" {
targets = [
{
"__address__" = "iam-folder-reconciler.default.svc.cluster.local:6060",
"service_name" = "iam-folder-reconciler",
},
]
forward_to = [pyroscope.write.default.receiver]
job_name = "iam-operator"
scrape_interval = "30s"
scrape_timeout = "25s"
profiling_config {
profile.process_cpu {
enabled = true
path = "/debug/pprof/profile"
delta = false
}
profile.godeltaprof_memory {
enabled = true
path = "/debug/pprof/delta_heap"
}
profile.memory {
enabled = true
path = "/debug/pprof/heap"
delta = false
}
profile.godeltaprof_mutex {
enabled = true
path = "/debug/pprof/delta_mutex"
}
profile.godeltaprof_block {
enabled = true
path = "/debug/pprof/delta_block"
}
profile.goroutine {
enabled = true
path = "/debug/pprof/goroutine"
delta = false
}
}
}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: alloy
namespace: default
spec:
replicas: 1
selector:
matchLabels:
name: alloy
template:
metadata:
labels:
name: alloy
spec:
containers:
- name: alloy
image: grafana/alloy:v1.10.0
args:
- run
- /etc/alloy/config.alloy
- --storage.path=/var/lib/alloy/data
- --server.http.listen-addr=0.0.0.0:12345
- --stability.level=experimental
ports:
- containerPort: 12345
name: http
volumeMounts:
- name: config
mountPath: /etc/alloy
- name: storage
mountPath: /var/lib/alloy/data
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "256Mi"
cpu: "200m"
volumes:
- name: config
configMap:
name: alloy-config
- name: storage
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: alloy
namespace: default
spec:
selector:
name: alloy
ports:
- name: http
port: 12345
targetPort: 12345
+197
View File
@@ -0,0 +1,197 @@
---
apiVersion: v1
kind: ConfigMap
metadata:
name: grafana-datasources
namespace: default
data:
datasources.yaml: |
apiVersion: 1
datasources:
- name: Jaeger
type: jaeger
uid: local-jaeger
access: proxy
url: http://jaeger-agent.jaeger.svc.cluster.local:16686
editable: true
isDefault: true
jsonData:
tracesToLogsV2:
datasourceUid: 'local-loki'
customQuery: false
filterByTraceID: true
filterBySpanID: true
nodeGraph:
enabled: true
search:
hide: false
spanBar:
type: 'Duration'
- name: Pyroscope
type: grafana-pyroscope-datasource
uid: local-pyroscope
access: proxy
url: http://pyroscope.default.svc.cluster.local:4040
editable: true
isDefault: false
---
apiVersion: v1
kind: ConfigMap
metadata:
name: grafana-config
namespace: default
data:
grafana.ini: |
app_mode = development
[paths]
data = /var/lib/grafana
logs = /var/log/grafana
plugins = /var/lib/grafana/plugins
provisioning = /etc/grafana/provisioning
[server]
http_port = 3000
[database]
type = sqlite3
path = grafana.db
[session]
provider = file
provider_config = sessions
[analytics]
reporting_enabled = false
check_for_updates = false
[security]
admin_user = admin
admin_password = admin
disable_gravatar = true
[snapshots]
external_enabled = false
[users]
allow_sign_up = false
allow_org_create = false
auto_assign_org = true
auto_assign_org_role = Viewer
[plugin.grafana-pyroscope-app]
app_enabled = true
[auth.anonymous]
enabled = true
org_name = Main Org.
org_role = Editor
[log]
mode = console
level = info
[explore]
enabled = true
[feature_toggles]
enable = traceqlStreaming,correlations,traceToMetrics
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: grafana
namespace: default
labels:
app: grafana
spec:
replicas: 1
selector:
matchLabels:
app: grafana
template:
metadata:
labels:
app: grafana
spec:
securityContext:
fsGroup: 472
supplementalGroups:
- 0
containers:
- name: grafana
image: grafana/grafana:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 3000
name: http-grafana
protocol: TCP
env:
- name: GF_PATHS_CONFIG
value: /etc/grafana/grafana.ini
- name: GF_PATHS_PROVISIONING
value: /etc/grafana/provisioning
resources:
requests:
cpu: 250m
memory: 750Mi
limits:
cpu: 500m
memory: 1Gi
volumeMounts:
- mountPath: /var/lib/grafana
name: grafana-storage
- mountPath: /etc/grafana
name: grafana-config
- mountPath: /etc/grafana/provisioning/datasources
name: grafana-datasources
readinessProbe:
failureThreshold: 3
httpGet:
path: /api/health
port: 3000
scheme: HTTP
initialDelaySeconds: 10
periodSeconds: 30
successThreshold: 1
timeoutSeconds: 2
livenessProbe:
failureThreshold: 3
initialDelaySeconds: 30
periodSeconds: 10
successThreshold: 1
httpGet:
path: /api/health
port: 3000
timeoutSeconds: 1
volumes:
- name: grafana-storage
emptyDir: {}
- name: grafana-config
configMap:
name: grafana-config
- name: grafana-datasources
configMap:
name: grafana-datasources
items:
- key: datasources.yaml
path: datasources.yaml
---
apiVersion: v1
kind: Service
metadata:
name: grafana
namespace: default
labels:
app: grafana
spec:
type: NodePort
ports:
- port: 3000
protocol: TCP
targetPort: http-grafana
nodePort: 30000
name: grafana-http
selector:
app: grafana
---
+101
View File
@@ -0,0 +1,101 @@
---
apiVersion: v1
kind: Namespace
metadata:
name: jaeger
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: jaeger-agent
namespace: jaeger
labels:
app: jaeger-agent
spec:
replicas: 1
selector:
matchLabels:
app: jaeger-agent
template:
metadata:
labels:
app: jaeger-agent
spec:
containers:
- name: jaeger-agent
image: jaegertracing/all-in-one:latest
ports:
- containerPort: 6831
protocol: UDP
name: agent-udp
- containerPort: 16686
name: ui
- containerPort: 14268
name: collector
- containerPort: 4317
name: otlp-grpc
- containerPort: 4318
name: otlp-http
- containerPort: 5778
name: sampling
env:
- name: MEMORY_MAX_TRACES
value: "100000"
- name: SPAN_STORAGE_TYPE
value: "badger"
- name: COLLECTOR_OTLP_ENABLED
value: "true"
- name: LOG_LEVEL
value: "debug"
resources:
limits:
memory: "1Gi"
cpu: "500m"
requests:
memory: "512Mi"
cpu: "250m"
readinessProbe:
httpGet:
path: /
port: 16686
initialDelaySeconds: 10
periodSeconds: 10
livenessProbe:
httpGet:
path: /
port: 16686
initialDelaySeconds: 30
periodSeconds: 30
---
apiVersion: v1
kind: Service
metadata:
name: jaeger-agent
namespace: jaeger
labels:
app: jaeger-agent
spec:
selector:
app: jaeger-agent
ports:
- name: agent-udp
port: 6831
targetPort: 6831
protocol: UDP
- name: ui
port: 16686
targetPort: 16686
- name: collector
port: 14268
targetPort: 14268
- name: otlp-grpc
port: 4317
targetPort: 4317
- name: otlp-http
port: 4318
targetPort: 4318
- name: sampling
port: 5778
targetPort: 5778
type: ClusterIP
---
+85 -33
View File
@@ -4,10 +4,43 @@ metadata:
name: operator
namespace: default
---
apiVersion: v1
kind: ConfigMap
metadata:
name: operator-config
namespace: default
data:
operator.ini: |
app_mode = development
target = operator
ensure_default_org_and_user = false
skip_migrations = true
[grpc_client_authentication]
token_exchange_url = http://host.docker.internal:8080/v1/sign-access-token
token_namespace = *
[operator]
folder_app_url = https://host.docker.internal:6446
max_concurrent_workers = 20
tls_ca_file =
tls_insecure = true
zanzana_url = zanzana.default.svc.cluster.local:50051
[tracing.opentelemetry]
custom_attributes = namespace:grafana-iam
sampler_param = 1
sampler_type = remote
sampling_server_url = http://jaeger-agent.jaeger.svc.cluster.local.:5778/sampling
[tracing.opentelemetry.otlp]
address = jaeger-agent.jaeger.svc.cluster.local.:4317
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: iam-app-operator
name: iam-folder-reconciler
namespace: default
spec:
minReadySeconds: 10
@@ -15,44 +48,63 @@ spec:
revisionHistoryLimit: 10
selector:
matchLabels:
name: iam-app-operator
name: iam-folder-reconciler
template:
metadata:
labels:
name: iam-app-operator
name: iam-folder-reconciler
spec:
serviceAccount: operator
containers:
- image: grafana-iam-operator
imagePullPolicy: IfNotPresent
name: iam-app-operator
command: ["/bin/sh"]
args:
- -c
- exec /usr/bin/operator
- command:
- grafana-server
- target
- --config=/etc/grafana-config/operator.ini
- --homepath=/usr/share/grafana
env:
- name: KUBE_FEATURE_WatchListClient
value: "false"
- name: ZANZANA_ADDR
value: zanzana.default.svc.cluster.local:50051
- name: FOLDER_APP_URL
value: https://host.docker.internal:6446
- name: FOLDER_APP_NAMESPACE
value: grafana-folder
- name: TOKEN_EXCHANGE_URL
value: http://host.docker.internal:8080/v1/sign-access-token
- name: AUTH_TOKEN
value: "true"
- name: GF_DEFAULT_TARGET
value: operator
- name: GF_OPERATOR_NAME
value: iam-folder-reconciler
- name: GF_DIAGNOSTICS_PROFILING_ENABLED
value: "true"
- name: GF_DIAGNOSTICS_PROFILING_ADDR
value: "0.0.0.0"
- name: GF_DIAGNOSTICS_PROFILING_PORT
value: "6060"
- name: GF_GRPC_CLIENT_AUTHENTICATION_TOKEN
valueFrom:
secretKeyRef:
name: iam-app-operator-secrets
key: auth-token
- name: ZANZANA_TOKEN
valueFrom:
secretKeyRef:
name: iam-app-operator-secrets
key: zanzana-token
- name: FOLDER_RECONCILER_NAMESPACE
valueFrom:
secretKeyRef:
name: iam-app-operator-secrets
key: folder-reconciler-namespace
name: iam-operator-secrets
key: grpc_auth_token
image: grafana/grafana-dev:12.3.0-17863745596
imagePullPolicy: IfNotPresent
name: iam-folder-reconciler
volumeMounts:
- name: operator-config
mountPath: /etc/grafana-config
serviceAccount: operator
volumes:
- configMap:
name: operator-config
name: operator-config
---
apiVersion: v1
kind: Service
metadata:
name: iam-folder-reconciler
namespace: default
labels:
name: iam-folder-reconciler
spec:
ports:
- name: iam-folder-reconciler-http-metrics
port: 8080
targetPort: 8080
- name: iam-folder-reconciler-pprof
port: 6060
targetPort: 6060
selector:
name: iam-folder-reconciler
---
+45
View File
@@ -0,0 +1,45 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: pyroscope
namespace: default
spec:
replicas: 1
selector:
matchLabels:
name: pyroscope
template:
metadata:
labels:
name: pyroscope
spec:
containers:
- name: pyroscope
image: grafana/pyroscope:1.14.0
ports:
- containerPort: 4040
name: http
env:
- name: PYROSCOPE_LOG_LEVEL
value: "info"
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
volumes: []
---
apiVersion: v1
kind: Service
metadata:
name: pyroscope
namespace: default
spec:
selector:
name: pyroscope
ports:
- name: http
port: 4040
targetPort: 4040
+2 -9
View File
@@ -16,7 +16,7 @@ spec:
spec:
containers:
- name: postgres
image: postgres:15.7
image: postgres:15
env:
- name: POSTGRES_USER
value: grafana
@@ -32,13 +32,6 @@ spec:
volumeMounts:
- name: postgres-storage
mountPath: /var/lib/postgresql/data
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
readinessProbe:
exec:
command:
@@ -165,7 +158,7 @@ spec:
env:
- name: GF_PATHS_CONFIG
value: /etc/grafana-config/grafana.ini
image: grafana/grafana-dev:12.2.0-257970
image: grafana/grafana-dev:12.3.0-17863745596
imagePullPolicy: IfNotPresent
name: zanzana
ports:
@@ -102,14 +102,14 @@ Following is a list of PostgreSQL configuration options:
**Authentication section:**
| Setting | Description |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Username | Enter the username used to connect to your PostgreSQL database. |
| Password | Enter the password used to connect to the PostgreSQL database. |
| TLS/SSL Mode | Determines whether or with what priority a secure SSL TCP/IP connection will be negotiated with the server. When TLS/SSL Mode is disabled, TLS/SSL Method and TLS/SSL Auth Details aren’t visible options. |
| TLS/SSL Method | Determines how TLS/SSL certificates are configured. |
| - File system path | This option allows you to configure certificates by specifying paths to existing certificates on the local file system where Grafana is running. Ensure this file is readable by the user executing the Grafana process. |
| - Certificate content | This option allows you to configure certificate by specifying their content. The content is stored and encrypted in the Grafana database. When connecting to the database, the certificates are saved as files, on the local filesystem, in the Grafana data path. |
| Setting | Description |
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Username | Enter the username used to connect to your PostgreSQL database. |
| Password | Enter the password used to connect to the PostgreSQL database. If a password is not specified and your PostgreSQL is configured to request a password, data source will look for a [standard PostgreSQL password file](https://www.postgresql.org/docs/current/static/libpq-pgpass.html). |
| TLS/SSL Mode | Determines whether or with what priority a secure SSL TCP/IP connection will be negotiated with the server. When TLS/SSL Mode is disabled, TLS/SSL Method and TLS/SSL Auth Details aren’t visible options. |
| TLS/SSL Method | Determines how TLS/SSL certificates are configured. |
| - File system path | This option allows you to configure certificates by specifying paths to existing certificates on the local file system where Grafana is running. Ensure this file is readable by the user executing the Grafana process. |
| - Certificate content | This option allows you to configure certificate by specifying their content. The content is stored and encrypted in the Grafana database. When connecting to the database, the certificates are saved as files, on the local filesystem, in the Grafana data path. |
**TLS/SSL Auth Details:**
@@ -121,7 +121,7 @@ query_result(max_over_time(<metric>[${__range_s}s]) != <state>)
{{< admonition type="note" >}}
Saved queries is currently in [public preview](https://grafana.com/docs/release-life-cycle/). Grafana Labs offers limited support, and breaking changes might occur prior to the feature being made generally available.
This feature is only available on Grafana Enterprise and Grafana Cloud.
This feature is only available on Grafana Enterprise and Grafana Cloud. It will gradually roll out to all Grafana Cloud users with no action required. To try out this feature on Grafana Enterprise, enable the `queryLibrary` feature toggle.
{{< /admonition >}}
You can save queries that you've created so they can be reused by you and others in your organization.
@@ -103,7 +103,7 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general-
| `regressionTransformation` | Enables regression analysis transformation |
| `alertingSaveStateCompressed` | Enables the compressed protobuf-based alert state storage |
| `sqlExpressions` | Enables SQL Expressions, which can execute SQL queries against data source results. |
| `savedQueries` | Enables Saved Queries feature |
| `queryLibrary` | Enables Saved queries (query library) feature |
| `enableSCIM` | Enables SCIM support for user and group management |
| `alertRuleRestore` | Enables the alert rule restore feature |
| `azureMonitorLogsBuilderEditor` | Enables the logs builder mode for the Azure Monitor data source |
@@ -33,7 +33,7 @@ Grafana provides OAuth2 integrations for the following auth providers:
If your OAuth2 provider is not listed, you can use Generic OAuth authentication.
This topic describes how to configure Generic OAuth authentication using different methods and includes [examples of setting up Generic OAuth](#examples-of-setting-up-generic-oauth2) with specific OAuth2 providers.
This topic describes how to configure Generic OAuth authentication using different methods and includes [examples of setting up Generic OAuth](#examples-of-setting-up-generic-oauth) with specific OAuth2 providers.
## Before you begin
@@ -29,7 +29,7 @@ SAML authentication integration allows your Grafana users to log in by using an
You can configure SAML authentication in Grafana through one of the following methods:
- Configure SAML using the [Grafana configuration file](#configure-saml-using-the-grafana-config-file)
- Configure SAML using the [Grafana configuration file](#configure-saml-using-the-grafana-configuration-file)
- Configure SAML using the [SSO Settings API](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/developers/http_api/sso-settings/)
- Configure SAML using the [SAML user interface](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-authentication/saml/saml-ui/)
- Configure SAML using the [Grafana Terraform provider](https://registry.terraform.io/providers/grafana/grafana/<GRAFANA_VERSION>/docs/resources/sso_settings)
@@ -238,7 +238,7 @@ To allow Grafana to initiate a POST request to the IdP, update the `content_secu
For Grafana Cloud instances, please contact Grafana Support to update the `content_security_policy_template` and `content_security_policy_report_only_template` settings of your Grafana instance. Please provide the metadata URL/file of your IdP.
{{< /admonition >}}
## IdP-initiated login
## IdP-initiated Single Sign-On (SSO)
By default, Grafana allows only service provider (SP) initiated logins (when the user logs in with SAML via the login page in Grafana). If you want users to log in into Grafana directly from your identity provider (IdP), set the `allow_idp_initiated` configuration option to `true` and configure `relay_state` with the same value specified in the IdP configuration.
@@ -71,7 +71,40 @@ When you enable SCIM in Grafana, the following requirements and restrictions app
- Configure `userUID` SAML assertion in [Azure AD](/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-authentication/saml/configure-saml-with-azuread/#configure-saml-assertions-when-using-scim-provisioning)
- Configure `userUID` SAML assertion in [Okta](/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-authentication/saml/configure-saml-with-okta/#configure-saml-assertions-when-using-scim-provisioning)
## Configure SCIM in Grafana
## Configure SCIM using the Grafana user interface
You can configure SCIM in Grafana using the Grafana user interface. To do this, navigate to **Administration > Authentication > SCIM**.
The Grafana SCIM UI provides the following advantages over configuring SCIM in the Grafana configuration file:
- It is accessible by Grafana Cloud users
- It doesn't require Grafana to be restarted after a configuration update
- Using the authentication settings permission allows us to restrict Grafana’s access scope rather than relying on an overly permissive role such as Admin.
{{< admonition type="note" >}}
Any configuration changes made through the Grafana user interface (UI) will take precedence over settings specified in the Grafana configuration file or through environment variables. This means that if you modify any configuration settings in the UI, they will override any corresponding settings set via environment variables or defined in the configuration file.
{{< /admonition >}}
### Configure SCIM settings
Sign in to Grafana and navigate to **Administration > Authentication > SCIM**. Here you can configure the following settings:
| Setting | Required | Description | Default |
| ------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- |
| `Enable Group Sync` | No | Enable SCIM group provisioning. When enabled, Grafana will create, update, and delete teams based on SCIM requests from your identity provider. Cannot be enabled if Team Sync is enabled. | `false` |
| `Reject Non-Provisioned Users` | No | When enabled, prevents non-SCIM provisioned users from signing in. Cloud Portal users can always sign in regardless of this setting. | `false` |
| `Enable User Sync` | Yes | Enable SCIM user provisioning. When enabled, Grafana will create, update, and deactivate users based on SCIM requests from your identity provider. | `false` |
The SCIM UI also displays information that may help you configure SCIM in your identity provider, including stack domain, stack ID, and tenant URL.
### Next steps
After configuring SCIM in Grafana, configure your identity provider:
- [Configure SCIM with Okta](configure-scim-with-okta/)
- [Configure SCIM with Azure AD](configure-scim-with-azuread/)
## Configure SCIM using the configuration file
The table below describes all SCIM configuration options. Like any other Grafana configuration, you can apply these options as [environment variables](/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/#override-configuration-with-environment-variables).
@@ -61,8 +61,22 @@ To enable SCIM provisioning in Grafana, create a service account and generate a
1. Navigate to **Administration > Users and access > Service accounts**
2. Click **Add service account**
3. Create a new service account with Admin role
4. Create a new token for the newly created service account and save it securely
3. Create a new service account with **Role: "None"**
4. In the service account **Permissions** tab, add these permissions:
**Allow the service account to sync users:**
- `org.users:read`
- `org.users:write`
- `org.users:add`
- `org.users:remove`
**Allow the service account to sync groups:**
- `teams:read`
- `teams:create`
- `teams:write`
- `teams:delete`
5. Create a new token for the newly created service account and save it securely
- This token will be used in the Azure AD configuration
## Configure SCIM in Azure AD
@@ -84,6 +98,10 @@ Configure the enterprise application in Azure AD to enable automated user and te
3. Configure the following settings:
- **Tenant URL:**
You can copy the tenant URL directly from the SCIM UI at **Administration > Authentication > SCIM**. Your stack domain and stack ID can also be found in the SCIM UI.
Alternatively, you can construct the URL manually:
- For Grafana Cloud instances:
```
https://{stack-name}.grafana.net/apis/scim.grafana.app/v0alpha1/namespaces/stacks-{stack-id}
@@ -94,6 +112,7 @@ Configure the enterprise application in Azure AD to enable automated user and te
https://{your-grafana-domain}/apis/scim.grafana.app/v0alpha1/namespaces/default
```
Replace `{your-grafana-domain}` with your Grafana instance's domain (e.g., `grafana.yourcompany.com`).
- **Secret Token:** Enter the service account token from Grafana
4. Click **Test connection** to verify the configuration
@@ -60,8 +60,22 @@ To enable SCIM provisioning in Grafana, create a service account and generate an
1. Navigate to **Administration > Users and access > Service accounts**
2. Click **Add service account**
3. Create a new service account with Admin role
4. Create a new token for the newly created service account and save it securely
3. Create a new service account with **Role: "None"**
4. In the service account **Permissions** tab, add these permissions:
**Allow the service account to sync users:**
- `org.users:read`
- `org.users:write`
- `org.users:add`
- `org.users:remove`
**Allow the service account to sync groups:**
- `teams:read`
- `teams:create`
- `teams:write`
- `teams:delete`
5. Create a new token for the newly created service account and save it securely
- This token will be used in the Okta configuration
## Configure SCIM in Okta
@@ -83,6 +97,10 @@ To enable user provisioning through SCIM, configure the SCIM integration setting
In the **Integration** tab, configure:
- **SCIM Connector base URL:**
You can copy the complete SCIM Connector base URL directly from the SCIM UI at **Administration > Authentication > SCIM**. This is displayed as the Tenant URL in the UI. Your stack domain and stack ID can also be found in the SCIM UI.
Alternatively, you can construct the URL manually:
- For Grafana Cloud instances:
```
https://{stack-name}.grafana.net/apis/scim.grafana.app/v0alpha1/namespaces/stacks-{stack-id}
@@ -93,6 +111,7 @@ In the **Integration** tab, configure:
https://{your-grafana-domain}/apis/scim.grafana.app/v0alpha1/namespaces/default
```
Replace `{your-grafana-domain}` with your Grafana instance's domain (e.g., `grafana.yourcompany.com`).
- **Unique identifier field:** userName
- **Supported provisioning actions:**
- Import New Users and Profile Updates
@@ -32,6 +32,12 @@ Alert notifications can include images, but rendering many images at the same ti
## Install Grafana Image Renderer plugin
{{< admonition type="caution" >}}
Starting with Grafana v12.2, the Grafana Image Renderer plugin is deprecated and is no longer maintained.
Instead, use the Grafana Image Renderer remote rendering service.
{{< /admonition >}}
{{< admonition type="note" >}}
All PhantomJS support has been removed. Instead, use the Grafana Image Renderer plugin or remote rendering service.
{{< /admonition >}}
@@ -11,7 +11,7 @@ import {
getScopesDashboardsSearchInput,
getScopesSelectorInput,
} from './cuj-selectors';
import { getConfigDashboards } from './utils';
import { checkDashboardReloadBehavior, getConfigDashboards, trackDashboardReloadRequests } from './utils';
test.use({
featureToggles: {
@@ -118,8 +118,14 @@ test.describe(
await groupByVariable.press('Escape');
await expect(scopesDashboards.first()).toBeVisible();
const { getRequests, waitForExpectedRequests } = await trackDashboardReloadRequests(page);
await scopesDashboards.first().click();
await page.waitForURL('**/d/**');
await waitForExpectedRequests();
await page.waitForLoadState('networkidle');
const requests = getRequests();
expect(checkDashboardReloadBehavior(requests)).toBe(true);
//all values are set after dashboard switch
await expect(markdownContent).toContainText(`GroupByVar: dev\n\nAdHocVar: ${processedPills}`);
@@ -1,6 +1,6 @@
import { test, expect } from '@grafana/plugin-e2e';
import { getConfigDashboards } from './utils';
import { getConfigDashboards, trackDashboardReloadRequests, checkDashboardReloadBehavior } from './utils';
test.use({
featureToggles: {
@@ -27,7 +27,13 @@ test.describe(
for (const db of dashboards) {
await test.step('1.Loads dashboard successfully - ' + db, async () => {
const { getRequests, waitForExpectedRequests } = await trackDashboardReloadRequests(page);
const dashboardPage = await gotoDashboardPage({ uid: db });
await waitForExpectedRequests();
await page.waitForLoadState('networkidle');
const requests = getRequests();
expect(checkDashboardReloadBehavior(requests)).toBe(true);
const panelTitle = dashboardPage.getByGrafanaSelector(
selectors.components.Panels.Panel.title(PANEL_UNDER_TEST)
+72
View File
@@ -5,6 +5,8 @@ import * as path from 'path';
const USE_LIVE_DATA = Boolean(process.env.API_CONFIG_PATH);
const API_CONFIG_PATH = process.env.API_CONFIG_PATH ?? '../dashboards/cujs/config.json';
const RELOADABLE_DASHBOARD_REQUEST_NO = 2;
async function loadApiConfig() {
const configPath = path.resolve(__dirname, API_CONFIG_PATH);
@@ -63,3 +65,73 @@ export async function prepareAPIMocks(page: Page) {
return apiConfig;
}
interface DashboardRequest {
url: string;
timestamp: number;
response: { metadata: { annotations: string[] } };
}
export async function trackDashboardReloadRequests(page: Page): Promise<{
getRequests: () => DashboardRequest[];
waitForExpectedRequests: () => Promise<void>;
}> {
const dashboardRequests: DashboardRequest[] = [];
let resolveWhenComplete: () => void;
let expectedRequestCount = 1; //initial request that gives us the meta param
const completionPromise = new Promise<void>((resolve) => {
resolveWhenComplete = resolve;
});
const handler = async (route) => {
const response = await route.fetch();
const responseJson = await response.json();
const isFirstRequest = dashboardRequests.length === 0;
dashboardRequests.push({
url: route.request().url(),
timestamp: Date.now(),
response: responseJson,
});
// After first request, check if we should expect more
if (isFirstRequest) {
const hasReloadAnnotation = responseJson?.metadata?.annotations?.['grafana.app/reloadOnParamsChange'] === 'true';
if (hasReloadAnnotation) {
expectedRequestCount = RELOADABLE_DASHBOARD_REQUEST_NO;
}
}
// Resolve if we've reached the expected count
if (dashboardRequests.length >= expectedRequestCount) {
resolveWhenComplete();
}
await route.fulfill({ response });
};
await page.route('**/dashboards/**/dto?**', handler);
return {
getRequests: () => dashboardRequests,
waitForExpectedRequests: () => completionPromise,
};
}
export function checkDashboardReloadBehavior(requests: DashboardRequest[]): boolean {
if (requests.length === 0) {
return false;
}
const firstRequest = requests[0];
const hasReloadAnnotation =
firstRequest?.response?.metadata?.annotations?.['grafana.app/reloadOnParamsChange'] === 'true';
if (hasReloadAnnotation) {
return requests.length === RELOADABLE_DASHBOARD_REQUEST_NO;
} else {
return requests.length === 1;
}
}
+6 -1
View File
@@ -21,7 +21,12 @@ export async function addDashboard(page: Page, title?: string): Promise<string>
// Click save
const saveAsButton = page.getByTestId('data-testid Save dashboard drawer button');
await saveAsButton.click();
// Ensure button is ready and click using the method that works with React
// Doing simply saveAsButton.click doesn't work, even with force: true and when button is enabled
// It stopped working when https://github.com/grafana/grafana/pull/111518 introduced proper title validation
// This should be a an ok alternative since we are checking that the button is enabled first
await expect(saveAsButton).toBeEnabled();
await saveAsButton.evaluate((btn: HTMLElement) => btn.click());
// Wait for success notification
await expect(page.getByText('Dashboard saved')).toBeVisible();
+1 -1
View File
@@ -86,7 +86,7 @@ require (
github.com/googleapis/gax-go/v2 v2.14.2 // @grafana/grafana-backend-group
github.com/gorilla/mux v1.8.1 // @grafana/grafana-backend-group
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // @grafana/grafana-app-platform-squad
github.com/grafana/alerting v0.0.0-20250915130141-a8ee25091876 // @grafana/alerting-backend
github.com/grafana/alerting v0.0.0-20250925200825-7a889aa4934d // @grafana/alerting-backend
github.com/grafana/authlib v0.0.0-20250924100039-ea07223cdb6c // @grafana/identity-access-team
github.com/grafana/authlib/types v0.0.0-20250917093142-83a502239781 // @grafana/identity-access-team
github.com/grafana/dataplane/examples v0.0.1 // @grafana/observability-metrics
+2 -2
View File
@@ -1585,8 +1585,8 @@ github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7Fsg
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/grafana/alerting v0.0.0-20250915130141-a8ee25091876 h1:BzoGpzARwRCNOHcqQdYPAFp2LS1pqnkLWhIuDdq1zho=
github.com/grafana/alerting v0.0.0-20250915130141-a8ee25091876/go.mod h1:T5sitas9VhVj8/S9LeRLy6H75kTBdh/sCCqHo7gaQI8=
github.com/grafana/alerting v0.0.0-20250925200825-7a889aa4934d h1:zzEty7HgfXbQ/RiBCJFMqaZiJlqiXuz/Zbc6/H6ksuM=
github.com/grafana/alerting v0.0.0-20250925200825-7a889aa4934d/go.mod h1:T5sitas9VhVj8/S9LeRLy6H75kTBdh/sCCqHo7gaQI8=
github.com/grafana/authlib v0.0.0-20250924100039-ea07223cdb6c h1:8GIMe1KclDdfogaeRsiU69Ev2zTF9kmjqjQqqZMzerc=
github.com/grafana/authlib v0.0.0-20250924100039-ea07223cdb6c/go.mod h1:C6CmTG6vfiqebjJswKsc6zes+1F/OtTCi6aAtL5Um6A=
github.com/grafana/authlib/types v0.0.0-20250917093142-83a502239781 h1:jymmOFIWnW26DeUjFgYEoltI170KeT5r1rI8a/dUf0E=
+4 -2
View File
@@ -1040,6 +1040,10 @@ github.com/grafana/alerting v0.0.0-20250701210250-cea2d1683945 h1:3imTbxFpZSVI6I
github.com/grafana/alerting v0.0.0-20250701210250-cea2d1683945/go.mod h1:gtR7agmxVfJOmNKV/n2ZULgOYTYNL+PDKYB5N48tQ7Q=
github.com/grafana/alerting v0.0.0-20250709204613-c5c6f9c1653d/go.mod h1:gtR7agmxVfJOmNKV/n2ZULgOYTYNL+PDKYB5N48tQ7Q=
github.com/grafana/alerting v0.0.0-20250911172908-2b26ef8f17eb/go.mod h1:XWqj/rlsy4OV/E9XNNyFn+a7U4GNsSugPb2rDBj9+58=
github.com/grafana/alerting v0.0.0-20250923203439-adb598e7d509 h1:8JMtYCClxrxRXsF5jc64GTURZFHJHFK/kzC7joRNTtI=
github.com/grafana/alerting v0.0.0-20250923203439-adb598e7d509/go.mod h1:T5sitas9VhVj8/S9LeRLy6H75kTBdh/sCCqHo7gaQI8=
github.com/grafana/alerting v0.0.0-20250925193206-bd061d3d9185 h1:R494uXJOz7glN76hJXKjbwu+VBYFsT0CFprsXmdHla0=
github.com/grafana/alerting v0.0.0-20250925193206-bd061d3d9185/go.mod h1:T5sitas9VhVj8/S9LeRLy6H75kTBdh/sCCqHo7gaQI8=
github.com/grafana/authlib v0.0.0-20250123104008-e99947858901/go.mod h1:/gYfphsNu9v1qYWXxpv1NSvMEMSwvdf8qb8YlgwIRl8=
github.com/grafana/authlib v0.0.0-20250909101823-1b466dbd19a1/go.mod h1:C6CmTG6vfiqebjJswKsc6zes+1F/OtTCi6aAtL5Um6A=
github.com/grafana/authlib/types v0.0.0-20250120144156-d6737a7dc8f5/go.mod h1:qYjSd1tmJiuVoSICp7Py9/zD54O9uQQA3wuM6Gg4DFM=
@@ -1074,8 +1078,6 @@ github.com/grafana/grafana-aws-sdk v0.38.2/go.mod h1:j3vi+cXYHEFqjhBGrI6/lw1TNM+
github.com/grafana/grafana-aws-sdk v1.0.2 h1:98eBuHYFmgvH0xO9kKf4RBsEsgQRp8EOA/9yhDIpkss=
github.com/grafana/grafana-aws-sdk v1.0.2/go.mod h1:hO7q7yWV+t6dmiyJjMa3IbuYnYkBua+G/IAlOPVIYKE=
github.com/grafana/grafana-azure-sdk-go/v2 v2.1.6/go.mod h1:V7y2BmsWxS3A9Ohebwn4OiSfJJqi//4JQydQ8fHTduo=
github.com/grafana/grafana-google-sdk-go v0.4.2 h1:F44hQF1y6UVJhlJPi+Mz+GCJsioVgezEgPMMEQbUZRo=
github.com/grafana/grafana-google-sdk-go v0.4.2/go.mod h1:U73+w9DlbEtUonhQUzERwlXnzWTtfRoyrtKH8d3VY40=
github.com/grafana/grafana-plugin-sdk-go v0.263.0/go.mod h1:U43Cnrj/9DNYyvFcNdeUWNjMXTKNB0jcTcQGpWKd2gw=
github.com/grafana/grafana-plugin-sdk-go v0.267.0/go.mod h1:OuwS4c/JYgn0rr/w5zhJBpLo4gKm/vw15RsfpYAvK9Q=
github.com/grafana/grafana-plugin-sdk-go v0.269.1/go.mod h1:yv2KbO4mlr9WuDK2f+2gHAMTwwLmLuqaEnrPXTRU+OI=
@@ -549,14 +549,10 @@ export interface FeatureToggles {
*/
grafanaManagedRecordingRules?: boolean;
/**
* Renamed feature toggle, enables Saved queries feature
* Enables Saved queries (query library) feature
*/
queryLibrary?: boolean;
/**
* Enables Saved Queries feature
*/
savedQueries?: boolean;
/**
* Sets the logs table as default visualisation in logs explore
*/
logsExploreTableDefaultVisualization?: boolean;
@@ -1173,11 +1169,6 @@ export interface FeatureToggles {
*/
prometheusTypeMigration?: boolean;
/**
* Enables dskit background service wrapper
* @default false
*/
dskitBackgroundServices?: boolean;
/**
* Enables running plugins in containers
* @default false
*/
@@ -1192,4 +1183,9 @@ export interface FeatureToggles {
* @default false
*/
filterOutBotsFromFrontendLogs?: boolean;
/**
* Prioritize loading plugins from the CDN before other sources
* @default false
*/
cdnPluginsLoadFirst?: boolean;
}
+4 -4
View File
@@ -49,10 +49,10 @@ export interface ScopeSpecFilter {
export interface ScopeSpec {
title: string;
type: string;
description: string;
category: string;
filters: ScopeSpecFilter[];
// Used to display the title next to the selected scope and expand the selector to the proper path.
// This will override whichever is selected from in the selector.
defaultPath?: string[];
filters?: ScopeSpecFilter[];
}
// TODO: Use Resource from apiserver when we export the types
@@ -18,7 +18,6 @@
// THIS SOFTWARE.
import { css, cx } from '@emotion/css';
import { useEffect, useState } from 'react';
import * as React from 'react';
import { Icon } from '@grafana/ui';
@@ -37,7 +36,6 @@ type Props = {
matchedLabels?: Set<string>;
setRangeMin: (range: number) => void;
setRangeMax: (range: number) => void;
style?: React.CSSProperties;
onItemFocused: (data: ClickedItemData) => void;
focusedItemData?: ClickedItemData;
textAlign: TextAlign;
@@ -111,6 +111,20 @@ describe('FlameGraphTooltip', () => {
});
});
function setupDiffData2() {
const flameGraphData = createDataFrame({
fields: [
{ name: 'level', values: [0, 1] },
{ name: 'value', values: [101, 101] },
{ name: 'valueRight', values: [100, 100] },
{ name: 'self', values: [100, 100] },
{ name: 'selfRight', values: [1, 1] },
{ name: 'label', values: ['total', 'func1'] },
],
});
return new FlameGraphDataContainer(flameGraphData, { collapsing: true });
}
describe('getDiffTooltipData', () => {
it('works with diff data', () => {
const tooltipData = getDiffTooltipData(
@@ -142,6 +156,36 @@ describe('getDiffTooltipData', () => {
},
]);
});
it('works with diff data and short values', () => {
const tooltipData = getDiffTooltipData(
setupDiffData2(),
{ start: 0, itemIndexes: [1], value: 101, valueRight: 100, children: [], level: 0 },
200
);
expect(tooltipData).toEqual([
{
rowId: '1',
label: '% of total',
baseline: '1%',
comparison: '100%',
diff: '9.90 K%',
},
{
rowId: '2',
label: 'Value',
baseline: '1',
comparison: '100',
diff: '99',
},
{
rowId: '3',
label: 'Samples',
baseline: '1',
comparison: '100',
diff: '99',
},
]);
});
});
function makeField(name: string, unit: string, values: number[]): Field {
@@ -1,6 +1,6 @@
import { css } from '@emotion/css';
import { DisplayValue, getValueFormat, GrafanaTheme2 } from '@grafana/data';
import { DisplayValue, getValueFormat, GrafanaTheme2, ValueFormatter } from '@grafana/data';
import { InteractiveTable, Portal, useStyles2, VizTooltipContainer } from '@grafana/ui';
import { CollapseConfig, FlameGraphDataContainer, LevelItem } from './dataTransform';
@@ -122,6 +122,11 @@ type DiffTableData = {
diff: string | number;
};
const formatWithSuffix = (value: number, formatter: ValueFormatter): string => {
const displayValue = formatter(value);
return displayValue.text + displayValue.suffix;
};
export const getDiffTooltipData = (
data: FlameGraphDataContainer,
item: LevelItem,
@@ -148,7 +153,7 @@ export const getDiffTooltipData = (
label: '% of total',
baseline: percentageLeft + '%',
comparison: percentageRight + '%',
diff: shortValFormat(diff).text + '%',
diff: formatWithSuffix(diff, shortValFormat) + '%',
},
{
rowId: '2',
@@ -160,9 +165,9 @@ export const getDiffTooltipData = (
{
rowId: '3',
label: 'Samples',
baseline: shortValFormat(valueLeft).text,
comparison: shortValFormat(item.valueRight!).text,
diff: shortValFormat(item.valueRight! - valueLeft).text,
baseline: formatWithSuffix(valueLeft, shortValFormat),
comparison: formatWithSuffix(item.valueRight!, shortValFormat),
diff: formatWithSuffix(item.valueRight! - valueLeft, shortValFormat),
},
];
};
@@ -488,7 +488,9 @@ export default class PromQlLanguageProvider extends LanguageProvider implements
})
),
scopes: scopes?.reduce<ScopeSpecFilter[]>((acc, scope) => {
acc.push(...scope.spec.filters);
if (scope.spec.filters) {
acc.push(...scope.spec.filters);
}
return acc;
}, []),
+34 -5
View File
@@ -5,19 +5,48 @@ import (
"slices"
"strings"
"github.com/grafana/alerting/notify"
"github.com/grafana/alerting/receivers/schema"
"github.com/grafana/grafana/pkg/api/response"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/ngalert/notifier/channels_config"
)
func (hs *HTTPServer) GetAlertNotifiers() func(*contextmodel.ReqContext) response.Response {
return func(r *contextmodel.ReqContext) response.Response {
v2 := notify.GetSchemaForAllIntegrations()
slices.SortFunc(v2, func(a, b schema.IntegrationTypeSchema) int {
return strings.Compare(string(a.Type), string(b.Type))
})
if r.Query("version") == "2" {
v2 := slices.SortedFunc(channels_config.GetAvailableNotifiersV2(), func(a, b *channels_config.VersionedNotifierPlugin) int {
return strings.Compare(a.Type, b.Type)
})
return response.JSON(http.StatusOK, v2)
}
return response.JSON(http.StatusOK, channels_config.GetAvailableNotifiers())
type NotifierPlugin struct {
Type string `json:"type"`
TypeAlias string `json:"typeAlias,omitempty"`
Name string `json:"name"`
Heading string `json:"heading"`
Description string `json:"description"`
Info string `json:"info"`
Options []schema.Field `json:"options"`
}
result := make([]*NotifierPlugin, 0, len(v2))
for _, s := range v2 {
v1, ok := s.GetVersion(schema.V1)
if !ok {
continue
}
result = append(result, &NotifierPlugin{
Type: string(s.Type),
Name: s.Name,
Description: s.Description,
Heading: s.Heading,
Info: s.Info,
Options: v1.Options,
})
}
return response.JSON(http.StatusOK, result)
}
}
@@ -52,17 +52,22 @@ type Scope struct {
}
type ScopeSpec struct {
Title string `json:"title"`
Description string `json:"description"`
Title string `json:"title"`
// Provides a default path for the scope. This refers to a list of nodes in the selector. This is used to display the title next to the selected scope and expand the selector to the proper path.
// This will override whichever is selected from in the selector.
// The path is a list of node ids, starting at the direct parent of the selected node towards the root.
// +listType=atomic
DefaultPath []string `json:"defaultPath,omitempty"`
// +listType=atomic
Filters []ScopeFilter `json:"filters"`
Filters []ScopeFilter `json:"filters,omitempty"`
}
type ScopeFilter struct {
Key string `json:"key"`
Value string `json:"value"`
// Values is used for operators that require multiple values (e.g. one-of and not-one-of).
// +listType=atomic
Values []string `json:"values,omitempty"`
Operator FilterOperator `json:"operator"`
}
@@ -94,6 +94,11 @@ func (in *ScopeFilter) DeepCopy() *ScopeFilter {
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ScopeSpec) DeepCopyInto(out *ScopeSpec) {
*out = *in
if in.DefaultPath != nil {
in, out := &in.DefaultPath, &out.DefaultPath
*out = make([]string, len(*in))
copy(*out, *in)
}
if in.Filters != nil {
in, out := &in.Filters, &out.Filters
*out = make([]ScopeFilter, len(*in))
@@ -200,6 +200,11 @@ func schema_apimachinery_apis_common_v0alpha1_ScopeFilter(ref common.ReferenceCa
},
},
"values": {
VendorExtensible: spec.VendorExtensible{
Extensions: spec.Extensions{
"x-kubernetes-list-type": "atomic",
},
},
SchemaProps: spec.SchemaProps{
Description: "Values is used for operators that require multiple values (e.g. one-of and not-one-of).",
Type: []string{"array"},
@@ -243,11 +248,24 @@ func schema_apimachinery_apis_common_v0alpha1_ScopeSpec(ref common.ReferenceCall
Format: "",
},
},
"description": {
"defaultPath": {
VendorExtensible: spec.VendorExtensible{
Extensions: spec.Extensions{
"x-kubernetes-list-type": "atomic",
},
},
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
Description: "Provides a default path for the scope. This refers to a list of nodes in the selector. This is used to display the title next to the selected scope and expand the selector to the proper path. This will override whichever is selected from in the selector. The path is a list of node ids, starting at the direct parent of the selected node towards the root.",
Type: []string{"array"},
Items: &spec.SchemaOrArray{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
},
},
},
"filters": {
@@ -269,7 +287,7 @@ func schema_apimachinery_apis_common_v0alpha1_ScopeSpec(ref common.ReferenceCall
},
},
},
Required: []string{"title", "description", "filters"},
Required: []string{"title"},
},
},
Dependencies: []string{
@@ -1,4 +1,3 @@
API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1,ScopeFilter,Values
API rule violation: names_match,github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1,Unstructured,Object
API rule violation: names_match,k8s.io/apimachinery/pkg/apis/meta/v1,APIResourceList,APIResources
API rule violation: names_match,k8s.io/apimachinery/pkg/apis/meta/v1,Duration,Duration
+1
View File
@@ -23,6 +23,7 @@ type Registry interface {
}
type Manager interface {
services.NamedService
Registry
Engine
}
+2 -4
View File
@@ -80,10 +80,8 @@ type PrometheusQueryProperties struct {
type ScopeSpec struct {
Name string `json:"name"` // This is the identifier from metadata.name of the scope model.
Title string `json:"title"`
Type string `json:"type"`
Description string `json:"description"`
Category string `json:"category"`
Filters []ScopeFilter `json:"filters"`
DefaultPath []string `json:"defaultPath,omitempty"`
Filters []ScopeFilter `json:"filters,omitempty"`
}
// ScopeFilter is a hand copy of the ScopeFilter struct from pkg/apis/scope/v0alpha1/types.go
+6 -13
View File
@@ -188,18 +188,14 @@
"type": "object",
"required": [
"name",
"title",
"type",
"description",
"category",
"filters"
"title"
],
"properties": {
"category": {
"type": "string"
},
"description": {
"type": "string"
"defaultPath": {
"type": "array",
"items": {
"type": "string"
}
},
"filters": {
"type": "array",
@@ -238,9 +234,6 @@
},
"title": {
"type": "string"
},
"type": {
"type": "string"
}
},
"additionalProperties": false
+6 -13
View File
@@ -198,18 +198,14 @@
"type": "object",
"required": [
"name",
"title",
"type",
"description",
"category",
"filters"
"title"
],
"properties": {
"category": {
"type": "string"
},
"description": {
"type": "string"
"defaultPath": {
"type": "array",
"items": {
"type": "string"
}
},
"filters": {
"type": "array",
@@ -248,9 +244,6 @@
},
"title": {
"type": "string"
},
"type": {
"type": "string"
}
},
"additionalProperties": false
+7 -14
View File
@@ -8,7 +8,7 @@
{
"metadata": {
"name": "default",
"resourceVersion": "1725885733879",
"resourceVersion": "1758739325095",
"creationTimestamp": "2024-03-25T13:19:04Z"
},
"spec": {
@@ -105,11 +105,11 @@
"additionalProperties": false,
"description": "ScopeSpec is a hand copy of the ScopeSpec struct from pkg/apis/scope/v0alpha1/types.go to avoid import (temp fix).",
"properties": {
"category": {
"type": "string"
},
"description": {
"type": "string"
"defaultPath": {
"items": {
"type": "string"
},
"type": "array"
},
"filters": {
"items": {
@@ -148,18 +148,11 @@
},
"title": {
"type": "string"
},
"type": {
"type": "string"
}
},
"required": [
"name",
"title",
"type",
"description",
"category",
"filters"
"title"
],
"type": "object"
},
+20 -2
View File
@@ -11,7 +11,10 @@ import (
"github.com/grafana/grafana/pkg/apimachinery/utils"
)
// "Almost nobody should use this hook" but we do because we need ctx and AfterCreate doesn't have it.
// K8S docs say "Almost nobody should use this hook" about the "begin" hooks, but we do because we only need to
// propagate if unistore write is successful. It also allows us to be a bit smarter about when to propagate, e.g.
// skipping root-level folders, skipping updates that don't change parent, etc.
func (b *FolderAPIBuilder) beginCreate(ctx context.Context, obj runtime.Object, _ *metav1.CreateOptions) (registry.FinishFunc, error) {
log := logging.FromContext(ctx)
meta, err := utils.MetaAccessor(obj)
@@ -36,7 +39,6 @@ func (b *FolderAPIBuilder) beginCreate(ctx context.Context, obj runtime.Object,
}, nil
}
// "Almost nobody should use this hook" but we do because we need ctx and AfterUpdate doesn't have it.
func (b *FolderAPIBuilder) beginUpdate(ctx context.Context, obj runtime.Object, old runtime.Object, _ *metav1.UpdateOptions) (registry.FinishFunc, error) {
log := logging.FromContext(ctx)
updatedMeta, err := utils.MetaAccessor(obj)
@@ -66,6 +68,22 @@ func (b *FolderAPIBuilder) beginUpdate(ctx context.Context, obj runtime.Object,
}, nil
}
func (b *FolderAPIBuilder) afterDelete(obj runtime.Object, _ *metav1.DeleteOptions) {
ctx := context.Background()
log := logging.DefaultLogger
meta, err := utils.MetaAccessor(obj)
if err != nil {
log.Error("Failed to access deleted folder object metadata", "error", err)
return
}
log.Info("Propagating deleted folder to Zanzana", "folder", meta.GetName(), "parent", meta.GetFolder())
err = b.permissionStore.DeleteFolderParents(ctx, meta.GetNamespace(), meta.GetName())
if err != nil {
log.Warn("failed to propagate folder to zanzana", "err", err)
}
}
func (b *FolderAPIBuilder) writeFolderToZanzana(ctx context.Context, folder utils.GrafanaMetaAccessor) {
err := b.permissionStore.SetFolderParent(ctx, folder.GetNamespace(), folder.GetName(), folder.GetFolder())
if err != nil {
+1
View File
@@ -200,6 +200,7 @@ func (b *FolderAPIBuilder) registerPermissionHooks(store *genericregistry.Store)
log.Info("Enabling Zanzana folder propagation hooks")
store.BeginCreate = b.beginCreate
store.BeginUpdate = b.beginUpdate
store.AfterDelete = b.afterDelete
} else {
log.Info("Zanzana is not enabled; skipping folder propagation hooks")
}
+6
View File
@@ -328,9 +328,15 @@ func (b *IdentityAccessManagementAPIBuilder) Validate(ctx context.Context, a adm
return serviceaccount.ValidateOnCreate(ctx, typedObj)
case *iamv0.Team:
return team.ValidateOnCreate(ctx, typedObj)
case *iamv0.ResourcePermission:
return resourcepermission.ValidateCreateAndUpdateInput(ctx, typedObj)
}
return nil
case admission.Update:
switch typedObj := a.GetObject().(type) {
case *iamv0.ResourcePermission:
return resourcepermission.ValidateCreateAndUpdateInput(ctx, typedObj)
}
return nil
case admission.Delete:
return nil
@@ -249,22 +249,28 @@ func (s *ResourcePermSqlBackend) parseScope(scope string) (*groupResourceName, e
}
// splitResourceName splits a resource name in the format <group>-<resource>-<name> (e.g. dashboard.grafana.app-dashboards-ad5rwqs) into its components
func (s *ResourcePermSqlBackend) splitResourceName(resourceName string) (Mapper, *groupResourceName, error) {
func splitResourceName(resourceName string) (*groupResourceName, error) {
// e.g. dashboard.grafana.app-dashboards-ad5rwqs
parts := strings.SplitN(resourceName, "-", 3)
if len(parts) != 3 {
return nil, nil, fmt.Errorf("%w: %s", errInvalidName, resourceName)
return nil, fmt.Errorf("%w: %s", errInvalidName, resourceName)
}
group, resourceType, uid := parts[0], parts[1], parts[2]
mapper, ok := s.mappers[schema.GroupResource{Group: group, Resource: resourceType}]
if !ok {
return nil, nil, fmt.Errorf("%w: %s/%s", errUnknownGroupResource, group, resourceType)
}
return mapper, &groupResourceName{
return &groupResourceName{
Group: group,
Resource: resourceType,
Name: uid,
}, nil
}
// getResourceMapper returns the Mapper of the given group and resource to access levels and scope prefix for that resource.
func (s *ResourcePermSqlBackend) getResourceMapper(group, resource string) (Mapper, error) {
mapper, ok := s.mappers[schema.GroupResource{Group: group, Resource: resource}]
if !ok {
return nil, fmt.Errorf("%w: %s/%s", errUnknownGroupResource, group, resource)
}
return mapper, nil
}
+12 -39
View File
@@ -158,7 +158,12 @@ func (s *ResourcePermSqlBackend) getRbacAssignmentsWithTx(ctx context.Context, s
// getResourcePermission retrieves a single ResourcePermission by its name in the format <group>-<resource>-<name> (e.g. dashboard.grafana.app-dashboards-ad5rwqs)
func (s *ResourcePermSqlBackend) getResourcePermission(ctx context.Context, sql *legacysql.LegacyDatabaseHelper, tx *session.SessionTx, ns types.NamespaceInfo, name string) (*v0alpha1.ResourcePermission, error) {
mapper, grn, err := s.splitResourceName(name)
grn, err := splitResourceName(name)
if err != nil {
return nil, apierrors.NewInternalError(err)
}
mapper, err := s.getResourceMapper(grn.Group, grn.Resource)
if err != nil {
return nil, apierrors.NewInternalError(err)
}
@@ -371,10 +376,6 @@ func (s *ResourcePermSqlBackend) existsResourcePermission(ctx context.Context, t
func (s *ResourcePermSqlBackend) createResourcePermission(
ctx context.Context, dbHelper *legacysql.LegacyDatabaseHelper, ns types.NamespaceInfo, mapper Mapper, grn *groupResourceName, v0ResourcePerm *v0alpha1.ResourcePermission,
) (int64, error) {
if err := validateCreateAndUpdateInput(v0ResourcePerm, grn); err != nil {
return 0, err
}
assignments, err := s.buildRbacAssignments(ctx, ns, mapper, v0ResourcePerm.Spec.Permissions, mapper.Scope(grn.Name))
if err != nil {
return 0, err
@@ -404,10 +405,6 @@ func (s *ResourcePermSqlBackend) createResourcePermission(
}
func (s *ResourcePermSqlBackend) updateResourcePermission(ctx context.Context, dbHelper *legacysql.LegacyDatabaseHelper, ns types.NamespaceInfo, mapper Mapper, grn *groupResourceName, v0ResourcePerm *v0alpha1.ResourcePermission) (int64, error) {
if err := validateCreateAndUpdateInput(v0ResourcePerm, grn); err != nil {
return 0, err
}
err := dbHelper.DB.GetSqlxSession().WithTransaction(ctx, func(tx *session.SessionTx) error {
currentPerms, err := s.getResourcePermission(ctx, dbHelper, tx, ns, grn.string())
if err != nil {
@@ -500,38 +497,14 @@ func diffPermissions(currentPermissions, desiredPermissions []v0alpha1.ResourceP
return permissionsToAdd, permissionsToRemove
}
func validateCreateAndUpdateInput(v0ResourcePerm *v0alpha1.ResourcePermission, grn *groupResourceName) error {
if v0ResourcePerm == nil {
return fmt.Errorf("resource permission cannot be nil")
}
if len(v0ResourcePerm.Spec.Permissions) == 0 {
return fmt.Errorf("resource permission must have at least one permission: %w", errInvalidSpec)
}
// Validate that the group/resource/name in the name matches the spec
if grn.Group != v0ResourcePerm.Spec.Resource.ApiGroup ||
grn.Resource != v0ResourcePerm.Spec.Resource.Resource ||
grn.Name != v0ResourcePerm.Spec.Resource.Name {
return fmt.Errorf("resource permission name does not match spec: %w", errInvalidSpec)
}
// Check for duplicate entities (same kind and name should appear only once)
seen := make(map[string]bool)
for _, perm := range v0ResourcePerm.Spec.Permissions {
key := fmt.Sprintf("%s:%s", perm.Kind, perm.Name)
if seen[key] {
return fmt.Errorf("duplicate entity found: kind=%s, name=%s (each entity can only appear once per resource): %w", perm.Kind, perm.Name, errInvalidSpec)
}
seen[key] = true
}
return nil
}
// deleteResourcePermission deletes resource permissions for a single ResourcePermission resource referenced by its name in the format <group>-<resource>-<name> (e.g. dashboard.grafana.app-dashboards-ad5rwqs)
func (s *ResourcePermSqlBackend) deleteResourcePermission(ctx context.Context, sql *legacysql.LegacyDatabaseHelper, ns types.NamespaceInfo, name string) error {
mapper, grn, err := s.splitResourceName(name)
grn, err := splitResourceName(name)
if err != nil {
return err
}
mapper, err := s.getResourceMapper(grn.Group, grn.Resource)
if err != nil {
return err
}
@@ -450,7 +450,10 @@ func TestIntegration_ResourcePermSqlBackend_CreateResourcePermission(t *testing.
sqlHelper, _ := backend.dbProvider(ctx)
backend.identityStore = NewFakeIdentityStore(t)
mapper, grn, err := backend.splitResourceName(resourcePerm.Name)
grn, err := splitResourceName(resourcePerm.Name)
require.NoError(t, err)
mapper, err := backend.getResourceMapper(grn.Group, grn.Resource)
require.NoError(t, err)
rv, err := backend.createResourcePermission(ctx, sqlHelper, types.NamespaceInfo{Value: "default", OrgID: 1}, mapper, grn, resourcePerm)
@@ -544,7 +547,10 @@ func TestIntegration_ResourcePermSqlBackend_UpdateResourcePermission(t *testing.
},
}
mapper, grn, err := backend.splitResourceName(resourcePerm.Name)
grn, err := splitResourceName(resourcePerm.Name)
require.NoError(t, err)
mapper, err := backend.getResourceMapper(grn.Group, grn.Resource)
require.NoError(t, err)
_, err = backend.updateResourcePermission(ctx, sql, types.NamespaceInfo{Value: "default", OrgID: 1}, mapper, grn, resourcePerm)
@@ -583,7 +589,10 @@ func TestIntegration_ResourcePermSqlBackend_UpdateResourcePermission(t *testing.
},
}
mapper, grn, err := backend.splitResourceName(resourcePerm.Name)
grn, err := splitResourceName(resourcePerm.Name)
require.NoError(t, err)
mapper, err := backend.getResourceMapper(grn.Group, grn.Resource)
require.NoError(t, err)
rv, err := backend.updateResourcePermission(ctx, sql, types.NamespaceInfo{Value: "default", OrgID: 1}, mapper, grn, resourcePerm)
@@ -670,104 +679,6 @@ func (f *fakeIdentityStore) GetUserInternalID(ctx context.Context, ns types.Name
return &legacy.GetUserInternalIDResult{ID: id}, nil
}
func TestValidateCreateAndUpdateInput(t *testing.T) {
grn := &groupResourceName{
Group: "dashboard.grafana.app",
Resource: "dashboards",
Name: "test-dashboard",
}
t.Run("Should pass validation with valid permissions", func(t *testing.T) {
resourcePerm := &v0alpha1.ResourcePermission{
Spec: v0alpha1.ResourcePermissionSpec{
Resource: v0alpha1.ResourcePermissionspecResource{
ApiGroup: "dashboard.grafana.app",
Resource: "dashboards",
Name: "test-dashboard",
},
Permissions: []v0alpha1.ResourcePermissionspecPermission{
{
Kind: v0alpha1.ResourcePermissionSpecPermissionKindBasicRole,
Name: "Editor",
Verb: "edit",
},
{
Kind: v0alpha1.ResourcePermissionSpecPermissionKindBasicRole,
Name: "Viewer", // Different entity name - should be allowed
Verb: "view",
},
{
Kind: v0alpha1.ResourcePermissionSpecPermissionKindUser,
Name: "user-1",
Verb: "edit", // Different kind - should be allowed
},
},
},
}
err := validateCreateAndUpdateInput(resourcePerm, grn)
require.NoError(t, err)
})
t.Run("Should fail validation with duplicate entities", func(t *testing.T) {
resourcePerm := &v0alpha1.ResourcePermission{
Spec: v0alpha1.ResourcePermissionSpec{
Resource: v0alpha1.ResourcePermissionspecResource{
ApiGroup: "dashboard.grafana.app",
Resource: "dashboards",
Name: "test-dashboard",
},
Permissions: []v0alpha1.ResourcePermissionspecPermission{
{
Kind: v0alpha1.ResourcePermissionSpecPermissionKindBasicRole,
Name: "Editor",
Verb: "edit",
},
{
Kind: v0alpha1.ResourcePermissionSpecPermissionKindBasicRole,
Name: "Editor",
Verb: "view", // Same entity, different verb - should fail
},
},
},
}
err := validateCreateAndUpdateInput(resourcePerm, grn)
require.Error(t, err)
require.Contains(t, err.Error(), "duplicate entity found")
require.Contains(t, err.Error(), "kind=BasicRole")
require.Contains(t, err.Error(), "name=Editor")
require.Contains(t, err.Error(), "each entity can only appear once per resource")
})
t.Run("Should pass validation with same name but different kinds", func(t *testing.T) {
resourcePerm := &v0alpha1.ResourcePermission{
Spec: v0alpha1.ResourcePermissionSpec{
Resource: v0alpha1.ResourcePermissionspecResource{
ApiGroup: "dashboard.grafana.app",
Resource: "dashboards",
Name: "test-dashboard",
},
Permissions: []v0alpha1.ResourcePermissionspecPermission{
{
Kind: v0alpha1.ResourcePermissionSpecPermissionKindUser,
Name: "editor", // Same name but different kind
Verb: "edit",
},
{
Kind: v0alpha1.ResourcePermissionSpecPermissionKindBasicRole,
Name: "editor", // Same name but different kind
Verb: "view",
},
},
},
}
err := validateCreateAndUpdateInput(resourcePerm, grn)
require.NoError(t, err)
})
}
func TestIntegration_UpdateResourcePermission_VerbChange(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
@@ -234,11 +234,16 @@ func (s *ResourcePermSqlBackend) WriteEvent(ctx context.Context, event resource.
return 0, errDatabaseHelper
}
mapper, grn, err := s.splitResourceName(event.Key.Name)
grn, err := splitResourceName(event.Key.Name)
if err != nil {
return 0, apierrors.NewBadRequest(fmt.Sprintf("invalid resource name %q: %v", event.Key.Name, err.Error()))
}
mapper, err := s.getResourceMapper(grn.Group, grn.Resource)
if err != nil {
return 0, apierrors.NewBadRequest(fmt.Sprintf("invalid group/resource in resource name %q: %v", event.Key.Name, err.Error()))
}
if grn.Name == "" {
return 0, fmt.Errorf("resource name cannot be empty: %w", errInvalidName)
}
@@ -143,71 +143,6 @@ func TestWriteEvent_Add(t *testing.T) {
require.Contains(t, err.Error(), "requires a valid namespace")
})
t.Run("should error if there is no permission", func(t *testing.T) {
backend := ProvideStorageBackend(dbProvider)
resourcePerm, err := utils.MetaAccessor(&v0alpha1.ResourcePermission{
ObjectMeta: metav1.ObjectMeta{
Name: "folder.grafana.app-folders-fold1",
Namespace: "default",
},
Spec: v0alpha1.ResourcePermissionSpec{
Resource: v0alpha1.ResourcePermissionspecResource{
ApiGroup: "folder.grafana.app",
Resource: "folders",
Name: "fold1",
},
},
})
require.NoError(t, err)
gr := v0alpha1.ResourcePermissionInfo.GroupResource()
rv, err := backend.WriteEvent(context.Background(), resource.WriteEvent{
Type: resourcepb.WatchEvent_ADDED,
Key: &resourcepb.ResourceKey{Group: gr.Group, Resource: gr.Resource, Name: "folder.grafana.app-folders-fold1", Namespace: "default"},
Object: resourcePerm,
})
require.Zero(t, rv)
require.NotNil(t, err)
require.Contains(t, err.Error(), errInvalidSpec.Error())
})
t.Run("should error if name and spec do not match", func(t *testing.T) {
backend := ProvideStorageBackend(dbProvider)
resourcePerm, err := utils.MetaAccessor(&v0alpha1.ResourcePermission{
ObjectMeta: metav1.ObjectMeta{
Name: "folder.grafana.app-folders-fold1",
Namespace: "default",
},
Spec: v0alpha1.ResourcePermissionSpec{
Resource: v0alpha1.ResourcePermissionspecResource{
ApiGroup: "folder.grafana.app",
Resource: "folders",
Name: "fold2",
},
Permissions: []v0alpha1.ResourcePermissionspecPermission{
{
Kind: v0alpha1.ResourcePermissionSpecPermissionKindBasicRole,
Name: "Viewer",
Verb: "Admin",
},
},
},
})
require.NoError(t, err)
gr := v0alpha1.ResourcePermissionInfo.GroupResource()
rv, err := backend.WriteEvent(context.Background(), resource.WriteEvent{
Type: resourcepb.WatchEvent_ADDED,
Key: &resourcepb.ResourceKey{Group: gr.Group, Resource: gr.Resource, Name: "folder.grafana.app-folders-fold1", Namespace: "default"},
Object: resourcePerm,
})
require.Zero(t, rv)
require.NotNil(t, err)
require.Contains(t, err.Error(), errInvalidSpec.Error())
})
t.Run("should error if resource name is empty", func(t *testing.T) {
backend := ProvideStorageBackend(dbProvider)
@@ -747,71 +682,6 @@ func TestWriteEvent_Modify(t *testing.T) {
require.Contains(t, err.Error(), "requires a valid namespace")
})
t.Run("should error if there are no permission specified in the body", func(t *testing.T) {
backend := ProvideStorageBackend(dbProvider)
resourcePerm, err := utils.MetaAccessor(&v0alpha1.ResourcePermission{
ObjectMeta: metav1.ObjectMeta{
Name: "folder.grafana.app-folders-fold1",
Namespace: "default",
},
Spec: v0alpha1.ResourcePermissionSpec{
Resource: v0alpha1.ResourcePermissionspecResource{
ApiGroup: "folder.grafana.app",
Resource: "folders",
Name: "fold1",
},
},
})
require.NoError(t, err)
gr := v0alpha1.ResourcePermissionInfo.GroupResource()
rv, err := backend.WriteEvent(context.Background(), resource.WriteEvent{
Type: resourcepb.WatchEvent_MODIFIED,
Key: &resourcepb.ResourceKey{Group: gr.Group, Resource: gr.Resource, Name: "folder.grafana.app-folders-fold1", Namespace: "default"},
Object: resourcePerm,
})
require.Zero(t, rv)
require.NotNil(t, err)
require.Contains(t, err.Error(), errInvalidSpec.Error())
})
t.Run("should error if name and spec do not match", func(t *testing.T) {
backend := ProvideStorageBackend(dbProvider)
resourcePerm, err := utils.MetaAccessor(&v0alpha1.ResourcePermission{
ObjectMeta: metav1.ObjectMeta{
Name: "folder.grafana.app-folders-fold1",
Namespace: "default",
},
Spec: v0alpha1.ResourcePermissionSpec{
Resource: v0alpha1.ResourcePermissionspecResource{
ApiGroup: "folder.grafana.app",
Resource: "folders",
Name: "fold2",
},
Permissions: []v0alpha1.ResourcePermissionspecPermission{
{
Kind: v0alpha1.ResourcePermissionSpecPermissionKindBasicRole,
Name: "Viewer",
Verb: "Admin",
},
},
},
})
require.NoError(t, err)
gr := v0alpha1.ResourcePermissionInfo.GroupResource()
rv, err := backend.WriteEvent(context.Background(), resource.WriteEvent{
Type: resourcepb.WatchEvent_MODIFIED,
Key: &resourcepb.ResourceKey{Group: gr.Group, Resource: gr.Resource, Name: "folder.grafana.app-folders-fold1", Namespace: "default"},
Object: resourcePerm,
})
require.Zero(t, rv)
require.NotNil(t, err)
require.Contains(t, err.Error(), errInvalidSpec.Error())
})
t.Run("should error if resource name is empty", func(t *testing.T) {
backend := ProvideStorageBackend(dbProvider)
@@ -0,0 +1,42 @@
package resourcepermission
import (
"context"
"fmt"
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
)
func ValidateCreateAndUpdateInput(ctx context.Context, v0ResourcePerm *v0alpha1.ResourcePermission) error {
if v0ResourcePerm == nil {
return fmt.Errorf("resource permission cannot be nil")
}
if len(v0ResourcePerm.Spec.Permissions) == 0 {
return fmt.Errorf("resource permission must have at least one permission: %w", errInvalidSpec)
}
grn, err := splitResourceName(v0ResourcePerm.Name)
if err != nil {
return fmt.Errorf("invalid resource permission name: %w", err)
}
// Validate that the group/resource/name in the name matches the spec
if grn.Group != v0ResourcePerm.Spec.Resource.ApiGroup ||
grn.Resource != v0ResourcePerm.Spec.Resource.Resource ||
grn.Name != v0ResourcePerm.Spec.Resource.Name {
return fmt.Errorf("resource permission name does not match spec: %w", errInvalidSpec)
}
// Check for duplicate entities (same kind and name should appear only once)
seen := make(map[string]bool)
for _, perm := range v0ResourcePerm.Spec.Permissions {
key := fmt.Sprintf("%s:%s", perm.Kind, perm.Name)
if seen[key] {
return fmt.Errorf("duplicate entity found: kind=%s, name=%s (each entity can only appear once per resource): %w", perm.Kind, perm.Name, errInvalidSpec)
}
seen[key] = true
}
return nil
}
@@ -0,0 +1,169 @@
package resourcepermission
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
iamv0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
)
func TestValidateOnCreate(t *testing.T) {
tests := []struct {
name string
obj *iamv0alpha1.ResourcePermission
want error
}{
{
name: "missing permissions - should fail",
obj: &iamv0alpha1.ResourcePermission{
ObjectMeta: v1.ObjectMeta{
Name: "folder.grafana.app-folders-test_folder",
},
Spec: iamv0alpha1.ResourcePermissionSpec{
Resource: iamv0alpha1.ResourcePermissionspecResource{
ApiGroup: "folder.grafana.app",
Resource: "folders",
Name: "test_folder",
},
Permissions: []iamv0alpha1.ResourcePermissionspecPermission{},
},
},
want: errInvalidSpec,
},
{
name: "invalid name - should fail",
obj: &iamv0alpha1.ResourcePermission{
ObjectMeta: v1.ObjectMeta{
Name: "some-invalid-name",
},
},
want: errInvalidName,
},
{
name: "mismatched name and spec - should fail",
obj: &iamv0alpha1.ResourcePermission{
ObjectMeta: v1.ObjectMeta{
Name: "folder.grafana.app-folders-test_folder",
},
Spec: iamv0alpha1.ResourcePermissionSpec{
Resource: iamv0alpha1.ResourcePermissionspecResource{
ApiGroup: "folder.grafana.app",
Resource: "folders",
Name: "some_other_folder",
},
Permissions: []iamv0alpha1.ResourcePermissionspecPermission{
{
Kind: iamv0alpha1.ResourcePermissionSpecPermissionKindUser,
Name: "test-user",
Verb: "view",
},
},
},
},
want: errInvalidSpec,
},
{
name: "valid spec - should pass",
obj: &iamv0alpha1.ResourcePermission{
ObjectMeta: v1.ObjectMeta{
Name: "folder.grafana.app-folders-test_folder",
},
Spec: iamv0alpha1.ResourcePermissionSpec{
Resource: iamv0alpha1.ResourcePermissionspecResource{
ApiGroup: "folder.grafana.app",
Resource: "folders",
Name: "test_folder",
},
Permissions: []iamv0alpha1.ResourcePermissionspecPermission{
{
Kind: iamv0alpha1.ResourcePermissionSpecPermissionKindBasicRole,
Name: "Editor",
Verb: "edit",
},
{
Kind: iamv0alpha1.ResourcePermissionSpecPermissionKindBasicRole,
Name: "Viewer", // Different entity name - should be allowed
Verb: "view",
},
{
Kind: iamv0alpha1.ResourcePermissionSpecPermissionKindUser,
Name: "user-1",
Verb: "edit", // Different kind - should be allowed
},
},
},
},
want: nil,
},
{
name: "duplicate entities - should fail",
obj: &iamv0alpha1.ResourcePermission{
ObjectMeta: v1.ObjectMeta{
Name: "dashboard.grafana.app-dashboards-test_dashboard",
},
Spec: iamv0alpha1.ResourcePermissionSpec{
Resource: iamv0alpha1.ResourcePermissionspecResource{
ApiGroup: "dashboard.grafana.app",
Resource: "dashboards",
Name: "test_dashboard",
},
Permissions: []iamv0alpha1.ResourcePermissionspecPermission{
{
Kind: iamv0alpha1.ResourcePermissionSpecPermissionKindBasicRole,
Name: "Editor",
Verb: "edit",
},
{
Kind: iamv0alpha1.ResourcePermissionSpecPermissionKindBasicRole,
Name: "Editor",
Verb: "view", // Same entity, different verb - should fail
},
},
},
},
want: errInvalidSpec,
},
{
name: "duplicate names but different kinds - should pass",
obj: &iamv0alpha1.ResourcePermission{
ObjectMeta: v1.ObjectMeta{
Name: "dashboard.grafana.app-dashboards-test_dashboard",
},
Spec: iamv0alpha1.ResourcePermissionSpec{
Resource: iamv0alpha1.ResourcePermissionspecResource{
ApiGroup: "dashboard.grafana.app",
Resource: "dashboards",
Name: "test_dashboard",
},
Permissions: []iamv0alpha1.ResourcePermissionspecPermission{
{
Kind: iamv0alpha1.ResourcePermissionSpecPermissionKindUser,
Name: "Editor",
Verb: "edit",
},
{
Kind: iamv0alpha1.ResourcePermissionSpecPermissionKindBasicRole,
Name: "Editor",
Verb: "view",
},
},
},
},
want: nil,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := ValidateCreateAndUpdateInput(context.Background(), test.obj)
if test.want == nil {
assert.NoError(t, err)
} else {
assert.ErrorAs(t, test.want, &err)
}
})
}
}
+23 -12
View File
@@ -5,6 +5,7 @@ import (
"time"
"github.com/grafana/dskit/services"
"go.opentelemetry.io/otel/trace"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
@@ -16,7 +17,7 @@ var (
stopTimeout = 30 * time.Second
)
type managerAdapter struct {
type ManagerAdapter struct {
services.NamedService
reg registry.BackgroundServiceRegistry
@@ -32,8 +33,8 @@ type managerAdapter struct {
// - Graceful shutdown with proper cleanup ordering
//
// Services implementing CanBeDisabled that are disabled will be skipped.
func NewManagerAdapter(reg registry.BackgroundServiceRegistry) *managerAdapter {
m := &managerAdapter{
func NewManagerAdapter(reg registry.BackgroundServiceRegistry) *ManagerAdapter {
m := &ManagerAdapter{
reg: reg,
dependencyMap: dependencyMap(),
}
@@ -41,7 +42,12 @@ func NewManagerAdapter(reg registry.BackgroundServiceRegistry) *managerAdapter {
return m
}
func (m *managerAdapter) starting(ctx context.Context) error {
func (m *ManagerAdapter) WithDependencies(dependencyMap map[string][]string) *ManagerAdapter {
m.dependencyMap = dependencyMap
return m
}
func (m *ManagerAdapter) starting(ctx context.Context) error {
spanCtx, span := tracing.Start(ctx, "backgroundsvcs.managerAdapter.starting")
defer span.End()
logger := log.New("backgroundsvcs.managerAdapter").FromContext(spanCtx)
@@ -76,16 +82,20 @@ func (m *managerAdapter) starting(ctx context.Context) error {
manager.RegisterModule(BackgroundServices, nil)
m.manager = manager
return nil
if err := m.manager.StartAsync(ctx); err != nil {
return err
}
return m.manager.AwaitRunning(ctx)
}
func (m *managerAdapter) running(ctx context.Context) error {
spanCtx, span := tracing.Start(ctx, "backgroundsvcs.managerAdapter.running")
func (m *ManagerAdapter) running(ctx context.Context) error {
newCtx := trace.ContextWithSpan(context.Background(), trace.SpanFromContext(ctx))
spanCtx, span := tracing.Start(newCtx, "backgroundsvcs.managerAdapter.running")
defer span.End()
return m.manager.Run(spanCtx)
return m.manager.AwaitTerminated(spanCtx)
}
func (m *managerAdapter) stopping(failure error) error {
func (m *ManagerAdapter) stopping(failure error) error {
ctx, cancel := context.WithTimeout(context.Background(), stopTimeout)
defer cancel()
spanCtx, span := tracing.Start(ctx, "backgroundsvcs.managerAdapter.stopping")
@@ -98,15 +108,16 @@ func (m *managerAdapter) stopping(failure error) error {
}
// Run initializes and starts all background services using dskit's module and service patterns.
func (m *managerAdapter) Run(ctx context.Context) error {
func (m *ManagerAdapter) Run(ctx context.Context) error {
if err := m.StartAsync(ctx); err != nil {
return err
}
return m.AwaitTerminated(ctx)
stopCtx := trace.ContextWithSpan(context.Background(), trace.SpanFromContext(ctx))
return m.AwaitTerminated(stopCtx)
}
// Shutdown calls calls the underlying manager's Shutdown
func (m *managerAdapter) Shutdown(ctx context.Context, reason string) error {
func (m *ManagerAdapter) Shutdown(ctx context.Context, reason string) error {
m.StopAsync()
return m.AwaitTerminated(ctx)
}
@@ -28,10 +28,10 @@ func TestNewManagerAdapter(t *testing.T) {
func TestManagerAdapter_Starting(t *testing.T) {
t.Run("empty registry initializes manager", func(t *testing.T) {
reg := &mockBackgroundServiceRegistry{services: []registry.BackgroundService{}}
adapter := NewManagerAdapter(reg)
adapter.dependencyMap = map[string][]string{
BackgroundServices: {},
}
adapter := NewManagerAdapter(reg).WithDependencies(map[string][]string{
BackgroundServices: {Core},
Core: {},
})
ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
defer cancel()
@@ -55,10 +55,10 @@ func TestManagerAdapter_Starting(t *testing.T) {
reg := &mockBackgroundServiceRegistry{
services: []registry.BackgroundService{enabledSvc, disabledSvc, namedSvc},
}
adapter := NewManagerAdapter(reg)
adapter.dependencyMap = map[string][]string{
BackgroundServices: {},
}
adapter := NewManagerAdapter(reg).WithDependencies(map[string][]string{
BackgroundServices: {Core},
Core: {},
})
ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
defer cancel()
@@ -110,8 +110,11 @@ func TestManagerAdapter_Starting(t *testing.T) {
// Pre-populate the dependency map with the service using the actual service name that will be used
serviceName := "*adapter.mockNamedService"
adapter.dependencyMap[serviceName] = []string{"custom-dependency"}
initialBgDeps := append([]string{}, adapter.dependencyMap[BackgroundServices]...)
adapter.WithDependencies(map[string][]string{
serviceName: {BackgroundServices},
Core: {},
BackgroundServices: {Core},
})
ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
defer cancel()
@@ -121,18 +124,20 @@ func TestManagerAdapter_Starting(t *testing.T) {
require.NotNil(t, adapter.manager)
// Verify the existing dependency was not overwritten
require.Equal(t, []string{"custom-dependency"}, adapter.dependencyMap[serviceName])
require.Equal(t, []string{BackgroundServices}, adapter.dependencyMap[serviceName])
// Verify BackgroundServices dependencies were not modified (should not contain the service twice)
finalBgDeps := adapter.dependencyMap[BackgroundServices]
require.Equal(t, initialBgDeps, finalBgDeps)
require.Equal(t, []string{Core}, finalBgDeps)
})
t.Run("service without NamedService interface gets wrapped", func(t *testing.T) {
// Create a service that doesn't implement NamedService
plainSvc := &mockService{}
reg := &mockBackgroundServiceRegistry{services: []registry.BackgroundService{plainSvc}}
adapter := NewManagerAdapter(reg)
adapter := NewManagerAdapter(reg).WithDependencies(map[string][]string{
BackgroundServices: {Core},
Core: {},
})
ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
defer cancel()
@@ -151,11 +156,13 @@ func TestManagerAdapter_Starting(t *testing.T) {
})
t.Run("service without CanBeDisabled interface is always enabled", func(t *testing.T) {
// Create a service that doesn't implement CanBeDisabled
simpleSvc := &simpleBackgroundService{}
reg := &mockBackgroundServiceRegistry{services: []registry.BackgroundService{simpleSvc}}
adapter := NewManagerAdapter(reg)
adapter := NewManagerAdapter(reg).WithDependencies(map[string][]string{
BackgroundServices: {Core},
Core: {},
})
ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
defer cancel()
@@ -168,36 +175,18 @@ func TestManagerAdapter_Starting(t *testing.T) {
expectedServiceName := reflect.TypeOf(simpleSvc).String()
require.Contains(t, adapter.dependencyMap, expectedServiceName)
})
t.Run("real manager integration test", func(t *testing.T) {
testSvc := &mockService{}
reg := &mockBackgroundServiceRegistry{services: []registry.BackgroundService{testSvc}}
adapter := NewManagerAdapter(reg)
ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
defer cancel()
// Use the real manager - this tests actual integration
err := adapter.starting(ctx)
require.NoError(t, err)
require.NotNil(t, adapter.manager)
// Verify the service was registered in dependency map
expectedServiceName := reflect.TypeOf(testSvc).String()
require.Contains(t, adapter.dependencyMap, expectedServiceName)
})
}
func TestManagerAdapter_Running(t *testing.T) {
t.Run("runs with real manager", func(t *testing.T) {
t.Run("runs with manager", func(t *testing.T) {
mock := &mockNamedService{name: "mock"}
reg := &mockBackgroundServiceRegistry{services: []registry.BackgroundService{
mock,
}}
adapter := NewManagerAdapter(reg)
adapter.dependencyMap = map[string][]string{
BackgroundServices: {},
}
adapter := NewManagerAdapter(reg).WithDependencies(map[string][]string{
BackgroundServices: {Core},
Core: {},
})
ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
defer cancel()
@@ -208,25 +197,6 @@ func TestManagerAdapter_Running(t *testing.T) {
err = adapter.AwaitRunning(ctx)
require.NoError(t, err)
})
t.Run("running delegates to manager", func(t *testing.T) {
reg := &mockBackgroundServiceRegistry{services: []registry.BackgroundService{}}
adapter := NewManagerAdapter(reg)
ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
defer cancel()
// Initialize with real manager
err := adapter.starting(ctx)
require.NoError(t, err)
// Test running method directly - this will likely fail due to missing production modules
// but it covers the running method code path
err = adapter.running(ctx)
if err != nil {
require.Contains(t, err.Error(), "no such module")
}
})
}
func TestManagerAdapter_Stopping(t *testing.T) {
@@ -262,7 +232,6 @@ func TestManagerAdapter_Stopping(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
defer cancel()
// Initialize the manager first - need to go through starting to initialize manager
err := adapter.starting(ctx)
require.NoError(t, err)
require.NotNil(t, adapter.manager)
+18 -99
View File
@@ -2,19 +2,14 @@ package server
import (
"context"
"errors"
"fmt"
"net"
"os"
"path/filepath"
"reflect"
"strconv"
"sync"
"github.com/grafana/dskit/modules"
"github.com/prometheus/client_golang/prometheus"
"go.opentelemetry.io/otel/trace"
"golang.org/x/sync/errgroup"
"github.com/grafana/grafana/pkg/api"
_ "github.com/grafana/grafana/pkg/extensions"
@@ -24,9 +19,7 @@ import (
"github.com/grafana/grafana/pkg/infra/usagestats/statscollector"
"github.com/grafana/grafana/pkg/registry"
"github.com/grafana/grafana/pkg/registry/backgroundsvcs/adapter"
"github.com/grafana/grafana/pkg/semconv"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/provisioning"
"github.com/grafana/grafana/pkg/setting"
)
@@ -66,18 +59,14 @@ func newServer(opts Options, cfg *setting.Cfg, httpServer *api.HTTPServer, roleR
tracerProvider *tracing.TracingService,
promReg prometheus.Registerer,
) (*Server, error) {
rootCtx, shutdownFn := context.WithCancel(context.Background())
childRoutines, childCtx := errgroup.WithContext(rootCtx)
rootCtx := context.Background()
s := &Server{
promReg: promReg,
context: childCtx,
childRoutines: childRoutines,
context: rootCtx,
HTTPServer: httpServer,
provisioningService: provisioningService,
roleRegistry: roleRegistry,
shutdownFn: shutdownFn,
shutdownFinished: make(chan struct{}),
log: log.New("server"),
cfg: cfg,
pidFile: opts.PidFile,
@@ -86,6 +75,7 @@ func newServer(opts Options, cfg *setting.Cfg, httpServer *api.HTTPServer, roleR
buildBranch: opts.BuildBranch,
backgroundServiceRegistry: backgroundServiceProvider,
tracerProvider: tracerProvider,
managerAdapter: adapter.NewManagerAdapter(backgroundServiceProvider),
}
return s, nil
@@ -95,15 +85,12 @@ func newServer(opts Options, cfg *setting.Cfg, httpServer *api.HTTPServer, roleR
// core Server implementation which starts the entire Grafana server. Use
// ModuleServer to launch specific modules.
type Server struct {
context context.Context
shutdownFn func()
childRoutines *errgroup.Group
log log.Logger
cfg *setting.Cfg
shutdownOnce sync.Once
shutdownFinished chan struct{}
isInitialized bool
mtx sync.Mutex
context context.Context
log log.Logger
cfg *setting.Cfg
shutdownOnce sync.Once
isInitialized bool
mtx sync.Mutex
pidFile string
version string
@@ -117,6 +104,7 @@ type Server struct {
roleRegistry accesscontrol.RoleRegistry
provisioningService provisioning.ProvisioningService
promReg prometheus.Registerer
managerAdapter *adapter.ManagerAdapter
}
// Init initializes the server and its services.
@@ -145,83 +133,14 @@ func (s *Server) Init() error {
}
func (s *Server) Run() error {
if s.cfg.IsFeatureToggleEnabled(featuremgmt.FlagDskitBackgroundServices) {
s.log.Debug("Running background services with dskit wrapper")
return s.dskitRun()
}
s.log.Debug("Running standard background services")
return s.backgroundServicesRun()
}
func (s *Server) dskitRun() error {
if err := s.Init(); err != nil {
return err
}
managerAdapter := adapter.NewManagerAdapter(s.backgroundServiceRegistry)
s.notifySystemd("READY=1")
ctx, span := s.tracerProvider.Start(s.context, "server.dskitRun")
defer span.End()
// override the shutdownFn (context cancel func) for now until the feature flag is removed.
// this is a temporary solution to ensure that the services are shutdown properly.
cancelFn := s.shutdownFn
s.shutdownFn = func() {
defer close(s.shutdownFinished)
s.log.Debug("Shutting down background services")
if err := managerAdapter.Shutdown(s.context, modules.ErrStopProcess.Error()); err != nil {
s.log.Error("Failed to shutdown background services", "error", err)
}
cancelFn()
}
return managerAdapter.Run(ctx)
}
func (s *Server) backgroundServicesRun() error {
ctx, span := s.tracerProvider.Start(s.context, "server.backgroundServicesRun")
defer span.End()
defer close(s.shutdownFinished)
if err := s.Init(); err != nil {
return err
}
services := s.backgroundServiceRegistry.GetServices()
// Start background services.
for _, svc := range services {
if registry.IsDisabled(svc) {
continue
}
service := svc
serviceName := reflect.TypeOf(service).String()
s.childRoutines.Go(func() error {
select {
case <-s.context.Done():
return s.context.Err()
default:
}
s.log.Debug("Starting background service", "service", serviceName)
span.AddEvent(fmt.Sprintf("%s start", serviceName), trace.WithAttributes(semconv.GrafanaServiceName(serviceName)))
err := service.Run(ctx)
// Do not return context.Canceled error since errgroup.Group only
// returns the first error to the caller - thus we can miss a more
// interesting error.
if err != nil && !errors.Is(err, context.Canceled) {
s.log.Error("Stopped background service", "service", serviceName, "reason", err)
return fmt.Errorf("%s run error: %w", serviceName, err)
}
s.log.Debug("Stopped background service", "service", serviceName, "reason", err)
return nil
})
}
ctx, span := s.tracerProvider.Start(s.context, "server.Run")
defer span.End()
s.notifySystemd("READY=1")
s.log.Debug("Waiting on services...")
return s.childRoutines.Wait()
return s.managerAdapter.Run(ctx)
}
// Shutdown initiates Grafana graceful shutdown. This shuts down all
@@ -231,15 +150,15 @@ func (s *Server) Shutdown(ctx context.Context, reason string) error {
var err error
s.shutdownOnce.Do(func() {
s.log.Info("Shutdown started", "reason", reason)
// Call cancel func to stop background services.
s.shutdownFn()
// Wait for server to shut down
if shutdownErr := s.managerAdapter.Shutdown(ctx, "shutdown"); shutdownErr != nil {
s.log.Error("Failed to shutdown background services", "error", shutdownErr)
}
select {
case <-s.shutdownFinished:
s.log.Debug("Finished waiting for server to shut down")
case <-ctx.Done():
s.log.Warn("Timed out while waiting for server to shut down")
err = fmt.Errorf("timeout waiting for shutdown")
default:
s.log.Debug("Finished waiting for server to shut down")
}
})
+25 -25
View File
@@ -13,6 +13,7 @@ import (
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/registry"
"github.com/grafana/grafana/pkg/registry/backgroundsvcs"
"github.com/grafana/grafana/pkg/registry/backgroundsvcs/adapter"
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
"github.com/grafana/grafana/pkg/setting"
)
@@ -52,6 +53,10 @@ func testServer(t *testing.T, services ...registry.BackgroundService) *Server {
t.Helper()
s, err := newServer(Options{}, setting.NewCfg(), nil, &acimpl.Service{}, nil, backgroundsvcs.NewBackgroundServiceRegistry(services...), tracing.NewNoopTracerService(), prometheus.NewRegistry())
require.NoError(t, err)
s.managerAdapter.WithDependencies(map[string][]string{
adapter.Core: {},
adapter.BackgroundServices: {adapter.Core},
})
// Required to skip configuration initialization that causes
// DI errors in this test.
s.isInitialized = true
@@ -62,33 +67,28 @@ func TestServer_Run_Error(t *testing.T) {
testErr := errors.New("boom")
s := testServer(t, newTestService(nil, false), newTestService(testErr, false))
err := s.Run()
require.ErrorIs(t, err, testErr)
require.Error(t, err)
require.Contains(t, err.Error(), testErr.Error())
}
func TestServer_Shutdown(t *testing.T) {
ctx := context.Background()
t.Run("successful shutdown", func(t *testing.T) {
ctx := context.Background()
s := testServer(t, newTestService(nil, false), newTestService(nil, true))
ch := make(chan error)
go func() {
defer close(ch)
err := s.managerAdapter.AwaitRunning(ctx)
require.NoError(t, err)
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
err = s.Shutdown(ctx, "test interrupt")
ch <- err
}()
err := s.Run()
require.NoError(t, err)
s := testServer(t, newTestService(nil, false), newTestService(nil, true))
ch := make(chan error)
go func() {
defer close(ch)
// Wait until all services launched.
for _, svc := range s.backgroundServiceRegistry.GetServices() {
if !svc.(*testService).isDisabled {
<-svc.(*testService).started
}
}
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
err := s.Shutdown(ctx, "test interrupt")
ch <- err
}()
err := s.Run()
require.NoError(t, err)
err = <-ch
require.NoError(t, err)
err = <-ch
require.NoError(t, err)
})
}
+2 -2
View File
@@ -600,7 +600,6 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
deleteExpiredService := image.ProvideDeleteExpiredService(dBstore)
tempuserService := tempuserimpl.ProvideService(sqlStore, cfg)
cleanupServiceImpl := annotationsimpl.ProvideCleanupService(sqlStore, cfg)
cleanUpService := cleanup.ProvideService(cfg, featureToggles, serverLockService, shortURLService, sqlStore, queryHistoryService, dashverService, serviceImpl, deleteExpiredService, tempuserService, tracingService, cleanupServiceImpl, dBstore, eventualRestConfigProvider, orgService)
secretsKVStore, err := kvstore2.ProvideService(sqlStore, secretsService)
if err != nil {
return nil, err
@@ -612,6 +611,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
if err != nil {
return nil, err
}
cleanUpService := cleanup.ProvideService(cfg, featureToggles, serverLockService, shortURLService, sqlStore, queryHistoryService, dashverService, serviceImpl, deleteExpiredService, tempuserService, tracingService, cleanupServiceImpl, dBstore, eventualRestConfigProvider, orgService, teamService, service15)
correlationsService, err := correlations.ProvideService(sqlStore, routeRegisterImpl, service15, accessControl, inProcBus, quotaService, cfg)
if err != nil {
return nil, err
@@ -1200,7 +1200,6 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
deleteExpiredService := image.ProvideDeleteExpiredService(dBstore)
tempuserService := tempuserimpl.ProvideService(sqlStore, cfg)
cleanupServiceImpl := annotationsimpl.ProvideCleanupService(sqlStore, cfg)
cleanUpService := cleanup.ProvideService(cfg, featureToggles, serverLockService, shortURLService, sqlStore, queryHistoryService, dashverService, serviceImpl, deleteExpiredService, tempuserService, tracingService, cleanupServiceImpl, dBstore, eventualRestConfigProvider, orgService)
secretsKVStore, err := kvstore2.ProvideService(sqlStore, secretsService)
if err != nil {
return nil, err
@@ -1212,6 +1211,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
if err != nil {
return nil, err
}
cleanUpService := cleanup.ProvideService(cfg, featureToggles, serverLockService, shortURLService, sqlStore, queryHistoryService, dashverService, serviceImpl, deleteExpiredService, tempuserService, tracingService, cleanupServiceImpl, dBstore, eventualRestConfigProvider, orgService, teamService, service15)
correlationsService, err := correlations.ProvideService(sqlStore, routeRegisterImpl, service15, accessControl, inProcBus, quotaService, cfg)
if err != nil {
return nil, err
+144 -1
View File
@@ -28,11 +28,13 @@ import (
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/dashboardsnapshots"
dashver "github.com/grafana/grafana/pkg/services/dashboardversion"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/ngalert/image"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/queryhistory"
"github.com/grafana/grafana/pkg/services/shorturls"
"github.com/grafana/grafana/pkg/services/team"
tempuser "github.com/grafana/grafana/pkg/services/temp_user"
"github.com/grafana/grafana/pkg/setting"
)
@@ -58,12 +60,14 @@ type CleanUpService struct {
alertRuleService AlertRuleService
clientConfigProvider grafanaapiserver.RestConfigProvider
orgService org.Service
teamService team.Service
dataSourceService datasources.DataSourceService
}
func ProvideService(cfg *setting.Cfg, Features featuremgmt.FeatureToggles, serverLockService *serverlock.ServerLockService,
shortURLService shorturls.Service, sqlstore db.DB, queryHistoryService queryhistory.Service,
dashboardVersionService dashver.Service, dashSnapSvc dashboardsnapshots.Service, deleteExpiredImageService *image.DeleteExpiredService,
tempUserService tempuser.Service, tracer tracing.Tracer, annotationCleaner annotations.Cleaner, service AlertRuleService, clientConfigProvider grafanaapiserver.RestConfigProvider, orgService org.Service) *CleanUpService {
tempUserService tempuser.Service, tracer tracing.Tracer, annotationCleaner annotations.Cleaner, service AlertRuleService, clientConfigProvider grafanaapiserver.RestConfigProvider, orgService org.Service, teamService team.Service, dataSourceService datasources.DataSourceService) *CleanUpService {
s := &CleanUpService{
Cfg: cfg,
Features: Features,
@@ -81,6 +85,8 @@ func ProvideService(cfg *setting.Cfg, Features featuremgmt.FeatureToggles, serve
alertRuleService: service,
clientConfigProvider: clientConfigProvider,
orgService: orgService,
teamService: teamService,
dataSourceService: dataSourceService,
}
return s
}
@@ -125,6 +131,7 @@ func (srv *CleanUpService) clean(ctx context.Context) {
{"expire old user invites", srv.expireOldUserInvites},
{"delete stale query history", srv.deleteStaleQueryHistory},
{"expire old email verifications", srv.expireOldVerifications},
{"cleanup stale LBAC rules", srv.cleanupStaleLBACRules},
}
if srv.Cfg.ShortLinkExpiration > 0 {
@@ -418,3 +425,139 @@ func (srv *CleanUpService) cleanUpTrashAlertRules(ctx context.Context) {
logger.Debug("Cleaned up deleted alert rules", "rows affected", affected)
}
}
// cleanupStaleLBACRules exists to clean up lbac rules that are stale from teams getting deleted as we do not have
// cascading deletions on teams to delete existing lbac rules
func (srv *CleanUpService) cleanupStaleLBACRules(ctx context.Context) {
logger := srv.log.FromContext(ctx)
// Get all datasources
allDataSources, err := srv.dataSourceService.GetAllDataSources(ctx, &datasources.GetAllDataSourcesQuery{})
if err != nil {
logger.Error("Failed to get datasources for LBAC cleanup", "error", err)
return
}
var totalCleaned int
var totalDataSources int
for _, ds := range allDataSources {
if ds.JsonData == nil {
continue
}
// Check if datasource has team LBAC rules
teamHTTPHeaders, err := datasources.GetTeamHTTPHeaders(ds.JsonData)
if err != nil || teamHTTPHeaders == nil {
continue
}
totalDataSources++
// Extract team UIDs and check if teams still exist
cleanedRules, removedCount := srv.getLBACRulesForTeamsStillExisting(ctx, teamHTTPHeaders, ds.OrgID)
if removedCount > 0 {
// Update the datasource with cleaned rules
err := srv.updateDataSourceLBACRules(ctx, ds, cleanedRules)
if err != nil {
logger.Error("Failed to update datasource LBAC rules",
"datasource", ds.UID, "error", err)
} else {
totalCleaned += removedCount
logger.Debug("Cleaned stale LBAC rules",
"datasource", ds.UID, "removed", removedCount)
}
}
}
if totalCleaned > 0 {
logger.Info("Cleaned up stale team LBAC rules",
"datasources_processed", totalDataSources,
"total_rules_removed", totalCleaned)
}
}
func (srv *CleanUpService) getLBACRulesForTeamsStillExisting(ctx context.Context, teamHeaders *datasources.TeamHTTPHeaders, orgID int64) (*datasources.TeamHTTPHeaders, int) {
logger := srv.log.FromContext(ctx)
cleanedHeaders := &datasources.TeamHTTPHeaders{Headers: make(map[string][]datasources.TeamHTTPHeader)}
removedCount := 0
allTeams, err := srv.teamService.SearchTeams(ctx, &team.SearchTeamsQuery{
OrgID: orgID,
})
if err != nil {
logger.Error("Failed to get teams for LBAC cleanup", "error", err)
return nil, removedCount
}
teamUIDs := make(map[string]bool)
for _, team := range allTeams.Teams {
teamUIDs[team.UID] = true
}
teamIDs := make(map[int64]bool)
for _, team := range allTeams.Teams {
teamIDs[team.ID] = true
}
for teamIdentifier, headers := range teamHeaders.Headers {
// Determine if this is a UID or ID
teamID, err := strconv.ParseInt(teamIdentifier, 10, 64)
if err != nil {
// It's a UID
if _, ok := teamUIDs[teamIdentifier]; !ok {
logger.Debug("Team UID no longer exists, removing LBAC rules",
"teamUID", teamIdentifier, "orgID", orgID)
removedCount++
continue
}
} else {
if _, ok := teamIDs[teamID]; !ok {
logger.Debug("Team ID no longer exists, removing LBAC rules",
"teamID", teamIdentifier, "orgID", orgID)
removedCount++
continue
}
// team exists in lbac and exists in teams
// lbac rule has team.ID and team exists
// update the rule with the UID instead
// TODO: we could replace the ID for the UID here we want
}
// Team exists, keep the rules
cleanedHeaders.Headers[teamIdentifier] = headers
}
return cleanedHeaders, removedCount
}
func (srv *CleanUpService) updateDataSourceLBACRules(ctx context.Context, ds *datasources.DataSource, cleanedHeaders *datasources.TeamHTTPHeaders) error {
// Update JsonData with cleaned rules
jsonData := ds.JsonData
jsonData.Set("teamHttpHeaders", cleanedHeaders)
updateCmd := &datasources.UpdateDataSourceCommand{
ID: ds.ID,
OrgID: ds.OrgID,
UID: ds.UID,
Name: ds.Name,
Type: ds.Type,
Access: ds.Access,
URL: ds.URL,
User: ds.User,
Database: ds.Database,
BasicAuth: ds.BasicAuth,
BasicAuthUser: ds.BasicAuthUser,
WithCredentials: ds.WithCredentials,
IsDefault: ds.IsDefault,
JsonData: jsonData,
AllowLBACRuleUpdates: true,
Version: ds.Version,
ReadOnly: ds.ReadOnly,
APIVersion: ds.APIVersion,
}
_, err := srv.dataSourceService.UpdateDataSource(ctx, updateCmd)
return err
}
+9 -19
View File
@@ -942,15 +942,7 @@ var (
},
{
Name: "queryLibrary",
Description: "Renamed feature toggle, enables Saved queries feature",
Stage: FeatureStagePrivatePreview,
Owner: grafanaSharingSquad,
FrontendOnly: false,
AllowSelfServe: false,
},
{
Name: "savedQueries",
Description: "Enables Saved Queries feature",
Description: "Enables Saved queries (query library) feature",
Stage: FeatureStagePublicPreview,
Owner: grafanaSharingSquad,
FrontendOnly: false,
@@ -2035,16 +2027,6 @@ var (
Owner: grafanaPartnerPluginsSquad,
Expression: "false",
},
{
Name: "dskitBackgroundServices",
Description: "Enables dskit background service wrapper",
HideFromAdminPage: true,
HideFromDocs: true,
Stage: FeatureStageExperimental,
RequiresRestart: true,
Owner: grafanaPluginsPlatformSquad,
Expression: "false",
},
{
Name: "pluginContainers",
Description: "Enables running plugins in containers",
@@ -2069,6 +2051,14 @@ var (
Owner: grafanaPluginsPlatformSquad,
Expression: "false",
},
{
Name: "cdnPluginsLoadFirst",
Description: "Prioritize loading plugins from the CDN before other sources",
Stage: FeatureStageExperimental,
FrontendOnly: false,
Owner: grafanaPluginsPlatformSquad,
Expression: "false",
},
}
)
+2 -3
View File
@@ -122,8 +122,7 @@ newDashboardWithFiltersAndGroupBy,experimental,@grafana/dashboards-squad,false,f
cloudWatchNewLabelParsing,GA,@grafana/aws-datasources,false,false,false
disableNumericMetricsSortingInExpressions,experimental,@grafana/oss-big-tent,false,true,false
grafanaManagedRecordingRules,experimental,@grafana/alerting-squad,false,false,false
queryLibrary,privatePreview,@grafana/sharing-squad,false,false,false
savedQueries,preview,@grafana/sharing-squad,false,false,false
queryLibrary,preview,@grafana/sharing-squad,false,false,false
logsExploreTableDefaultVisualization,experimental,@grafana/observability-logs,false,false,true
alertingListViewV2,privatePreview,@grafana/alerting-squad,false,false,true
alertingDisableSendAlertsExternal,experimental,@grafana/alerting-squad,false,false,false
@@ -261,7 +260,7 @@ alertingTriage,experimental,@grafana/alerting-squad,false,false,true
graphiteBackendMode,privatePreview,@grafana/partner-datasources,false,false,false
azureResourcePickerUpdates,preview,@grafana/partner-datasources,false,false,true
prometheusTypeMigration,experimental,@grafana/partner-datasources,false,true,false
dskitBackgroundServices,experimental,@grafana/plugins-platform-backend,false,true,false
pluginContainers,privatePreview,@grafana/plugins-platform-backend,false,true,false
tempoSearchBackendMigration,GA,@grafana/oss-big-tent,false,true,false
filterOutBotsFromFrontendLogs,experimental,@grafana/plugins-platform-backend,false,false,true
cdnPluginsLoadFirst,experimental,@grafana/plugins-platform-backend,false,false,false
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
122 cloudWatchNewLabelParsing GA @grafana/aws-datasources false false false
123 disableNumericMetricsSortingInExpressions experimental @grafana/oss-big-tent false true false
124 grafanaManagedRecordingRules experimental @grafana/alerting-squad false false false
125 queryLibrary privatePreview preview @grafana/sharing-squad false false false
savedQueries preview @grafana/sharing-squad false false false
126 logsExploreTableDefaultVisualization experimental @grafana/observability-logs false false true
127 alertingListViewV2 privatePreview @grafana/alerting-squad false false true
128 alertingDisableSendAlertsExternal experimental @grafana/alerting-squad false false false
260 graphiteBackendMode privatePreview @grafana/partner-datasources false false false
261 azureResourcePickerUpdates preview @grafana/partner-datasources false false true
262 prometheusTypeMigration experimental @grafana/partner-datasources false true false
dskitBackgroundServices experimental @grafana/plugins-platform-backend false true false
263 pluginContainers privatePreview @grafana/plugins-platform-backend false true false
264 tempoSearchBackendMigration GA @grafana/oss-big-tent false true false
265 filterOutBotsFromFrontendLogs experimental @grafana/plugins-platform-backend false false true
266 cdnPluginsLoadFirst experimental @grafana/plugins-platform-backend false false false
+5 -9
View File
@@ -500,13 +500,9 @@ const (
FlagGrafanaManagedRecordingRules = "grafanaManagedRecordingRules"
// FlagQueryLibrary
// Renamed feature toggle, enables Saved queries feature
// Enables Saved queries (query library) feature
FlagQueryLibrary = "queryLibrary"
// FlagSavedQueries
// Enables Saved Queries feature
FlagSavedQueries = "savedQueries"
// FlagLogsExploreTableDefaultVisualization
// Sets the logs table as default visualisation in logs explore
FlagLogsExploreTableDefaultVisualization = "logsExploreTableDefaultVisualization"
@@ -1055,10 +1051,6 @@ const (
// Checks for deprecated Prometheus authentication methods (SigV4 and Azure), installs the relevant data source, and migrates the Prometheus data sources
FlagPrometheusTypeMigration = "prometheusTypeMigration"
// FlagDskitBackgroundServices
// Enables dskit background service wrapper
FlagDskitBackgroundServices = "dskitBackgroundServices"
// FlagPluginContainers
// Enables running plugins in containers
FlagPluginContainers = "pluginContainers"
@@ -1070,4 +1062,8 @@ const (
// FlagFilterOutBotsFromFrontendLogs
// Filter out bots from collecting data for Frontend Observability
FlagFilterOutBotsFromFrontendLogs = "filterOutBotsFromFrontendLogs"
// FlagCdnPluginsLoadFirst
// Prioritize loading plugins from the CDN before other sources
FlagCdnPluginsLoadFirst = "cdnPluginsLoadFirst"
)
+33 -4
View File
@@ -836,6 +836,33 @@
"frontend": true
}
},
{
"metadata": {
"name": "cdnPluginsLoadFirst",
"resourceVersion": "1758882341746",
"creationTimestamp": "2025-09-26T10:25:41Z"
},
"spec": {
"description": "Prioritize loading plugins from the CDN before other sources",
"stage": "experimental",
"codeowner": "@grafana/plugins-platform-backend",
"expression": "false"
}
},
{
"metadata": {
"name": "cdnPluginsLoadedFirst",
"resourceVersion": "1758881920003",
"creationTimestamp": "2025-09-26T10:18:40Z",
"deletionTimestamp": "2025-09-26T10:25:41Z"
},
"spec": {
"description": "Prioritize loading plugins from the CDN before other sources",
"stage": "experimental",
"codeowner": "@grafana/plugins-platform-backend",
"expression": "false"
}
},
{
"metadata": {
"name": "cloudRBACRoles",
@@ -1218,6 +1245,7 @@
"name": "dskitBackgroundServices",
"resourceVersion": "1757339637779",
"creationTimestamp": "2025-09-03T12:20:24Z",
"deletionTimestamp": "2025-09-17T12:19:32Z",
"annotations": {
"grafana.app/updatedTimestamp": "2025-09-08 13:53:57.77994 +0000 UTC"
}
@@ -2976,16 +3004,16 @@
{
"metadata": {
"name": "queryLibrary",
"resourceVersion": "1755721444487",
"resourceVersion": "1758208636622",
"creationTimestamp": "2022-10-07T18:31:45Z",
"deletionTimestamp": "2023-03-20T16:00:14Z",
"annotations": {
"grafana.app/updatedTimestamp": "2025-08-20 20:24:04.487598 +0000 UTC"
"grafana.app/updatedTimestamp": "2025-09-18 15:17:16.622111 +0000 UTC"
}
},
"spec": {
"description": "Renamed feature toggle, enables Saved queries feature",
"stage": "privatePreview",
"description": "Enables Saved queries (query library) feature",
"stage": "preview",
"codeowner": "@grafana/sharing-squad"
}
},
@@ -3186,6 +3214,7 @@
"name": "savedQueries",
"resourceVersion": "1756920131554",
"creationTimestamp": "2025-08-25T21:22:09Z",
"deletionTimestamp": "2025-09-18T12:07:31Z",
"annotations": {
"grafana.app/updatedTimestamp": "2025-09-03 17:22:11.554759 +0000 UTC"
}
@@ -15,6 +15,7 @@ import (
"time"
alertingNotify "github.com/grafana/alerting/notify"
"github.com/grafana/alerting/receivers/schema"
prometheus "github.com/prometheus/alertmanager/config"
"github.com/prometheus/alertmanager/pkg/labels"
"github.com/prometheus/alertmanager/timeinterval"
@@ -2037,7 +2038,7 @@ func TestApiContactPointExportSnapshot(t *testing.T) {
integration := models.IntegrationGen(
models.IntegrationMuts.WithName(allIntegrationsName),
models.IntegrationMuts.WithUID(fmt.Sprintf("%s-uid", integrationType)),
models.IntegrationMuts.WithValidConfig(integrationType),
models.IntegrationMuts.WithValidConfig(schema.IntegrationType(integrationType)),
)()
integration.DisableResolveMessage = redacted
allIntegrations = append(allIntegrations, integration)
@@ -206,8 +206,10 @@ func (f *AlertmanagerApiHandler) handleRoutePostAlertingConfig(ctx *contextmodel
if err != nil {
return errorToResponse(err)
}
if !body.AlertmanagerConfig.ReceiverType().Can(apimodels.AlertmanagerReceiverType) {
return errorToResponse(backendTypeDoesNotMatchPayloadTypeError(apimodels.AlertmanagerBackend, body.AlertmanagerConfig.ReceiverType().String()))
for _, p := range body.AlertmanagerConfig.Receivers {
if p.HasGrafanaIntegrations() {
return errorToResponse(backendTypeDoesNotMatchPayloadTypeError(apimodels.AlertmanagerBackend, apimodels.GrafanaBackend.String()))
}
}
return s.RoutePostAlertingConfig(ctx, body)
}
@@ -267,7 +267,6 @@ type (
ObjectMatchers = definition.ObjectMatchers
PostableApiReceiver = definition.PostableApiReceiver
PostableGrafanaReceivers = definition.PostableGrafanaReceivers
ReceiverType = definition.ReceiverType
)
type MergeResult definition.MergeResult
@@ -295,9 +294,6 @@ func (m MergeResult) LogContext() []any {
}
const (
GrafanaReceiverType = definition.GrafanaReceiverType
AlertmanagerReceiverType = definition.AlertmanagerReceiverType
errInvalidExtraConfigurationMsg = "Invalid Alertmanager configuration: {{.Public.Error}}"
)
@@ -870,12 +866,8 @@ func (c *PostableUserConfig) validate() error {
func (c *PostableUserConfig) GetGrafanaReceiverMap() map[string]*PostableGrafanaReceiver {
UIDs := make(map[string]*PostableGrafanaReceiver)
for _, r := range c.AlertmanagerConfig.Receivers {
switch r.Type() {
case GrafanaReceiverType:
for _, gr := range r.GrafanaManagedReceivers {
UIDs[gr.UID] = gr
}
default:
for _, gr := range r.GrafanaManagedReceivers {
UIDs[gr.UID] = gr
}
}
return UIDs
@@ -975,12 +967,8 @@ func (c *GettableUserConfig) MarshalJSON() ([]byte, error) {
func (c *GettableUserConfig) GetGrafanaReceiverMap() map[string]*GettableGrafanaReceiver {
UIDs := make(map[string]*GettableGrafanaReceiver)
for _, r := range c.AlertmanagerConfig.Receivers {
switch r.Type() {
case GrafanaReceiverType:
for _, gr := range r.GrafanaManagedReceivers {
UIDs[gr.UID] = gr
}
default:
for _, gr := range r.GrafanaManagedReceivers {
UIDs[gr.UID] = gr
}
}
return UIDs
@@ -1097,47 +1085,7 @@ type GettableApiReceiver struct {
func (r *GettableApiReceiver) UnmarshalJSON(b []byte) error {
type plain GettableApiReceiver
if err := json.Unmarshal(b, (*plain)(r)); err != nil {
return err
}
hasGrafanaReceivers := len(r.GrafanaManagedReceivers) > 0
if hasGrafanaReceivers {
if len(r.EmailConfigs) > 0 {
return fmt.Errorf("cannot have both Alertmanager EmailConfigs & Grafana receivers together")
}
if len(r.PagerdutyConfigs) > 0 {
return fmt.Errorf("cannot have both Alertmanager PagerdutyConfigs & Grafana receivers together")
}
if len(r.SlackConfigs) > 0 {
return fmt.Errorf("cannot have both Alertmanager SlackConfigs & Grafana receivers together")
}
if len(r.WebhookConfigs) > 0 {
return fmt.Errorf("cannot have both Alertmanager WebhookConfigs & Grafana receivers together")
}
if len(r.OpsGenieConfigs) > 0 {
return fmt.Errorf("cannot have both Alertmanager OpsGenieConfigs & Grafana receivers together")
}
if len(r.WechatConfigs) > 0 {
return fmt.Errorf("cannot have both Alertmanager WechatConfigs & Grafana receivers together")
}
if len(r.PushoverConfigs) > 0 {
return fmt.Errorf("cannot have both Alertmanager PushoverConfigs & Grafana receivers together")
}
if len(r.VictorOpsConfigs) > 0 {
return fmt.Errorf("cannot have both Alertmanager VictorOpsConfigs & Grafana receivers together")
}
}
return nil
}
func (r *GettableApiReceiver) Type() ReceiverType {
if len(r.GrafanaManagedReceivers) > 0 {
return GrafanaReceiverType
}
return AlertmanagerReceiverType
return json.Unmarshal(b, (*plain)(r))
}
func (r *GettableApiReceiver) GetName() string {
+21 -17
View File
@@ -13,8 +13,7 @@ import (
"strings"
alertingNotify "github.com/grafana/alerting/notify"
"github.com/grafana/grafana/pkg/services/ngalert/notifier/channels_config"
"github.com/grafana/alerting/receivers/schema"
)
// GetReceiverQuery represents a query for a single receiver.
@@ -228,30 +227,35 @@ func (f IntegrationFieldPath) With(segment string) IntegrationFieldPath {
// IntegrationConfig - The integration configuration
// error - Error if integration type not found or invalid version specified
func IntegrationConfigFromType(integrationType string, version *string) (IntegrationConfig, error) {
versionConfig, err := channels_config.ConfigForIntegrationType(integrationType)
if err != nil {
return IntegrationConfig{}, err
typeSchema, ok := alertingNotify.GetSchemaForIntegration(schema.IntegrationType(integrationType))
if !ok {
return IntegrationConfig{}, fmt.Errorf("integration type %s not found", integrationType)
}
// if particular version is requested and the version returned does not match, try to get the correct version
if version != nil && *version != string(versionConfig.Version) {
exists := false
versionConfig, exists = versionConfig.Plugin.GetVersion(channels_config.NotifierVersion(*version))
if !exists {
return IntegrationConfig{}, fmt.Errorf("version %s not found in config", *version)
}
if version == nil {
return IntegrationConfigFromSchema(typeSchema, typeSchema.CurrentVersion)
}
return IntegrationConfigFromSchema(typeSchema, schema.Version(*version))
}
// IntegrationConfigFromSchema returns an integration configuration for a given version of the integration type schema.
// Returns an error if the schema does not have such version
func IntegrationConfigFromSchema(typeSchema schema.IntegrationTypeSchema, version schema.Version) (IntegrationConfig, error) {
typeVersion, ok := typeSchema.GetVersion(version)
if !ok {
return IntegrationConfig{}, fmt.Errorf("version %s not found in config", version)
}
integrationConfig := IntegrationConfig{
Type: versionConfig.Plugin.Type,
Version: string(versionConfig.Version),
Fields: make(map[string]IntegrationField, len(versionConfig.Options)),
Type: string(typeSchema.Type),
Version: string(typeVersion.Version),
Fields: make(map[string]IntegrationField, len(typeVersion.Options)),
}
for _, option := range versionConfig.Options {
for _, option := range typeVersion.Options {
integrationConfig.Fields[option.PropertyName] = notifierOptionToIntegrationField(option)
}
return integrationConfig, nil
}
func notifierOptionToIntegrationField(option channels_config.NotifierOption) IntegrationField {
func notifierOptionToIntegrationField(option schema.Field) IntegrationField {
f := IntegrationField{
Name: option.PropertyName,
Secure: option.Secure,
+35 -38
View File
@@ -6,11 +6,10 @@ import (
"testing"
alertingNotify "github.com/grafana/alerting/notify"
"github.com/grafana/alerting/receivers/schema"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/services/ngalert/notifier/channels_config"
"github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/util/testutil"
)
@@ -41,14 +40,14 @@ func TestReceiver_EncryptDecrypt(t *testing.T) {
encryptFn := Base64Enrypt
decryptnFn := Base64Decrypt
// Test that all known integration types encrypt and decrypt their secrets.
for integrationType := range alertingNotify.AllKnownConfigsForTesting {
t.Run(integrationType, func(t *testing.T) {
for it := range alertingNotify.AllKnownConfigsForTesting {
integrationType := schema.IntegrationType(it)
t.Run(string(integrationType), func(t *testing.T) {
decrypedIntegration := IntegrationGen(IntegrationMuts.WithValidConfig(integrationType))()
encrypted := decrypedIntegration.Clone()
secrets, err := channels_config.GetSecretKeysForContactPointType(integrationType, channels_config.V1)
assert.NoError(t, err)
for _, key := range secrets {
typeVersion, ok := alertingNotify.GetSchemaVersionForIntegration(integrationType, schema.V1)
require.True(t, ok)
for _, key := range typeVersion.GetSecretFieldsPaths() {
val, ok, err := extractField(encrypted.Settings, NewIntegrationFieldPath(key))
assert.NoError(t, err)
if ok {
@@ -59,7 +58,7 @@ func TestReceiver_EncryptDecrypt(t *testing.T) {
}
testIntegration := decrypedIntegration.Clone()
err = testIntegration.Encrypt(encryptFn)
err := testIntegration.Encrypt(encryptFn)
assert.NoError(t, err)
require.Equal(t, encrypted, testIntegration)
@@ -77,14 +76,15 @@ func TestIntegration_Redact(t *testing.T) {
return "TESTREDACTED"
}
// Test that all known integration types redact their secrets.
for integrationType := range alertingNotify.AllKnownConfigsForTesting {
t.Run(integrationType, func(t *testing.T) {
for it := range alertingNotify.AllKnownConfigsForTesting {
integrationType := schema.IntegrationType(it)
t.Run(string(integrationType), func(t *testing.T) {
validIntegration := IntegrationGen(IntegrationMuts.WithValidConfig(integrationType))()
expected := validIntegration.Clone()
secrets, err := channels_config.GetSecretKeysForContactPointType(integrationType, channels_config.V1)
assert.NoError(t, err)
for _, key := range secrets {
version, ok := alertingNotify.GetSchemaVersionForIntegration(integrationType, schema.V1)
require.True(t, ok)
for _, key := range version.GetSecretFieldsPaths() {
err := setField(expected.Settings, NewIntegrationFieldPath(key), func(current any) any {
if s, isString := current.(string); isString && s != "" {
delete(expected.SecureSettings, key)
@@ -106,8 +106,9 @@ func TestIntegration_Validate(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
// Test that all known integration types are valid.
for integrationType := range alertingNotify.AllKnownConfigsForTesting {
t.Run(integrationType, func(t *testing.T) {
for it := range alertingNotify.AllKnownConfigsForTesting {
integrationType := schema.IntegrationType(it)
t.Run(string(integrationType), func(t *testing.T) {
validIntegration := IntegrationGen(IntegrationMuts.WithValidConfig(integrationType))()
assert.NoError(t, validIntegration.Encrypt(Base64Enrypt))
assert.NoErrorf(t, validIntegration.Validate(Base64Decrypt), "integration should be valid")
@@ -241,19 +242,19 @@ func TestIntegration_WithExistingSecureFields(t *testing.T) {
func TestSecretsIntegrationConfig(t *testing.T) {
// Test that all known integration types have a config and correctly mark their secrets as secure.
for integrationType := range alertingNotify.AllKnownConfigsForTesting {
t.Run(integrationType, func(t *testing.T) {
config, err := IntegrationConfigFromType(integrationType, nil)
for it := range alertingNotify.AllKnownConfigsForTesting {
integrationType := schema.IntegrationType(it)
t.Run(string(integrationType), func(t *testing.T) {
schemaType, ok := alertingNotify.GetSchemaForIntegration(integrationType)
require.True(t, ok)
config, err := IntegrationConfigFromSchema(schemaType, schema.V1)
assert.NoError(t, err)
t.Run("v1 is current", func(t *testing.T) {
configv1, err := IntegrationConfigFromType(integrationType, util.Pointer(string(channels_config.V1)))
assert.NoError(t, err)
assert.Equal(t, config, configv1)
})
version, ok := schemaType.GetVersion(schema.V1)
require.True(t, ok)
secrets, err := channels_config.GetSecretKeysForContactPointType(integrationType, channels_config.V1)
assert.NoError(t, err)
secrets := version.GetSecretFieldsPaths()
allSecrets := make(map[string]struct{}, len(secrets))
for _, key := range secrets {
allSecrets[key] = struct{}{}
@@ -270,17 +271,12 @@ func TestSecretsIntegrationConfig(t *testing.T) {
})
}
t.Run("Unknown type returns error", func(t *testing.T) {
_, err := IntegrationConfigFromType("__--**unknown_type**--__", nil)
assert.Error(t, err)
})
t.Run("Unknown version returns error", func(t *testing.T) {
version := util.Pointer("__--**unknown_version**--__")
types := maps.Keys(alertingNotify.AllKnownConfigsForTesting)
for itype := range types {
_, err := IntegrationConfigFromType(itype, version)
assert.Errorf(t, err, "unknown version for integration type %s did not return error but should", itype)
for s := range maps.Keys(alertingNotify.AllKnownConfigsForTesting) {
schemaType, _ := alertingNotify.GetSchemaForIntegration(schema.IntegrationType(s))
_, err := IntegrationConfigFromSchema(schemaType, "unknown")
require.Error(t, err)
return
}
})
}
@@ -289,8 +285,9 @@ func TestIntegration_SecureFields(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
// Test that all known integration types have a config and correctly mark their secrets as secure.
for integrationType := range alertingNotify.AllKnownConfigsForTesting {
t.Run(integrationType, func(t *testing.T) {
for it := range alertingNotify.AllKnownConfigsForTesting {
integrationType := schema.IntegrationType(it)
t.Run(string(integrationType), func(t *testing.T) {
t.Run("contains SecureSettings", func(t *testing.T) {
validIntegration := IntegrationGen(IntegrationMuts.WithValidConfig(integrationType))()
expected := make(map[string]bool, len(validIntegration.SecureSettings))
+13 -10
View File
@@ -13,6 +13,8 @@ import (
"github.com/go-openapi/strfmt"
"github.com/google/uuid"
alertingNotify "github.com/grafana/alerting/notify"
"github.com/grafana/alerting/receivers/schema"
"github.com/grafana/alerting/receivers/webex"
"github.com/grafana/grafana-plugin-sdk-go/data"
amv2 "github.com/prometheus/alertmanager/api/v2/models"
"github.com/prometheus/alertmanager/pkg/labels"
@@ -1209,7 +1211,7 @@ func (n ReceiverMutators) WithProvenance(provenance Provenance) Mutator[Receiver
}
}
func (n ReceiverMutators) WithValidIntegration(integrationType string) Mutator[Receiver] {
func (n ReceiverMutators) WithValidIntegration(integrationType schema.IntegrationType) Mutator[Receiver] {
return func(r *Receiver) {
// TODO add support for v0
integration := IntegrationGen(IntegrationMuts.WithValidConfig(integrationType))()
@@ -1217,7 +1219,7 @@ func (n ReceiverMutators) WithValidIntegration(integrationType string) Mutator[R
}
}
func (n ReceiverMutators) WithInvalidIntegration(integrationType string) Mutator[Receiver] {
func (n ReceiverMutators) WithInvalidIntegration(integrationType schema.IntegrationType) Mutator[Receiver] {
return func(r *Receiver) {
// TODO add support for v0
integration := IntegrationGen(IntegrationMuts.WithInvalidConfig(integrationType))()
@@ -1278,7 +1280,7 @@ func IntegrationGen(mutators ...Mutator[Integration]) func() Integration {
SecureSettings: make(map[string]string),
}
IntegrationMuts.WithValidConfig(randomIntegrationType)(&c)
IntegrationMuts.WithValidConfig(schema.IntegrationType(randomIntegrationType))(&c)
for _, mutator := range mutators {
mutator(&c)
@@ -1312,11 +1314,12 @@ func (n IntegrationMutators) WithName(name string) Mutator[Integration] {
}
}
func (n IntegrationMutators) WithValidConfig(integrationType string) Mutator[Integration] {
func (n IntegrationMutators) WithValidConfig(integrationType schema.IntegrationType) Mutator[Integration] {
return func(c *Integration) {
// TODO add support for v0 integrations
config := alertingNotify.AllKnownConfigsForTesting[integrationType].GetRawNotifierConfig(c.Name)
integrationConfig, _ := IntegrationConfigFromType(integrationType, nil)
config := alertingNotify.AllKnownConfigsForTesting[string(integrationType)].GetRawNotifierConfig(c.Name)
typeSchema, _ := alertingNotify.GetSchemaForIntegration(integrationType)
integrationConfig, _ := IntegrationConfigFromSchema(typeSchema, schema.V1)
c.Config = integrationConfig
var settings map[string]any
@@ -1332,13 +1335,13 @@ func (n IntegrationMutators) WithValidConfig(integrationType string) Mutator[Int
}
}
func (n IntegrationMutators) WithInvalidConfig(integrationType string) Mutator[Integration] {
func (n IntegrationMutators) WithInvalidConfig(integrationType schema.IntegrationType) Mutator[Integration] {
return func(c *Integration) {
integrationConfig, _ := IntegrationConfigFromType(integrationType, nil)
c.Config = integrationConfig
typeSchema, _ := alertingNotify.GetSchemaForIntegration(integrationType)
c.Config, _ = IntegrationConfigFromSchema(typeSchema, schema.V1)
c.Settings = map[string]interface{}{}
c.SecureSettings = map[string]string{}
if integrationType == "webex" {
if integrationType == webex.Type {
// Webex passes validation without any settings but should fail with an unparsable URL.
c.Settings["api_url"] = "(*^$*^%!@#$*()"
}
@@ -452,26 +452,22 @@ func assignReceiverConfigsUIDs(c []*definitions.PostableApiReceiver) error {
seenUIDs := make(map[string]struct{})
// encrypt secure settings for storing them in DB
for _, r := range c {
switch r.Type() {
case definitions.GrafanaReceiverType:
for _, gr := range r.GrafanaManagedReceivers {
if gr.UID == "" {
retries := 5
for i := 0; i < retries; i++ {
gen := util.GenerateShortUID()
_, ok := seenUIDs[gen]
if !ok {
gr.UID = gen
break
}
}
if gr.UID == "" {
return fmt.Errorf("all %d attempts to generate UID for receiver have failed; please retry", retries)
for _, gr := range r.GrafanaManagedReceivers {
if gr.UID == "" {
retries := 5
for i := 0; i < retries; i++ {
gen := util.GenerateShortUID()
_, ok := seenUIDs[gen]
if !ok {
gr.UID = gen
break
}
}
seenUIDs[gr.UID] = struct{}{}
if gr.UID == "" {
return fmt.Errorf("all %d attempts to generate UID for receiver have failed; please retry", retries)
}
}
default:
seenUIDs[gr.UID] = struct{}{}
}
}
return nil
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,290 +0,0 @@
package channels_config
import (
"encoding/json"
"fmt"
"maps"
"reflect"
"slices"
"strings"
"testing"
"github.com/grafana/alerting/notify/notifytest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetSecretKeysForContactPointType(t *testing.T) {
httpConfigSecrets := []string{"http_config.authorization.credentials", "http_config.basic_auth.password", "http_config.oauth2.client_secret"}
testCases := []struct {
receiverType string
version NotifierVersion
expectedSecretFields []string
}{
{receiverType: "dingding", version: V1, expectedSecretFields: []string{"url"}},
{receiverType: "kafka", version: V1, expectedSecretFields: []string{"password"}},
{receiverType: "email", version: V1, expectedSecretFields: []string{}},
{receiverType: "pagerduty", version: V1, expectedSecretFields: []string{"integrationKey"}},
{receiverType: "victorops", version: V1, expectedSecretFields: []string{"url"}},
{receiverType: "oncall", version: V1, expectedSecretFields: []string{"password", "authorization_credentials"}},
{receiverType: "pushover", version: V1, expectedSecretFields: []string{"apiToken", "userKey"}},
{receiverType: "slack", version: V1, expectedSecretFields: []string{"token", "url"}},
{receiverType: "sensugo", version: V1, expectedSecretFields: []string{"apikey"}},
{receiverType: "teams", version: V1, expectedSecretFields: []string{}},
{receiverType: "telegram", version: V1, expectedSecretFields: []string{"bottoken"}},
{receiverType: "webhook", version: V1, expectedSecretFields: []string{
"password",
"authorization_credentials",
"tlsConfig.caCertificate",
"tlsConfig.clientCertificate",
"tlsConfig.clientKey",
"hmacConfig.secret",
"http_config.oauth2.client_secret",
"http_config.oauth2.tls_config.caCertificate",
"http_config.oauth2.tls_config.clientCertificate",
"http_config.oauth2.tls_config.clientKey",
}},
{receiverType: "wecom", version: V1, expectedSecretFields: []string{"url", "secret"}},
{receiverType: "prometheus-alertmanager", version: V1, expectedSecretFields: []string{"basicAuthPassword"}},
{receiverType: "discord", version: V1, expectedSecretFields: []string{"url"}},
{receiverType: "googlechat", version: V1, expectedSecretFields: []string{"url"}},
{receiverType: "LINE", version: V1, expectedSecretFields: []string{"token"}},
{receiverType: "threema", version: V1, expectedSecretFields: []string{"api_secret"}},
{receiverType: "opsgenie", version: V1, expectedSecretFields: []string{"apiKey"}},
{receiverType: "webex", version: V1, expectedSecretFields: []string{"bot_token"}},
{receiverType: "sns", version: V1, expectedSecretFields: []string{"sigv4.access_key", "sigv4.secret_key"}},
{receiverType: "mqtt", version: V1, expectedSecretFields: []string{"password", "tlsConfig.caCertificate", "tlsConfig.clientCertificate", "tlsConfig.clientKey"}},
{receiverType: "jira", version: V1, expectedSecretFields: []string{"user", "password", "api_token"}},
{receiverType: "victorops", version: V0mimir1, expectedSecretFields: append([]string{"api_key"}, httpConfigSecrets...)},
{receiverType: "sns", version: V0mimir1, expectedSecretFields: append([]string{"sigv4.SecretKey"}, httpConfigSecrets...)},
{receiverType: "telegram", version: V0mimir1, expectedSecretFields: append([]string{"token"}, httpConfigSecrets...)},
{receiverType: "discord", version: V0mimir1, expectedSecretFields: append([]string{"webhook_url"}, httpConfigSecrets...)},
{receiverType: "pagerduty", version: V0mimir1, expectedSecretFields: append([]string{"routing_key", "service_key"}, httpConfigSecrets...)},
{receiverType: "pushover", version: V0mimir1, expectedSecretFields: append([]string{"user_key", "token"}, httpConfigSecrets...)},
{receiverType: "jira", version: V0mimir1, expectedSecretFields: httpConfigSecrets},
{receiverType: "opsgenie", version: V0mimir1, expectedSecretFields: append([]string{"api_key"}, httpConfigSecrets...)},
{receiverType: "teams", version: V0mimir1, expectedSecretFields: append([]string{"webhook_url"}, httpConfigSecrets...)},
{receiverType: "teams", version: V0mimir2, expectedSecretFields: append([]string{"webhook_url"}, httpConfigSecrets...)},
{receiverType: "email", version: V0mimir1, expectedSecretFields: []string{"auth_password", "auth_secret"}},
{receiverType: "slack", version: V0mimir1, expectedSecretFields: append([]string{"api_url"}, httpConfigSecrets...)},
{receiverType: "webex", version: V0mimir1, expectedSecretFields: httpConfigSecrets},
{receiverType: "wechat", version: V0mimir1, expectedSecretFields: append([]string{"api_secret"}, httpConfigSecrets...)},
{receiverType: "webhook", version: V0mimir1, expectedSecretFields: append([]string{"url"}, httpConfigSecrets...)},
}
n := slices.Collect(GetAvailableNotifiersV2())
type typeWithVersion struct {
Type string
Version NotifierVersion
}
allTypes := make(map[typeWithVersion]struct{}, len(n))
getKey := func(pluginType string, version NotifierVersion) typeWithVersion {
return typeWithVersion{pluginType, version}
}
for _, p := range n {
for _, v := range p.Versions {
allTypes[getKey(p.Type, v.Version)] = struct{}{}
}
}
for _, testCase := range testCases {
delete(allTypes, getKey(testCase.receiverType, testCase.version))
t.Run(fmt.Sprintf("%s-%s", testCase.receiverType, testCase.version), func(t *testing.T) {
got, err := GetSecretKeysForContactPointType(testCase.receiverType, testCase.version)
require.NoError(t, err)
require.ElementsMatch(t, testCase.expectedSecretFields, got)
})
}
for it := range allTypes {
t.Run(fmt.Sprintf("%s-%s", it.Type, it.Version), func(t *testing.T) {
got, err := GetSecretKeysForContactPointType(it.Type, it.Version)
require.NoError(t, err)
require.Emptyf(t, got, "secret keys for version %s of %s should be empty", it.Version, it.Type)
})
}
require.Emptyf(t, allTypes, "not all types are covered: %s", allTypes)
}
func TestGetAvailableNotifiersV2(t *testing.T) {
n := slices.Collect(GetAvailableNotifiersV2())
require.NotEmpty(t, n)
for _, notifier := range n {
t.Run(fmt.Sprintf("integration %s [%s]", notifier.Type, notifier.Name), func(t *testing.T) {
currentVersion := V1
if notifier.Type == "wechat" {
currentVersion = V0mimir1
}
t.Run(fmt.Sprintf("current version is %s", currentVersion), func(t *testing.T) {
require.Equal(t, currentVersion, notifier.GetCurrentVersion().Version)
})
t.Run("should be able to create only v1", func(t *testing.T) {
for _, version := range notifier.Versions {
if version.Version == V1 {
require.True(t, version.CanCreate, "v1 should be able to create")
continue
}
require.False(t, version.CanCreate, "v0 should not be able to create")
}
})
})
}
}
func TestConfigForIntegrationType(t *testing.T) {
t.Run("should return current version for all common types", func(t *testing.T) {
for plugin := range GetAvailableNotifiersV2() {
t.Run(plugin.Type, func(t *testing.T) {
version, err := ConfigForIntegrationType(plugin.Type)
require.NoErrorf(t, err, "expected config but got error for plugin type %s", plugin.Type)
assert.Equal(t, version.Plugin, plugin)
assert.Equal(t, version, plugin.GetCurrentVersion())
})
}
})
t.Run("should return specific version if matched by alias", func(t *testing.T) {
for plugin := range GetAvailableNotifiersV2() {
for _, version := range plugin.Versions {
if version.TypeAlias == "" {
continue
}
t.Run(version.TypeAlias, func(t *testing.T) {
actualVersion, err := ConfigForIntegrationType(version.TypeAlias)
require.NoErrorf(t, err, "expected config but got error for plugin type %s", plugin.Type)
assert.Equal(t, version, actualVersion)
})
}
}
})
t.Run("should return error if not known type", func(t *testing.T) {
_, err := ConfigForIntegrationType("unknown")
require.Error(t, err)
})
}
func TestTypeUniqueness(t *testing.T) {
knownTypes := make(map[string]struct{})
for plugin := range GetAvailableNotifiersV2() {
iType := strings.ToLower(plugin.Type)
if _, ok := knownTypes[iType]; ok {
assert.Failf(t, "duplicate plugin type", "plugin type %s", plugin.Type)
}
knownTypes[iType] = struct{}{}
for _, version := range plugin.Versions {
if version.TypeAlias == "" {
continue
}
iType = strings.ToLower(version.TypeAlias)
if _, ok := knownTypes[iType]; ok {
assert.Failf(t, "mimir type duplicates Grafana plugin type", "plugin type %s", iType)
}
knownTypes[iType] = struct{}{}
}
}
}
func Test_getSecretFields(t *testing.T) {
testCases := []struct {
name string
parentPath string
options []NotifierOption
expectedFields []string
}{
{
name: "No secure fields",
parentPath: "",
options: []NotifierOption{
{PropertyName: "field1", Secure: false, SubformOptions: nil},
{PropertyName: "field2", Secure: false, SubformOptions: nil},
},
expectedFields: []string{},
},
{
name: "Single secure field",
parentPath: "",
options: []NotifierOption{
{PropertyName: "field1", Secure: true, SubformOptions: nil},
{PropertyName: "field2", Secure: false, SubformOptions: nil},
},
expectedFields: []string{"field1"},
},
{
name: "Secure field in subform",
parentPath: "parent",
options: []NotifierOption{
{PropertyName: "field1", Secure: true, SubformOptions: nil},
{PropertyName: "field2", Secure: false, SubformOptions: []NotifierOption{
{PropertyName: "subfield1", Secure: true, SubformOptions: nil},
}},
},
expectedFields: []string{"parent.field1", "parent.field2.subfield1"},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
got := getSecretFields(tc.parentPath, tc.options)
require.ElementsMatch(t, got, tc.expectedFields)
})
}
}
func TestV0IntegrationsSecrets(t *testing.T) {
// This test ensures that all known integrations' secrets are listed in the schema definition.
notifytest.ForEachIntegrationType(t, func(configType reflect.Type) {
t.Run(configType.Name(), func(t *testing.T) {
integrationType := strings.ToLower(strings.TrimSuffix(configType.Name(), "Config"))
pluginVersion, err := ConfigForIntegrationType(integrationType)
require.NoError(t, err)
if pluginVersion.Version == V1 {
var ok bool
pluginVersion, ok = pluginVersion.Plugin.GetVersion(V0mimir1)
require.True(t, ok)
}
expectedSecrets := pluginVersion.GetSecretFieldsPaths()
var secrets []string
for option := range maps.Keys(notifytest.ValidMimirHTTPConfigs) {
cfg, err := notifytest.GetMimirIntegrationForType(configType, option)
require.NoError(t, err)
data, err := json.Marshal(cfg)
require.NoError(t, err)
m := map[string]any{}
err = json.Unmarshal(data, &m)
require.NoError(t, err)
secrets = append(secrets, getSecrets(m, "")...)
}
secrets = unique(secrets)
t.Log(secrets)
require.ElementsMatch(t, expectedSecrets, secrets)
})
})
}
func unique(slice []string) []string {
keys := make(map[string]struct{}, len(slice))
list := make([]string, 0, len(slice))
for _, entry := range slice {
if _, value := keys[entry]; !value {
keys[entry] = struct{}{}
list = append(list, entry)
}
}
return list
}
func getSecrets(m map[string]any, parent string) []string {
var result []string
for key, val := range m {
str, ok := val.(string)
if ok && str == "<secret>" {
result = append(result, parent+key)
}
m, ok := val.(map[string]any)
if ok {
subSecrets := getSecrets(m, parent+key+".")
result = append(result, subSecrets...)
}
}
return result
}
@@ -1,122 +0,0 @@
package channels_config
// NotifierPlugin holds meta information about a notifier.
type NotifierPlugin struct {
Type string `json:"type"`
TypeAlias string `json:"typeAlias,omitempty"`
Name string `json:"name"`
Heading string `json:"heading"`
Description string `json:"description"`
Info string `json:"info"`
Options []NotifierOption `json:"options"`
}
// VersionedNotifierPlugin represents a notifier plugin with multiple versions and detailed configuration options.
// It includes metadata such as type, name, description, and version-specific details.
type VersionedNotifierPlugin struct {
Type string `json:"type"`
CurrentVersion NotifierVersion `json:"currentVersion"`
Name string `json:"name"`
Heading string `json:"heading,omitempty"`
Description string `json:"description,omitempty"`
Info string `json:"info,omitempty"`
Versions []NotifierPluginVersion `json:"versions"`
}
// GetVersion retrieves a specific version of the notifier plugin by its version string. Returns the version and a boolean indicating success.
func (p VersionedNotifierPlugin) GetVersion(v NotifierVersion) (NotifierPluginVersion, bool) {
for _, version := range p.Versions {
if version.Version == v {
return version, true
}
}
return NotifierPluginVersion{}, false
}
// GetCurrentVersion retrieves the current version of the notifier plugin based on the CurrentVersion property.
// Panics if the version specified in CurrentVersion is not found in the configured versions.
func (p VersionedNotifierPlugin) GetCurrentVersion() NotifierPluginVersion {
v, ok := p.GetVersion(p.CurrentVersion)
if !ok {
panic("version not found for current version: " + p.CurrentVersion)
}
return v
}
// NotifierPluginVersion represents a version of a notifier plugin, including configuration options and metadata.
type NotifierPluginVersion struct {
TypeAlias string `json:"typeAlias,omitempty"`
Version NotifierVersion `json:"version"`
CanCreate bool `json:"canCreate"`
Options []NotifierOption `json:"options"`
Info string `json:"info,omitempty"`
Plugin *VersionedNotifierPlugin `json:"-"`
}
// GetSecretFieldsPaths returns a list of paths for fields marked as secure within the NotifierPluginVersion's options.
func (v NotifierPluginVersion) GetSecretFieldsPaths() []string {
return getSecretFields("", v.Options)
}
// NotifierOption holds information about options specific for the NotifierPlugin.
type NotifierOption struct {
Element ElementType `json:"element"`
InputType InputType `json:"inputType"`
Label string `json:"label"`
Description string `json:"description"`
Placeholder string `json:"placeholder"`
PropertyName string `json:"propertyName"`
SelectOptions []SelectOption `json:"selectOptions"`
ShowWhen ShowWhen `json:"showWhen"`
Required bool `json:"required"`
ValidationRule string `json:"validationRule"`
Secure bool `json:"secure"`
DependsOn string `json:"dependsOn"`
SubformOptions []NotifierOption `json:"subformOptions"`
}
// ElementType is the type of element that can be rendered in the frontend.
type ElementType string
const (
// ElementTypeInput will render an input
ElementTypeInput = "input"
// ElementTypeSelect will render a select
ElementTypeSelect = "select"
// ElementTypeCheckbox will render a checkbox
ElementTypeCheckbox = "checkbox"
// ElementTypeTextArea will render a textarea
ElementTypeTextArea = "textarea"
// ElementTypeKeyValueMap will render inputs to add arbitrary key-value pairs
ElementTypeKeyValueMap = "key_value_map"
// ElementSubformArray will render a sub-form with schema defined in SubformOptions
ElementTypeSubform = "subform"
// ElementSubformArray will render a multiple sub-forms with schema defined in SubformOptions
ElementSubformArray = "subform_array"
// ElementStringArray will render a set of fields to manage an array of strings.
ElementStringArray = "string_array"
)
// InputType is the type of input that can be rendered in the frontend.
type InputType string
const (
// InputTypeText will render a text field in the frontend
InputTypeText = "text"
// InputTypePassword will render a password field in the frontend
InputTypePassword = "password"
)
// SelectOption is a simple type for Options that have dropdown options. Should be used when Element is ElementTypeSelect.
type SelectOption struct {
Value string `json:"value"`
Label string `json:"label"`
}
// ShowWhen holds information about when options are dependant on other options.
// Should be used when Element is ElementTypeSelect.
// Does not work for ElementTypeCheckbox.
type ShowWhen struct {
Field string `json:"field"`
Is string `json:"is"`
}
+64 -66
View File
@@ -8,10 +8,12 @@ import (
"fmt"
"strings"
alertingNotify "github.com/grafana/alerting/notify"
"github.com/grafana/alerting/receivers/schema"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/notifier/channels_config"
"github.com/grafana/grafana/pkg/services/ngalert/notifier/legacy_storage"
"github.com/grafana/grafana/pkg/services/ngalert/store"
"github.com/grafana/grafana/pkg/services/secrets"
@@ -87,81 +89,77 @@ func EncryptReceiverConfigSettings(c []*definitions.PostableApiReceiver, encrypt
func encryptReceiverConfigs(c []*definitions.PostableApiReceiver, encrypt definitions.EncryptFn, encryptExisting bool) error {
// encrypt secure settings for storing them in DB
for _, r := range c {
switch r.Type() {
case definitions.GrafanaReceiverType:
for _, gr := range r.GrafanaManagedReceivers {
if encryptExisting {
for k, v := range gr.SecureSettings {
encryptedData, err := encrypt(context.Background(), []byte(v))
for _, gr := range r.GrafanaManagedReceivers {
if encryptExisting {
for k, v := range gr.SecureSettings {
encryptedData, err := encrypt(context.Background(), []byte(v))
if err != nil {
return fmt.Errorf("failed to encrypt secure settings: %w", err)
}
gr.SecureSettings[k] = base64.StdEncoding.EncodeToString(encryptedData)
}
}
if len(gr.Settings) > 0 {
// We need to parse the settings to check for secret keys. If we find any, we encrypt them and
// store them in SecureSettings. This can happen from incorrect configuration or when an integration
// definition is updated to make a field secure.
settings := make(map[string]any)
if err := json.Unmarshal(gr.Settings, &settings); err != nil {
return fmt.Errorf("integration '%s' of receiver '%s' has settings that cannot be parsed as JSON: %w", gr.Type, gr.Name, err)
}
typeSchema, ok := alertingNotify.GetSchemaVersionForIntegration(schema.IntegrationType(gr.Type), schema.V1)
if !ok {
return fmt.Errorf("failed to get secret keys for contact point type %s", gr.Type)
}
secretKeys := typeSchema.GetSecretFieldsPaths()
secureSettings := gr.SecureSettings
if secureSettings == nil {
secureSettings = make(map[string]string)
}
settingsChanged := false
secureSettingsChanged := false
for _, secretKey := range secretKeys {
settingsValue, ok := settings[secretKey]
if !ok {
continue
}
// Secrets should not be stored in settings regardless.
delete(settings, secretKey)
settingsChanged = true
// If the secret is already encrypted, we don't need to encrypt it again.
if _, ok := secureSettings[secretKey]; ok {
continue
}
if strVal, isString := settingsValue.(string); isString {
encrypted, err := encrypt(context.Background(), []byte(strVal))
if err != nil {
return fmt.Errorf("failed to encrypt secure settings: %w", err)
}
gr.SecureSettings[k] = base64.StdEncoding.EncodeToString(encryptedData)
secureSettings[secretKey] = base64.StdEncoding.EncodeToString(encrypted)
secureSettingsChanged = true
}
}
if len(gr.Settings) > 0 {
// We need to parse the settings to check for secret keys. If we find any, we encrypt them and
// store them in SecureSettings. This can happen from incorrect configuration or when an integration
// definition is updated to make a field secure.
settings := make(map[string]any)
if err := json.Unmarshal(gr.Settings, &settings); err != nil {
return fmt.Errorf("integration '%s' of receiver '%s' has settings that cannot be parsed as JSON: %w", gr.Type, gr.Name, err)
}
secretKeys, err := channels_config.GetSecretKeysForContactPointType(gr.Type, channels_config.V1)
// Defensive checks to limit the risk of unintentional edge case changes in this legacy API.
if settingsChanged {
// If we removed any secret keys from settings, we need to save the updated settings.
jsonBytes, err := json.Marshal(settings)
if err != nil {
return fmt.Errorf("failed to get secret keys for contact point type %s: %w", gr.Type, err)
}
secureSettings := gr.SecureSettings
if secureSettings == nil {
secureSettings = make(map[string]string)
}
settingsChanged := false
secureSettingsChanged := false
for _, secretKey := range secretKeys {
settingsValue, ok := settings[secretKey]
if !ok {
continue
}
// Secrets should not be stored in settings regardless.
delete(settings, secretKey)
settingsChanged = true
// If the secret is already encrypted, we don't need to encrypt it again.
if _, ok := secureSettings[secretKey]; ok {
continue
}
if strVal, isString := settingsValue.(string); isString {
encrypted, err := encrypt(context.Background(), []byte(strVal))
if err != nil {
return fmt.Errorf("failed to encrypt secure settings: %w", err)
}
secureSettings[secretKey] = base64.StdEncoding.EncodeToString(encrypted)
secureSettingsChanged = true
}
}
// Defensive checks to limit the risk of unintentional edge case changes in this legacy API.
if settingsChanged {
// If we removed any secret keys from settings, we need to save the updated settings.
jsonBytes, err := json.Marshal(settings)
if err != nil {
return err
}
gr.Settings = jsonBytes
}
if secureSettingsChanged {
// If we added any secure settings, we need to save the updated secure settings.
gr.SecureSettings = secureSettings
return err
}
gr.Settings = jsonBytes
}
if secureSettingsChanged {
// If we added any secure settings, we need to save the updated secure settings.
gr.SecureSettings = secureSettings
}
}
default:
}
}
return nil
+1 -1
View File
@@ -7,7 +7,7 @@ import (
alertingImages "github.com/grafana/alerting/images"
"github.com/grafana/alerting/receivers"
alertingEmail "github.com/grafana/alerting/receivers/email"
alertingEmail "github.com/grafana/alerting/receivers/email/v1"
alertingTemplates "github.com/grafana/alerting/templates"
"github.com/prometheus/alertmanager/types"
"github.com/prometheus/common/model"
@@ -8,13 +8,14 @@ import (
"github.com/grafana/alerting/definition"
"github.com/grafana/alerting/notify"
"github.com/grafana/alerting/receivers/schema"
"github.com/grafana/alerting/receivers/webhook"
"github.com/prometheus/alertmanager/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/util"
)
func TestReceiverInUse(t *testing.T) {
@@ -91,8 +92,9 @@ func TestDeleteReceiver(t *testing.T) {
}
func TestCreateReceiver(t *testing.T) {
rawCfg := notify.AllKnownConfigsForTesting["webhook"]
cfgSchema, err := models.IntegrationConfigFromType(rawCfg.NotifierType, util.Pointer("v1"))
rawCfg := notify.AllKnownConfigsForTesting[string(webhook.Type)]
typeSchema, _ := notify.GetSchemaForIntegration(webhook.Type)
cfgSchema, err := models.IntegrationConfigFromSchema(typeSchema, schema.V1)
require.NoError(t, err)
settings := map[string]any{}
require.NoError(t, json.Unmarshal([]byte(rawCfg.Config), &settings))
@@ -197,8 +199,9 @@ func TestCreateReceiver(t *testing.T) {
}
func TestUpdateReceiver(t *testing.T) {
rawCfg := notify.AllKnownConfigsForTesting["webhook"]
cfgSchema, err := models.IntegrationConfigFromType(rawCfg.NotifierType, util.Pointer("v1"))
rawCfg := notify.AllKnownConfigsForTesting[string(webhook.Type)]
typeSchema, _ := notify.GetSchemaForIntegration(webhook.Type)
cfgSchema, err := models.IntegrationConfigFromSchema(typeSchema, schema.V1)
require.NoError(t, err)
settings := map[string]any{}
require.NoError(t, json.Unmarshal([]byte(rawCfg.Config), &settings))
@@ -297,8 +300,9 @@ func TestUpdateReceiver(t *testing.T) {
}
func TestGetReceiver(t *testing.T) {
rawCfg := notify.AllKnownConfigsForTesting["webhook"]
cfgSchema, err := models.IntegrationConfigFromType(rawCfg.NotifierType, util.Pointer("v1"))
rawCfg := notify.AllKnownConfigsForTesting[string(webhook.Type)]
typeSchema, _ := notify.GetSchemaForIntegration(webhook.Type)
cfgSchema, err := models.IntegrationConfigFromSchema(typeSchema, schema.V1)
require.NoError(t, err)
settings := map[string]any{}
require.NoError(t, json.Unmarshal([]byte(rawCfg.Config), &settings))
@@ -9,6 +9,7 @@ import (
"strings"
alertingNotify "github.com/grafana/alerting/notify"
"github.com/grafana/alerting/receivers/schema"
"github.com/prometheus/alertmanager/config"
"github.com/grafana/grafana/pkg/apimachinery/identity"
@@ -17,7 +18,6 @@ import (
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/notifier/channels_config"
"github.com/grafana/grafana/pkg/services/ngalert/notifier/legacy_storage"
"github.com/grafana/grafana/pkg/services/ngalert/store"
"github.com/grafana/grafana/pkg/services/secrets"
@@ -247,11 +247,11 @@ func (ecp *ContactPointService) UpdateContactPoint(ctx context.Context, orgID in
if err != nil {
return err
}
secretKeys, err := channels_config.GetSecretKeysForContactPointType(contactPoint.Type, channels_config.V1)
if err != nil {
return fmt.Errorf("%w: %s", ErrValidation, err.Error())
typeSchema, ok := alertingNotify.GetSchemaVersionForIntegration(schema.IntegrationType(contactPoint.Type), schema.V1)
if !ok {
return fmt.Errorf("%w: failed to get secret keys for contact point type %s", ErrValidation, contactPoint.Type)
}
for _, secretKey := range secretKeys {
for _, secretKey := range typeSchema.GetSecretFieldsPaths() {
secretValue := contactPoint.Settings.Get(secretKey).MustString()
if secretValue == apimodels.RedactedValue {
contactPoint.Settings.Set(secretKey, rawContactPoint.Settings.Get(secretKey).MustString())
@@ -522,11 +522,11 @@ func ValidateContactPoint(ctx context.Context, e apimodels.EmbeddedContactPoint,
// RemoveSecretsForContactPoint removes all secrets from the contact point's settings and returns them as a map. Returns error if contact point type is not known.
func RemoveSecretsForContactPoint(e *apimodels.EmbeddedContactPoint) (map[string]string, error) {
s := map[string]string{}
secretKeys, err := channels_config.GetSecretKeysForContactPointType(e.Type, channels_config.V1)
if err != nil {
return nil, err
typeSchema, ok := alertingNotify.GetSchemaVersionForIntegration(schema.IntegrationType(e.Type), schema.V1)
if !ok {
return nil, fmt.Errorf("failed to get secret keys for contact point type %s", e.Type)
}
for _, secretKey := range secretKeys {
for _, secretKey := range typeSchema.GetSecretFieldsPaths() {
secretValue, err := extractCaseInsensitive(e.Settings, secretKey)
if err != nil {
return nil, err
@@ -9,6 +9,7 @@ import (
"testing"
"github.com/grafana/alerting/notify"
"github.com/grafana/alerting/receivers/schema"
"github.com/prometheus/alertmanager/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -25,7 +26,6 @@ import (
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/notifier"
"github.com/grafana/grafana/pkg/services/ngalert/notifier/channels_config"
"github.com/grafana/grafana/pkg/services/ngalert/notifier/legacy_storage"
"github.com/grafana/grafana/pkg/services/ngalert/tests/fakes"
"github.com/grafana/grafana/pkg/services/secrets"
@@ -441,15 +441,14 @@ func TestRemoveSecretsForContactPoint(t *testing.T) {
keys := maps.Keys(configs)
slices.Sort(keys)
for _, integrationType := range keys {
integration := models.IntegrationGen(models.IntegrationMuts.WithValidConfig(integrationType))()
integration := models.IntegrationGen(models.IntegrationMuts.WithValidConfig(schema.IntegrationType(integrationType)))()
if f, ok := overrides[integrationType]; ok {
f(integration.Settings)
}
settingsRaw, err := json.Marshal(integration.Settings)
require.NoError(t, err)
expectedFields, err := channels_config.GetSecretKeysForContactPointType(integrationType, channels_config.V1)
require.NoError(t, err)
typeSchema, _ := notify.GetSchemaVersionForIntegration(schema.IntegrationType(integrationType), schema.V1)
expectedFields := typeSchema.GetSecretFieldsPaths()
t.Run(integrationType, func(t *testing.T) {
cp := definitions.EmbeddedContactPoint{
@@ -146,4 +146,8 @@ func addDataSourceMigration(mg *Migrator) {
mg.AddMigration("Update secure_json_data column to MediumText", NewRawSQLMigration("").
Mysql("ALTER TABLE data_source MODIFY COLUMN secure_json_data MEDIUMTEXT;"),
)
mg.AddMigration("Update json_data column to MediumText", NewRawSQLMigration("").
Mysql("ALTER TABLE data_source MODIFY COLUMN json_data MEDIUMTEXT;"),
)
}
+14
View File
@@ -20,6 +20,7 @@ import (
"github.com/grafana/dskit/grpcclient"
"github.com/grafana/dskit/middleware"
"github.com/grafana/dskit/services"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
infraDB "github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/tracing"
secrets "github.com/grafana/grafana/pkg/registry/apis/secret/contracts"
@@ -66,10 +67,23 @@ func ProvideUnifiedStorageClient(opts *Options,
BlobThresholdBytes: apiserverCfg.Key("blob_threshold_bytes").MustInt(options.BlobThresholdDefault),
}, opts.Cfg, opts.Features, opts.DB, opts.Tracer, opts.Reg, opts.Authzc, opts.Docs, storageMetrics, indexMetrics, opts.SecureValues)
if err == nil {
// Decide whether to disable SQL fallback stats per resource in Mode 5.
// Otherwise we would still try to query the legacy SQL database in Mode 5.
var disableDashboardsFallback, disableFoldersFallback bool
if opts.Cfg != nil {
// String are static here, so we don't need to import the packages.
foldersMode := opts.Cfg.UnifiedStorage["folders.folder.grafana.app"].DualWriterMode
disableFoldersFallback = foldersMode == grafanarest.Mode5
dashboardsMode := opts.Cfg.UnifiedStorage["dashboards.dashboard.grafana.app"].DualWriterMode
disableDashboardsFallback = dashboardsMode == grafanarest.Mode5
}
// Used to get the folder stats
client = federated.NewFederatedClient(
client, // The original
legacysql.NewDatabaseProvider(opts.DB),
disableDashboardsFallback,
disableFoldersFallback,
)
}
+4 -2
View File
@@ -10,11 +10,13 @@ import (
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
)
func NewFederatedClient(base resource.ResourceClient, sql legacysql.LegacyDatabaseProvider) resource.ResourceClient {
func NewFederatedClient(base resource.ResourceClient, sql legacysql.LegacyDatabaseProvider, disableDashboardsFallback bool, disableFoldersFallback bool) resource.ResourceClient {
return &federatedClient{
ResourceClient: base,
stats: &LegacyStatsGetter{
SQL: sql,
SQL: sql,
DisableSQLFallbackDashboards: disableDashboardsFallback,
DisableSQLFallbackFolders: disableFoldersFallback,
},
}
}
+13 -7
View File
@@ -13,7 +13,9 @@ import (
// Read stats from legacy SQL
type LegacyStatsGetter struct {
SQL legacysql.LegacyDatabaseProvider
SQL legacysql.LegacyDatabaseProvider
DisableSQLFallbackDashboards bool
DisableSQLFallbackFolders bool
}
func (s *LegacyStatsGetter) GetStats(ctx context.Context, in *resourcepb.ResourceStatsRequest) (*resourcepb.ResourceStatsResponse, error) {
@@ -64,15 +66,19 @@ func (s *LegacyStatsGetter) GetStats(ctx context.Context, in *resourcepb.Resourc
}
// Legacy dashboard table
err = fn("dashboard", "org_id=? AND folder_uid=? AND is_folder=false", group, "dashboards", true)
if err != nil {
return err
if !s.DisableSQLFallbackDashboards {
err = fn("dashboard", "org_id=? AND folder_uid=? AND is_folder=false", group, "dashboards", true)
if err != nil {
return err
}
}
// Legacy folder table
err = fn("folder", "org_id=? AND parent_uid=?", group, "folders", true)
if err != nil {
return err
if !s.DisableSQLFallbackFolders {
err = fn("folder", "org_id=? AND parent_uid=?", group, "folders", true)
if err != nil {
return err
}
}
// Legacy library_elements table
@@ -1,4 +1,4 @@
package federatedtests
package federated
import (
"context"
@@ -22,7 +22,6 @@ import (
"github.com/grafana/grafana/pkg/services/tag/tagimpl"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/storage/legacysql"
"github.com/grafana/grafana/pkg/storage/unified/federated"
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
"github.com/grafana/grafana/pkg/tests/testsuite"
"github.com/grafana/grafana/pkg/util/testutil"
@@ -32,7 +31,6 @@ func TestMain(m *testing.M) {
testsuite.Run(m)
}
// tests stats are correctly reported from legacy tables
func TestIntegrationDirectSQLStats(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
@@ -44,7 +42,6 @@ func TestIntegrationDirectSQLStats(t *testing.T) {
fStore := folderimpl.ProvideStore(db)
tempUser := &user.SignedInUser{UserID: 1, OrgID: 1, Permissions: map[int64]map[string][]string{}}
// legacy expects the folder to be in both the dashboards and folder tables
folder1UID := "test1"
now := time.Now()
dashFolder1 := dashboards.NewDashboardFolder("test1")
@@ -80,7 +77,6 @@ func TestIntegrationDirectSQLStats(t *testing.T) {
_, err = fStore.Create(ctx, folder.CreateFolderCommand{Title: "test2", UID: folder2UID, OrgID: 1, ParentUID: folder1UID, SignedInUser: tempUser})
require.NoError(t, err)
// create an alert rule inside of folder test2
ruleStore := ngalertstore.SetupStoreForTesting(t, db)
_, err = ruleStore.InsertAlertRules(context.Background(), ngmodels.NewUserUID(tempUser), []ngmodels.AlertRule{
{
@@ -108,7 +104,6 @@ func TestIntegrationDirectSQLStats(t *testing.T) {
}})
require.NoError(t, err)
// finally, create dashboard inside of test1
_, err = dashStore.SaveDashboard(ctx, dashboards.SaveDashboardCommand{
Dashboard: simplejson.New(),
FolderUID: folder1UID,
@@ -116,7 +111,7 @@ func TestIntegrationDirectSQLStats(t *testing.T) {
})
require.NoError(t, err)
store := &federated.LegacyStatsGetter{
store := &LegacyStatsGetter{
SQL: legacysql.NewDatabaseProvider(db),
}
@@ -153,6 +148,105 @@ func TestIntegrationDirectSQLStats(t *testing.T) {
]`, string(jj))
})
// New tests to verify per-resource fallback disabling
t.Run("GetStatsForFolder1_DisableDashboardsFallback", func(t *testing.T) {
ctx := context.Background()
ctx = request.WithNamespace(ctx, "default")
store := &LegacyStatsGetter{
SQL: legacysql.NewDatabaseProvider(db),
DisableSQLFallbackDashboards: true,
DisableSQLFallbackFolders: false,
}
stats, err := store.GetStats(ctx, &resourcepb.ResourceStatsRequest{
Namespace: "default",
Folder: folder1UID,
})
require.NoError(t, err)
var hasDashboards, hasFolders bool
for _, s := range stats.Stats {
if s.Resource == "dashboards" {
hasDashboards = true
}
if s.Resource == "folders" {
hasFolders = true
require.EqualValues(t, 1, s.Count)
}
}
require.False(t, hasDashboards, "dashboards stats should be disabled")
require.True(t, hasFolders, "folders stats should be present")
})
t.Run("GetStatsForFolder1_DisableFoldersFallback", func(t *testing.T) {
ctx := context.Background()
ctx = request.WithNamespace(ctx, "default")
store := &LegacyStatsGetter{
SQL: legacysql.NewDatabaseProvider(db),
DisableSQLFallbackDashboards: false,
DisableSQLFallbackFolders: true,
}
stats, err := store.GetStats(ctx, &resourcepb.ResourceStatsRequest{
Namespace: "default",
Folder: folder1UID,
})
require.NoError(t, err)
var hasDashboards, hasFolders bool
for _, s := range stats.Stats {
if s.Resource == "dashboards" {
hasDashboards = true
require.EqualValues(t, 1, s.Count)
}
if s.Resource == "folders" {
hasFolders = true
}
}
require.True(t, hasDashboards, "dashboards stats should be present")
require.False(t, hasFolders, "folders stats should be disabled")
})
t.Run("GetStatsForFolder1_DisableBothFallbacks", func(t *testing.T) {
ctx := context.Background()
ctx = request.WithNamespace(ctx, "default")
store := &LegacyStatsGetter{
SQL: legacysql.NewDatabaseProvider(db),
DisableSQLFallbackDashboards: true,
DisableSQLFallbackFolders: true,
}
stats, err := store.GetStats(ctx, &resourcepb.ResourceStatsRequest{
Namespace: "default",
Folder: folder1UID,
})
require.NoError(t, err)
var hasDashboards, hasFolders bool
var hasAlertRules, hasLibrary bool
for _, s := range stats.Stats {
if s.Resource == "dashboards" {
hasDashboards = true
}
if s.Resource == "folders" {
hasFolders = true
}
if s.Resource == "alertrules" {
hasAlertRules = true
}
if s.Resource == "library_elements" {
hasLibrary = true
}
}
require.False(t, hasDashboards, "dashboards stats should be disabled")
require.False(t, hasFolders, "folders stats should be disabled")
require.True(t, hasAlertRules, "alertrules should still be present")
require.True(t, hasLibrary, "library_elements should still be present")
})
t.Run("GetStatsForFolder2", func(t *testing.T) {
ctx := context.Background()
ctx = request.WithNamespace(ctx, "default")
@@ -178,7 +272,7 @@ func TestIntegrationDirectSQLStats(t *testing.T) {
"group": "sql-fallback",
"resource": "folders"
},
{
{
"group": "sql-fallback",
"resource": "library_elements"
}
@@ -13,7 +13,6 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/services/ngalert/notifier/channels_config"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/tests/testinfra"
@@ -54,10 +53,17 @@ func TestIntegrationAvailableChannels(t *testing.T) {
require.NoError(t, err)
require.Equal(t, 200, resp.StatusCode)
expNotifiers := channels_config.GetAvailableNotifiers()
expJson, err := json.Marshal(expNotifiers)
expectedBytes, err := os.ReadFile(path.Join("test-data", "alert-notifiers-v1-snapshot.json"))
require.NoError(t, err)
require.Equal(t, string(expJson), string(b))
require.NoError(t, err)
if !assert.JSONEq(t, string(expectedBytes), string(b)) {
var prettyJSON bytes.Buffer
err := json.Indent(&prettyJSON, b, "", " ")
require.NoError(t, err)
err = os.WriteFile(path.Join("test-data", "alert-notifiers-v1-snapshot.json"), prettyJSON.Bytes(), 0o644)
require.NoError(t, err)
}
})
t.Run("should return versioned notifiers", func(t *testing.T) {
@@ -17,11 +17,11 @@ import (
"time"
"github.com/grafana/alerting/receivers"
alertingLine "github.com/grafana/alerting/receivers/line"
alertingPushover "github.com/grafana/alerting/receivers/pushover"
alertingSlack "github.com/grafana/alerting/receivers/slack"
alertingTelegram "github.com/grafana/alerting/receivers/telegram"
alertingThreema "github.com/grafana/alerting/receivers/threema"
alertingLine "github.com/grafana/alerting/receivers/line/v1"
alertingPushover "github.com/grafana/alerting/receivers/pushover/v1"
alertingSlack "github.com/grafana/alerting/receivers/slack/v1"
alertingTelegram "github.com/grafana/alerting/receivers/telegram/v1"
alertingThreema "github.com/grafana/alerting/receivers/threema/v1"
alertingTemplates "github.com/grafana/alerting/templates"
"github.com/prometheus/alertmanager/template"
"github.com/prometheus/common/model"
File diff suppressed because it is too large Load Diff
@@ -7,6 +7,7 @@
"description": "Send notifications to LINE notify",
"versions": [
{
"typeAlias": "line",
"version": "v1",
"canCreate": true,
"options": [
@@ -8143,7 +8144,7 @@
"type": "sns",
"currentVersion": "v1",
"name": "AWS SNS",
"heading": "Webex settings",
"heading": "AWS SNS settings",
"description": "Sends notifications to AWS Simple Notification Service",
"versions": [
{
@@ -13,6 +13,7 @@ import (
"strings"
"testing"
"github.com/grafana/alerting/receivers/schema"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/api/errors"
@@ -37,7 +38,6 @@ import (
"github.com/grafana/grafana/pkg/services/ngalert/api"
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/notifier/channels_config"
"github.com/grafana/grafana/pkg/services/ngalert/store"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/tests/api/alerting"
@@ -1311,12 +1311,14 @@ func TestIntegrationCRUD(t *testing.T) {
require.Equal(t, receiver, get)
t.Run("should return secrets in secureFields but not settings", func(t *testing.T) {
for _, integration := range get.Spec.Integrations {
integrationType := schema.IntegrationType(integration.Type)
t.Run(integration.Type, func(t *testing.T) {
expected := notify.AllKnownConfigsForTesting[strings.ToLower(integration.Type)]
var fields map[string]any
require.NoError(t, json.Unmarshal([]byte(expected.Config), &fields))
secretFields, err := channels_config.GetSecretKeysForContactPointType(integration.Type, channels_config.V1)
require.NoError(t, err)
typeSchema, ok := notify.GetSchemaVersionForIntegration(integrationType, schema.V1)
require.True(t, ok)
secretFields := typeSchema.GetSecretFieldsPaths()
for _, field := range secretFields {
if _, ok := fields[field]; !ok { // skip field that is not in the original setting
continue
@@ -329,8 +329,13 @@ func parseNetworkAddress(url string, logger log.Logger) (string, int, error) {
}
func buildBaseConnectionString(params connectionParams) string {
connStr := fmt.Sprintf("user='%s' password='%s' host='%s' dbname='%s'",
escape(params.user), escape(params.password), escape(params.host), escape(params.database))
connStr := fmt.Sprintf("user='%s' host='%s' dbname='%s'",
escape(params.user), escape(params.host), escape(params.database))
if params.password != "" {
connStr += fmt.Sprintf(" password='%s'", escape(params.password))
}
if params.port > 0 {
connStr += fmt.Sprintf(" port=%d", params.port)
}
@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"math/rand"
"os"
"strings"
"testing"
"time"
@@ -41,7 +42,7 @@ func TestIntegrationGenerateConnectionStringPGX(t *testing.T) {
password: "password",
database: "database",
tlsSettings: tlsSettings{Mode: "verify-full"},
expConnStr: "user='user' password='password' host='/var/run/postgresql' dbname='database' sslmode='verify-full'",
expConnStr: "user='user' host='/var/run/postgresql' dbname='database' password='password' sslmode='verify-full'",
},
{
desc: "TCP host",
@@ -50,7 +51,7 @@ func TestIntegrationGenerateConnectionStringPGX(t *testing.T) {
password: "password",
database: "database",
tlsSettings: tlsSettings{Mode: "verify-full"},
expConnStr: "user='user' password='password' host='host' dbname='database' sslmode='verify-full'",
expConnStr: "user='user' host='host' dbname='database' password='password' sslmode='verify-full'",
},
{
desc: "verify-ca automatically adds disable-sni",
@@ -59,7 +60,7 @@ func TestIntegrationGenerateConnectionStringPGX(t *testing.T) {
password: "password",
database: "database",
tlsSettings: tlsSettings{Mode: "verify-ca"},
expConnStr: "user='user' password='password' host='host' dbname='database' port=1234 sslmode='verify-ca' sslsni=0",
expConnStr: "user='user' host='host' dbname='database' password='password' port=1234 sslmode='verify-ca' sslsni=0",
},
{
desc: "TCP/port host",
@@ -68,7 +69,7 @@ func TestIntegrationGenerateConnectionStringPGX(t *testing.T) {
password: "password",
database: "database",
tlsSettings: tlsSettings{Mode: "verify-full"},
expConnStr: "user='user' password='password' host='host' dbname='database' port=1234 sslmode='verify-full'",
expConnStr: "user='user' host='host' dbname='database' password='password' port=1234 sslmode='verify-full'",
},
{
desc: "Ipv6 host",
@@ -77,7 +78,7 @@ func TestIntegrationGenerateConnectionStringPGX(t *testing.T) {
password: "password",
database: "database",
tlsSettings: tlsSettings{Mode: "verify-full"},
expConnStr: "user='user' password='password' host='::1' dbname='database' sslmode='verify-full'",
expConnStr: "user='user' host='::1' dbname='database' password='password' sslmode='verify-full'",
},
{
desc: "Ipv6/port host",
@@ -86,7 +87,7 @@ func TestIntegrationGenerateConnectionStringPGX(t *testing.T) {
password: "password",
database: "database",
tlsSettings: tlsSettings{Mode: "verify-full"},
expConnStr: "user='user' password='password' host='::1' dbname='database' port=1234 sslmode='verify-full'",
expConnStr: "user='user' host='::1' dbname='database' password='password' port=1234 sslmode='verify-full'",
},
{
desc: "Invalid port",
@@ -103,7 +104,7 @@ func TestIntegrationGenerateConnectionStringPGX(t *testing.T) {
password: `p'\assword`,
database: "database",
tlsSettings: tlsSettings{Mode: "verify-full"},
expConnStr: `user='user' password='p\'\\assword' host='host' dbname='database' sslmode='verify-full'`,
expConnStr: `user='user' host='host' dbname='database' password='p\'\\assword' sslmode='verify-full'`,
},
{
desc: "User/DB with single quote and backslash",
@@ -112,7 +113,7 @@ func TestIntegrationGenerateConnectionStringPGX(t *testing.T) {
password: `password`,
database: `d'\atabase`,
tlsSettings: tlsSettings{Mode: "verify-full"},
expConnStr: `user='u\'\\ser' password='password' host='host' dbname='d\'\\atabase' sslmode='verify-full'`,
expConnStr: `user='u\'\\ser' host='host' dbname='d\'\\atabase' password='password' sslmode='verify-full'`,
},
{
desc: "Custom TLS mode disabled",
@@ -121,7 +122,7 @@ func TestIntegrationGenerateConnectionStringPGX(t *testing.T) {
password: "password",
database: "database",
tlsSettings: tlsSettings{Mode: "disable"},
expConnStr: "user='user' password='password' host='host' dbname='database' sslmode='disable'",
expConnStr: "user='user' host='host' dbname='database' password='password' sslmode='disable'",
},
{
desc: "Custom TLS mode verify-full with certificate files",
@@ -135,9 +136,18 @@ func TestIntegrationGenerateConnectionStringPGX(t *testing.T) {
CertFile: "i/am/coding/client.crt",
CertKeyFile: "i/am/coding/client.key",
},
expConnStr: "user='user' password='password' host='host' dbname='database' sslmode='verify-full' " +
expConnStr: "user='user' host='host' dbname='database' password='password' sslmode='verify-full' " +
"sslrootcert='i/am/coding/ca.crt' sslcert='i/am/coding/client.crt' sslkey='i/am/coding/client.key'",
},
{
desc: "No password",
host: "host",
user: "user",
password: "",
database: "database",
tlsSettings: tlsSettings{Mode: "verify-full"},
expConnStr: "user='user' host='host' dbname='database' sslmode='verify-full'",
},
}
for _, tt := range testCases {
t.Run(tt.desc, func(t *testing.T) {
@@ -1555,4 +1565,18 @@ func TestIntegrationPostgresPGX(t *testing.T) {
require.Equal(t, "updated", *nameValue)
})
})
t.Run("Test Postgres connection with pgpass file", func(t *testing.T) {
require.NoError(t, preparePgpassFile(t))
require.FileExists(t, os.Getenv("PGPASSFILE"), "Make sure that PGPASSFILE is set and file exists")
cnnstr := postgresTestDBConnString()
require.NotContains(t, cnnstr, "password=", "Make sure that password is not in the connection string")
pgpassPool, _, err := newPostgresPGX(t.Context(), "error", 10000, dsInfo, cnnstr, logger, backend.DataSourceInstanceSettings{})
require.NoError(t, err)
_, err = pgpassPool.Query(t.Context(), "SELECT 1") // Test connection
require.NoError(t, err)
})
}
@@ -5,6 +5,7 @@ import (
"fmt"
"math/rand"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -46,7 +47,7 @@ func TestIntegrationGenerateConnectionString(t *testing.T) {
password: "password",
database: "database",
tlsSettings: tlsSettings{Mode: "verify-full"},
expConnStr: "user='user' password='password' host='/var/run/postgresql' dbname='database' sslmode='verify-full'",
expConnStr: "user='user' host='/var/run/postgresql' dbname='database' password='password' sslmode='verify-full'",
},
{
desc: "TCP host",
@@ -55,7 +56,7 @@ func TestIntegrationGenerateConnectionString(t *testing.T) {
password: "password",
database: "database",
tlsSettings: tlsSettings{Mode: "verify-full"},
expConnStr: "user='user' password='password' host='host' dbname='database' sslmode='verify-full'",
expConnStr: "user='user' host='host' dbname='database' password='password' sslmode='verify-full'",
},
{
desc: "verify-ca automatically adds disable-sni",
@@ -64,7 +65,7 @@ func TestIntegrationGenerateConnectionString(t *testing.T) {
password: "password",
database: "database",
tlsSettings: tlsSettings{Mode: "verify-ca"},
expConnStr: "user='user' password='password' host='host' dbname='database' port=1234 sslmode='verify-ca' sslsni=0",
expConnStr: "user='user' host='host' dbname='database' password='password' port=1234 sslmode='verify-ca' sslsni=0",
},
{
desc: "TCP/port host",
@@ -73,7 +74,7 @@ func TestIntegrationGenerateConnectionString(t *testing.T) {
password: "password",
database: "database",
tlsSettings: tlsSettings{Mode: "verify-full"},
expConnStr: "user='user' password='password' host='host' dbname='database' port=1234 sslmode='verify-full'",
expConnStr: "user='user' host='host' dbname='database' password='password' port=1234 sslmode='verify-full'",
},
{
desc: "Ipv6 host",
@@ -82,7 +83,7 @@ func TestIntegrationGenerateConnectionString(t *testing.T) {
password: "password",
database: "database",
tlsSettings: tlsSettings{Mode: "verify-full"},
expConnStr: "user='user' password='password' host='::1' dbname='database' sslmode='verify-full'",
expConnStr: "user='user' host='::1' dbname='database' password='password' sslmode='verify-full'",
},
{
desc: "Ipv6/port host",
@@ -91,7 +92,7 @@ func TestIntegrationGenerateConnectionString(t *testing.T) {
password: "password",
database: "database",
tlsSettings: tlsSettings{Mode: "verify-full"},
expConnStr: "user='user' password='password' host='::1' dbname='database' port=1234 sslmode='verify-full'",
expConnStr: "user='user' host='::1' dbname='database' password='password' port=1234 sslmode='verify-full'",
},
{
desc: "Invalid port",
@@ -108,7 +109,7 @@ func TestIntegrationGenerateConnectionString(t *testing.T) {
password: `p'\assword`,
database: "database",
tlsSettings: tlsSettings{Mode: "verify-full"},
expConnStr: `user='user' password='p\'\\assword' host='host' dbname='database' sslmode='verify-full'`,
expConnStr: `user='user' host='host' dbname='database' password='p\'\\assword' sslmode='verify-full'`,
},
{
desc: "User/DB with single quote and backslash",
@@ -117,7 +118,7 @@ func TestIntegrationGenerateConnectionString(t *testing.T) {
password: `password`,
database: `d'\atabase`,
tlsSettings: tlsSettings{Mode: "verify-full"},
expConnStr: `user='u\'\\ser' password='password' host='host' dbname='d\'\\atabase' sslmode='verify-full'`,
expConnStr: `user='u\'\\ser' host='host' dbname='d\'\\atabase' password='password' sslmode='verify-full'`,
},
{
desc: "Custom TLS mode disabled",
@@ -126,7 +127,7 @@ func TestIntegrationGenerateConnectionString(t *testing.T) {
password: "password",
database: "database",
tlsSettings: tlsSettings{Mode: "disable"},
expConnStr: "user='user' password='password' host='host' dbname='database' sslmode='disable'",
expConnStr: "user='user' host='host' dbname='database' password='password' sslmode='disable'",
},
{
desc: "Custom TLS mode verify-full with certificate files",
@@ -140,9 +141,18 @@ func TestIntegrationGenerateConnectionString(t *testing.T) {
CertFile: "i/am/coding/client.crt",
CertKeyFile: "i/am/coding/client.key",
},
expConnStr: "user='user' password='password' host='host' dbname='database' sslmode='verify-full' " +
expConnStr: "user='user' host='host' dbname='database' password='password' sslmode='verify-full' " +
"sslrootcert='i/am/coding/ca.crt' sslcert='i/am/coding/client.crt' sslkey='i/am/coding/client.key'",
},
{
desc: "No password",
host: "host",
user: "user",
password: "",
database: "database",
tlsSettings: tlsSettings{Mode: "verify-full"},
expConnStr: "user='user' host='host' dbname='database' sslmode='verify-full'",
},
}
for _, tt := range testCases {
t.Run(tt.desc, func(t *testing.T) {
@@ -1370,6 +1380,20 @@ func TestIntegrationPostgres(t *testing.T) {
require.Empty(t, frames[0].Fields)
})
})
t.Run("Test Postgres connection with pgpass file", func(t *testing.T) {
require.NoError(t, preparePgpassFile(t))
require.FileExists(t, os.Getenv("PGPASSFILE"), "Make sure that PGPASSFILE is set and file exists")
cnnstr := postgresTestDBConnString()
require.NotContains(t, cnnstr, "password=", "Make sure that password is not in the connection string")
dbPgpass, _, err := newPostgres(context.Background(), "error", 10000, dsInfo, cnnstr, logger, backend.DataSourceInstanceSettings{})
require.NoError(t, err)
_, err = dbPgpass.Exec("SELECT 1") // Test connection
require.NoError(t, err)
})
}
func genTimeRangeByInterval(from time.Time, duration time.Duration, interval time.Duration) []time.Time {
@@ -1401,6 +1425,23 @@ func isTestDbPostgres() bool {
return false
}
func preparePgpassFile(t *testing.T) error {
dir := t.TempDir()
t.Setenv("PGPASSFILE", filepath.Join(dir, ".pgpass"))
host := os.Getenv("POSTGRES_HOST")
if host == "" {
host = "localhost"
}
port := os.Getenv("POSTGRES_PORT")
if port == "" {
port = "5432"
}
return os.WriteFile(filepath.Join(dir, ".pgpass"),
[]byte(fmt.Sprintf("%s:%s:grafanadstest:grafanatest:grafanatest", host, port)), 0600)
}
func postgresTestDBConnString() string {
host := os.Getenv("POSTGRES_HOST")
if host == "" {
@@ -1410,6 +1451,13 @@ func postgresTestDBConnString() string {
if port == "" {
port = "5432"
}
return fmt.Sprintf("user=grafanatest password=grafanatest host=%s port=%s dbname=grafanadstest sslmode=disable",
connStr := fmt.Sprintf("user=grafanatest host=%s port=%s dbname=grafanadstest sslmode=disable",
host, port)
if os.Getenv("PGPASSFILE") == "" {
connStr += " password=grafanatest"
}
return connStr
}
+7
View File
@@ -162,6 +162,13 @@ func callResource(ctx context.Context, req *backend.CallResourceRequest, sender
respHeaders := map[string][]string{
"content-type": {"application/json"},
}
// frontend sets the X-Grafana-Cache with the desired response cache control value
if len(req.GetHTTPHeaders().Get("X-Grafana-Cache")) > 0 {
respHeaders["X-Grafana-Cache"] = []string{"y"}
respHeaders["Cache-Control"] = []string{req.GetHTTPHeaders().Get("X-Grafana-Cache")}
}
if rawLokiResponse.Encoding != "" {
respHeaders["content-encoding"] = []string{rawLokiResponse.Encoding}
}
@@ -0,0 +1,53 @@
import { memo } from 'react';
import Skeleton from 'react-loading-skeleton';
import { Box, Text } from '@grafana/ui';
import { useGetFolderQueryFacade } from '../../../api/clients/folder/v1beta1/hooks';
import { DashboardsTreeItem } from '../../../features/browse-dashboards/types';
interface ParentTextProps {
folder: string;
}
function ParentText({ folder }: ParentTextProps) {
return (
<Box marginLeft={1}>
<Text variant={'bodySmall'} color={'secondary'} truncate>
/{folder}
</Text>
</Box>
);
}
export const FolderParent = memo(function FolderParent({ item }: { item: DashboardsTreeItem }) {
if (item.item.kind !== 'folder') {
return null;
}
if (item.item.parentTitle) {
return <ParentText folder={item.item.parentTitle} />;
}
const parentUID = item.item.parentUID || item.parentUID;
if (parentUID) {
return <NetworkFolderParent uid={parentUID} />;
}
return null;
});
function NetworkFolderParent({ uid }: { uid: string }) {
const result = useGetFolderQueryFacade(uid);
if (result.isLoading) {
return <Skeleton width={50} />;
}
if (result.data) {
return <ParentText folder={result.data.title} />;
}
return null;
}
@@ -14,6 +14,7 @@ import { DashboardsTreeItem } from 'app/features/browse-dashboards/types';
import { DashboardViewItem } from 'app/features/search/types';
import { useSelector } from 'app/types/store';
import { FolderParent } from './FolderParent';
import { FolderRepo } from './FolderRepo';
const ROW_HEIGHT = 40;
@@ -216,6 +217,7 @@ function Row({ index, style: virtualStyles, data }: RowProps) {
<Text truncate>{item.title}</Text>
<FolderRepo folder={item} />
</label>
<FolderParent item={items[index]} />
</div>
</div>
);
@@ -282,7 +284,6 @@ const getStyles = (theme: GrafanaTheme2) => {
alignItems: 'center',
gap: theme.spacing(1),
lineHeight: ROW_HEIGHT + 'px',
flexGrow: 1,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
@@ -234,6 +234,7 @@ export function NestedFolderPicker({
title: item.title,
uid: item.uid,
parentUID: item.parentUID,
parentTitle: item.parentTitle,
},
})) ?? [];
}
@@ -31,7 +31,7 @@ export function useScopeServicesState() {
},
};
}
const { updateNode, selectScope, resetSelection, searchAllNodes, deselectScope, apply } =
const { updateNode, filterNode, selectScope, resetSelection, searchAllNodes, deselectScope, apply } =
services.scopesSelectorService;
const selectorServiceState: ScopesSelectorServiceState | undefined = useObservable(
services.scopesSelectorService.stateObservable ?? new Observable(),
@@ -39,6 +39,7 @@ export function useScopeServicesState() {
);
return {
filterNode,
updateNode,
selectScope,
resetSelection,
@@ -43,7 +43,7 @@ export function SaveDashboardAsForm({ dashboard, changeInfo }: Props) {
},
});
const { errors, isValid } = formState;
const { errors, isValid, validatingFields } = formState;
const formValues = watch();
const { state, onSaveDashboard } = useSaveDashboard(false);
@@ -85,7 +85,16 @@ export function SaveDashboardAsForm({ dashboard, changeInfo }: Props) {
const saveButton = (overwrite: boolean) => {
const showSaveButton = !isValid && hasFolderChanged ? true : isValid;
return <SaveButton isValid={showSaveButton} isLoading={state.loading} onSave={onSave} overwrite={overwrite} />;
const isTitleValidating = !!validatingFields.title;
return (
<SaveButton
isValid={showSaveButton && !isTitleValidating}
isLoading={state.loading}
onSave={onSave}
overwrite={overwrite}
/>
);
};
function renderFooter(error?: Error) {
const formValuesMatchContentSent =
@@ -53,6 +53,7 @@ import {
} from 'app/features/apiserver/types';
import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types';
import {
getDashboardComponentInteractionCallback,
getDashboardInteractionCallback,
getDashboardSceneProfiler,
} from 'app/features/dashboard/services/DashboardProfiler';
@@ -176,7 +177,7 @@ export function transformSaveModelSchemaV2ToScene(dto: DashboardWithAccessInfo<D
{
enableInteractionTracking:
config.dashboardPerformanceMetrics.findIndex((uid) => uid === '*' || uid === metadata.name) !== -1,
onInteractionComplete: getDashboardInteractionCallback(metadata.name, dashboard.title),
onInteractionComplete: getDashboardComponentInteractionCallback(metadata.name, dashboard.title),
},
getDashboardSceneProfiler()
);
@@ -23,6 +23,7 @@ import {
import { isWeekStart } from '@grafana/ui';
import { K8S_V1_DASHBOARD_API_CONFIG } from 'app/features/dashboard/api/v1';
import {
getDashboardComponentInteractionCallback,
getDashboardInteractionCallback,
getDashboardSceneProfiler,
} from 'app/features/dashboard/services/DashboardProfiler';
@@ -309,7 +310,7 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel,
{
enableInteractionTracking:
config.dashboardPerformanceMetrics.findIndex((uid) => uid === '*' || uid === oldModel.uid) !== -1,
onInteractionComplete: getDashboardInteractionCallback(oldModel.uid, oldModel.title),
onInteractionComplete: getDashboardComponentInteractionCallback(oldModel.uid, oldModel.title),
},
getDashboardSceneProfiler()
);
@@ -72,7 +72,6 @@ exports[`thresholdReducer should update Threshold Type, and unloadEvaluator para
{
"evaluator": {
"params": [
10,
0,
],
"type": "lt",
@@ -90,7 +89,6 @@ exports[`thresholdReducer should update Threshold Type, and unloadEvaluator para
"type": "query",
"unloadEvaluator": {
"params": [
10,
0,
],
"type": "gt",

Some files were not shown because too many files have changed in this diff Show More