Merge branch 'main' into kristina/static-transform-refIds
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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=
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
---
|
||||
@@ -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
|
||||
---
|
||||
@@ -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
|
||||
---
|
||||
|
||||
@@ -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
|
||||
@@ -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 |
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+2
-2
@@ -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.
|
||||
|
||||
|
||||
+34
-1
@@ -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).
|
||||
|
||||
|
||||
+21
-2
@@ -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
|
||||
|
||||
+21
-2
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -23,6 +23,7 @@ type Registry interface {
|
||||
}
|
||||
|
||||
type Manager interface {
|
||||
services.NamedService
|
||||
Registry
|
||||
Engine
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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"`
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 +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
|
||||
|
||||
+102
-8
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
+2
-1
@@ -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
Reference in New Issue
Block a user