diff --git a/.betterer.results b/.betterer.results index 9d652fc8743..d48b7f33ac0 100644 --- a/.betterer.results +++ b/.betterer.results @@ -3709,7 +3709,7 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "6"], [0, 0, 0, "Unexpected any. Specify a different type.", "7"] ], - "public/app/features/dashboard/api/v0.ts:5381": [ + "public/app/features/dashboard/api/v1.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], "public/app/features/dashboard/components/AddLibraryPanelWidget/AddLibraryPanelWidget.tsx:5381": [ @@ -5641,15 +5641,11 @@ exports[`better eslint`] = { [0, 0, 0, "\'@grafana/data/src/types/pluginExtensions\' import is restricted from being used by a pattern. Import from the public export instead.", "0"] ], "public/app/features/plugins/extensions/usePluginComponents.tsx:5381": [ - [0, 0, 0, "\'@grafana/runtime/src/services/pluginExtensions/getPluginExtensions\' import is restricted from being used by a pattern. Import from the public export instead.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"] + [0, 0, 0, "Do not use any type assertions.", "0"] ], "public/app/features/plugins/extensions/usePluginFunctions.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], - "public/app/features/plugins/extensions/usePluginLinks.tsx:5381": [ - [0, 0, 0, "\'@grafana/runtime/src/services/pluginExtensions/getPluginExtensions\' import is restricted from being used by a pattern. Import from the public export instead.", "0"] - ], "public/app/features/plugins/extensions/validators.ts:5381": [ [0, 0, 0, "\'@grafana/data/src/types/pluginExtensions\' import is restricted from being used by a pattern. Import from the public export instead.", "0"] ], diff --git a/.drone.yml b/.drone.yml index 4e3b9943f49..df536b9f81c 100644 --- a/.drone.yml +++ b/.drone.yml @@ -4119,51 +4119,6 @@ volumes: path: /var/run/docker.sock name: docker --- -clone: - retries: 3 -depends_on: [] -environment: - EDITION: oss -image_pull_secrets: -- gcr -- gar -kind: pipeline -name: release-whatsnew-checker -node: - type: no-parallel -platform: - arch: amd64 - os: linux -services: [] -steps: -- commands: - - go build -o ./bin/build -ldflags '-extldflags -static' ./pkg/build/cmd - depends_on: [] - environment: - CGO_ENABLED: 0 - image: golang:1.23.5-alpine - name: compile-build-cmd -- commands: - - ./bin/build whatsnew-checker - depends_on: - - compile-build-cmd - image: golang:1.23.5-alpine - name: whats-new-checker -trigger: - event: - exclude: - - promote - ref: - exclude: - - refs/tags/*-cloud* - include: - - refs/tags/v* -type: docker -volumes: -- host: - path: /var/run/docker.sock - name: docker ---- clone: retries: 3 depends_on: @@ -5605,6 +5560,6 @@ kind: secret name: gcr_credentials --- kind: signature -hmac: 7a16cbedacf8a7381304abfde88319ce712a133e24c1ab304543c4c7ac318816 +hmac: 6e01278547a6f0803b7723e5f1e73bd94c572fa4805517232d3897717cee30f6 ... diff --git a/apps/alerting/notifications/go.mod b/apps/alerting/notifications/go.mod index de67c2ef820..ebbf0284681 100644 --- a/apps/alerting/notifications/go.mod +++ b/apps/alerting/notifications/go.mod @@ -36,7 +36,7 @@ require ( github.com/google/gofuzz v1.2.0 // indirect github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad // indirect github.com/google/uuid v1.6.0 // indirect - github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250121113133-e747350fee2d // indirect + github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250220154326-6e5de80ef295 // indirect github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect diff --git a/apps/alerting/notifications/go.sum b/apps/alerting/notifications/go.sum index 60a25e25bcd..34db4a1a692 100644 --- a/apps/alerting/notifications/go.sum +++ b/apps/alerting/notifications/go.sum @@ -73,8 +73,8 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grafana/grafana-app-sdk v0.31.0 h1:/mFCcx+YqG8cWAi9hePDJQxIdtXDClDIDRgZwHkksFk= github.com/grafana/grafana-app-sdk v0.31.0/go.mod h1:Xw00NL7qpRLo5r3Gn48Bl1Xn2n4eUDI5pYf/wMufKWs= -github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250121113133-e747350fee2d h1:0WJ4uZ3gtbX7S5RGMarA+0TccmB7qEPhhNm34NcfBzs= -github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250121113133-e747350fee2d/go.mod h1:PNmbi49lVrv2b2I8pdu46dTs2728lKEbnVuQD8I5MnM= +github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250220154326-6e5de80ef295 h1:bOKkAzmkCnK2EN0LGsHoq5HyfXMLKXrr8+q35Fh+XQI= +github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250220154326-6e5de80ef295/go.mod h1:1iWqntBW91TsiyqneHrqTFV12vd1Tei+f5VY2ovkNAo= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 h1:uGoIog/wiQHI9GAxXO5TJbT0wWKH3O9HhOJW1F9c3fY= diff --git a/docs/sources/administration/data-source-management/teamlbac/configure-teamlbac-for-loki/index.md b/docs/sources/administration/data-source-management/teamlbac/configure-teamlbac-for-loki/index.md index 0e5875eb26c..18a21b24ee8 100644 --- a/docs/sources/administration/data-source-management/teamlbac/configure-teamlbac-for-loki/index.md +++ b/docs/sources/administration/data-source-management/teamlbac/configure-teamlbac-for-loki/index.md @@ -19,8 +19,6 @@ You cannot configure LBAC rules for Grafana-provisioned data sources from the UI ## Before you begin -To be able to use LBAC for data sources rules, you need to enable the feature toggle `teamHttpHeaders` on your Grafana instance. Contact support to enable the feature toggle for you. - - Be sure that you have the permission setup to create a Loki tenant in Grafana Cloud - Be sure that you have admin data source permissions for Grafana. diff --git a/docs/sources/datasources/pyroscope/_index.md b/docs/sources/datasources/pyroscope/_index.md index 069902bfc0a..60efe8f3f33 100644 --- a/docs/sources/datasources/pyroscope/_index.md +++ b/docs/sources/datasources/pyroscope/_index.md @@ -56,7 +56,7 @@ To use profiling data, you should: - [Configure your application to send profiles](/docs/pyroscope//configure-client/) - [Configure the Grafana Pyroscope data source](./configure-pyroscope-data-source/). -- [View and query profiling data using Explore Profiles or the query editor ](./query-profile-data/) +- [View and query profiling data using Profiles Drilldown or the query editor ](./query-profile-data/) ## Continuous profiling diff --git a/docs/sources/datasources/pyroscope/configure-pyroscope-data-source.md b/docs/sources/datasources/pyroscope/configure-pyroscope-data-source.md index 6f6b30ad045..0acfd01df3c 100644 --- a/docs/sources/datasources/pyroscope/configure-pyroscope-data-source.md +++ b/docs/sources/datasources/pyroscope/configure-pyroscope-data-source.md @@ -104,7 +104,7 @@ If you are running a self-managed Grafana instance or need to configure an addit The connection URL for a self-managed Pyroscope backend depends on how Pyroscope is deployed. Refer to the steps under [Query profiles in Grafana](https://grafana.com/docs/pyroscope//deploy-kubernetes/helm/#query-profiles-in-grafana) for more information on how to configure the data source. -If you plan to use the [Explore Profiles](ref:explore-profiles) application and you are running a self-managed Pyroscope backend in microservices mode, the data source connection URL should point to a gateway or proxy that routes requests to the corresponding Pyroscope service. +If you plan to use the [Profiles Drilldown](ref:explore-profiles) application and you are running a self-managed Pyroscope backend in microservices mode, the data source connection URL should point to a gateway or proxy that routes requests to the corresponding Pyroscope service. Refer to the [Helm ingress configuration](https://github.com/grafana/pyroscope/blob/main/operations/pyroscope/helm/pyroscope/templates/ingress.yaml) for specific routing requirements. ## Authentication diff --git a/docs/sources/datasources/pyroscope/query-profile-data.md b/docs/sources/datasources/pyroscope/query-profile-data.md index 5f391efe0b7..d0917ccfe12 100644 --- a/docs/sources/datasources/pyroscope/query-profile-data.md +++ b/docs/sources/datasources/pyroscope/query-profile-data.md @@ -1,5 +1,5 @@ --- -description: Explore your profiling data using Explore Profiles or the Pyroscope query editor. +description: Explore your profiling data using Profiles Drilldown or the Pyroscope query editor. keywords: - query - profiling @@ -54,22 +54,22 @@ https://grafana.com/docs/grafana-cloud/visualizations/panels-visualizations/visu # Query profile data -You can query your profile data using the open source Grafana Explore Profiles app or the built-in Grafana Pyroscope data source query editor. +You can query your profile data using the open source Grafana Profiles Drilldown app or the built-in Grafana Pyroscope data source query editor. -- Explore Profiles provides a queryless experience for inspecting your profiling data with opinionated workflows to assist your investigation. +- Profiles Drilldown provides a queryless experience for inspecting your profiling data with opinionated workflows to assist your investigation. - Data source query editor provides complete control over your data exploration and is recommended for experienced users. In addition, you can embed flame graph panels in Grafana dashboards. Refer to the [Flame graph panel](ref:flame-graph-panel) documentation for details. -## Explore Profiles +## Profiles Drilldown -[Explore Profiles](ref:explore-profiles) is a native Grafana application designed to integrate seamlessly with Pyroscope, the open source continuous profiling platform, providing a smooth, queryless experience for browsing and analyzing profiling data. +[Grafana Profiles Drilldown](ref:explore-profiles) is a native Grafana application designed to integrate seamlessly with Pyroscope, the open source continuous profiling platform, providing a smooth, queryless experience for browsing and analyzing profiling data. -You can use Explore Profiles in Grafana Cloud or in your own Grafana instance. -For more information, refer to [Access or install Explore Profiles](ref:explore-profiles-install). +You can use Profiles Drilldown in Grafana Cloud or in your own Grafana instance. +For more information, refer to [Access or install Profiles Drilldown](ref:explore-profiles-install). -![Explore Profiles home screen](/media/docs/explore-profiles/explore-profiles-homescreen-v0.1.17.png) +![Profiles Drilldown home screen](/media/docs/explore-profiles/explore-profiles-homescreen-v0.1.17.png) ### Use cases @@ -80,10 +80,10 @@ The main use cases are the following: - Proactive: Cutting costs, addressing latency issues, or optimizing memory usage for applications - Reactive: Resolving incidents with line-level accuracy or debugging active latency/memory issues -Explore Profiles provides an intuitive interface to specifically support proactivee and reactive use cases. +Profiles Drilldown provides an intuitive interface to specifically support proactivee and reactive use cases. You get a holistic view of all of your services and how they're functioning, but also the ability to drill down for more targeted root cause analysis. -Explore Profiles offers a convenient platform to analyze profiles and get insights that are impossible to get from using other traditional signals like logs, metrics, or tracing. +Profiles Drilldown offers a convenient platform to analyze profiles and get insights that are impossible to get from using other traditional signals like logs, metrics, or tracing. {{< youtube id="x9aPw_CbIQc" >}} @@ -93,7 +93,7 @@ Explore Profiles offers a convenient platform to analyze profiles and get insigh The Pyroscope data source query editor gives you access to a profile type selector, a label selector, and collapsible options. -Like Explore Profiles, the query editor also provides a flame graph to visualize data. +Like Profiles Drilldown, the query editor also provides a flame graph to visualize data. ![Query editor](/media/docs/pyroscope/query-editor/query-editor.png 'Query editor') diff --git a/docs/sources/datasources/tempo/query-editor/_index.md b/docs/sources/datasources/tempo/query-editor/_index.md index b6aa5c0f49b..a6b85cfd436 100644 --- a/docs/sources/datasources/tempo/query-editor/_index.md +++ b/docs/sources/datasources/tempo/query-editor/_index.md @@ -61,7 +61,7 @@ The queries use [TraceQL](/docs/tempo/latest/traceql), the query language design For general documentation on querying data sources in Grafana, refer to [Query and transform data](ref:query-transform-data). {{< admonition type="tip" >}} -Don't know TraceQL? Try [Explore Traces](ref:explore-traces-app), an intuitive, queryless app that lets you explore your tracing data using RED metrics. +Don't know TraceQL? Try [Grafana Traces Drilldown](ref:explore-traces-app), an intuitive, queryless app that lets you explore your tracing data using RED metrics. {{< /admonition >}} ## Before you begin diff --git a/docs/sources/explore/simplified-exploration/_index.md b/docs/sources/explore/simplified-exploration/_index.md index 60612ae02ba..bf43b04d41e 100644 --- a/docs/sources/explore/simplified-exploration/_index.md +++ b/docs/sources/explore/simplified-exploration/_index.md @@ -8,27 +8,27 @@ title: Simplified exploration menuTitle: Simplified exploration weight: 100 hero: - title: Simplified exploration with the Explore apps + title: Simplified exploration with the Drilldown apps level: 1 width: 100 height: 100 - description: Use Explore Profiles to investigate and identify issues using profiling data. + description: Use the Drilldown apps to investigate and identify issues using telemetry data. cards: title_class: pt-0 lh-1 items: - - title: Explore Metrics + - title: Grafana Metrics Drilldown href: ./metrics/ description: Quickly find related metrics with a few clicks, without needing to write PromQL queries to retrieve metrics. height: 24 - - title: Explore Logs + - title: Grafana Logs Drilldown href: ./logs/ description: Visualize log volumes to easily detect anomalies or significant changes over time, without needing to compose LogQL queries. height: 24 - - title: Explore Traces + - title: Grafana Traces Drilldown href: ./traces/ description: Use Rate, Errors, and Duration (RED) metrics derived from traces to investigate and understand errors and latency issues within complex distributed systems. height: 24 - - title: Explore Profiles + - title: Grafana Profiles Drilldown href: ./profiles/ description: View and analyze high-level service performance, identify problem processes for optimization, and diagnose issues to determine root causes. height: 24 @@ -36,14 +36,14 @@ cards: # Simplified exploration -The Grafana Explore apps are designed for effortless data exploration through intuitive, queryless interactions. +The Grafana Drilldown apps are designed for effortless data exploration through intuitive, queryless interactions. Easily explore telemetry signals with these specialized tools, tailored specifically for the Grafana databases to provide quick and accurate insights. To learn more, read: - [From multi-line queries to no-code investigations: meeting Grafana users where they are](https://grafana.com/blog/2024/10/22/from-multi-line-queries-to-no-code-investigations-meeting-grafana-users-where-they-are/) -- [A queryless experience for exploring metrics, logs, traces, and profiles: Introducing the Explore apps suite for Grafana](https://grafana.com/blog/2024/09/24/queryless-metrics-logs-traces-profiles/). +- [A queryless experience for exploring metrics, logs, traces, and profiles: Introducing the Drilldown apps suite for Grafana](https://grafana.com/blog/2024/09/24/queryless-metrics-logs-traces-profiles/). {{< youtube id="MSHeWWsHaIA" >}} diff --git a/docs/sources/explore/simplified-exploration/metrics/index.md b/docs/sources/explore/simplified-exploration/metrics/index.md index 127dd6cb265..a9d5d291772 100644 --- a/docs/sources/explore/simplified-exploration/metrics/index.md +++ b/docs/sources/explore/simplified-exploration/metrics/index.md @@ -4,19 +4,19 @@ labels: - cloud - enterprise - oss -title: Explore Metrics +title: Metrics Drilldown aliases: - ../explore-metrics/ # /docs/grafana/latest/explore/explore-metrics/ canonical: https://grafana.com/docs/grafana/latest/explore/simplified-exploration/metrics/ -description: Explore Metrics lets you browse Prometheus-compatible metrics using an intuitive, queryless experience. +description: Grafana Metrics Drilldown lets you browse Prometheus-compatible metrics using an intuitive, queryless experience. weight: 200 --- -# Grafana Explore Metrics +# Grafana Metrics Drilldown -Grafana Explore Metrics is a query-less experience for browsing **Prometheus-compatible** metrics. Quickly find related metrics with just a few simple clicks, without needing to write PromQL queries to retrieve metrics. +Grafana Metrics Drilldown is a query-less experience for browsing **Prometheus-compatible** metrics. Quickly find related metrics with just a few simple clicks, without needing to write PromQL queries to retrieve metrics. -With Explore Metrics, you can: +With Grafana Metrics Drilldown, you can: - Easily segment metrics based on their labels, so you can immediately spot anomalies and identify issues. - Automatically display the optimal visualization for each metric type (gauge vs. counter, for example) without manual setup. @@ -25,26 +25,25 @@ With Explore Metrics, you can: - View a history of user steps when navigating through metrics and their filters. - Seamlessly pivot to related telemetry, including log data. -{{< docs/play title="Explore Metrics" url="https://play.grafana.org/explore/metrics/trail?from=now-1h&to=now&var-ds=grafanacloud-demoinfra-prom&var-filters=&refresh=&metricPrefix=all" >}} +{{< docs/play title="Grafana Metrics Drilldown" url="https://play.grafana.org/explore/metrics/trail?from=now-1h&to=now&var-ds=grafanacloud-demoinfra-prom&var-filters=&refresh=&metricPrefix=all" >}} -You can access Explore Metrics either as a standalone experience or as part of Grafana dashboards. +You can access Grafana Metrics Drilldown either as a standalone experience or as part of Grafana dashboards. ## Standalone experience -To access Explore Metrics as a standalone experience: +To access Grafana Metrics Drilldown as a standalone experience: -1. Click the arrow next to **Explore** in the Grafana left-side menu and click **Metrics**. You are taken to an overview page that shows recent metrics, bookmarks, and the option to select a new metric exploration. -1. To get started with a new exploration, click **+ New metric exploration**. +1. Click the arrow next to **Drilldown** in the Grafana left-side menu and click **Metrics**. You are taken to an overview page that shows recent metrics, bookmarks, and the option to select a new metric exploration. +1. To get started with a new exploration, click **Let's start!**. 1. Select **Prometheus** or any Prometheus-compatible data source available in the drop-down menu under **Data source**. -1. Click **+ Add label** to select a label-value pair from the drop-down menu. You can add multiple label-value pairs. A label type will appear above the selected label with a drop-down list of options from which to choose. For example, if you select the label `container` a drop-down list of available containers appears. +1. Click **+ Add label** to select a label-value pair from the drop-down menu. You can add multiple label-value pairs. A label type appears above the selected label with a drop-down list of options from which to choose. For example, if you select the label `container` a drop-down list of available containers appears. 1. You can also search for metrics using keywords under **Search metrics** in the search bar. 1. Use the time picker to select a date and time range from the drop-down menu or use an absolute time range. 1. Click the down arrow next to the **Refresh** icon to set a refresh rate from the drop-down menu. The default is `Off`. -1. Click the **Settings** icon and toggle **Always keep selected metric graph in-view** to keep your main graph always in view on the Breakdown drill-down tab. The **History** button in the upper left corner tracks every step navigating through metric exploration. -![show metrics explore overview](/media/metrics-explore/metrics-explore-overview.png) +![show metrics explore overview](/media/metrics-explore/metrics-drilldown-overview.png) ### Metrics exploration @@ -54,19 +53,19 @@ To further explore a metric, click **Select** in the upper right corner of the m - The **Overview** tab provides a description for each metric, as well as the metric `type` and `unit` associated with the metric. It also provides a list of labels associated with the metric. Click on any label to view drill-down visualizations. - The **Breakdown** tab depicts time series visualizations for each of the label-value pairs for the selected metric. You can further drill down on each label and click **Add to filter** to add the label/value pair into your filters. You can also change the **View** from grid to rows. -- The **Related metrics** tab depicts related metrics with relevant key words. You can repeat the drill down process for any related metric. Toggle **SHow previews** to preview visualizations. +- The **Related metrics** tab depicts related metrics with relevant key words. You can repeat the drill down process for any related metric. Toggle **Show previews** to preview visualizations. -Once you have gathered your metrics exploration data you can: +After you have gathered your metrics exploration data you can: -- Click the **Explore** icon on the right side to open the graph in Explore, where you can modify the query or add the graph to a dashboard or incident. -- Click the **Share** icon on the right side to copy the metric drill down URL to the clipboard so it can be shared. +- Click the **Open in Explore** icon on the right side to open the graph in Explore, where you can modify the query or add the graph to a dashboard or incident. +- Click the **Copy URL** icon on the right side to copy the metric drill down URL to the clipboard so it can be shared. - Click the **Star** icon on the right side to bookmark and save the metrics exploration. ## Dashboard experience -To access Explore Metrics via a dashboard: +To access Grafana Metrics Drilldown via a dashboard: 1. Navigate to your dashboard. 1. Select a time series panel. -1. Click the panel menu in the upper right and select **Explore Metrics**. If there are multiple metrics, click on the one you want to explore. -1. You will see a slide out drawer with the Metrics Experience, starting with the drill down. You can access the standalone experience by clicking **Open** in the upper right. +1. Click the panel menu in the upper right and select **Metrics Drilldown**. If there are multiple metrics, click on the one you want to explore. +1. You see a slide out drawer with the Metrics Experience, starting with the drill down. You can access the standalone experience by clicking **Open** in the upper right. diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index a3e28d65042..716f2ace6f9 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -56,7 +56,7 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general- | `dashboardScene` | Enables dashboard rendering using scenes for all roles | Yes | | `ssoSettingsApi` | Enables the SSO settings API and the OAuth configuration UIs in Grafana | Yes | | `logsInfiniteScrolling` | Enables infinite scrolling for the Logs panel in Explore and Dashboards | Yes | -| `exploreMetrics` | Enables the new Explore Metrics core app | Yes | +| `exploreMetrics` | Enables the new Grafana Metrics Drilldown core app | Yes | | `alertingSimplifiedRouting` | Enables users to easily configure alert notifications by specifying a contact point directly when editing or creating an alert rule | Yes | | `logRowsPopoverMenu` | Enable filtering menu displayed when text of a log line is selected | Yes | | `lokiQueryHints` | Enables query hints for Loki | Yes | @@ -200,9 +200,9 @@ Experimental features might be changed or removed without prior notice. | `dataplaneAggregator` | Enable grafana dataplane aggregator | | `lokiSendDashboardPanelNames` | Send dashboard and panel names to Loki when querying | | `alertingPrometheusRulesPrimary` | Uses Prometheus rules as the primary source of truth for ruler-enabled data sources | -| `exploreLogsShardSplitting` | Used in Explore Logs to split queries into multiple queries based on the number of shards | -| `exploreLogsAggregatedMetrics` | Used in Explore Logs to query by aggregated metrics | -| `exploreLogsLimitedTimeRange` | Used in Explore Logs to limit the time range | +| `exploreLogsShardSplitting` | Used in Logs Drilldown to split queries into multiple queries based on the number of shards | +| `exploreLogsAggregatedMetrics` | Used in Logs Drilldown to query by aggregated metrics | +| `exploreLogsLimitedTimeRange` | Used in Logs Drilldown to limit the time range | | `homeSetupGuide` | Used in Home for users who want to return to the onboarding flow or quickly find popular config pages | | `appSidecar` | Enable the app sidecar feature that allows rendering 2 apps at the same time | | `rolePickerDrawer` | Enables the new role picker drawer design | diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/passwordless/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/passwordless/index.md index 06120e6c7da..4a4874ecb2a 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/passwordless/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/passwordless/index.md @@ -15,7 +15,9 @@ Passwordless authentication lets Grafana users authenticate with a magic link or ## Enable passwordless authentication -{{< docs/experimental product="passwordless authentication" featureFlag="passwordlessMagicLinkAuthentication" >}} +{{% admonition type="note" %}} +Passwordless authentication is an experimental feature. Engineering and on-call support is not available. Documentation is either limited or not provided outside of code comments. No SLA is provided. Enable the `passwordlessMagicLinkAuthentication` feature toggle in Grafana to use this feature. +{{% /admonition %}} To enable passwordless authentication, use the following configuration: diff --git a/e2e/various-suite/loki-table-explore-to-dash.spec.ts b/e2e/various-suite/loki-table-explore-to-dash.spec.ts index 1477f6f0165..f4867855ca3 100644 --- a/e2e/various-suite/loki-table-explore-to-dash.spec.ts +++ b/e2e/various-suite/loki-table-explore-to-dash.spec.ts @@ -175,6 +175,7 @@ describe('Loki Query Editor', () => { // One row with two cells cy.get('[role="cell"]').should('have.length', 2); + cy.contains('label', 'targetLabelName').scrollIntoView(); cy.contains('label', 'targetLabelName').should('be.visible'); cy.contains('label', 'targetLabelName').click(); cy.contains('label', 'targetLabelName').within(() => { diff --git a/go.mod b/go.mod index b429516c145..8b7a04b730e 100644 --- a/go.mod +++ b/go.mod @@ -74,7 +74,7 @@ require ( github.com/googleapis/gax-go/v2 v2.14.1 // @grafana/grafana-backend-group github.com/gorilla/mux v1.8.1 // @grafana/grafana-backend-group github.com/gorilla/websocket v1.5.3 // @grafana/grafana-app-platform-squad - github.com/grafana/alerting v0.0.0-20250218192337-92d5f29b5664 // @grafana/alerting-backend + github.com/grafana/alerting v0.0.0-20250219153626-c475b1a572b0 // @grafana/alerting-backend github.com/grafana/authlib v0.0.0-20250219100139-6a3b1bbb50e7 // @grafana/identity-access-team github.com/grafana/authlib/types v0.0.0-20250219092154-21ce22b49f31 // @grafana/identity-access-team github.com/grafana/dashboard-linter v0.0.0-20241106223805-1e7999311752 // @grafana/grafana-app-platform-squad @@ -205,21 +205,21 @@ require ( ) require ( - github.com/grafana/grafana/apps/advisor v0.0.0-20250123151950-b066a6313173 // @grafana/plugins-platform-backend - github.com/grafana/grafana/apps/alerting/notifications v0.0.0-20250121113133-e747350fee2d // @grafana/alerting-backend - github.com/grafana/grafana/apps/investigations v0.0.0-20250217190546-9df6412e9255 // @fcjack @matryer - github.com/grafana/grafana/apps/playlist v0.0.0-20250121113133-e747350fee2d // @grafana/grafana-app-platform-squad - github.com/grafana/grafana/pkg/aggregator v0.0.0-20250121113133-e747350fee2d // @grafana/grafana-app-platform-squad - github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250121113133-e747350fee2d // @grafana/grafana-app-platform-squad - github.com/grafana/grafana/pkg/apiserver v0.0.0-20250121113133-e747350fee2d // @grafana/grafana-app-platform-squad + github.com/grafana/grafana/apps/advisor v0.0.0-20250220163425-b4c4b9abbdc8 // @grafana/plugins-platform-backend + github.com/grafana/grafana/apps/alerting/notifications v0.0.0-20250220163425-b4c4b9abbdc8 // @grafana/alerting-backend + github.com/grafana/grafana/apps/investigations v0.0.0-20250220163425-b4c4b9abbdc8 // @fcjack @matryer + github.com/grafana/grafana/apps/playlist v0.0.0-20250220164708-c8d4ff28a450 // @grafana/grafana-app-platform-squad + github.com/grafana/grafana/pkg/aggregator v0.0.0-20250220163425-b4c4b9abbdc8 // @grafana/grafana-app-platform-squad + github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250220154326-6e5de80ef295 // @grafana/grafana-app-platform-squad + github.com/grafana/grafana/pkg/apiserver v0.0.0-20250220154326-6e5de80ef295 // @grafana/grafana-app-platform-squad // This needs to be here for other projects that import grafana/grafana // For local development grafana/grafana will always use the local files // Check go.work file for details github.com/grafana/grafana/pkg/promlib v0.0.8 // @grafana/oss-big-tent - github.com/grafana/grafana/pkg/semconv v0.0.0-20250121113133-e747350fee2d // @grafana/grafana-app-platform-squad - github.com/grafana/grafana/pkg/storage/unified/apistore v0.0.0-20250121113133-e747350fee2d // @grafana/grafana-search-and-storage - github.com/grafana/grafana/pkg/storage/unified/resource v0.0.0-20250121113133-e747350fee2d // @grafana/grafana-search-and-storage + github.com/grafana/grafana/pkg/semconv v0.0.0-20250220164708-c8d4ff28a450 // @grafana/grafana-app-platform-squad + github.com/grafana/grafana/pkg/storage/unified/apistore v0.0.0-20250220154326-6e5de80ef295 // @grafana/grafana-search-and-storage + github.com/grafana/grafana/pkg/storage/unified/resource v0.0.0-20250220154326-6e5de80ef295 // @grafana/grafana-search-and-storage ) require github.com/grafana/grafana-api-golang-client v0.27.0 // @grafana/alerting-backend @@ -375,7 +375,7 @@ require ( github.com/grafana/loki/pkg/push v0.0.0-20231124142027-e52380921608 // indirect github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc // indirect github.com/grafana/sqlds/v4 v4.1.3 // indirect - github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect + github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // @grafana/grafana-search-and-storage github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 // indirect github.com/hashicorp/consul/api v1.30.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect @@ -457,9 +457,9 @@ require ( github.com/oklog/ulid/v2 v2.1.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.0 // indirect - github.com/opentracing-contrib/go-grpc v0.0.0-20210225150812-73cb765af46e // indirect + github.com/opentracing-contrib/go-grpc v0.0.0-20210225150812-73cb765af46e // @grafana/grafana-search-and-storage github.com/opentracing-contrib/go-stdlib v1.0.0 // indirect - github.com/opentracing/opentracing-go v1.2.0 // indirect + github.com/opentracing/opentracing-go v1.2.0 // @grafana/grafana-search-and-storage github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/pierrec/lz4/v4 v4.1.21 // indirect diff --git a/go.sum b/go.sum index d13d9e201a5..3e19517df3b 100644 --- a/go.sum +++ b/go.sum @@ -1528,8 +1528,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.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/grafana/alerting v0.0.0-20250218192337-92d5f29b5664 h1:avtd90OVg2wMHz5pUsTWypu4rEt5bn5H0kOLXa7xgqc= -github.com/grafana/alerting v0.0.0-20250218192337-92d5f29b5664/go.mod h1:hdGB3dSl8Ma9Rjo2YiAEAjMkZ5HiNJbNDqRKDefRZrM= +github.com/grafana/alerting v0.0.0-20250219153626-c475b1a572b0 h1:LGH+tVzHCDrR9hsltmkP4jmNRg5IreQw5CNFbJKlnts= +github.com/grafana/alerting v0.0.0-20250219153626-c475b1a572b0/go.mod h1:hdGB3dSl8Ma9Rjo2YiAEAjMkZ5HiNJbNDqRKDefRZrM= github.com/grafana/authlib v0.0.0-20250219100139-6a3b1bbb50e7 h1:NTMmow+74I3Jb033xhbRgWQS7A//5TDhiM4tl7bsVP4= github.com/grafana/authlib v0.0.0-20250219100139-6a3b1bbb50e7/go.mod h1:T3X4z0ejGfJOiOmZLFeKCRT/yxWJq/RtclAc/PHj/w4= github.com/grafana/authlib/types v0.0.0-20250219092154-21ce22b49f31 h1:EokLC5grHwLPs4tXW8T6E8187H1e5G9AP0QQ5B60HbA= @@ -1568,28 +1568,28 @@ github.com/grafana/grafana-openapi-client-go v0.0.0-20231213163343-bd475d63fb79 github.com/grafana/grafana-openapi-client-go v0.0.0-20231213163343-bd475d63fb79/go.mod h1:wc6Hbh3K2TgCUSfBC/BOzabItujtHMESZeFk5ZhdxhQ= github.com/grafana/grafana-plugin-sdk-go v0.266.0 h1:YP+iEpXH3HRX9Xo4NHjsrJhN2W7uVTtkLNzMHYbmiLI= github.com/grafana/grafana-plugin-sdk-go v0.266.0/go.mod h1:bxkXrBQ4QSmOncsWdIOcpgP+M6wajQNMAPXlbWrqAWY= -github.com/grafana/grafana/apps/advisor v0.0.0-20250123151950-b066a6313173 h1:uOM89HiWVVOTls0LrD4coHTckb2lA4U0sIJwCYdbhbw= -github.com/grafana/grafana/apps/advisor v0.0.0-20250123151950-b066a6313173/go.mod h1:goSDiy3jtC2cp8wjpPZdUHRENcoSUHae1/Px/MDfddA= -github.com/grafana/grafana/apps/alerting/notifications v0.0.0-20250121113133-e747350fee2d h1:NRVOtiG1aUwOazBj9KM7X2o2shsM6TchqisezzoH1gw= -github.com/grafana/grafana/apps/alerting/notifications v0.0.0-20250121113133-e747350fee2d/go.mod h1:AvleS6icyPmcBjihtx5jYEvdzLmHGBp66NuE0AMR57A= -github.com/grafana/grafana/apps/investigations v0.0.0-20250217190546-9df6412e9255 h1:4bEH/v1cbODIie8rMAqrsUlXeWLu7ht9SXNIrdvMWmA= -github.com/grafana/grafana/apps/investigations v0.0.0-20250217190546-9df6412e9255/go.mod h1:ygFcJP2McdSeMJVj/3YrKafZMc/lZBsp54HO51MtJYw= -github.com/grafana/grafana/apps/playlist v0.0.0-20250121113133-e747350fee2d h1:cJ+9jgHw8Z17rM+DCdxvyWxPqH9OB9fkPLbnWEO+QGg= -github.com/grafana/grafana/apps/playlist v0.0.0-20250121113133-e747350fee2d/go.mod h1:DjJe5osrW/BKrzN9hAAOSElNWutj1bcriExa7iDP7kA= -github.com/grafana/grafana/pkg/aggregator v0.0.0-20250121113133-e747350fee2d h1:aBD5kzsIAh50vjNqUkWK9mNpLGIBYAnKkWtUepGNAiQ= -github.com/grafana/grafana/pkg/aggregator v0.0.0-20250121113133-e747350fee2d/go.mod h1:1sq0guad+G4SUTlBgx7SXfhnzy7D86K/LcVOtiQCiMA= -github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250121113133-e747350fee2d h1:0WJ4uZ3gtbX7S5RGMarA+0TccmB7qEPhhNm34NcfBzs= -github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250121113133-e747350fee2d/go.mod h1:PNmbi49lVrv2b2I8pdu46dTs2728lKEbnVuQD8I5MnM= -github.com/grafana/grafana/pkg/apiserver v0.0.0-20250121113133-e747350fee2d h1:vVaLOibSj5lDwCb+pa0fly9uods8nTq1NJPYrTuPAGE= -github.com/grafana/grafana/pkg/apiserver v0.0.0-20250121113133-e747350fee2d/go.mod h1:srJ6H0DL7c9Cz4CmK0h4AOlC1sm8UDwn9jBZ+RHxBt4= +github.com/grafana/grafana/apps/advisor v0.0.0-20250220163425-b4c4b9abbdc8 h1:mG/6nDlEBVxWlo2GQJVASzucw3ByPIBsec06XcPrjgQ= +github.com/grafana/grafana/apps/advisor v0.0.0-20250220163425-b4c4b9abbdc8/go.mod h1:9I1dKV3Dqr0NPR9Af0WJGxOytp5/6W3JLiNChOz8r+c= +github.com/grafana/grafana/apps/alerting/notifications v0.0.0-20250220163425-b4c4b9abbdc8 h1:w42GlvkmHG4nM/p1kb2nKmROVP+AHtL3qWEYMhnhCVM= +github.com/grafana/grafana/apps/alerting/notifications v0.0.0-20250220163425-b4c4b9abbdc8/go.mod h1:dHhFF484qs1cmdIShKCB3kl+tMJyc4yuwgTQ3Afz37o= +github.com/grafana/grafana/apps/investigations v0.0.0-20250220163425-b4c4b9abbdc8 h1:jRcI2fE/u0tHfCmPLIt21M3DYOt3L6BmZcu/LeV2sRw= +github.com/grafana/grafana/apps/investigations v0.0.0-20250220163425-b4c4b9abbdc8/go.mod h1:ygFcJP2McdSeMJVj/3YrKafZMc/lZBsp54HO51MtJYw= +github.com/grafana/grafana/apps/playlist v0.0.0-20250220164708-c8d4ff28a450 h1:h3HsylGnuZiBgT5Q/N6bH+LazY3+nA98R76Xwcd0zXg= +github.com/grafana/grafana/apps/playlist v0.0.0-20250220164708-c8d4ff28a450/go.mod h1:KKIsWpbv88Lwwcvdjon73zFL7vNJvuXLtsSoUjJErTw= +github.com/grafana/grafana/pkg/aggregator v0.0.0-20250220163425-b4c4b9abbdc8 h1:9qOLpC21AmXZqZ6rUhrBWl2mVqS3CzV53pzw0BCuHt0= +github.com/grafana/grafana/pkg/aggregator v0.0.0-20250220163425-b4c4b9abbdc8/go.mod h1:deLQ/ywLvpVGbncRGUA4UDGt8a5Ei9sivOP+x6AQ2ko= +github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250220154326-6e5de80ef295 h1:bOKkAzmkCnK2EN0LGsHoq5HyfXMLKXrr8+q35Fh+XQI= +github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250220154326-6e5de80ef295/go.mod h1:1iWqntBW91TsiyqneHrqTFV12vd1Tei+f5VY2ovkNAo= +github.com/grafana/grafana/pkg/apiserver v0.0.0-20250220154326-6e5de80ef295 h1:ivbywO8ZnmzDDkn169qUb9REsCGYAA7H8W6VfqSmCEw= +github.com/grafana/grafana/pkg/apiserver v0.0.0-20250220154326-6e5de80ef295/go.mod h1:OugmKouuvgWeOI8Kghram2Pv8b/1SuXRR8/iW068uLk= github.com/grafana/grafana/pkg/promlib v0.0.8 h1:VUWsqttdf0wMI4j9OX9oNrykguQpZcruudDAFpJJVw0= github.com/grafana/grafana/pkg/promlib v0.0.8/go.mod h1:U1ezG/MGaEPoThqsr3lymMPN5yIPdVTJnDZ+wcXT+ao= -github.com/grafana/grafana/pkg/semconv v0.0.0-20250121113133-e747350fee2d h1:cYBjuhb3m5oC6Z00Kw8DdySFaNhwb38SMxx0oXnz5vQ= -github.com/grafana/grafana/pkg/semconv v0.0.0-20250121113133-e747350fee2d/go.mod h1:tfLnBpPYgwrBMRz4EXqPCZJyCjEG4Ev37FSlXnocJ2c= -github.com/grafana/grafana/pkg/storage/unified/apistore v0.0.0-20250121113133-e747350fee2d h1:4w4x8g6xCSRj06er81atNSQVL0fTSwLekDYsRxpz+M0= -github.com/grafana/grafana/pkg/storage/unified/apistore v0.0.0-20250121113133-e747350fee2d/go.mod h1:CXpwZ3Mkw6xVlGKc0SqUxqXCP3Uv182q6qAQnLaLxRg= -github.com/grafana/grafana/pkg/storage/unified/resource v0.0.0-20250121113133-e747350fee2d h1:xzJi+ioYZpiGYjiox9eZYqu1zczNV2PBnG6Y5XK7Ar0= -github.com/grafana/grafana/pkg/storage/unified/resource v0.0.0-20250121113133-e747350fee2d/go.mod h1:2jv9CnelxFIH0q4FBaKt8BqaJyjruLmGk/31TzgsrPo= +github.com/grafana/grafana/pkg/semconv v0.0.0-20250220164708-c8d4ff28a450 h1:wSqgLKFwI7fyeqf3djRXGClBLb/UPjZ4XPm/UsKFDB0= +github.com/grafana/grafana/pkg/semconv v0.0.0-20250220164708-c8d4ff28a450/go.mod h1:HGz9/wKeN6U48g4F8RxHzpsFxJAR9anVltZp07A86UA= +github.com/grafana/grafana/pkg/storage/unified/apistore v0.0.0-20250220154326-6e5de80ef295 h1:mDRND2VJaVzhIHrcAfOCV1X39QHWGcVByQkUeqBjpNs= +github.com/grafana/grafana/pkg/storage/unified/apistore v0.0.0-20250220154326-6e5de80ef295/go.mod h1:b4i60JgZZjH8haVc4Uw8ye0FzxX4r28Y0EvduqxBdWo= +github.com/grafana/grafana/pkg/storage/unified/resource v0.0.0-20250220154326-6e5de80ef295 h1:AtOyCicDN4xqF2ZHCXaVBQj/BQpFtb/RfYD8KlwGkaY= +github.com/grafana/grafana/pkg/storage/unified/resource v0.0.0-20250220154326-6e5de80ef295/go.mod h1:QAzEkmHuxP5RPdc9H+l/Nxl+VOUtV075/Daqf77Nhf0= github.com/grafana/grafana/pkg/util/xorm v0.0.1 h1:72QZjxWIWpSeOF8ob4aMV058kfgZyeetkAB8dmeti2o= github.com/grafana/grafana/pkg/util/xorm v0.0.1/go.mod h1:eNfbB9f2jM8o9RfwqwjY8SYm5tvowJ8Ly+iE4P9rXII= github.com/grafana/jsonparser v0.0.0-20240425183733-ea80629e1a32 h1:NznuPwItog+rwdVg8hAuGKP29ndRSzJAwhxKldkP8oQ= diff --git a/go.work.sum b/go.work.sum index 602e9172b9e..e92bae1a534 100644 --- a/go.work.sum +++ b/go.work.sum @@ -587,8 +587,6 @@ github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9 github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/cel-go v0.22.0/go.mod h1:BuznPXXfQDpXKWQ9sPW3TzlAJN5zzFe+i9tIs0yC4s8= github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY= -github.com/google/go-github/v64 v64.0.0 h1:4G61sozmY3eiPAjjoOHponXDBONm+utovTKbyUb2Qdg= -github.com/google/go-github/v64 v64.0.0/go.mod h1:xB3vqMQNdHzilXBiO2I+M7iEFtHf+DP/omBOv6tQzVo= github.com/google/go-jsonnet v0.18.0 h1:/6pTy6g+Jh1a1I2UMoAODkqELFiVIdOxbNwv0DDzoOg= github.com/google/go-jsonnet v0.18.0/go.mod h1:C3fTzyVJDslXdiTqw/bTFk7vSGyCtH3MGRbDfvEwGd0= github.com/google/go-pkcs11 v0.3.0 h1:PVRnTgtArZ3QQqTGtbtjtnIkzl2iY2kt24yqbrf7td8= @@ -615,8 +613,11 @@ github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad github.com/grafana/authlib/types v0.0.0-20250120144156-d6737a7dc8f5/go.mod h1:qYjSd1tmJiuVoSICp7Py9/zD54O9uQQA3wuM6Gg4DFM= github.com/grafana/cloudflare-go v0.0.0-20230110200409-c627cf6792f2 h1:qhugDMdQ4Vp68H0tp/0iN17DM2ehRo1rLEdOFe/gB8I= github.com/grafana/cloudflare-go v0.0.0-20230110200409-c627cf6792f2/go.mod h1:w/aiO1POVIeXUQyl0VQSZjl5OAGDTL5aX+4v0RA1tcw= +github.com/grafana/cog v0.0.23 h1:/0CCJ24Z8XXM2DnboSd2FzoIswUroqIZzVr8oJWmMQs= +github.com/grafana/cog v0.0.23/go.mod h1:jrS9indvWuDs60RHEZpLaAkmZdgyoLKMOEUT0jiB1t0= github.com/grafana/go-gelf/v2 v2.0.1 h1:BOChP0h/jLeD+7F9mL7tq10xVkDG15he3T1zHuQaWak= github.com/grafana/go-gelf/v2 v2.0.1/go.mod h1:lexHie0xzYGwCgiRGcvZ723bSNyNI8ZRD4s0CLobh90= +github.com/grafana/grafana/apps/advisor v0.0.0-20250220154326-6e5de80ef295/go.mod h1:9I1dKV3Dqr0NPR9Af0WJGxOytp5/6W3JLiNChOz8r+c= github.com/grafana/tail v0.0.0-20230510142333-77b18831edf0 h1:bjh0PVYSVVFxzINqPFYJmAmJNrWPgnVjuSdYJGHmtFU= github.com/grafana/tail v0.0.0-20230510142333-77b18831edf0/go.mod h1:7t5XR+2IA8P2qggOAHTj/GCZfoLBle3OvNSYh1VkRBU= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= @@ -931,6 +932,8 @@ github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c h1:u40Z8hqBAAQyv+vATcGgV0YCnDjqSL7/q/JyPhhJSPk= github.com/xdg/stringprep v1.0.0 h1:d9X0esnoa3dFsV0FG35rAT0RIhYFlPq7MiP+DW89La0= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= diff --git a/hack/update-codegen.sh b/hack/update-codegen.sh index 4df77aec6c3..57841bbbeb2 100755 --- a/hack/update-codegen.sh +++ b/hack/update-codegen.sh @@ -87,4 +87,8 @@ grafana::codegen:run pkg grafana::codegen:run pkg/apimachinery grafana::codegen:run pkg/aggregator +if [ -d "pkg/extensions/apis" ]; then + grafana::codegen:run pkg/extensions +fi + echo "done." diff --git a/package.json b/package.json index 949b224ab90..5a830bf9c88 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "generate-apis": "yarn process-specs && rtk-query-codegen-openapi ./scripts/generate-rtk-apis.ts" }, "grafana": { - "whatsNewUrl": "https://grafana.com/docs/grafana/next/whatsnew/whats-new-in-v11-5/", + "whatsNewUrl": "https://grafana.com/docs/grafana/next/whatsnew/whats-new-in-v%[1]s-%[2]s/", "releaseNotesUrl": "https://grafana.com/docs/grafana/next/release-notes/" }, "devDependencies": { diff --git a/packages/grafana-data/src/types/icon.ts b/packages/grafana-data/src/types/icon.ts index 357cf07e46e..b1092680181 100644 --- a/packages/grafana-data/src/types/icon.ts +++ b/packages/grafana-data/src/types/icon.ts @@ -93,6 +93,7 @@ export const availableIconsIndex = { 'document-layout-left': true, 'download-alt': true, draggabledots: true, + drilldown: true, edit: true, 'ellipsis-v': true, enter: true, diff --git a/packages/grafana-data/src/types/navModel.ts b/packages/grafana-data/src/types/navModel.ts index 066d879df3d..f9ebb23fc07 100644 --- a/packages/grafana-data/src/types/navModel.ts +++ b/packages/grafana-data/src/types/navModel.ts @@ -14,6 +14,7 @@ export interface NavLinkDTO { sortWeight?: number; hideFromTabs?: boolean; roundIcon?: boolean; + isNew?: boolean; /** * This is true for some sections that have no children (but is still a section) **/ diff --git a/packages/grafana-icons/svg/drilldown.svg b/packages/grafana-icons/svg/drilldown.svg new file mode 100644 index 00000000000..27f627ce19f --- /dev/null +++ b/packages/grafana-icons/svg/drilldown.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/grafana-prometheus/src/index.ts b/packages/grafana-prometheus/src/index.ts index 4eb15995aae..2e7bc495150 100644 --- a/packages/grafana-prometheus/src/index.ts +++ b/packages/grafana-prometheus/src/index.ts @@ -86,7 +86,7 @@ export { } from './types'; export { PrometheusVariableSupport } from './variables'; -// For explore metrics +// For Metrics Drilldown export { default as PromQlLanguageProvider } from './language_provider'; export { getPrometheusTime } from './language_utils'; export { isValidLegacyName, utf8Support, wrapUtf8Filters } from './utf8_support'; diff --git a/packages/grafana-runtime/src/services/index.ts b/packages/grafana-runtime/src/services/index.ts index 5e8892c5cc7..3ceef02923a 100644 --- a/packages/grafana-runtime/src/services/index.ts +++ b/packages/grafana-runtime/src/services/index.ts @@ -21,9 +21,6 @@ export { type GetPluginExtensionsResult, type UsePluginExtensions, type UsePluginExtensionsResult, - type UsePluginComponentResult, - type UsePluginFunctionsOptions, - type UsePluginFunctionsResult, } from './pluginExtensions/getPluginExtensions'; export { setPluginExtensionsHook, @@ -32,10 +29,29 @@ export { usePluginComponentExtensions, } from './pluginExtensions/usePluginExtensions'; -export { setPluginComponentHook, usePluginComponent } from './pluginExtensions/usePluginComponent'; -export { setPluginComponentsHook, usePluginComponents } from './pluginExtensions/usePluginComponents'; -export { setPluginLinksHook, usePluginLinks } from './pluginExtensions/usePluginLinks'; -export { setPluginFunctionsHook, usePluginFunctions } from './pluginExtensions/usePluginFunctions'; +export { + setPluginComponentHook, + usePluginComponent, + type UsePluginComponentResult, +} from './pluginExtensions/usePluginComponent'; +export { + setPluginComponentsHook, + usePluginComponents, + type UsePluginComponentsResult, + type UsePluginComponentsOptions, +} from './pluginExtensions/usePluginComponents'; +export { + setPluginLinksHook, + usePluginLinks, + type UsePluginLinksOptions, + type UsePluginLinksResult, +} from './pluginExtensions/usePluginLinks'; +export { + setPluginFunctionsHook, + usePluginFunctions, + type UsePluginFunctionsOptions, + type UsePluginFunctionsResult, +} from './pluginExtensions/usePluginFunctions'; export { isPluginExtensionLink, isPluginExtensionComponent } from './pluginExtensions/utils'; export { setCurrentUser } from './user'; diff --git a/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts b/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts index 3d5de8ab3dc..edc5a48db6b 100644 --- a/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts +++ b/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts @@ -1,10 +1,4 @@ -import type { - PluginExtension, - PluginExtensionLink, - PluginExtensionComponent, - PluginExtensionFunction, - PluginExtensionComponentMeta, -} from '@grafana/data'; +import type { PluginExtension, PluginExtensionLink, PluginExtensionComponent } from '@grafana/data'; import { isPluginExtensionComponent, isPluginExtensionLink } from './utils'; @@ -23,11 +17,6 @@ export type GetPluginExtensionsOptions = { limitPerPlugin?: number; }; -export type UsePluginComponentOptions = { - extensionPointId: string; - limitPerPlugin?: number; -}; - export type GetPluginExtensionsResult = { extensions: T[]; }; @@ -37,37 +26,6 @@ export type UsePluginExtensionsResult = { isLoading: boolean; }; -export type UsePluginComponentResult = { - component: React.ComponentType | undefined | null; - isLoading: boolean; -}; - -export type UsePluginComponentsResult = { - components: Array & { meta: PluginExtensionComponentMeta }>; - isLoading: boolean; -}; - -export type UsePluginLinksOptions = { - extensionPointId: string; - context?: object | Record; - limitPerPlugin?: number; -}; - -export type UsePluginLinksResult = { - isLoading: boolean; - links: PluginExtensionLink[]; -}; - -export type UsePluginFunctionsOptions = { - extensionPointId: string; - limitPerPlugin?: number; -}; - -export type UsePluginFunctionsResult = { - isLoading: boolean; - functions: Array>; -}; - let singleton: GetPluginExtensions | undefined; export function setPluginExtensionGetter(instance: GetPluginExtensions): void { diff --git a/packages/grafana-runtime/src/services/pluginExtensions/usePluginComponent.ts b/packages/grafana-runtime/src/services/pluginExtensions/usePluginComponent.ts index a11a37389c3..40fb6b24880 100644 --- a/packages/grafana-runtime/src/services/pluginExtensions/usePluginComponent.ts +++ b/packages/grafana-runtime/src/services/pluginExtensions/usePluginComponent.ts @@ -1,6 +1,9 @@ -import { UsePluginComponentResult } from './getPluginExtensions'; +export type UsePluginComponent = (componentId: string) => UsePluginComponentResult; -export type UsePluginComponent = (id: string) => UsePluginComponentResult; +export type UsePluginComponentResult = { + component: React.ComponentType | undefined | null; + isLoading: boolean; +}; let singleton: UsePluginComponent | undefined; @@ -12,9 +15,9 @@ export function setPluginComponentHook(hook: UsePluginComponent): void { singleton = hook; } -export function usePluginComponent(id: string): UsePluginComponentResult { +export function usePluginComponent(componentId: string): UsePluginComponentResult { if (!singleton) { throw new Error('setPluginComponentHook(options) can only be used after the Grafana instance has started.'); } - return singleton(id) as UsePluginComponentResult; + return singleton(componentId) as UsePluginComponentResult; } diff --git a/packages/grafana-runtime/src/services/pluginExtensions/usePluginComponents.ts b/packages/grafana-runtime/src/services/pluginExtensions/usePluginComponents.ts index aafe2bb8aac..649c54d0414 100644 --- a/packages/grafana-runtime/src/services/pluginExtensions/usePluginComponents.ts +++ b/packages/grafana-runtime/src/services/pluginExtensions/usePluginComponents.ts @@ -1,13 +1,23 @@ -import { GetPluginExtensionsOptions, UsePluginComponentsResult } from './getPluginExtensions'; +import { PluginExtensionComponentMeta } from '@grafana/data'; + +export type UsePluginComponentsOptions = { + extensionPointId: string; + limitPerPlugin?: number; +}; + +export type UsePluginComponentsResult = { + components: Array & { meta: PluginExtensionComponentMeta }>; + isLoading: boolean; +}; export type UsePluginComponents = ( - options: GetPluginExtensionsOptions + options: UsePluginComponentsOptions ) => UsePluginComponentsResult; let singleton: UsePluginComponents | undefined; export function setPluginComponentsHook(hook: UsePluginComponents): void { - // We allow overriding the registry in tests + // We allow overriding the hook in tests if (singleton && process.env.NODE_ENV !== 'test') { throw new Error('setPluginComponentsHook() function should only be called once, when Grafana is starting.'); } @@ -15,7 +25,7 @@ export function setPluginComponentsHook(hook: UsePluginComponents): void { } export function usePluginComponents( - options: GetPluginExtensionsOptions + options: UsePluginComponentsOptions ): UsePluginComponentsResult { if (!singleton) { throw new Error('setPluginComponentsHook(options) can only be used after the Grafana instance has started.'); diff --git a/packages/grafana-runtime/src/services/pluginExtensions/usePluginFunctions.ts b/packages/grafana-runtime/src/services/pluginExtensions/usePluginFunctions.ts index 1eb86b70e14..2e480855b38 100644 --- a/packages/grafana-runtime/src/services/pluginExtensions/usePluginFunctions.ts +++ b/packages/grafana-runtime/src/services/pluginExtensions/usePluginFunctions.ts @@ -1,4 +1,14 @@ -import { UsePluginFunctionsOptions, UsePluginFunctionsResult } from './getPluginExtensions'; +import { PluginExtensionFunction } from '@grafana/data'; + +export type UsePluginFunctionsOptions = { + extensionPointId: string; + limitPerPlugin?: number; +}; + +export type UsePluginFunctionsResult = { + isLoading: boolean; + functions: Array>; +}; export type UsePluginFunctions = (options: UsePluginFunctionsOptions) => UsePluginFunctionsResult; diff --git a/packages/grafana-runtime/src/services/pluginExtensions/usePluginLinks.ts b/packages/grafana-runtime/src/services/pluginExtensions/usePluginLinks.ts index d90d88cf4f7..d4940127f61 100644 --- a/packages/grafana-runtime/src/services/pluginExtensions/usePluginLinks.ts +++ b/packages/grafana-runtime/src/services/pluginExtensions/usePluginLinks.ts @@ -1,4 +1,15 @@ -import { UsePluginLinksOptions, UsePluginLinksResult } from './getPluginExtensions'; +import { PluginExtensionLink } from '@grafana/data'; + +export type UsePluginLinksOptions = { + extensionPointId: string; + context?: object | Record; + limitPerPlugin?: number; +}; + +export type UsePluginLinksResult = { + isLoading: boolean; + links: PluginExtensionLink[]; +}; export type UsePluginLinks = (options: UsePluginLinksOptions) => UsePluginLinksResult; diff --git a/pkg/api/accesscontrol.go b/pkg/api/accesscontrol.go index f74e32f1b50..1855531c0ce 100644 --- a/pkg/api/accesscontrol.go +++ b/pkg/api/accesscontrol.go @@ -60,12 +60,15 @@ func (hs *HTTPServer) declareFixedRoles() error { Role: ac.RoleDTO{ Name: "fixed:datasources:explorer", DisplayName: "Explorer", - Description: "Enable the Explore feature. Data source permissions still apply; you can only query data sources for which you have query permissions.", + Description: "Enable the Explore and Drilldown features. Data source permissions still apply; you can only query data sources for which you have query permissions.", Group: "Data sources", Permissions: []ac.Permission{ { Action: ac.ActionDatasourcesExplore, }, + { + Action: ac.ActionDatasourcesDrilldown, + }, }, }, Grants: []string{string(org.RoleEditor)}, diff --git a/pkg/api/api.go b/pkg/api/api.go index 13d7fd24dd3..92cc5f074e5 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -184,6 +184,7 @@ func (hs *HTTPServer) registerRoutes() { } r.Get("/explore", authorize(ac.EvalPermission(ac.ActionDatasourcesExplore)), hs.Index) + r.Get("/drilldown", authorize(ac.EvalPermission(ac.ActionDatasourcesDrilldown)), hs.Index) r.Get("/playlists/", reqSignedIn, hs.Index) r.Get("/playlists/*", reqSignedIn, hs.Index) diff --git a/pkg/apimachinery/identity/context.go b/pkg/apimachinery/identity/context.go index 7600f000e7e..8699b3a3ad9 100644 --- a/pkg/apimachinery/identity/context.go +++ b/pkg/apimachinery/identity/context.go @@ -5,6 +5,7 @@ import ( "fmt" "reflect" + "github.com/grafana/authlib/authn" "github.com/grafana/authlib/types" ) @@ -30,27 +31,34 @@ func checkNilRequester(r Requester) bool { return r == nil || (reflect.ValueOf(r).Kind() == reflect.Ptr && reflect.ValueOf(r).IsNil()) } -const serviceName = "service" -const serviceNameForProvisioning = "provisioning" +const ( + serviceName = "service" + serviceNameForProvisioning = "provisioning" +) -// WithServiceIdentity sets an identity representing the service itself in provided org and store it in context. -// This is useful for background tasks that has to communicate with unfied storage. It also returns a Requester with -// static permissions so it can be used in legacy code paths. -func WithServiceIdentity(ctx context.Context, orgID int64) (context.Context, Requester) { - r := &StaticRequester{ +func newInternalIdentity(name string, namespace string, orgID int64) Requester { + return &StaticRequester{ Type: types.TypeAccessPolicy, - Name: serviceName, - UserUID: serviceName, - AuthID: serviceName, - Login: serviceName, + Name: name, + UserUID: name, + AuthID: name, + Login: name, OrgRole: RoleAdmin, + Namespace: namespace, IsGrafanaAdmin: true, OrgID: orgID, Permissions: map[int64]map[string][]string{ orgID: serviceIdentityPermissions, }, + AccessTokenClaims: ServiceIdentityClaims, } +} +// WithServiceIdentity sets an identity representing the service itself in provided org and store it in context. +// This is useful for background tasks that has to communicate with unfied storage. It also returns a Requester with +// static permissions so it can be used in legacy code paths. +func WithServiceIdentity(ctx context.Context, orgID int64) (context.Context, Requester) { + r := newInternalIdentity(serviceName, "", orgID) return WithRequester(ctx, r), r } @@ -60,21 +68,7 @@ func WithProvisioningIdentitiy(ctx context.Context, namespace string) (context.C return nil, nil, err } - r := &StaticRequester{ - Type: types.TypeAccessPolicy, - Name: serviceNameForProvisioning, - UserUID: serviceNameForProvisioning, - AuthID: serviceNameForProvisioning, - Login: serviceNameForProvisioning, - OrgRole: RoleAdmin, - IsGrafanaAdmin: true, - Namespace: namespace, - OrgID: ns.OrgID, - Permissions: map[int64]map[string][]string{ - ns.OrgID: serviceIdentityPermissions, - }, - } - + r := newInternalIdentity(serviceNameForProvisioning, ns.Value, ns.OrgID) return WithRequester(ctx, r), r, nil } @@ -97,6 +91,14 @@ func getWildcardPermissions(actions ...string) map[string][]string { return permissions } +func getTokenPermissions(groups ...string) []string { + out := make([]string, 0, len(groups)) + for _, group := range groups { + out = append(out, group+":*") + } + return out +} + // serviceIdentityPermissions is a list of wildcard permissions for provided actions. // We should add every action required "internally" here. var serviceIdentityPermissions = getWildcardPermissions( @@ -118,6 +120,19 @@ var serviceIdentityPermissions = getWildcardPermissions( "teams:read", // accesscontrol.ActionTeamsRead, ) +var serviceIdentityTokenPermissions = getTokenPermissions( + "folder.grafana.app", + "dashboard.grafana.app", + "secret.grafana.app", +) + +var ServiceIdentityClaims = &authn.Claims[authn.AccessTokenClaims]{ + Rest: authn.AccessTokenClaims{ + Permissions: serviceIdentityTokenPermissions, + DelegatedPermissions: serviceIdentityTokenPermissions, + }, +} + func IsServiceIdentity(ctx context.Context) bool { ident, ok := types.AuthInfoFrom(ctx) if !ok { diff --git a/pkg/apimachinery/identity/static.go b/pkg/apimachinery/identity/static.go index 05678db4568..e948f2c1e65 100644 --- a/pkg/apimachinery/identity/static.go +++ b/pkg/apimachinery/identity/static.go @@ -30,10 +30,11 @@ type StaticRequester struct { Namespace string IsGrafanaAdmin bool // Permissions grouped by orgID and actions - Permissions map[int64]map[string][]string - IDToken string - IDTokenClaims *authnlib.Claims[authnlib.IDTokenClaims] - CacheKey string + Permissions map[int64]map[string][]string + IDToken string + IDTokenClaims *authnlib.Claims[authnlib.IDTokenClaims] + AccessTokenClaims *authnlib.Claims[authnlib.AccessTokenClaims] + CacheKey string } // GetID returns typed id for the entity @@ -62,10 +63,16 @@ func (u *StaticRequester) GetAudience() []string { } func (u *StaticRequester) GetTokenPermissions() []string { + if u.AccessTokenClaims != nil { + return u.AccessTokenClaims.Rest.Permissions + } return []string{} } func (u *StaticRequester) GetTokenDelegatedPermissions() []string { + if u.AccessTokenClaims != nil { + return u.AccessTokenClaims.Rest.DelegatedPermissions + } return []string{} } diff --git a/pkg/build/cmd/grafanacom.go b/pkg/build/cmd/grafanacom.go index ab53217f7a4..cb413f697bc 100644 --- a/pkg/build/cmd/grafanacom.go +++ b/pkg/build/cmd/grafanacom.go @@ -21,6 +21,7 @@ import ( "github.com/grafana/grafana/pkg/build/gcloud/storage" "github.com/grafana/grafana/pkg/build/gcom" "github.com/grafana/grafana/pkg/build/packaging" + "github.com/grafana/grafana/pkg/build/versions" ) const grafanaAPI = "https://grafana.com/api" @@ -45,6 +46,7 @@ func GrafanaCom(c *cli.Context) error { } version := metadata.GrafanaVersion + semver := versions.ParseSemver(version) if releaseMode.Mode == config.Cronjob { gcs, err := storage.New() if err != nil { @@ -69,7 +71,13 @@ func GrafanaCom(c *cli.Context) error { if grafanaAPIKey == "" { return cli.Exit("the environment variable GRAFANA_COM_API_KEY must be set", 1) } - whatsNewURL, releaseNotesURL, err := getReleaseURLs() + + pkgjson, err := getPackageJSON() + if err != nil { + return cli.Exit(err.Error(), 1) + } + + whatsNewURL, releaseNotesURL, err := getReleaseURLs(semver, pkgjson) if err != nil { return cli.Exit(err.Error(), 1) } @@ -97,25 +105,32 @@ func GrafanaCom(c *cli.Context) error { return nil } -func getReleaseURLs() (string, string, error) { - type grafanaConf struct { - WhatsNewURL string `json:"whatsNewUrl"` - ReleaseNotesURL string `json:"releaseNotesUrl"` - } - type packageConf struct { - Grafana grafanaConf `json:"grafana"` - } +type grafanaConf struct { + WhatsNewURL string `json:"whatsNewUrl"` + ReleaseNotesURL string `json:"releaseNotesUrl"` +} +type packageConf struct { + Grafana grafanaConf `json:"grafana"` +} + +func getPackageJSON() (*packageConf, error) { pkgB, err := os.ReadFile("package.json") if err != nil { - return "", "", fmt.Errorf("failed to read package.json: %w", err) + return nil, fmt.Errorf("failed to read package.json: %w", err) } var pconf packageConf if err := json.Unmarshal(pkgB, &pconf); err != nil { - return "", "", fmt.Errorf("failed to decode package.json: %w", err) + return nil, fmt.Errorf("failed to decode package.json: %w", err) } - if _, err := url.ParseRequestURI(pconf.Grafana.WhatsNewURL); err != nil { + + return &pconf, nil +} + +func getReleaseURLs(semver versions.Semver, pconf *packageConf) (string, string, error) { + u := fmt.Sprintf(pconf.Grafana.WhatsNewURL, semver.Major, semver.Minor, semver.Patch) + if _, err := url.ParseRequestURI(u); err != nil { return "", "", fmt.Errorf("grafana.whatsNewUrl is invalid in package.json: %q", pconf.Grafana.WhatsNewURL) } if _, err := url.ParseRequestURI(pconf.Grafana.ReleaseNotesURL); err != nil { @@ -123,7 +138,7 @@ func getReleaseURLs() (string, string, error) { pconf.Grafana.ReleaseNotesURL) } - return pconf.Grafana.WhatsNewURL, pconf.Grafana.ReleaseNotesURL, nil + return u, pconf.Grafana.ReleaseNotesURL, nil } func Builds(baseURL *url.URL, grafana, version string, packages []packaging.BuildArtifact) ([]GCOMPackage, error) { diff --git a/pkg/build/cmd/grafanacom_test.go b/pkg/build/cmd/grafanacom_test.go index 9f4fbd7c981..1b0eb0bcf20 100644 --- a/pkg/build/cmd/grafanacom_test.go +++ b/pkg/build/cmd/grafanacom_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/grafana/grafana/pkg/build/packaging" + "github.com/grafana/grafana/pkg/build/versions" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -258,3 +259,26 @@ func TestBuildsWithPlus(t *testing.T) { }) } } + +func TestReleaseURLs(t *testing.T) { + f := "https://grafana.com/whats-new-in-v%[1]s-%[2]s" + + smv := versions.Semver{ + Major: "1", + Minor: "2", + Patch: "3", + } + + conf := packageConf{ + Grafana: grafanaConf{ + WhatsNewURL: f, + ReleaseNotesURL: "https://example.com", + }, + } + + expect := "https://grafana.com/whats-new-in-v1-2" + + a, _, err := getReleaseURLs(smv, &conf) + require.NoError(t, err) + require.Equal(t, expect, a) +} diff --git a/pkg/build/versions/parse.go b/pkg/build/versions/parse.go index 0a38af4329c..cf53f6230d4 100644 --- a/pkg/build/versions/parse.go +++ b/pkg/build/versions/parse.go @@ -1,6 +1,9 @@ package versions -import "regexp" +import ( + "regexp" + "strings" +) var semverRegex = regexp.MustCompile(`^(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$`) @@ -13,6 +16,7 @@ type Semver struct { } func ParseSemver(version string) Semver { + version = strings.TrimPrefix(version, "v") matches := semverRegex.FindStringSubmatch(version) results := make(map[string]string) for i, name := range semverRegex.SubexpNames() { diff --git a/pkg/login/social/connectors/grafana_com_oauth.go b/pkg/login/social/connectors/grafana_com_oauth.go index 84ea01632d1..a4efd5cd116 100644 --- a/pkg/login/social/connectors/grafana_com_oauth.go +++ b/pkg/login/social/connectors/grafana_com_oauth.go @@ -39,13 +39,13 @@ type OrgRecord struct { } func NewGrafanaComProvider(info *social.OAuthInfo, cfg *setting.Cfg, orgRoleMapper *OrgRoleMapper, ssoSettings ssosettings.Service, features featuremgmt.FeatureToggles) *SocialGrafanaCom { - s := newSocialBase(social.GrafanaComProviderName, orgRoleMapper, info, features, cfg) - // Override necessary settings info.AuthUrl = cfg.GrafanaComURL + "/oauth2/authorize" info.TokenUrl = cfg.GrafanaComURL + "/api/oauth2/token" info.AuthStyle = "inheader" + s := newSocialBase(social.GrafanaComProviderName, orgRoleMapper, info, features, cfg) + allowedOrganizations, err := util.SplitStringWithError(info.Extra[allowedOrganizationsKey]) if err != nil { s.log.Error("Invalid auth configuration setting", "config", allowedOrganizationsKey, "provider", social.GrafanaComProviderName, "error", err) diff --git a/pkg/registry/apis/dashboard/legacy/storage.go b/pkg/registry/apis/dashboard/legacy/storage.go index 7064d1d9994..239bc77ef90 100644 --- a/pkg/registry/apis/dashboard/legacy/storage.go +++ b/pkg/registry/apis/dashboard/legacy/storage.go @@ -8,6 +8,7 @@ import ( "time" claims "github.com/grafana/authlib/types" + "github.com/grafana/grafana/pkg/apimachinery/utils" dashboard "github.com/grafana/grafana/pkg/apis/dashboard" "github.com/grafana/grafana/pkg/storage/unified/resource" @@ -86,7 +87,10 @@ func (a *dashboardSqlAccess) WriteEvent(ctx context.Context, event resource.Writ if a.subscribers != nil { go func() { write := &resource.WrittenEvent{ - WriteEvent: event, + Type: event.Type, + Key: event.Key, + PreviousRV: event.PreviousRV, + Value: event.Value, Timestamp: time.Now().UnixMilli(), ResourceVersion: rv, diff --git a/pkg/registry/apis/dashboard/search.go b/pkg/registry/apis/dashboard/search.go index 3060972a32d..fe97d4cabb1 100644 --- a/pkg/registry/apis/dashboard/search.go +++ b/pkg/registry/apis/dashboard/search.go @@ -333,10 +333,14 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) { return } - // hijacks the "name" query param to only search for shared dashboard UIDs - if len(dashboardUIDs) > 0 { - names = append(names, dashboardUIDs...) + if len(dashboardUIDs) == 0 { + s.write(w, dashboardv0alpha1.SearchResults{ + Hits: []dashboardv0alpha1.DashboardHit{}, + }) + return } + // hijacks the "name" query param to only search for shared dashboard UIDs + names = append(names, dashboardUIDs...) } else if folder != "" { if folder == rootFolder { folder = "" // root folder is empty in the search index diff --git a/pkg/registry/apis/dashboard/search_test.go b/pkg/registry/apis/dashboard/search_test.go index 3d8967359c9..de2658c28c1 100644 --- a/pkg/registry/apis/dashboard/search_test.go +++ b/pkg/registry/apis/dashboard/search_test.go @@ -357,7 +357,131 @@ func TestSearchHandlerSharedDashboards(t *testing.T) { searchHandler.DoSearch(rr, req) - assert.Equal(t, mockClient.CallCount, 1) + assert.Equal(t, mockClient.CallCount, 0) + }) + + t.Run("should return empty result without searching if user does not have shared dashboards", func(t *testing.T) { + mockClient := &MockClient{} + + features := featuremgmt.WithFeatures(featuremgmt.FlagUnifiedStorageSearchPermissionFiltering) + searchHandler := SearchHandler{ + log: log.New("test", "test"), + client: mockClient, + tracer: tracing.NewNoopTracerService(), + features: features, + } + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/search?folder=sharedwithme", nil) + req.Header.Add("content-type", "application/json") + // "Permissions" prop in "SignedInUser" is where we store the uid of dashboards shared with the user + // doesn't exist here, which represents a user without any shared dashboards + req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"})) + + searchHandler.DoSearch(rr, req) + + assert.Equal(t, mockClient.CallCount, 0) + + resp := rr.Result() + defer func() { + if err := resp.Body.Close(); err != nil { + t.Fatal(err) + } + }() + + p := &v0alpha1.SearchResults{} + err := json.NewDecoder(resp.Body).Decode(p) + require.NoError(t, err) + assert.Equal(t, 0, len(p.Hits)) + }) + + t.Run("should return empty result if user has access to folder of all shared dashboards", func(t *testing.T) { + // dashboardSearchRequest + mockResponse1 := &resource.ResourceSearchResponse{ + Results: &resource.ResourceTable{ + Columns: []*resource.ResourceTableColumnDefinition{ + { + Name: "folder", + }, + }, + Rows: []*resource.ResourceTableRow{ + { + Key: &resource.ResourceKey{ + Name: "dashboardinroot", + Resource: "dashboard", + }, + Cells: [][]byte{[]byte("")}, // root folder doesn't have uid + }, + { + Key: &resource.ResourceKey{ + Name: "dashboardinpublicfolder", + Resource: "dashboard", + }, + Cells: [][]byte{ + []byte("publicfolder"), // folder uid + }, + }, + }, + }, + } + + // folderSearchRequest + mockResponse2 := &resource.ResourceSearchResponse{ + Results: &resource.ResourceTable{ + Columns: []*resource.ResourceTableColumnDefinition{ + { + Name: "folder", + }, + }, + Rows: []*resource.ResourceTableRow{ + { + Key: &resource.ResourceKey{ + Name: "publicfolder", + Resource: "folder", + }, + Cells: [][]byte{ + []byte(""), // root folder uid + }, + }, + }, + }, + } + + mockClient := &MockClient{ + MockResponses: []*resource.ResourceSearchResponse{mockResponse1, mockResponse2}, + } + + features := featuremgmt.WithFeatures(featuremgmt.FlagUnifiedStorageSearchPermissionFiltering) + searchHandler := SearchHandler{ + log: log.New("test", "test"), + client: mockClient, + tracer: tracing.NewNoopTracerService(), + features: features, + } + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/search?folder=sharedwithme", nil) + req.Header.Add("content-type", "application/json") + allPermissions := make(map[int64]map[string][]string) + permissions := make(map[string][]string) + permissions[dashboards.ActionDashboardsRead] = []string{"dashboards:uid:dashboardinroot", "dashboards:uid:dashboardinpublicfolder"} + allPermissions[1] = permissions + // "Permissions" is where we store the uid of dashboards shared with the user + req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test", OrgID: 1, Permissions: allPermissions})) + + searchHandler.DoSearch(rr, req) + + assert.Equal(t, mockClient.CallCount, 2) + + resp := rr.Result() + defer func() { + if err := resp.Body.Close(); err != nil { + t.Fatal(err) + } + }() + + p := &v0alpha1.SearchResults{} + err := json.NewDecoder(resp.Body).Decode(p) + require.NoError(t, err) + assert.Equal(t, 0, len(p.Hits)) }) t.Run("should return the dashboards shared with the user", func(t *testing.T) { @@ -421,8 +545,29 @@ func TestSearchHandlerSharedDashboards(t *testing.T) { }, } + mockResponse3 := &resource.ResourceSearchResponse{ + Results: &resource.ResourceTable{ + Columns: []*resource.ResourceTableColumnDefinition{ + { + Name: "folder", + }, + }, + Rows: []*resource.ResourceTableRow{ + { + Key: &resource.ResourceKey{ + Name: "dashboardinprivatefolder", + Resource: "dashboard", + }, + Cells: [][]byte{ + []byte("privatefolder"), // folder uid + }, + }, + }, + }, + } + mockClient := &MockClient{ - MockResponses: []*resource.ResourceSearchResponse{mockResponse1, mockResponse2}, + MockResponses: []*resource.ResourceSearchResponse{mockResponse1, mockResponse2, mockResponse3}, } features := featuremgmt.WithFeatures(featuremgmt.FlagUnifiedStorageSearchPermissionFiltering) @@ -439,6 +584,7 @@ func TestSearchHandlerSharedDashboards(t *testing.T) { permissions := make(map[string][]string) permissions[dashboards.ActionDashboardsRead] = []string{"dashboards:uid:dashboardinroot", "dashboards:uid:dashboardinprivatefolder", "dashboards:uid:dashboardinpublicfolder"} allPermissions[1] = permissions + // "Permissions" is where we store the uid of dashboards shared with the user req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test", OrgID: 1, Permissions: allPermissions})) searchHandler.DoSearch(rr, req) @@ -455,6 +601,18 @@ func TestSearchHandlerSharedDashboards(t *testing.T) { // permission to read thirdCall := mockClient.MockCalls[2] assert.Equal(t, thirdCall.Options.Fields[0].Values, []string{"dashboardinprivatefolder"}) + + resp := rr.Result() + defer func() { + if err := resp.Body.Close(); err != nil { + t.Fatal(err) + } + }() + + p := &v0alpha1.SearchResults{} + err := json.NewDecoder(resp.Body).Decode(p) + require.NoError(t, err) + assert.Equal(t, len(mockResponse3.Results.Rows), len(p.Hits)) }) } diff --git a/pkg/services/accesscontrol/models.go b/pkg/services/accesscontrol/models.go index dc12171eaa5..4c15e3c5e56 100644 --- a/pkg/services/accesscontrol/models.go +++ b/pkg/services/accesscontrol/models.go @@ -386,7 +386,8 @@ const ( ActionSettingsWrite = "settings:write" // Datasources actions - ActionDatasourcesExplore = "datasources:explore" + ActionDatasourcesExplore = "datasources:explore" + ActionDatasourcesDrilldown = "datasources:drilldown" // Global Scopes ScopeGlobalUsersAll = "global.users:*" diff --git a/pkg/services/authn/authnimpl/registration.go b/pkg/services/authn/authnimpl/registration.go index e86fe283da7..da11684446a 100644 --- a/pkg/services/authn/authnimpl/registration.go +++ b/pkg/services/authn/authnimpl/registration.go @@ -149,5 +149,7 @@ func ProvideRegistration( nsSync := sync.ProvideNamespaceSync(cfg) authnSvc.RegisterPostAuthHook(nsSync.SyncNamespace, 150) + authnSvc.RegisterPostAuthHook(sync.AccessClaimsHook, 160) + return Registration{} } diff --git a/pkg/services/authn/authnimpl/sync/access_claims.go b/pkg/services/authn/authnimpl/sync/access_claims.go new file mode 100644 index 00000000000..e66bc440721 --- /dev/null +++ b/pkg/services/authn/authnimpl/sync/access_claims.go @@ -0,0 +1,32 @@ +package sync + +import ( + "context" + + authnlib "github.com/grafana/authlib/authn" + + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/services/authn" +) + +func NewAccessClaimsSync() AccessClaimsSync { + return AccessClaimsSync{} +} + +type AccessClaimsSync struct{} + +func AccessClaimsHook(ctx context.Context, id *authn.Identity, _ *authn.Request) error { + if id.AccessTokenClaims == nil { + // When normal authencation flows are used withint grafana we don't have any access token e.g. using user + // session. This makes it impossible to authorize using AccessClient because we don't have any access claims + // with deletegated permissions. To get around this we use the hardcoded delegated + // permissions. + id.AccessTokenClaims = &authnlib.Claims[authnlib.AccessTokenClaims]{ + Rest: authnlib.AccessTokenClaims{ + DelegatedPermissions: identity.ServiceIdentityClaims.Rest.DelegatedPermissions, + }, + } + } + + return nil +} diff --git a/pkg/services/authn/grpcutils/grpc_authenticator.go b/pkg/services/authn/grpcutils/grpc_authenticator.go index b85457dfaf9..542a5268eee 100644 --- a/pkg/services/authn/grpcutils/grpc_authenticator.go +++ b/pkg/services/authn/grpcutils/grpc_authenticator.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/authlib/types" "github.com/prometheus/client_golang/prometheus" "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" @@ -22,7 +23,7 @@ import ( ) func NewInProcGrpcAuthenticator() interceptors.Authenticator { - return newAuthenticator( + return NewAuthenticatorInterceptor( authn.NewDefaultAuthenticator( authn.NewUnsafeAccessTokenVerifier(authn.VerifierConfig{}), authn.NewUnsafeIDTokenVerifier(authn.VerifierConfig{}), @@ -46,7 +47,7 @@ func NewAuthenticator(cfg *GrpcServerConfig, tracer tracing.Tracer) interceptors authn.NewIDTokenVerifier(authn.VerifierConfig{}, kr), ) - return newAuthenticator(auth, tracer) + return NewAuthenticatorInterceptor(auth, tracer) } func NewAuthenticatorWithFallback(cfg *setting.Cfg, reg prometheus.Registerer, tracer tracing.Tracer, fallback interceptors.Authenticator) interceptors.Authenticator { @@ -64,7 +65,7 @@ func NewAuthenticatorWithFallback(cfg *setting.Cfg, reg prometheus.Registerer, t } } -func newAuthenticator(auth authn.Authenticator, tracer tracing.Tracer) interceptors.Authenticator { +func NewAuthenticatorInterceptor(auth authn.Authenticator, tracer trace.Tracer) interceptors.Authenticator { return interceptors.AuthenticatorFunc(func(ctx context.Context) (context.Context, error) { ctx, span := tracer.Start(ctx, "grpcutils.Authenticate") defer span.End() diff --git a/pkg/services/authn/grpcutils/inproc_exchanger.go b/pkg/services/authn/grpcutils/inproc_exchanger.go index f7e24ffed7b..af252ca2fb1 100644 --- a/pkg/services/authn/grpcutils/inproc_exchanger.go +++ b/pkg/services/authn/grpcutils/inproc_exchanger.go @@ -9,6 +9,7 @@ import ( "github.com/go-jose/go-jose/v3/jwt" "github.com/grafana/authlib/authn" "github.com/grafana/authlib/types" + "github.com/grafana/grafana/pkg/apimachinery/identity" ) type inProcExchanger struct { @@ -37,8 +38,8 @@ func createInProcToken() (*authn.TokenExchangeResponse, error) { }, Rest: authn.AccessTokenClaims{ Namespace: "*", - Permissions: []string{"folder.grafana.app:*", "dashboard.grafana.app:*"}, - DelegatedPermissions: []string{"folder.grafana.app:*", "dashboard.grafana.app:*"}, + Permissions: identity.ServiceIdentityClaims.Rest.Permissions, + DelegatedPermissions: identity.ServiceIdentityClaims.Rest.DelegatedPermissions, }, } diff --git a/pkg/services/authz/zanzana.go b/pkg/services/authz/zanzana.go index 60bea71c2ee..8d644167528 100644 --- a/pkg/services/authz/zanzana.go +++ b/pkg/services/authz/zanzana.go @@ -12,27 +12,26 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" healthv1pb "google.golang.org/grpc/health/grpc_health_v1" - "google.golang.org/grpc/metadata" authnlib "github.com/grafana/authlib/authn" authzv1 "github.com/grafana/authlib/authz/proto/v1" - claims "github.com/grafana/authlib/types" + "github.com/grafana/authlib/types" "github.com/grafana/dskit/services" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/services/authn/grpcutils" authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" "github.com/grafana/grafana/pkg/services/authz/zanzana" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/grpcserver" - "github.com/grafana/grafana/pkg/services/grpcserver/interceptors" "github.com/grafana/grafana/pkg/setting" ) // ProvideZanzana used to register ZanzanaClient. // It will also start an embedded ZanzanaSever if mode is set to "embedded". -func ProvideZanzana(cfg *setting.Cfg, db db.DB, features featuremgmt.FeatureToggles) (zanzana.Client, error) { +func ProvideZanzana(cfg *setting.Cfg, db db.DB, tracer tracing.Tracer, features featuremgmt.FeatureToggles) (zanzana.Client, error) { if !features.IsEnabledGlobally(featuremgmt.FlagZanzana) { return zanzana.NewNoopClient(), nil } @@ -82,7 +81,7 @@ func ProvideZanzana(cfg *setting.Cfg, db db.DB, features featuremgmt.FeatureTogg return nil, fmt.Errorf("failed to start zanzana: %w", err) } - srv, err := zanzana.NewServer(cfg.ZanzanaServer, openfga, logger) + srv, err := zanzana.NewServer(cfg.ZanzanaServer, openfga, logger, tracer) if err != nil { return nil, fmt.Errorf("failed to start zanzana: %w", err) } @@ -90,7 +89,7 @@ func ProvideZanzana(cfg *setting.Cfg, db db.DB, features featuremgmt.FeatureTogg channel := &inprocgrpc.Channel{} // Put * as a namespace so we can properly authorize request with in-proc mode channel.WithServerUnaryInterceptor(grpcAuth.UnaryServerInterceptor(func(ctx context.Context) (context.Context, error) { - ctx = claims.WithAuthInfo(ctx, authnlib.NewAccessTokenAuthInfo(authnlib.Claims[authnlib.AccessTokenClaims]{ + ctx = types.WithAuthInfo(ctx, authnlib.NewAccessTokenAuthInfo(authnlib.Claims[authnlib.AccessTokenClaims]{ Rest: authnlib.AccessTokenClaims{ Namespace: "*", }, @@ -144,6 +143,18 @@ type Zanzana struct { } func (z *Zanzana) start(ctx context.Context) error { + tracingCfg, err := tracing.ProvideTracingConfig(z.cfg) + if err != nil { + return err + } + + tracingCfg.ServiceName = "zanzana" + + tracer, err := tracing.ProvideService(tracingCfg) + if err != nil { + return err + } + store, err := zanzana.NewStore(z.cfg, z.logger) if err != nil { return fmt.Errorf("failed to initilize zanana store: %w", err) @@ -154,46 +165,27 @@ func (z *Zanzana) start(ctx context.Context) error { return fmt.Errorf("failed to start zanzana: %w", err) } - zanzanaServer, err := zanzana.NewServer(z.cfg.ZanzanaServer, openfgaServer, z.logger) + zanzanaServer, err := zanzana.NewServer(z.cfg.ZanzanaServer, openfgaServer, z.logger, tracer) if err != nil { return fmt.Errorf("failed to start zanzana: %w", err) } - tracingCfg, err := tracing.ProvideTracingConfig(z.cfg) - if err != nil { - return err - } - tracingCfg.ServiceName = "zanzana" - - tracer, err := tracing.ProvideService(tracingCfg) - if err != nil { - return err - } - authenticator := authnlib.NewAccessTokenAuthenticator( authnlib.NewAccessTokenVerifier( - authnlib.VerifierConfig{ - AllowedAudiences: []string{authzServiceAudience}, - }, + authnlib.VerifierConfig{AllowedAudiences: []string{authzServiceAudience}}, authnlib.NewKeyRetriever(authnlib.KeyRetrieverConfig{ SigningKeysURL: z.cfg.ZanzanaServer.SigningKeysURL, }), ), ) - authfn := interceptors.AuthenticatorFunc(func(ctx context.Context) (context.Context, error) { - md, ok := metadata.FromIncomingContext(ctx) - if !ok { - return nil, fmt.Errorf("missing metadata") - } - c, err := authenticator.Authenticate(ctx, authnlib.NewGRPCTokenProvider(md)) - if err != nil { - return nil, err - } - return claims.WithAuthInfo(ctx, c), nil - }) - - z.handle, err = grpcserver.ProvideService(z.cfg, z.features, authfn, tracer, prometheus.DefaultRegisterer) + z.handle, err = grpcserver.ProvideService( + z.cfg, + z.features, + grpcutils.NewAuthenticatorInterceptor(authenticator, tracer), + tracer, + prometheus.DefaultRegisterer, + ) if err != nil { return fmt.Errorf("failed to create zanzana grpc server: %w", err) } diff --git a/pkg/services/authz/zanzana/server.go b/pkg/services/authz/zanzana/server.go index b1b79cc179f..859e5d9a9bc 100644 --- a/pkg/services/authz/zanzana/server.go +++ b/pkg/services/authz/zanzana/server.go @@ -7,13 +7,14 @@ import ( openfgastorage "github.com/openfga/openfga/pkg/storage" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/authz/zanzana/server" "github.com/grafana/grafana/pkg/services/grpcserver" "github.com/grafana/grafana/pkg/setting" ) -func NewServer(cfg setting.ZanzanaServerSettings, openfga server.OpenFGAServer, logger log.Logger) (*server.Server, error) { - return server.NewServer(cfg, openfga, logger) +func NewServer(cfg setting.ZanzanaServerSettings, openfga server.OpenFGAServer, logger log.Logger, tracer tracing.Tracer) (*server.Server, error) { + return server.NewServer(cfg, openfga, logger, tracer) } func NewHealthServer(target server.DiagnosticServer) *server.HealthServer { diff --git a/pkg/services/authz/zanzana/server/openfga_server.go b/pkg/services/authz/zanzana/server/openfga_server.go index ba994a0cab8..2eb854a81c1 100644 --- a/pkg/services/authz/zanzana/server/openfga_server.go +++ b/pkg/services/authz/zanzana/server/openfga_server.go @@ -36,9 +36,6 @@ func NewOpenFGAServer(cfg setting.ZanzanaServerSettings, store storage.OpenFGADa server.WithListObjectsDeadline(cfg.ListObjectsDeadline), } - // FIXME(kalleep): Interceptors - // We probably need to at least need to add store id interceptor also - // would be nice to inject our own requestid? srv, err := server.NewServerWithOpts(opts...) if err != nil { return nil, err diff --git a/pkg/services/authz/zanzana/server/server.go b/pkg/services/authz/zanzana/server/server.go index 822a7c2828b..9ec9a79c735 100644 --- a/pkg/services/authz/zanzana/server/server.go +++ b/pkg/services/authz/zanzana/server/server.go @@ -9,12 +9,12 @@ import ( "github.com/fullstorydev/grpchan/inprocgrpc" authzv1 "github.com/grafana/authlib/authz/proto/v1" openfgav1 "github.com/openfga/api/proto/openfga/v1" - "go.opentelemetry.io/otel" "google.golang.org/protobuf/types/known/wrapperspb" dashboardalpha1 "github.com/grafana/grafana/pkg/apis/dashboard/v2alpha1" "github.com/grafana/grafana/pkg/infra/localcache" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/tracing" authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" "github.com/grafana/grafana/pkg/services/authz/zanzana/common" "github.com/grafana/grafana/pkg/setting" @@ -25,8 +25,6 @@ const cacheCleanInterval = 2 * time.Minute var _ authzv1.AuthzServiceServer = (*Server)(nil) var _ authzextv1.AuthzExtentionServiceServer = (*Server)(nil) -var tracer = otel.Tracer("github.com/grafana/grafana/pkg/services/authz/zanzana/server") - type OpenFGAServer interface { openfgav1.OpenFGAServiceServer IsReady(ctx context.Context) (bool, error) @@ -40,10 +38,12 @@ type Server struct { openfgaClient openfgav1.OpenFGAServiceClient cfg setting.ZanzanaServerSettings - logger log.Logger stores map[string]storeInfo storesMU *sync.Mutex cache *localcache.CacheService + + logger log.Logger + tracer tracing.Tracer } type storeInfo struct { @@ -51,7 +51,7 @@ type storeInfo struct { ModelID string } -func NewServer(cfg setting.ZanzanaServerSettings, openfga OpenFGAServer, logger log.Logger) (*Server, error) { +func NewServer(cfg setting.ZanzanaServerSettings, openfga OpenFGAServer, logger log.Logger, tracer tracing.Tracer) (*Server, error) { channel := &inprocgrpc.Channel{} openfgav1.RegisterOpenFGAServiceServer(channel, openfga) openFGAClient := openfgav1.NewOpenFGAServiceClient(channel) @@ -64,6 +64,7 @@ func NewServer(cfg setting.ZanzanaServerSettings, openfga OpenFGAServer, logger cfg: cfg, cache: localcache.New(cfg.CheckQueryCacheTTL, cacheCleanInterval), logger: logger, + tracer: tracer, } return s, nil diff --git a/pkg/services/authz/zanzana/server/server_batch_check.go b/pkg/services/authz/zanzana/server/server_batch_check.go index dcfade5d73b..8b9195b01c8 100644 --- a/pkg/services/authz/zanzana/server/server_batch_check.go +++ b/pkg/services/authz/zanzana/server/server_batch_check.go @@ -11,7 +11,7 @@ import ( ) func (s *Server) BatchCheck(ctx context.Context, r *authzextv1.BatchCheckRequest) (*authzextv1.BatchCheckResponse, error) { - ctx, span := tracer.Start(ctx, "server.BatchCheck") + ctx, span := s.tracer.Start(ctx, "server.BatchCheck") defer span.End() if err := authorize(ctx, r.GetNamespace()); err != nil { diff --git a/pkg/services/authz/zanzana/server/server_check.go b/pkg/services/authz/zanzana/server/server_check.go index 6cd41a57b5b..838a39aba83 100644 --- a/pkg/services/authz/zanzana/server/server_check.go +++ b/pkg/services/authz/zanzana/server/server_check.go @@ -10,7 +10,7 @@ import ( ) func (s *Server) Check(ctx context.Context, r *authzv1.CheckRequest) (*authzv1.CheckResponse, error) { - ctx, span := tracer.Start(ctx, "server.Check") + ctx, span := s.tracer.Start(ctx, "server.Check") defer span.End() if err := authorize(ctx, r.GetNamespace()); err != nil { diff --git a/pkg/services/authz/zanzana/server/server_list.go b/pkg/services/authz/zanzana/server/server_list.go index 85b1e961f46..c435cbc90cb 100644 --- a/pkg/services/authz/zanzana/server/server_list.go +++ b/pkg/services/authz/zanzana/server/server_list.go @@ -15,7 +15,7 @@ import ( ) func (s *Server) List(ctx context.Context, r *authzv1.ListRequest) (*authzv1.ListResponse, error) { - ctx, span := tracer.Start(ctx, "server.List") + ctx, span := s.tracer.Start(ctx, "server.List") defer span.End() if err := authorize(ctx, r.GetNamespace()); err != nil { @@ -141,7 +141,7 @@ func (s *Server) listObjects(ctx context.Context, req *openfgav1.ListObjectsRequ type listFn func(ctx context.Context, req *openfgav1.ListObjectsRequest) (*openfgav1.ListObjectsResponse, error) func (s *Server) listObjectCached(ctx context.Context, req *openfgav1.ListObjectsRequest, fn listFn) (*openfgav1.ListObjectsResponse, error) { - ctx, span := tracer.Start(ctx, "server.listObjectCached") + ctx, span := s.tracer.Start(ctx, "server.listObjectCached") defer span.End() key, err := getRequestHash(req) @@ -163,7 +163,7 @@ func (s *Server) listObjectCached(ctx context.Context, req *openfgav1.ListObject } func (s *Server) streamedListObjects(ctx context.Context, req *openfgav1.ListObjectsRequest) (*openfgav1.ListObjectsResponse, error) { - ctx, span := tracer.Start(ctx, "server.streamedListObjects") + ctx, span := s.tracer.Start(ctx, "server.streamedListObjects") defer span.End() r := &openfgav1.StreamedListObjectsRequest{ diff --git a/pkg/services/authz/zanzana/server/server_read.go b/pkg/services/authz/zanzana/server/server_read.go index e9c36727dbb..755f769c11b 100644 --- a/pkg/services/authz/zanzana/server/server_read.go +++ b/pkg/services/authz/zanzana/server/server_read.go @@ -10,7 +10,7 @@ import ( ) func (s *Server) Read(ctx context.Context, req *authzextv1.ReadRequest) (*authzextv1.ReadResponse, error) { - ctx, span := tracer.Start(ctx, "server.Read") + ctx, span := s.tracer.Start(ctx, "server.Read") defer span.End() if err := authorize(ctx, req.GetNamespace()); err != nil { diff --git a/pkg/services/authz/zanzana/server/server_test.go b/pkg/services/authz/zanzana/server/server_test.go index a300be92204..8a1fd1786ea 100644 --- a/pkg/services/authz/zanzana/server/server_test.go +++ b/pkg/services/authz/zanzana/server/server_test.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/authz/zanzana/common" "github.com/grafana/grafana/pkg/services/authz/zanzana/store" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" @@ -75,7 +76,7 @@ func setup(t *testing.T, testDB db.DB, cfg *setting.Cfg) *Server { openfga, err := NewOpenFGAServer(cfg.ZanzanaServer, store, log.NewNopLogger()) require.NoError(t, err) - srv, err := NewServer(cfg.ZanzanaServer, openfga, log.NewNopLogger()) + srv, err := NewServer(cfg.ZanzanaServer, openfga, log.NewNopLogger(), tracing.NewNoopTracerService()) require.NoError(t, err) storeInf, err := srv.getStoreInfo(context.Background(), namespace) diff --git a/pkg/services/authz/zanzana/server/server_write.go b/pkg/services/authz/zanzana/server/server_write.go index 9ae5b70f9d1..0aa932bc3f8 100644 --- a/pkg/services/authz/zanzana/server/server_write.go +++ b/pkg/services/authz/zanzana/server/server_write.go @@ -10,7 +10,7 @@ import ( ) func (s *Server) Write(ctx context.Context, req *authzextv1.WriteRequest) (*authzextv1.WriteResponse, error) { - ctx, span := tracer.Start(ctx, "server.Write") + ctx, span := s.tracer.Start(ctx, "server.Write") defer span.End() if err := authorize(ctx, req.GetNamespace()); err != nil { diff --git a/pkg/services/dashboards/database/database.go b/pkg/services/dashboards/database/database.go index 06c0c1a0550..33d9109257d 100644 --- a/pkg/services/dashboards/database/database.go +++ b/pkg/services/dashboards/database/database.go @@ -400,6 +400,20 @@ func saveDashboard(sess *db.Session, cmd *dashboards.SaveDashboardCommand, emitE userId = -1 } + // we don't save FolderID in kubernetes object when saving through k8s + // this block guarantees we save dashboards with folder_id and folder_uid in those cases + if !dash.IsFolder && dash.FolderUID != "" && dash.FolderID == 0 { // nolint:staticcheck + var existing dashboards.Dashboard + folderIdFound, err := sess.Where("uid=? AND org_id=?", dash.FolderUID, dash.OrgID).Get(&existing) + if err != nil { + return nil, err + } + + if folderIdFound { + dash.FolderID = existing.ID // nolint:staticcheck + } + } + if dash.ID > 0 { var existing dashboards.Dashboard dashWithIdExists, err := sess.Where("id=? AND org_id=?", dash.ID, dash.OrgID).Get(&existing) diff --git a/pkg/services/dashboards/database/database_test.go b/pkg/services/dashboards/database/database_test.go index ccb0aac838b..9849cc80691 100644 --- a/pkg/services/dashboards/database/database_test.go +++ b/pkg/services/dashboards/database/database_test.go @@ -378,6 +378,23 @@ func TestIntegrationDashboardDataAccess(t *testing.T) { require.False(t, dashboard.Updated.IsZero()) }) + t.Run("Should populate FolderID if FolderUID is provided when creating a dashboard", func(t *testing.T) { + setup() + cmd := dashboards.SaveDashboardCommand{ + OrgID: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "title": "folderId", + "tags": []interface{}{}, + }), + FolderUID: savedFolder.UID, + UserID: 100, + } + dashboard, err := dashboardStore.SaveDashboard(context.Background(), cmd) + require.NoError(t, err) + require.EqualValues(t, dashboard.FolderID, savedFolder.ID) //nolint:staticcheck + require.EqualValues(t, dashboard.FolderUID, savedFolder.UID) + }) + t.Run("Should be able to update dashboard by id and remove folderId", func(t *testing.T) { setup() cmd := dashboards.SaveDashboardCommand{ diff --git a/pkg/services/navtree/models.go b/pkg/services/navtree/models.go index bf324abc076..97d7ab1112b 100644 --- a/pkg/services/navtree/models.go +++ b/pkg/services/navtree/models.go @@ -16,6 +16,7 @@ const ( WeightSavedItems WeightDashboard WeightExplore + WeightDrilldown WeightAlerting WeightAlertsAndIncidents WeightTestingAndSynthetics @@ -37,6 +38,7 @@ const ( NavIDRoot = "root" NavIDDashboards = "dashboards/browse" NavIDExplore = "explore" + NavIDDrilldown = "drilldown" NavIDCfg = "cfg" // NavIDCfg is the id for org configuration navigation node NavIDAlertsAndIncidents = "alerts-and-incidents" NavIDTestingAndSynthetics = "testing-and-synthetics" @@ -70,6 +72,7 @@ type NavLink struct { EmptyMessageId string `json:"emptyMessageId,omitempty"` PluginID string `json:"pluginId,omitempty"` // (Optional) The ID of the plugin that registered nav link (e.g. as a standalone plugin page) IsCreateAction bool `json:"isCreateAction,omitempty"` + IsNew bool `json:"isNew,omitempty"` // (Optional) Adds "New!" badge to the nav link and expands it by default Keywords []string `json:"keywords,omitempty"` ParentItem *NavLink `json:"parentItem,omitempty"` // (Optional) The parent item of the nav link } diff --git a/pkg/services/navtree/navtreeimpl/applinks.go b/pkg/services/navtree/navtreeimpl/applinks.go index cf78f67e1d4..5c6b88756d3 100644 --- a/pkg/services/navtree/navtreeimpl/applinks.go +++ b/pkg/services/navtree/navtreeimpl/applinks.go @@ -286,9 +286,9 @@ func (s *ServiceImpl) readNavigationSettings() { "grafana-k8s-app": {SectionID: navtree.NavIDInfrastructure, SortWeight: 1, Text: "Kubernetes"}, "grafana-dbo11y-app": {SectionID: navtree.NavIDInfrastructure, SortWeight: 2, Text: "Databases"}, "grafana-app-observability-app": {SectionID: navtree.NavIDRoot, SortWeight: navtree.WeightApplication, Text: "Application", Icon: "graph-bar"}, - "grafana-lokiexplore-app": {SectionID: navtree.NavIDExplore, SortWeight: 2, Text: "Logs"}, - "grafana-exploretraces-app": {SectionID: navtree.NavIDExplore, SortWeight: 3, Text: "Traces"}, - "grafana-pyroscope-app": {SectionID: navtree.NavIDExplore, SortWeight: 4, Text: "Profiles"}, + "grafana-lokiexplore-app": {SectionID: navtree.NavIDDrilldown, SortWeight: 2, Text: "Logs"}, + "grafana-exploretraces-app": {SectionID: navtree.NavIDDrilldown, SortWeight: 3, Text: "Traces"}, + "grafana-pyroscope-app": {SectionID: navtree.NavIDDrilldown, SortWeight: 4, Text: "Profiles"}, "grafana-kowalski-app": {SectionID: navtree.NavIDRoot, SortWeight: navtree.WeightFrontend, Text: "Frontend", Icon: "frontend-observability"}, "grafana-synthetic-monitoring-app": {SectionID: navtree.NavIDTestingAndSynthetics, SortWeight: 2, Text: "Synthetics"}, "grafana-irm-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 1, Text: "IRM"}, @@ -310,7 +310,11 @@ func (s *ServiceImpl) readNavigationSettings() { } if s.features.IsEnabledGlobally(featuremgmt.FlagExploreMetricsUseExternalAppPlugin) { - s.navigationAppConfig["grafana-metricsdrilldown-app"] = NavigationAppConfig{SectionID: navtree.NavIDExplore, SortWeight: 1, Text: "Metrics"} + s.navigationAppConfig["grafana-metricsdrilldown-app"] = NavigationAppConfig{SectionID: navtree.NavIDDrilldown, SortWeight: 1, Text: "Metrics"} + } + + if s.features.IsEnabledGlobally(featuremgmt.FlagGrafanaAdvisor) { + s.navigationAppConfig["grafana-advisor-app"] = NavigationAppConfig{SectionID: navtree.NavIDCfg, Text: "Advisor"} } s.navigationAppPathConfig = map[string]NavigationAppConfig{ diff --git a/pkg/services/navtree/navtreeimpl/navtree.go b/pkg/services/navtree/navtreeimpl/navtree.go index 1f461db67f3..7158c8884dc 100644 --- a/pkg/services/navtree/navtreeimpl/navtree.go +++ b/pkg/services/navtree/navtreeimpl/navtree.go @@ -120,7 +120,6 @@ func (s *ServiceImpl) GetNavTree(c *contextmodel.ReqContext, prefs *pref.Prefere } if s.cfg.ExploreEnabled && hasAccess(ac.EvalPermission(ac.ActionDatasourcesExplore)) { - exploreChildNavLinks := s.buildExploreNavLinks(c) treeRoot.AddSection(&navtree.NavLink{ Text: "Explore", Id: navtree.NavIDExplore, @@ -128,7 +127,20 @@ func (s *ServiceImpl) GetNavTree(c *contextmodel.ReqContext, prefs *pref.Prefere Icon: "compass", SortWeight: navtree.WeightExplore, Url: s.cfg.AppSubURL + "/explore", - Children: exploreChildNavLinks, + }) + } + + if hasAccess(ac.EvalPermission(ac.ActionDatasourcesDrilldown)) { + drilldownChildNavLinks := s.buildDrilldownNavLinks(c) + treeRoot.AddSection(&navtree.NavLink{ + Text: "Drilldown", + Id: navtree.NavIDDrilldown, + SubTitle: "Drill down into your data using Grafana's powerful queryless apps", + Icon: "drilldown", + IsNew: true, + SortWeight: navtree.WeightDrilldown, + Url: s.cfg.AppSubURL + "/drilldown", + Children: drilldownChildNavLinks, }) } @@ -560,10 +572,10 @@ func (s *ServiceImpl) buildDataConnectionsNavLink(c *contextmodel.ReqContext) *n return nil } -func (s *ServiceImpl) buildExploreNavLinks(c *contextmodel.ReqContext) []*navtree.NavLink { - exploreChildNavs := []*navtree.NavLink{} +func (s *ServiceImpl) buildDrilldownNavLinks(c *contextmodel.ReqContext) []*navtree.NavLink { + drilldownChildNavs := []*navtree.NavLink{} if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagExploreMetrics) && !s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagExploreMetricsUseExternalAppPlugin) { - exploreChildNavs = append(exploreChildNavs, &navtree.NavLink{ + drilldownChildNavs = append(drilldownChildNavs, &navtree.NavLink{ Text: "Metrics", SubTitle: "Queryless exploration of your metrics", Id: "explore/metrics", @@ -571,5 +583,5 @@ func (s *ServiceImpl) buildExploreNavLinks(c *contextmodel.ReqContext) []*navtre Icon: "code-branch", }) } - return exploreChildNavs + return drilldownChildNavs } diff --git a/pkg/services/ngalert/models/image.go b/pkg/services/ngalert/models/image.go index 07c8578cdd9..1a90090aa53 100644 --- a/pkg/services/ngalert/models/image.go +++ b/pkg/services/ngalert/models/image.go @@ -6,8 +6,11 @@ import ( ) var ( - // ErrImageNotFound is returned when the image does not exist. + // ErrImageNotFound is returned when the image does not exist or is expired. ErrImageNotFound = errors.New("image not found") + + // ErrImageDataUnavailable is returned when image data is unavailable. Usually because the image is missing a path. + ErrImageDataUnavailable = errors.New("image data is unavailable") ) type Image struct { diff --git a/pkg/services/ngalert/notifier/images.go b/pkg/services/ngalert/notifier/images.go index d8effb176ce..d25b20bce80 100644 --- a/pkg/services/ngalert/notifier/images.go +++ b/pkg/services/ngalert/notifier/images.go @@ -6,145 +6,61 @@ import ( "io" "os" "path/filepath" - "strings" alertingImages "github.com/grafana/alerting/images" - alertingModels "github.com/grafana/alerting/models" - alertingNotify "github.com/grafana/alerting/notify" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/store" ) -type imageProvider struct { +type tokenStore struct { store store.ImageStore logger log.Logger } +var _ alertingImages.TokenStore = (*tokenStore)(nil) + func newImageProvider(store store.ImageStore, logger log.Logger) alertingImages.Provider { - return &imageProvider{ + return alertingImages.NewTokenProvider(&tokenStore{ store: store, logger: logger, - } + }, newLogWrapper(logger)) } -func (i imageProvider) GetImage(ctx context.Context, uri string) (*alertingImages.Image, error) { - image, err := i.getImageFromURI(ctx, uri) +func (t tokenStore) GetImage(ctx context.Context, token string) (*alertingImages.Image, error) { + image, err := t.store.GetImage(ctx, token) if err != nil { if errors.Is(err, models.ErrImageNotFound) { - i.logger.Info("Image not found in database", "uri", uri) return nil, alertingImages.ErrImageNotFound } return nil, err } return &alertingImages.Image{ - Token: image.Token, - Path: image.Path, - URL: image.URL, - CreatedAt: image.CreatedAt, + URL: image.URL, + RawData: func(_ context.Context) (alertingImages.ImageContent, error) { + if image.Path == "" { + return alertingImages.ImageContent{}, models.ErrImageDataUnavailable + } + b, err := readImage(image.Path, t.logger) + if err != nil { + return alertingImages.ImageContent{}, err + } + return alertingImages.ImageContent{ + Name: filepath.Base(image.Path), + Content: b, + }, nil + }, }, nil } -func (i imageProvider) GetImageURL(ctx context.Context, alert *alertingNotify.Alert) (string, error) { - uri, err := getImageURI(alert) - if err != nil { - return "", err - } - - // If the identifier is a URL, validate that it corresponds to a stored, non-expired image. - if strings.HasPrefix(uri, "http") { - i.logger.Debug("Received an image URL in annotations", "alert", alert) - exists, err := i.store.URLExists(ctx, uri) - if err != nil { - return "", err - } - if !exists { - i.logger.Info("Image URL not found in database", "alert", alert) - return "", alertingImages.ErrImageNotFound - } - return uri, nil - } - - // If the identifier is a token, remove the prefix, get the image and return the URL. - token := strings.TrimPrefix(uri, "token://") - i.logger.Debug("Received an image token in annotations", "alert", alert, "token", token) - return i.getImageURLFromToken(ctx, token) -} - -// getImageURLFromToken takes a token and returns the URL of the image that token belongs to. -func (i imageProvider) getImageURLFromToken(ctx context.Context, token string) (string, error) { - image, err := i.store.GetImage(ctx, token) - if err != nil { - if errors.Is(err, models.ErrImageNotFound) { - i.logger.Info("Image not found in database", "token", token) - return "", alertingImages.ErrImageNotFound - } - return "", err - } - - if !image.HasURL() { - return "", alertingImages.ErrImagesNoURL - } - return image.URL, nil -} - -func (i imageProvider) GetRawImage(ctx context.Context, alert *alertingNotify.Alert) (io.ReadCloser, string, error) { - uri, err := getImageURI(alert) - if err != nil { - return nil, "", err - } - - image, err := i.getImageFromURI(ctx, uri) - if err != nil { - if errors.Is(err, models.ErrImageNotFound) { - i.logger.Info("Image not found in database", "alert", alert) - return nil, "", alertingImages.ErrImageNotFound - } - return nil, "", err - } - if !image.HasPath() { - return nil, "", alertingImages.ErrImagesNoPath - } - - // Return image bytes and filename. - readCloser, err := openImage(image.Path) - if err != nil { - i.logger.Error("Error looking for image on disk", "alert", alert, "path", image.Path, "error", err) - return nil, "", err - } - filename := filepath.Base(image.Path) - return readCloser, filename, nil -} - -func (i imageProvider) getImageFromURI(ctx context.Context, uri string) (*models.Image, error) { - // Check whether the uri is a URL or a token to know how to query the DB. - if strings.HasPrefix(uri, "http") { - i.logger.Debug("Received an image URL in annotations") - return i.store.GetImageByURL(ctx, uri) - } - - token := strings.TrimPrefix(uri, "token://") - i.logger.Debug("Received an image token in annotations", "token", token) - return i.store.GetImage(ctx, token) -} - -// getImageURI is a helper function to retrieve the image URI from the alert annotations as a string. -func getImageURI(alert *alertingNotify.Alert) (string, error) { - uri, ok := alert.Annotations[alertingModels.ImageTokenAnnotation] - if !ok { - return "", alertingImages.ErrNoImageForAlert - } - return string(uri), nil -} - -// openImage returns an the io representation of an image from the given path. -func openImage(path string) (io.ReadCloser, error) { +// readImage returns an image from the given path. +func readImage(path string, logger log.Logger) ([]byte, error) { fp := filepath.Clean(path) _, err := os.Stat(fp) if os.IsNotExist(err) || os.IsPermission(err) { - return nil, alertingImages.ErrImageNotFound + return nil, models.ErrImageNotFound } f, err := os.Open(fp) @@ -152,5 +68,11 @@ func openImage(path string) (io.ReadCloser, error) { return nil, err } - return f, nil + defer func() { + if err := f.Close(); err != nil { + logger.Error("Failed to close image file", "error", err) + } + }() + + return io.ReadAll(f) } diff --git a/pkg/services/ngalert/notifier/images_test.go b/pkg/services/ngalert/notifier/images_test.go index b16bd0e6360..85ed780cb3b 100644 --- a/pkg/services/ngalert/notifier/images_test.go +++ b/pkg/services/ngalert/notifier/images_test.go @@ -2,7 +2,6 @@ package notifier import ( "context" - "io" "os" "path/filepath" "testing" @@ -11,177 +10,98 @@ import ( alertingImages "github.com/grafana/alerting/images" alertingModels "github.com/grafana/alerting/models" alertingNotify "github.com/grafana/alerting/notify" + "github.com/prometheus/common/model" + "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/store" - "github.com/prometheus/common/model" - "github.com/stretchr/testify/require" ) func TestGetImage(t *testing.T) { - fakeImageStore := store.NewFakeImageStore(t) - store := newImageProvider(fakeImageStore, log.NewNopLogger()) + testBytes := []byte("some test bytes") + testPath := generateTestFile(t, testBytes) - t.Run("queries by token when it gets a token", func(tt *testing.T) { - img := models.Image{ - Token: "test", - URL: "http://localhost:1234", - Path: "test.png", - } - err := fakeImageStore.SaveImage(context.Background(), &img) - require.NoError(tt, err) - - // nolint:staticcheck - savedImg, err := store.GetImage(context.Background(), "token://"+img.Token) - require.NoError(tt, err) - require.Equal(tt, savedImg.Token, img.Token) - require.Equal(tt, savedImg.URL, img.URL) - require.Equal(tt, savedImg.Path, img.Path) - }) - - t.Run("queries by URL when it gets a URL", func(tt *testing.T) { - img := models.Image{ - Token: "test", - Path: "test.png", - URL: "https://test.com/test.png", - } - err := fakeImageStore.SaveImage(context.Background(), &img) - require.NoError(tt, err) - - // nolint:staticcheck - savedImg, err := store.GetImage(context.Background(), img.URL) - require.NoError(tt, err) - require.Equal(tt, savedImg.Token, img.Token) - require.Equal(tt, savedImg.URL, img.URL) - require.Equal(tt, savedImg.Path, img.Path) - }) -} - -func TestGetImageURL(t *testing.T) { var ( - imageWithoutURL = models.Image{ - Token: "test-no-url", - CreatedAt: time.Now().UTC(), - ExpiresAt: time.Now().UTC().Add(24 * time.Hour), - } - testImage = models.Image{ - Token: "test", + imageWithoutPath = models.Image{ + Token: "test-token-no-path", URL: "https://test.com", CreatedAt: time.Now().UTC(), ExpiresAt: time.Now().UTC().Add(24 * time.Hour), } - ) - - fakeImageStore := store.NewFakeImageStore(t, &imageWithoutURL, &testImage) - store := newImageProvider(fakeImageStore, log.NewNopLogger()) - - tests := []struct { - name string - uri string - expURL string - expErr error - }{ - { - "URL does not exist", - "https://invalid.com/test", - "", - alertingImages.ErrImageNotFound, - }, { - "existing URL", - testImage.URL, - testImage.URL, - nil, - }, { - "token does not exist", - "token://invalid", - "", - alertingImages.ErrImageNotFound, - }, { - "existing token", - "token://" + testImage.Token, - testImage.URL, - nil, - }, { - "image has no URL", - "token://" + imageWithoutURL.Token, - "", - alertingImages.ErrImagesNoURL, - }, - } - - for _, test := range tests { - t.Run(test.name, func(tt *testing.T) { - alert := alertingNotify.Alert{ - Alert: model.Alert{ - Annotations: model.LabelSet{alertingModels.ImageTokenAnnotation: model.LabelValue(test.uri)}, - }, - } - url, err := store.GetImageURL(context.Background(), &alert) - require.ErrorIs(tt, err, test.expErr) - require.Equal(tt, test.expURL, url) - }) - } -} - -func TestGetRawImage(t *testing.T) { - var ( - testBytes = []byte("some test bytes") - testPath = generateTestFile(t, testBytes) - imageWithoutPath = models.Image{ - Token: "test-no-path", - URL: "https://test-no-path.com", - CreatedAt: time.Now().UTC(), - ExpiresAt: time.Now().UTC().Add(24 * time.Hour), - } testImage = models.Image{ - Token: "test", + Token: "test-token", URL: "https://test.com", Path: testPath, CreatedAt: time.Now().UTC(), ExpiresAt: time.Now().UTC().Add(24 * time.Hour), } + testImageMissingFile = models.Image{ + Token: "test-token-missing-file", + URL: "https://test.com", + Path: "/tmp/missing/1234asdf.png", + CreatedAt: time.Now().UTC(), + ExpiresAt: time.Now().UTC().Add(24 * time.Hour), + } ) - fakeImageStore := store.NewFakeImageStore(t, &imageWithoutPath, &testImage) + fakeImageStore := store.NewFakeImageStore(t, &imageWithoutPath, &testImage, &testImageMissingFile) store := newImageProvider(fakeImageStore, log.NewNopLogger()) tests := []struct { - name string - uri string - expFilename string - expBytes []byte - expErr error + name string + token string + url string + expImage *alertingImages.Image + expImageContent *alertingImages.ImageContent + expRawDataErr error }{ { - "URL does not exist", - "https://invalid.com/test", - "", - nil, - alertingImages.ErrImageNotFound, + name: "Given existing raw token, expect image", + token: testImage.Token, + expImage: &alertingImages.Image{ + URL: testImage.URL, + }, }, { - "existing URL", - testImage.URL, - filepath.Base(testPath), - testBytes, - nil, + name: "Given existing token and url, expect image", + token: testImage.Token, + url: testImage.URL, + expImage: &alertingImages.Image{ + URL: testImage.URL, + }, }, { - "token does not exist", - "token://invalid", - "", - nil, - alertingImages.ErrImageNotFound, + name: "Given existing with just url, expect nil", + token: "", + url: testImage.URL, + expImage: nil, }, { - "existing token", - "token://" + testImage.Token, - filepath.Base(testPath), - testBytes, - nil, + name: "Given missing raw token, expect nil", + token: "invalid", + expImage: nil, }, { - "image has no path", - "token://" + imageWithoutPath.Token, - "", - nil, - alertingImages.ErrImagesNoPath, + name: "Given image with Path, expect RawData", + token: testImage.Token, + expImage: &alertingImages.Image{ + URL: testImage.URL, + }, + expImageContent: &alertingImages.ImageContent{ + Name: filepath.Base(testImage.Path), + Content: testBytes, + }, + }, { + name: "Given image with Path but file doesn't exist, expect RawData error", + token: testImageMissingFile.Token, + expImage: &alertingImages.Image{ + URL: testImageMissingFile.URL, + }, + expRawDataErr: models.ErrImageNotFound, + }, { + name: "Given image without Path, expect RawData error", + token: imageWithoutPath.Token, + expImage: &alertingImages.Image{ + URL: imageWithoutPath.URL, + }, + expRawDataErr: models.ErrImageDataUnavailable, }, } @@ -189,18 +109,24 @@ func TestGetRawImage(t *testing.T) { t.Run(test.name, func(tt *testing.T) { alert := alertingNotify.Alert{ Alert: model.Alert{ - Annotations: model.LabelSet{alertingModels.ImageTokenAnnotation: model.LabelValue(test.uri)}, + Annotations: model.LabelSet{alertingModels.ImageTokenAnnotation: model.LabelValue(test.token)}, }, } - readCloser, filename, err := store.GetRawImage(context.Background(), &alert) - require.ErrorIs(tt, err, test.expErr) - require.Equal(tt, test.expFilename, filename) - - if test.expBytes != nil { - b, err := io.ReadAll(readCloser) + image, err := store.GetImage(context.Background(), alert) + require.NoError(tt, err) + if test.expImage == nil { + require.Nil(tt, image) + return + } + require.Equal(tt, test.expImage.URL, image.URL) + if test.expImageContent != nil { + ic, err := image.RawData(context.Background()) require.NoError(tt, err) - require.Equal(tt, test.expBytes, b) - require.NoError(t, readCloser.Close()) + require.Equal(tt, *test.expImageContent, ic) + } + if test.expRawDataErr != nil { + _, err := image.RawData(context.Background()) + require.ErrorIs(tt, err, test.expRawDataErr) } }) } diff --git a/pkg/services/ngalert/notifier/log.go b/pkg/services/ngalert/notifier/log.go index c9dc97ea413..4aef94c49ed 100644 --- a/pkg/services/ngalert/notifier/log.go +++ b/pkg/services/ngalert/notifier/log.go @@ -10,6 +10,10 @@ var LoggerFactory alertingLogging.LoggerFactory = func(logger string, ctx ...any return &logWrapper{log.New(append([]any{logger}, ctx...)...)} } +func newLogWrapper(logger log.Logger, ctx ...any) alertingLogging.Logger { + return &logWrapper{logger.New(ctx...)} +} + type logWrapper struct { *log.ConcreteLogger } diff --git a/pkg/services/ngalert/state/compat.go b/pkg/services/ngalert/state/compat.go index 55e1fdf76fe..7f4b5c41428 100644 --- a/pkg/services/ngalert/state/compat.go +++ b/pkg/services/ngalert/state/compat.go @@ -17,6 +17,7 @@ import ( apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/eval" + ngModels "github.com/grafana/grafana/pkg/services/ngalert/models" ) const ( @@ -48,8 +49,8 @@ func StateToPostableAlert(transition StateTransition, appURL *url.URL) *models.P nA[alertingModels.ValueStringAnnotation] = alertState.LastEvaluationString } - if alertState.Image != nil && alertState.Image.Token != "" { - nA[alertingModels.ImageTokenAnnotation] = alertState.Image.Token + if alertState.Image != nil { + attachImageAnnotations(alertState.Image, nA) } if alertState.StateReason != "" { @@ -153,3 +154,13 @@ func FromAlertsStateToStoppedAlert(firingStates []StateTransition, appURL *url.U } return alerts } + +// attachImageAnnotations attaches image annotations to the alert. +func attachImageAnnotations(image *ngModels.Image, a data.Labels) { + if image.Token != "" { + a[alertingModels.ImageTokenAnnotation] = image.Token + } + if image.URL != "" { + a[alertingModels.ImageURLAnnotation] = image.URL + } +} diff --git a/pkg/services/ngalert/state/compat_test.go b/pkg/services/ngalert/state/compat_test.go index bd0189badb8..84d5ad8e6fa 100644 --- a/pkg/services/ngalert/state/compat_test.go +++ b/pkg/services/ngalert/state/compat_test.go @@ -120,10 +120,10 @@ func Test_StateToPostableAlert(t *testing.T) { require.Equal(t, expected, result.Annotations) }) - t.Run("add __alertImageToken__ if there is an image token", func(t *testing.T) { + t.Run("add both annotations if there is an image token and url", func(t *testing.T) { alertState := randomTransition(eval.Normal, tc.state) alertState.Annotations = randomMapOfStrings() - alertState.Image = &ngModels.Image{Token: "test_token"} + alertState.Image = &ngModels.Image{Token: "test_token", URL: "test_url"} result := StateToPostableAlert(alertState, appURL) @@ -131,12 +131,17 @@ func Test_StateToPostableAlert(t *testing.T) { for k, v := range alertState.Annotations { expected[k] = v } - expected["__alertImageToken__"] = alertState.Image.Token + expected[alertingModels.ImageTokenAnnotation] = alertState.Image.Token + expected[alertingModels.ImageURLAnnotation] = alertState.Image.URL + + // Sanity check that the annotation is correct. + require.Contains(t, result.Annotations[alertingModels.ImageTokenAnnotation], alertState.Image.Token) + require.Contains(t, result.Annotations[alertingModels.ImageURLAnnotation], alertState.Image.URL) require.Equal(t, expected, result.Annotations) }) - t.Run("don't add __alertImageToken__ if there's no image token", func(t *testing.T) { + t.Run("don't add annotations if there's no image token or url", func(t *testing.T) { alertState := randomTransition(eval.Normal, tc.state) alertState.Annotations = randomMapOfStrings() alertState.Image = &ngModels.Image{} diff --git a/pkg/storage/unified/apistore/go.mod b/pkg/storage/unified/apistore/go.mod index 7626adb519a..8771f719641 100644 --- a/pkg/storage/unified/apistore/go.mod +++ b/pkg/storage/unified/apistore/go.mod @@ -16,9 +16,9 @@ require ( github.com/google/uuid v1.6.0 github.com/grafana/authlib/types v0.0.0-20250219092154-21ce22b49f31 github.com/grafana/grafana v11.4.0-00010101000000-000000000000+incompatible - github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250121113133-e747350fee2d - github.com/grafana/grafana/pkg/apiserver v0.0.0-20250121113133-e747350fee2d - github.com/grafana/grafana/pkg/storage/unified/resource v0.0.0-20250121113133-e747350fee2d + github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250220154326-6e5de80ef295 + github.com/grafana/grafana/pkg/apiserver v0.0.0-20250220154326-6e5de80ef295 + github.com/grafana/grafana/pkg/storage/unified/resource v0.0.0-20250220154326-6e5de80ef295 github.com/stretchr/testify v1.10.0 gocloud.dev v0.40.0 golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 @@ -191,7 +191,7 @@ require ( github.com/googleapis/gax-go/v2 v2.14.1 // indirect github.com/gorilla/mux v1.8.1 // indirect github.com/gorilla/websocket v1.5.3 // indirect - github.com/grafana/alerting v0.0.0-20250218192337-92d5f29b5664 // indirect + github.com/grafana/alerting v0.0.0-20250219153626-c475b1a572b0 // indirect github.com/grafana/authlib v0.0.0-20250219100139-6a3b1bbb50e7 // indirect github.com/grafana/dataplane/sdata v0.0.9 // indirect github.com/grafana/dskit v0.0.0-20241105154643-a6b453a88040 // indirect @@ -199,9 +199,9 @@ require ( github.com/grafana/grafana-aws-sdk v0.31.5 // indirect github.com/grafana/grafana-azure-sdk-go/v2 v2.1.6 // indirect github.com/grafana/grafana-plugin-sdk-go v0.266.0 // indirect - github.com/grafana/grafana/pkg/aggregator v0.0.0-20250121113133-e747350fee2d // indirect + github.com/grafana/grafana/pkg/aggregator v0.0.0-20250220163425-b4c4b9abbdc8 // indirect github.com/grafana/grafana/pkg/promlib v0.0.8 // indirect - github.com/grafana/grafana/pkg/semconv v0.0.0-20250121113133-e747350fee2d // indirect + github.com/grafana/grafana/pkg/semconv v0.0.0-20250220164708-c8d4ff28a450 // indirect github.com/grafana/otel-profiling-go v0.5.1 // indirect github.com/grafana/pyroscope-go/godeltaprof v0.1.8 // indirect github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc // indirect diff --git a/pkg/storage/unified/apistore/go.sum b/pkg/storage/unified/apistore/go.sum index cb5c0e43d5f..1886c36308f 100644 --- a/pkg/storage/unified/apistore/go.sum +++ b/pkg/storage/unified/apistore/go.sum @@ -564,8 +564,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.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/grafana/alerting v0.0.0-20250218192337-92d5f29b5664 h1:avtd90OVg2wMHz5pUsTWypu4rEt5bn5H0kOLXa7xgqc= -github.com/grafana/alerting v0.0.0-20250218192337-92d5f29b5664/go.mod h1:hdGB3dSl8Ma9Rjo2YiAEAjMkZ5HiNJbNDqRKDefRZrM= +github.com/grafana/alerting v0.0.0-20250219153626-c475b1a572b0 h1:LGH+tVzHCDrR9hsltmkP4jmNRg5IreQw5CNFbJKlnts= +github.com/grafana/alerting v0.0.0-20250219153626-c475b1a572b0/go.mod h1:hdGB3dSl8Ma9Rjo2YiAEAjMkZ5HiNJbNDqRKDefRZrM= github.com/grafana/authlib v0.0.0-20250219100139-6a3b1bbb50e7 h1:NTMmow+74I3Jb033xhbRgWQS7A//5TDhiM4tl7bsVP4= github.com/grafana/authlib v0.0.0-20250219100139-6a3b1bbb50e7/go.mod h1:T3X4z0ejGfJOiOmZLFeKCRT/yxWJq/RtclAc/PHj/w4= github.com/grafana/authlib/types v0.0.0-20250219092154-21ce22b49f31 h1:EokLC5grHwLPs4tXW8T6E8187H1e5G9AP0QQ5B60HbA= @@ -584,12 +584,12 @@ github.com/grafana/grafana-azure-sdk-go/v2 v2.1.6 h1:OfCkitCuomzZKW1WYHrG8MxKwtM github.com/grafana/grafana-azure-sdk-go/v2 v2.1.6/go.mod h1:V7y2BmsWxS3A9Ohebwn4OiSfJJqi//4JQydQ8fHTduo= github.com/grafana/grafana-plugin-sdk-go v0.266.0 h1:YP+iEpXH3HRX9Xo4NHjsrJhN2W7uVTtkLNzMHYbmiLI= github.com/grafana/grafana-plugin-sdk-go v0.266.0/go.mod h1:bxkXrBQ4QSmOncsWdIOcpgP+M6wajQNMAPXlbWrqAWY= -github.com/grafana/grafana/pkg/aggregator v0.0.0-20250121113133-e747350fee2d h1:aBD5kzsIAh50vjNqUkWK9mNpLGIBYAnKkWtUepGNAiQ= -github.com/grafana/grafana/pkg/aggregator v0.0.0-20250121113133-e747350fee2d/go.mod h1:1sq0guad+G4SUTlBgx7SXfhnzy7D86K/LcVOtiQCiMA= +github.com/grafana/grafana/pkg/aggregator v0.0.0-20250220163425-b4c4b9abbdc8 h1:9qOLpC21AmXZqZ6rUhrBWl2mVqS3CzV53pzw0BCuHt0= +github.com/grafana/grafana/pkg/aggregator v0.0.0-20250220163425-b4c4b9abbdc8/go.mod h1:deLQ/ywLvpVGbncRGUA4UDGt8a5Ei9sivOP+x6AQ2ko= github.com/grafana/grafana/pkg/promlib v0.0.8 h1:VUWsqttdf0wMI4j9OX9oNrykguQpZcruudDAFpJJVw0= github.com/grafana/grafana/pkg/promlib v0.0.8/go.mod h1:U1ezG/MGaEPoThqsr3lymMPN5yIPdVTJnDZ+wcXT+ao= -github.com/grafana/grafana/pkg/semconv v0.0.0-20250121113133-e747350fee2d h1:cYBjuhb3m5oC6Z00Kw8DdySFaNhwb38SMxx0oXnz5vQ= -github.com/grafana/grafana/pkg/semconv v0.0.0-20250121113133-e747350fee2d/go.mod h1:tfLnBpPYgwrBMRz4EXqPCZJyCjEG4Ev37FSlXnocJ2c= +github.com/grafana/grafana/pkg/semconv v0.0.0-20250220164708-c8d4ff28a450 h1:wSqgLKFwI7fyeqf3djRXGClBLb/UPjZ4XPm/UsKFDB0= +github.com/grafana/grafana/pkg/semconv v0.0.0-20250220164708-c8d4ff28a450/go.mod h1:HGz9/wKeN6U48g4F8RxHzpsFxJAR9anVltZp07A86UA= github.com/grafana/otel-profiling-go v0.5.1 h1:stVPKAFZSa7eGiqbYuG25VcqYksR6iWvF3YH66t4qL8= github.com/grafana/otel-profiling-go v0.5.1/go.mod h1:ftN/t5A/4gQI19/8MoWurBEtC6gFw8Dns1sJZ9W4Tls= github.com/grafana/pyroscope-go/godeltaprof v0.1.8 h1:iwOtYXeeVSAeYefJNaxDytgjKtUuKQbJqgAIjlnicKg= diff --git a/pkg/storage/unified/apistore/store.go b/pkg/storage/unified/apistore/store.go index 8d3da4b7c33..115443ad099 100644 --- a/pkg/storage/unified/apistore/store.go +++ b/pkg/storage/unified/apistore/store.go @@ -294,7 +294,7 @@ func (s *Storage) Watch(ctx context.Context, key string, opts storage.ListOption } reporter := apierrors.NewClientErrorReporter(500, "WATCH", "") - decoder := newStreamDecoder(client, s.newFunc, predicate, s.codec, cancelWatch) + decoder := newStreamDecoder(client, s.newFunc, predicate, s.codec, cancelWatch, s.opts.InternalConversion) return watch.NewStreamWatcher(decoder, reporter), nil } diff --git a/pkg/storage/unified/apistore/stream.go b/pkg/storage/unified/apistore/stream.go index 9546e3e8b64..e38e1a76d16 100644 --- a/pkg/storage/unified/apistore/stream.go +++ b/pkg/storage/unified/apistore/stream.go @@ -19,25 +19,33 @@ import ( ) type streamDecoder struct { - client resource.ResourceStore_WatchClient - newFunc func() runtime.Object - predicate storage.SelectionPredicate - codec runtime.Codec - cancelWatch context.CancelFunc - done sync.WaitGroup + client resource.ResourceStore_WatchClient + newFunc func() runtime.Object + predicate storage.SelectionPredicate + codec runtime.Codec + cancelWatch context.CancelFunc + done sync.WaitGroup + internalConversion func([]byte, runtime.Object) (runtime.Object, error) } -func newStreamDecoder(client resource.ResourceStore_WatchClient, newFunc func() runtime.Object, predicate storage.SelectionPredicate, codec runtime.Codec, cancelWatch context.CancelFunc) *streamDecoder { +func newStreamDecoder(client resource.ResourceStore_WatchClient, newFunc func() runtime.Object, predicate storage.SelectionPredicate, codec runtime.Codec, cancelWatch context.CancelFunc, internalConversion func([]byte, runtime.Object) (runtime.Object, error)) *streamDecoder { return &streamDecoder{ - client: client, - newFunc: newFunc, - predicate: predicate, - codec: codec, - cancelWatch: cancelWatch, + client: client, + newFunc: newFunc, + predicate: predicate, + codec: codec, + cancelWatch: cancelWatch, + internalConversion: internalConversion, } } func (d *streamDecoder) toObject(w *resource.WatchEvent_Resource) (runtime.Object, error) { - obj, _, err := d.codec.Decode(w.Value, nil, d.newFunc()) + var obj runtime.Object + var err error + if d.internalConversion != nil { + obj, err = d.internalConversion(w.Value, d.newFunc()) + } else { + obj, _, err = d.codec.Decode(w.Value, nil, d.newFunc()) + } if err == nil { accessor, err := utils.MetaAccessor(obj) if err != nil { diff --git a/pkg/storage/unified/apistore/stream_test.go b/pkg/storage/unified/apistore/stream_test.go new file mode 100644 index 00000000000..e707fbda5d8 --- /dev/null +++ b/pkg/storage/unified/apistore/stream_test.go @@ -0,0 +1,34 @@ +package apistore + +import ( + "testing" + + "github.com/grafana/grafana/pkg/storage/unified/resource" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" +) + +func TestStreamDecoder(t *testing.T) { + t.Run("toObject should handle internal conversion", func(t *testing.T) { + called := false + internalConversion := func(data []byte, obj runtime.Object) (runtime.Object, error) { + called = true + return obj, nil + } + + decoder := &streamDecoder{ + newFunc: func() runtime.Object { return &unstructured.Unstructured{} }, + internalConversion: internalConversion, + } + + event := &resource.WatchEvent_Resource{ + Value: []byte("test"), + } + + obj, err := decoder.toObject(event) + require.NoError(t, err) + require.NotNil(t, obj) + require.True(t, called, "internal conversion function should have been called") + }) +} diff --git a/pkg/storage/unified/client.go b/pkg/storage/unified/client.go index 7b34b95ab92..396ce141c06 100644 --- a/pkg/storage/unified/client.go +++ b/pkg/storage/unified/client.go @@ -4,8 +4,12 @@ import ( "context" "fmt" "path/filepath" + "time" + otgrpc "github.com/opentracing-contrib/go-grpc" + "github.com/opentracing/opentracing-go" "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" "gocloud.dev/blob/fileblob" "google.golang.org/grpc" @@ -13,6 +17,9 @@ import ( authnlib "github.com/grafana/authlib/authn" "github.com/grafana/authlib/types" + "github.com/grafana/dskit/flagext" + "github.com/grafana/dskit/grpcclient" + "github.com/grafana/dskit/middleware" infraDB "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/tracing" @@ -39,6 +46,11 @@ type Options struct { Docs resource.DocumentBuilderSupplier } +type clientMetrics struct { + requestDuration *prometheus.HistogramVec + requestRetries *prometheus.CounterVec +} + // This adds a UnifiedStorage client into the wire dependency tree func ProvideUnifiedStorageClient(opts *Options) (resource.ResourceClient, error) { // See: apiserver.ApplyGrafanaConfig(cfg, features, o) @@ -104,11 +116,8 @@ func newClient(opts options.StorageOptions, return nil, fmt.Errorf("expecting address for storage_type: %s", opts.StorageType) } - // Create a connection to the gRPC server - conn, err := grpc.NewClient(opts.Address, - grpc.WithStatsHandler(otelgrpc.NewClientHandler()), - grpc.WithTransportCredentials(insecure.NewCredentials()), - ) + // Create a connection to the gRPC server. + conn, err := GrpcConn(opts.Address, reg) if err != nil { return nil, err } @@ -153,3 +162,67 @@ func newResourceClient(conn *grpc.ClientConn, cfg *setting.Cfg, features feature } return resource.NewRemoteResourceClient(tracer, conn, clientCfgMapping(grpcutils.ReadGrpcClientConfig(cfg)), cfg.Env == setting.Dev) } + +// GrpcConn creates a new gRPC connection to the provided address. +func GrpcConn(address string, reg prometheus.Registerer) (*grpc.ClientConn, error) { + // This works for now as the Provide function is only called once during startup. + // We might eventually want to tight this factory to a struct for more runtime control. + metrics := clientMetrics{ + requestDuration: promauto.With(reg).NewHistogramVec(prometheus.HistogramOpts{ + Name: "resource_server_client_request_duration_seconds", + Help: "Time spent executing requests to the resource server.", + Buckets: prometheus.ExponentialBuckets(0.008, 4, 7), + }, []string{"operation", "status_code"}), + requestRetries: promauto.With(reg).NewCounterVec(prometheus.CounterOpts{ + Name: "resource_server_client_request_retries_total", + Help: "Total number of retries for requests to the resource server.", + }, []string{"operation"}), + } + + // Report gRPC status code errors as labels. + unary, stream := instrument(metrics.requestDuration, middleware.ReportGRPCStatusOption) + + // Add middleware to retry on transient connection issues. Note that + // we do not implement it for streams, as we don't currently use streams. + retryCfg := retryConfig{ + Max: 3, + Backoff: time.Second, + BackoffJitter: 0.5, + } + unary = append(unary, unaryRetryInterceptor(retryCfg)) + unary = append(unary, unaryRetryInstrument(metrics.requestRetries)) + + cfg := grpcclient.Config{} + // Set the defaults that are normally set by Config.RegisterFlags. + flagext.DefaultValues(&cfg) + + opts, err := cfg.DialOption(unary, stream) + if err != nil { + return nil, fmt.Errorf("could not instrument grpc client: %w", err) + } + + opts = append(opts, grpc.WithStatsHandler(otelgrpc.NewClientHandler())) + opts = append(opts, grpc.WithTransportCredentials(insecure.NewCredentials())) + + // Use round_robin to balances requests more evenly over the available Storage server. + opts = append(opts, grpc.WithDefaultServiceConfig(`{"loadBalancingPolicy":"round_robin"}`)) + + // Disable looking up service config from TXT DNS records. + // This reduces the number of requests made to the DNS servers. + opts = append(opts, grpc.WithDisableServiceConfig()) + + // Create a connection to the gRPC server + return grpc.NewClient(address, opts...) +} + +// instrument is the same as grpcclient.Instrument but without the middleware.ClientUserHeaderInterceptor +// and middleware.StreamClientUserHeaderInterceptor as we don't need them. +func instrument(requestDuration *prometheus.HistogramVec, instrumentationLabelOptions ...middleware.InstrumentationOption) ([]grpc.UnaryClientInterceptor, []grpc.StreamClientInterceptor) { + return []grpc.UnaryClientInterceptor{ + otgrpc.OpenTracingClientInterceptor(opentracing.GlobalTracer()), + middleware.UnaryClientInstrumentInterceptor(requestDuration, instrumentationLabelOptions...), + }, []grpc.StreamClientInterceptor{ + otgrpc.OpenTracingStreamClientInterceptor(opentracing.GlobalTracer()), + middleware.StreamClientInstrumentInterceptor(requestDuration, instrumentationLabelOptions...), + } +} diff --git a/pkg/storage/unified/client_retry.go b/pkg/storage/unified/client_retry.go new file mode 100644 index 00000000000..47b899e4198 --- /dev/null +++ b/pkg/storage/unified/client_retry.go @@ -0,0 +1,46 @@ +package unified + +import ( + "context" + "strconv" + "time" + + grpc_retry "github.com/grpc-ecosystem/go-grpc-middleware/retry" + "github.com/grpc-ecosystem/go-grpc-middleware/util/metautils" + "github.com/prometheus/client_golang/prometheus" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" +) + +type retryConfig struct { + Max uint + Backoff time.Duration + BackoffJitter float64 +} + +// unaryRetryInterceptor creates an interceptor to perform retries for unary methods. +// +// Note: Retry codes are the same as the default codes. +// +// From go-grpc-middleware/interceptors/retry/options.go: +// `ResourceExhausted` means that the user quota, e.g. per-RPC limits, have been reached. +// `Unavailable` means that system is currently unavailable and the client should retry again. +func unaryRetryInterceptor(cfg retryConfig) grpc.UnaryClientInterceptor { + return grpc_retry.UnaryClientInterceptor( + grpc_retry.WithMax(cfg.Max), + grpc_retry.WithBackoff(grpc_retry.BackoffExponentialWithJitter(cfg.Backoff, cfg.BackoffJitter)), + grpc_retry.WithCodes(codes.ResourceExhausted, codes.Unavailable), + ) +} + +// unaryRetryInstrument creates an interceptor to count and log retry attempts. +func unaryRetryInstrument(metric *prometheus.CounterVec) grpc.UnaryClientInterceptor { + return func(ctx context.Context, method string, req, resp interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { + // We can tell if a call is a retry by checking the retry attempt metadata. + attempt, err := strconv.Atoi(metautils.ExtractOutgoing(ctx).Get(grpc_retry.AttemptMetadataKey)) + if err == nil && attempt > 0 { + metric.WithLabelValues(method).Inc() + } + return invoker(ctx, method, req, resp, cc, opts...) + } +} diff --git a/pkg/storage/unified/resource/cdk_backend.go b/pkg/storage/unified/resource/cdk_backend.go index eefba09d78f..19a7385a3f5 100644 --- a/pkg/storage/unified/resource/cdk_backend.go +++ b/pkg/storage/unified/resource/cdk_backend.go @@ -131,7 +131,10 @@ func (s *cdkBackend) WriteEvent(ctx context.Context, event WriteEvent) (rv int64 if s.stream != nil { go func() { write := &WrittenEvent{ - WriteEvent: event, + Type: event.Type, + Key: event.Key, + PreviousRV: event.PreviousRV, + Value: event.Value, Timestamp: time.Now().UnixMilli(), ResourceVersion: rv, } diff --git a/pkg/storage/unified/resource/event.go b/pkg/storage/unified/resource/event.go index 91c9225ee0c..fc408e23027 100644 --- a/pkg/storage/unified/resource/event.go +++ b/pkg/storage/unified/resource/event.go @@ -21,18 +21,24 @@ type WriteEvent struct { ObjectOld utils.GrafanaMetaAccessor } -// WriteEvents after they include a resource version +// WrittenEvent is a WriteEvent reported with a resource version. type WrittenEvent struct { - WriteEvent + Type WatchEvent_Type + Key *ResourceKey + PreviousRV int64 + + // The json payload (without resourceVersion) + Value []byte + // Metadata Folder string - // The resource version + // The resource version. ResourceVersion int64 // Timestamp when the event is created Timestamp int64 } -// A function to write events +// EventAppender is a function to write events. type EventAppender = func(context.Context, *WriteEvent) (int64, error) diff --git a/pkg/storage/unified/resource/go.mod b/pkg/storage/unified/resource/go.mod index 0b4a2d07d7c..abf07a8209a 100644 --- a/pkg/storage/unified/resource/go.mod +++ b/pkg/storage/unified/resource/go.mod @@ -16,8 +16,8 @@ require ( github.com/grafana/dskit v0.0.0-20241105154643-a6b453a88040 github.com/grafana/grafana v11.4.0-00010101000000-000000000000+incompatible github.com/grafana/grafana-plugin-sdk-go v0.266.0 - github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250121113133-e747350fee2d - github.com/grafana/grafana/pkg/apiserver v0.0.0-20250121113133-e747350fee2d // indirect + github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250220154326-6e5de80ef295 + github.com/grafana/grafana/pkg/apiserver v0.0.0-20250220154326-6e5de80ef295 // indirect github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.2.0 github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 github.com/hashicorp/golang-lru/v2 v2.0.7 @@ -118,7 +118,7 @@ require ( github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect github.com/googleapis/gax-go/v2 v2.14.1 // indirect github.com/gorilla/mux v1.8.1 // indirect - github.com/grafana/alerting v0.0.0-20250218192337-92d5f29b5664 // indirect + github.com/grafana/alerting v0.0.0-20250219153626-c475b1a572b0 // indirect github.com/grafana/dataplane/sdata v0.0.9 // indirect github.com/grafana/grafana-app-sdk/logging v0.30.0 // indirect github.com/grafana/grafana-aws-sdk v0.31.5 // indirect diff --git a/pkg/storage/unified/resource/go.sum b/pkg/storage/unified/resource/go.sum index 3e09885d3fd..64e6ec57ba2 100644 --- a/pkg/storage/unified/resource/go.sum +++ b/pkg/storage/unified/resource/go.sum @@ -405,8 +405,8 @@ github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2z github.com/gorilla/mux v1.7.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= -github.com/grafana/alerting v0.0.0-20250218192337-92d5f29b5664 h1:avtd90OVg2wMHz5pUsTWypu4rEt5bn5H0kOLXa7xgqc= -github.com/grafana/alerting v0.0.0-20250218192337-92d5f29b5664/go.mod h1:hdGB3dSl8Ma9Rjo2YiAEAjMkZ5HiNJbNDqRKDefRZrM= +github.com/grafana/alerting v0.0.0-20250219153626-c475b1a572b0 h1:LGH+tVzHCDrR9hsltmkP4jmNRg5IreQw5CNFbJKlnts= +github.com/grafana/alerting v0.0.0-20250219153626-c475b1a572b0/go.mod h1:hdGB3dSl8Ma9Rjo2YiAEAjMkZ5HiNJbNDqRKDefRZrM= github.com/grafana/authlib v0.0.0-20250219100139-6a3b1bbb50e7 h1:NTMmow+74I3Jb033xhbRgWQS7A//5TDhiM4tl7bsVP4= github.com/grafana/authlib v0.0.0-20250219100139-6a3b1bbb50e7/go.mod h1:T3X4z0ejGfJOiOmZLFeKCRT/yxWJq/RtclAc/PHj/w4= github.com/grafana/authlib/types v0.0.0-20250219092154-21ce22b49f31 h1:EokLC5grHwLPs4tXW8T6E8187H1e5G9AP0QQ5B60HbA= diff --git a/pkg/storage/unified/sql/backend.go b/pkg/storage/unified/sql/backend.go index 777164b9e2b..18b95019429 100644 --- a/pkg/storage/unified/sql/backend.go +++ b/pkg/storage/unified/sql/backend.go @@ -851,17 +851,15 @@ func (b *backend) poll(ctx context.Context, grp string, res string, since int64, prevRV = new(int64) } stream <- &resource.WrittenEvent{ - WriteEvent: resource.WriteEvent{ - Value: rec.Value, - Key: &resource.ResourceKey{ - Namespace: rec.Key.Namespace, - Group: rec.Key.Group, - Resource: rec.Key.Resource, - Name: rec.Key.Name, - }, - Type: resource.WatchEvent_Type(rec.Action), - PreviousRV: *prevRV, + Value: rec.Value, + Key: &resource.ResourceKey{ + Namespace: rec.Key.Namespace, + Group: rec.Key.Group, + Resource: rec.Key.Resource, + Name: rec.Key.Name, }, + Type: resource.WatchEvent_Type(rec.Action), + PreviousRV: *prevRV, Folder: rec.Folder, ResourceVersion: rec.ResourceVersion, // Timestamp: , // TODO: add timestamp diff --git a/pkg/storage/unified/sql/test/integration_test.go b/pkg/storage/unified/sql/test/integration_test.go index 3d75ed1da3f..92c644d48aa 100644 --- a/pkg/storage/unified/sql/test/integration_test.go +++ b/pkg/storage/unified/sql/test/integration_test.go @@ -8,21 +8,19 @@ import ( "github.com/go-jose/go-jose/v3/jwt" "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/require" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "github.com/grafana/authlib/authn" "github.com/grafana/authlib/types" "github.com/grafana/dskit/services" - "github.com/grafana/grafana/pkg/apimachinery/utils" infraDB "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/storage/unified" "github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/grafana/grafana/pkg/storage/unified/sql" "github.com/grafana/grafana/pkg/storage/unified/sql/db/dbimpl" + unitest "github.com/grafana/grafana/pkg/storage/unified/testing" "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/util/testutil" ) @@ -31,451 +29,22 @@ func TestMain(m *testing.M) { testsuite.Run(m) } -func newServer(t *testing.T, cfg *setting.Cfg) (sql.Backend, resource.ResourceServer) { - t.Helper() - if cfg == nil { - cfg = setting.NewCfg() - } - - dbstore := infraDB.InitTestDB(t) - - eDB, err := dbimpl.ProvideResourceDB(dbstore, cfg, nil) - require.NoError(t, err) - require.NotNil(t, eDB) - - ret, err := sql.NewBackend(sql.BackendOptions{ - DBProvider: eDB, - }) - require.NoError(t, err) - require.NotNil(t, ret) - - err = ret.Init(testutil.NewDefaultTestContext(t)) - require.NoError(t, err) - - server, err := resource.NewResourceServer(resource.ResourceServerOptions{ - Backend: ret, - Diagnostics: ret, - Lifecycle: ret, - }) - require.NoError(t, err) - require.NotNil(t, server) - - return ret, server -} - -func TestIntegrationBackendHappyPath(t *testing.T) { - // if infraDB.IsTestDbSQLite() { - // t.Skip("TODO: test blocking, skipping to unblock Enterprise until we fix this") - // } - if testing.Short() { - t.Skip("skipping integration test") - } - - ctx := types.WithAuthInfo(context.Background(), authn.NewAccessTokenAuthInfo(authn.Claims[authn.AccessTokenClaims]{ - Claims: jwt.Claims{ - Subject: "testuser", - }, - Rest: authn.AccessTokenClaims{}, - })) - - backend, server := newServer(t, nil) - - stream, err := backend.WatchWriteEvents(context.Background()) // Using a different context to avoid canceling the stream after the DefaultContextTimeout - require.NoError(t, err) - var rv1, rv2, rv3, rv4, rv5 int64 - - t.Run("Add 3 resources", func(t *testing.T) { - rv1, err = writeEvent(ctx, backend, "item1", resource.WatchEvent_ADDED) +// TestStorageBackend is a test for the StorageBackend interface. +func TestIntegrationSQLStorageBackend(t *testing.T) { + unitest.RunStorageBackendTest(t, func(ctx context.Context) resource.StorageBackend { + dbstore := infraDB.InitTestDB(t) + eDB, err := dbimpl.ProvideResourceDB(dbstore, setting.NewCfg(), nil) require.NoError(t, err) - require.Greater(t, rv1, int64(0)) + require.NotNil(t, eDB) - rv2, err = writeEvent(ctx, backend, "item2", resource.WatchEvent_ADDED) - require.NoError(t, err) - require.Greater(t, rv2, rv1) - - rv3, err = writeEvent(ctx, backend, "item3", resource.WatchEvent_ADDED) - require.NoError(t, err) - require.Greater(t, rv3, rv2) - - stats, err := backend.GetResourceStats(ctx, "", 0) - require.NoError(t, err) - require.Len(t, stats, 1) - require.Equal(t, int64(3), stats[0].Count) - require.Equal(t, rv3, stats[0].ResourceVersion) - }) - - t.Run("Update item2", func(t *testing.T) { - rv4, err = writeEvent(ctx, backend, "item2", resource.WatchEvent_MODIFIED) - require.NoError(t, err) - require.Greater(t, rv4, rv3) - }) - - t.Run("Delete item1", func(t *testing.T) { - rv5, err = writeEvent(ctx, backend, "item1", resource.WatchEvent_DELETED) - require.NoError(t, err) - require.Greater(t, rv5, rv4) - }) - - t.Run("Read latest item 2", func(t *testing.T) { - resp := backend.ReadResource(ctx, &resource.ReadRequest{Key: resourceKey("item2")}) - require.Nil(t, resp.Error) - require.Equal(t, rv4, resp.ResourceVersion) - require.Equal(t, "item2 MODIFIED", string(resp.Value)) - require.Equal(t, "folderuid", resp.Folder) - }) - - t.Run("Read early version of item2", func(t *testing.T) { - resp := backend.ReadResource(ctx, &resource.ReadRequest{ - Key: resourceKey("item2"), - ResourceVersion: rv3, // item2 was created at rv2 and updated at rv4 - }) - require.Nil(t, resp.Error) - require.Equal(t, rv2, resp.ResourceVersion) - require.Equal(t, "item2 ADDED", string(resp.Value)) - }) - - t.Run("PrepareList latest", func(t *testing.T) { - resp, err := server.List(ctx, &resource.ListRequest{ - Options: &resource.ListOptions{ - Key: &resource.ResourceKey{ - Namespace: "namespace", - Group: "group", - Resource: "resource", - }, - }, + backend, err := sql.NewBackend(sql.BackendOptions{ + DBProvider: eDB, }) require.NoError(t, err) - require.Nil(t, resp.Error) - require.Len(t, resp.Items, 2) - require.Equal(t, "item2 MODIFIED", string(resp.Items[0].Value)) - require.Equal(t, "item3 ADDED", string(resp.Items[1].Value)) - require.Equal(t, rv5, resp.ResourceVersion) - }) - - t.Run("Watch events", func(t *testing.T) { - event := <-stream - require.Equal(t, "item1", event.Key.Name) - require.Equal(t, rv1, event.ResourceVersion) - require.Equal(t, resource.WatchEvent_ADDED, event.Type) - require.Equal(t, "folderuid", event.Folder) - - event = <-stream - require.Equal(t, "item2", event.Key.Name) - require.Equal(t, rv2, event.ResourceVersion) - require.Equal(t, resource.WatchEvent_ADDED, event.Type) - require.Equal(t, "folderuid", event.Folder) - - event = <-stream - require.Equal(t, "item3", event.Key.Name) - require.Equal(t, rv3, event.ResourceVersion) - require.Equal(t, resource.WatchEvent_ADDED, event.Type) - - event = <-stream - require.Equal(t, "item2", event.Key.Name) - require.Equal(t, rv4, event.ResourceVersion) - require.Equal(t, resource.WatchEvent_MODIFIED, event.Type) - - event = <-stream - require.Equal(t, "item1", event.Key.Name) - require.Equal(t, rv5, event.ResourceVersion) - require.Equal(t, resource.WatchEvent_DELETED, event.Type) - }) -} - -func TestIntegrationBackendWatchWriteEventsFromLastest(t *testing.T) { - if infraDB.IsTestDbSQLite() { - t.Skip("TODO: test blocking, skipping to unblock Enterprise until we fix this") - } - if testing.Short() { - t.Skip("skipping integration test") - } - - ctx := testutil.NewTestContext(t, time.Now().Add(5*time.Second)) - backend, _ := newServer(t, nil) - - // Create a few resources before initing the watch - _, err := writeEvent(ctx, backend, "item1", resource.WatchEvent_ADDED) - require.NoError(t, err) - - // Start the watch - stream, err := backend.WatchWriteEvents(ctx) - require.NoError(t, err) - - // Create one more event - _, err = writeEvent(ctx, backend, "item2", resource.WatchEvent_ADDED) - require.NoError(t, err) - require.Equal(t, "item2", (<-stream).Key.Name) -} - -func TestIntegrationBackendList(t *testing.T) { - if infraDB.IsTestDbSQLite() { - t.Skip("TODO: test blocking, skipping to unblock Enterprise until we fix this") - } - if testing.Short() { - t.Skip("skipping integration test") - } - - ctx := testutil.NewTestContext(t, time.Now().Add(5*time.Second)) - backend, server := newServer(t, nil) - - // Create a few resources before starting the watch - rv1, _ := writeEvent(ctx, backend, "item1", resource.WatchEvent_ADDED) - require.Greater(t, rv1, int64(0)) - rv2, _ := writeEvent(ctx, backend, "item2", resource.WatchEvent_ADDED) // rv=2 - will be modified at rv=6 - require.Greater(t, rv2, rv1) - rv3, _ := writeEvent(ctx, backend, "item3", resource.WatchEvent_ADDED) // rv=3 - will be deleted at rv=7 - require.Greater(t, rv3, rv2) - rv4, _ := writeEvent(ctx, backend, "item4", resource.WatchEvent_ADDED) - require.Greater(t, rv4, rv3) - rv5, _ := writeEvent(ctx, backend, "item5", resource.WatchEvent_ADDED) - require.Greater(t, rv5, rv4) - rv6, _ := writeEvent(ctx, backend, "item2", resource.WatchEvent_MODIFIED) - require.Greater(t, rv6, rv5) - rv7, _ := writeEvent(ctx, backend, "item3", resource.WatchEvent_DELETED) - require.Greater(t, rv7, rv6) - rv8, _ := writeEvent(ctx, backend, "item6", resource.WatchEvent_ADDED) - require.Greater(t, rv8, rv7) - - t.Run("fetch all latest", func(t *testing.T) { - res, err := server.List(ctx, &resource.ListRequest{ - Options: &resource.ListOptions{ - Key: &resource.ResourceKey{ - Group: "group", - Resource: "resource", - }, - }, - }) + require.NotNil(t, backend) + err = backend.Init(testutil.NewDefaultTestContext(t)) require.NoError(t, err) - require.Nil(t, res.Error) - require.Len(t, res.Items, 5) - // should be sorted by key ASC - require.Equal(t, "item1 ADDED", string(res.Items[0].Value)) - require.Equal(t, "item2 MODIFIED", string(res.Items[1].Value)) - require.Equal(t, "item4 ADDED", string(res.Items[2].Value)) - require.Equal(t, "item5 ADDED", string(res.Items[3].Value)) - require.Equal(t, "item6 ADDED", string(res.Items[4].Value)) - - require.Empty(t, res.NextPageToken) - }) - - t.Run("list latest first page ", func(t *testing.T) { - res, err := server.List(ctx, &resource.ListRequest{ - Limit: 3, - Options: &resource.ListOptions{ - Key: &resource.ResourceKey{ - Group: "group", - Resource: "resource", - }, - }, - }) - require.NoError(t, err) - require.Nil(t, res.Error) - require.Len(t, res.Items, 3) - continueToken, err := sql.GetContinueToken(res.NextPageToken) - require.NoError(t, err) - require.Equal(t, "item1 ADDED", string(res.Items[0].Value)) - require.Equal(t, "item2 MODIFIED", string(res.Items[1].Value)) - require.Equal(t, "item4 ADDED", string(res.Items[2].Value)) - require.Equal(t, rv8, continueToken.ResourceVersion) - }) - - t.Run("list at revision", func(t *testing.T) { - res, err := server.List(ctx, &resource.ListRequest{ - ResourceVersion: rv4, - Options: &resource.ListOptions{ - Key: &resource.ResourceKey{ - Group: "group", - Resource: "resource", - }, - }, - }) - require.NoError(t, err) - require.Nil(t, res.Error) - require.Len(t, res.Items, 4) - require.Equal(t, "item1 ADDED", string(res.Items[0].Value)) - require.Equal(t, "item2 ADDED", string(res.Items[1].Value)) - require.Equal(t, "item3 ADDED", string(res.Items[2].Value)) - require.Equal(t, "item4 ADDED", string(res.Items[3].Value)) - require.Empty(t, res.NextPageToken) - }) - - t.Run("fetch first page at revision with limit", func(t *testing.T) { - res, err := server.List(ctx, &resource.ListRequest{ - Limit: 3, - ResourceVersion: rv7, - Options: &resource.ListOptions{ - Key: &resource.ResourceKey{ - Group: "group", - Resource: "resource", - }, - }, - }) - require.NoError(t, err) - require.NoError(t, err) - require.Nil(t, res.Error) - require.Len(t, res.Items, 3) - t.Log(res.Items) - require.Equal(t, "item1 ADDED", string(res.Items[0].Value)) - require.Equal(t, "item2 MODIFIED", string(res.Items[1].Value)) - require.Equal(t, "item4 ADDED", string(res.Items[2].Value)) - - continueToken, err := sql.GetContinueToken(res.NextPageToken) - require.NoError(t, err) - require.Equal(t, rv7, continueToken.ResourceVersion) - }) - - t.Run("fetch second page at revision", func(t *testing.T) { - continueToken := &sql.ContinueToken{ - ResourceVersion: rv8, - StartOffset: 2, - } - res, err := server.List(ctx, &resource.ListRequest{ - NextPageToken: continueToken.String(), - Limit: 2, - Options: &resource.ListOptions{ - Key: &resource.ResourceKey{ - Group: "group", - Resource: "resource", - }, - }, - }) - require.NoError(t, err) - require.Nil(t, res.Error) - require.Len(t, res.Items, 2) - t.Log(res.Items) - require.Equal(t, "item4 ADDED", string(res.Items[0].Value)) - require.Equal(t, "item5 ADDED", string(res.Items[1].Value)) - - continueToken, err = sql.GetContinueToken(res.NextPageToken) - require.NoError(t, err) - require.Equal(t, rv8, continueToken.ResourceVersion) - require.Equal(t, int64(4), continueToken.StartOffset) - }) - - // add 5 events for item1 - should be saved to history - rvHistory1, err := writeEvent(ctx, backend, "item1", resource.WatchEvent_MODIFIED) - require.NoError(t, err) - require.Greater(t, rvHistory1, rv1) - rvHistory2, err := writeEvent(ctx, backend, "item1", resource.WatchEvent_MODIFIED) - require.NoError(t, err) - require.Greater(t, rvHistory2, rvHistory1) - rvHistory3, err := writeEvent(ctx, backend, "item1", resource.WatchEvent_MODIFIED) - require.NoError(t, err) - require.Greater(t, rvHistory3, rvHistory2) - rvHistory4, err := writeEvent(ctx, backend, "item1", resource.WatchEvent_MODIFIED) - require.NoError(t, err) - require.Greater(t, rvHistory4, rvHistory3) - rvHistory5, err := writeEvent(ctx, backend, "item1", resource.WatchEvent_MODIFIED) - require.NoError(t, err) - require.Greater(t, rvHistory5, rvHistory4) - - t.Run("fetch first history page at revision with limit", func(t *testing.T) { - res, err := server.List(ctx, &resource.ListRequest{ - Limit: 3, - Source: resource.ListRequest_HISTORY, - Options: &resource.ListOptions{ - Key: &resource.ResourceKey{ - Namespace: "namespace", - Group: "group", - Resource: "resource", - Name: "item1", - }, - }, - }) - require.NoError(t, err) - require.NoError(t, err) - require.Nil(t, res.Error) - require.Len(t, res.Items, 3) - t.Log(res.Items) - // should be in desc order, so the newest RVs are returned first - require.Equal(t, "item1 MODIFIED", string(res.Items[0].Value)) - require.Equal(t, rvHistory5, res.Items[0].ResourceVersion) - require.Equal(t, "item1 MODIFIED", string(res.Items[1].Value)) - require.Equal(t, rvHistory4, res.Items[1].ResourceVersion) - require.Equal(t, "item1 MODIFIED", string(res.Items[2].Value)) - require.Equal(t, rvHistory3, res.Items[2].ResourceVersion) - - continueToken, err := sql.GetContinueToken(res.NextPageToken) - require.NoError(t, err) - // should return the furthest back RV as the next page token - require.Equal(t, rvHistory3, continueToken.ResourceVersion) - }) - - t.Run("fetch second page of history at revision", func(t *testing.T) { - continueToken := &sql.ContinueToken{ - ResourceVersion: rvHistory3, - StartOffset: 2, - } - res, err := server.List(ctx, &resource.ListRequest{ - NextPageToken: continueToken.String(), - Limit: 2, - Source: resource.ListRequest_HISTORY, - Options: &resource.ListOptions{ - Key: &resource.ResourceKey{ - Namespace: "namespace", - Group: "group", - Resource: "resource", - Name: "item1", - }, - }, - }) - require.NoError(t, err) - require.Nil(t, res.Error) - require.Len(t, res.Items, 2) - t.Log(res.Items) - require.Equal(t, "item1 MODIFIED", string(res.Items[0].Value)) - require.Equal(t, rvHistory2, res.Items[0].ResourceVersion) - require.Equal(t, "item1 MODIFIED", string(res.Items[1].Value)) - require.Equal(t, rvHistory1, res.Items[1].ResourceVersion) - }) -} - -func TestIntegrationBlobSupport(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - - ctx := testutil.NewTestContext(t, time.Now().Add(5*time.Second)) - backend, server := newServer(t, nil) - store, ok := backend.(resource.BlobSupport) - require.True(t, ok) - - t.Run("put and fetch blob", func(t *testing.T) { - key := &resource.ResourceKey{ - Namespace: "ns", - Group: "g", - Resource: "r", - Name: "n", - } - - b1, err := server.PutBlob(ctx, &resource.PutBlobRequest{ - Resource: key, - Method: resource.PutBlobRequest_GRPC, - ContentType: "plain/text", - Value: []byte("hello 11111"), - }) - require.NoError(t, err) - require.Nil(t, b1.Error) - require.Equal(t, "c894ae57bd227b8f8c63f38a2ddf458b", b1.Hash) - - b2, err := server.PutBlob(ctx, &resource.PutBlobRequest{ - Resource: key, - Method: resource.PutBlobRequest_GRPC, - ContentType: "plain/text", - Value: []byte("hello 22222"), // the most recent - }) - require.NoError(t, err) - require.Nil(t, b2.Error) - require.Equal(t, "b0da48de4ff92e0ad0d836de4d746937", b2.Hash) - - // Check that we can still access both values - found, err := store.GetResourceBlob(ctx, key, &utils.BlobInfo{UID: b1.Uid}, true) - require.NoError(t, err) - require.Equal(t, []byte("hello 11111"), found.Value) - - found, err = store.GetResourceBlob(ctx, key, &utils.BlobInfo{UID: b2.Uid}, true) - require.NoError(t, err) - require.Equal(t, []byte("hello 22222"), found.Value) + return backend }) } @@ -510,7 +79,7 @@ func TestClientServer(t *testing.T) { }) t.Run("Create a client", func(t *testing.T) { - conn, err := grpc.NewClient(svc.GetAddress(), grpc.WithTransportCredentials(insecure.NewCredentials())) + conn, err := unified.GrpcConn(svc.GetAddress(), prometheus.NewPedanticRegistry()) require.NoError(t, err) client, err = resource.NewRemoteResourceClient(tracing.NewNoopTracerService(), conn, authn.GrpcClientConfig{}, true) require.NoError(t, err) @@ -550,28 +119,6 @@ func TestClientServer(t *testing.T) { }) } -func writeEvent(ctx context.Context, store sql.Backend, name string, action resource.WatchEvent_Type) (int64, error) { - res := &unstructured.Unstructured{ - Object: map[string]any{}, - } - meta, err := utils.MetaAccessor(res) - if err != nil { - return 0, err - } - meta.SetFolder("folderuid") - return store.WriteEvent(ctx, resource.WriteEvent{ - Type: action, - Value: []byte(name + " " + resource.WatchEvent_Type_name[int32(action)]), - Key: &resource.ResourceKey{ - Namespace: "namespace", - Group: "group", - Resource: "resource", - Name: name, - }, - Object: meta, - }) -} - func resourceKey(name string) *resource.ResourceKey { return &resource.ResourceKey{ Namespace: "namespace", diff --git a/pkg/storage/unified/testing/storage_backend.go b/pkg/storage/unified/testing/storage_backend.go new file mode 100644 index 00000000000..25a422aaede --- /dev/null +++ b/pkg/storage/unified/testing/storage_backend.go @@ -0,0 +1,516 @@ +package test + +import ( + "context" + "testing" + "time" + + "github.com/go-jose/go-jose/v3/jwt" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/grafana/authlib/authn" + "github.com/grafana/authlib/types" + "github.com/grafana/grafana/pkg/apimachinery/utils" + infraDB "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/storage/unified/resource" + "github.com/grafana/grafana/pkg/storage/unified/sql" + "github.com/grafana/grafana/pkg/util/testutil" +) + +type NewBackendFunc func(ctx context.Context) resource.StorageBackend + +// TestCase defines a test case for the storage backend +type TestCase struct { + name string + fn func(*testing.T, resource.StorageBackend) +} + +// StorageBackendTestSuite defines the test suite for storage backend +type StorageBackendTestSuite struct { + newBackend NewBackendFunc + cases []TestCase +} + +// NewStorageBackendTestSuite creates a new test suite +func NewStorageBackendTestSuite(newBackend NewBackendFunc) *StorageBackendTestSuite { + return &StorageBackendTestSuite{ + newBackend: newBackend, + cases: []TestCase{ + {"happy path", runTestIntegrationBackendHappyPath}, + {"watch write events from latest", runTestIntegrationBackendWatchWriteEventsFromLastest}, + {"list", runTestIntegrationBackendList}, + {"blob support", runTestIntegrationBlobSupport}, + }, + } +} + +// Run executes all test cases in the suite +func (s *StorageBackendTestSuite) Run(t *testing.T) { + for _, tc := range s.cases { + t.Run(tc.name, func(t *testing.T) { + tc.fn(t, s.newBackend(context.Background())) + }) + } +} + +// RunStorageBackendTest runs the storage backend test suite +func RunStorageBackendTest(t *testing.T, newBackend NewBackendFunc) { + suite := NewStorageBackendTestSuite(newBackend) + suite.Run(t) +} + +func runTestIntegrationBackendHappyPath(t *testing.T, backend resource.StorageBackend) { + if testing.Short() { + t.Skip("skipping integration test") + } + + ctx := types.WithAuthInfo(context.Background(), authn.NewAccessTokenAuthInfo(authn.Claims[authn.AccessTokenClaims]{ + Claims: jwt.Claims{ + Subject: "testuser", + }, + Rest: authn.AccessTokenClaims{}, + })) + + server := newServer(t, backend) + + stream, err := backend.WatchWriteEvents(context.Background()) // Using a different context to avoid canceling the stream after the DefaultContextTimeout + require.NoError(t, err) + var rv1, rv2, rv3, rv4, rv5 int64 + + t.Run("Add 3 resources", func(t *testing.T) { + rv1, err = writeEvent(ctx, backend, "item1", resource.WatchEvent_ADDED) + require.NoError(t, err) + require.Greater(t, rv1, int64(0)) + + rv2, err = writeEvent(ctx, backend, "item2", resource.WatchEvent_ADDED) + require.NoError(t, err) + require.Greater(t, rv2, rv1) + + rv3, err = writeEvent(ctx, backend, "item3", resource.WatchEvent_ADDED) + require.NoError(t, err) + require.Greater(t, rv3, rv2) + + stats, err := backend.GetResourceStats(ctx, "", 0) + require.NoError(t, err) + require.Len(t, stats, 1) + require.Equal(t, int64(3), stats[0].Count) + require.Equal(t, rv3, stats[0].ResourceVersion) + }) + + t.Run("Update item2", func(t *testing.T) { + rv4, err = writeEvent(ctx, backend, "item2", resource.WatchEvent_MODIFIED) + require.NoError(t, err) + require.Greater(t, rv4, rv3) + }) + + t.Run("Delete item1", func(t *testing.T) { + rv5, err = writeEvent(ctx, backend, "item1", resource.WatchEvent_DELETED) + require.NoError(t, err) + require.Greater(t, rv5, rv4) + }) + + t.Run("Read latest item 2", func(t *testing.T) { + resp := backend.ReadResource(ctx, &resource.ReadRequest{Key: resourceKey("item2")}) + require.Nil(t, resp.Error) + require.Equal(t, rv4, resp.ResourceVersion) + require.Equal(t, "item2 MODIFIED", string(resp.Value)) + require.Equal(t, "folderuid", resp.Folder) + }) + + t.Run("Read early version of item2", func(t *testing.T) { + resp := backend.ReadResource(ctx, &resource.ReadRequest{ + Key: resourceKey("item2"), + ResourceVersion: rv3, // item2 was created at rv2 and updated at rv4 + }) + require.Nil(t, resp.Error) + require.Equal(t, rv2, resp.ResourceVersion) + require.Equal(t, "item2 ADDED", string(resp.Value)) + }) + + t.Run("PrepareList latest", func(t *testing.T) { + resp, err := server.List(ctx, &resource.ListRequest{ + Options: &resource.ListOptions{ + Key: &resource.ResourceKey{ + Namespace: "namespace", + Group: "group", + Resource: "resource", + }, + }, + }) + require.NoError(t, err) + require.Nil(t, resp.Error) + require.Len(t, resp.Items, 2) + require.Equal(t, "item2 MODIFIED", string(resp.Items[0].Value)) + require.Equal(t, "item3 ADDED", string(resp.Items[1].Value)) + require.Equal(t, rv5, resp.ResourceVersion) + }) + + t.Run("Watch events", func(t *testing.T) { + event := <-stream + require.Equal(t, "item1", event.Key.Name) + require.Equal(t, rv1, event.ResourceVersion) + require.Equal(t, resource.WatchEvent_ADDED, event.Type) + require.Equal(t, "folderuid", event.Folder) + + event = <-stream + require.Equal(t, "item2", event.Key.Name) + require.Equal(t, rv2, event.ResourceVersion) + require.Equal(t, resource.WatchEvent_ADDED, event.Type) + require.Equal(t, "folderuid", event.Folder) + + event = <-stream + require.Equal(t, "item3", event.Key.Name) + require.Equal(t, rv3, event.ResourceVersion) + require.Equal(t, resource.WatchEvent_ADDED, event.Type) + + event = <-stream + require.Equal(t, "item2", event.Key.Name) + require.Equal(t, rv4, event.ResourceVersion) + require.Equal(t, resource.WatchEvent_MODIFIED, event.Type) + + event = <-stream + require.Equal(t, "item1", event.Key.Name) + require.Equal(t, rv5, event.ResourceVersion) + require.Equal(t, resource.WatchEvent_DELETED, event.Type) + }) +} + +func runTestIntegrationBackendWatchWriteEventsFromLastest(t *testing.T, backend resource.StorageBackend) { + if infraDB.IsTestDbSQLite() { + t.Skip("TODO: test blocking, skipping to unblock Enterprise until we fix this") + } + if testing.Short() { + t.Skip("skipping integration test") + } + + ctx := testutil.NewTestContext(t, time.Now().Add(5*time.Second)) + + // Create a few resources before initing the watch + _, err := writeEvent(ctx, backend, "item1", resource.WatchEvent_ADDED) + require.NoError(t, err) + + // Start the watch + stream, err := backend.WatchWriteEvents(ctx) + require.NoError(t, err) + + // Create one more event + _, err = writeEvent(ctx, backend, "item2", resource.WatchEvent_ADDED) + require.NoError(t, err) + require.Equal(t, "item2", (<-stream).Key.Name) +} + +func runTestIntegrationBackendList(t *testing.T, backend resource.StorageBackend) { + if infraDB.IsTestDbSQLite() { + t.Skip("TODO: test blocking, skipping to unblock Enterprise until we fix this") + } + if testing.Short() { + t.Skip("skipping integration test") + } + + ctx := testutil.NewTestContext(t, time.Now().Add(5*time.Second)) + server := newServer(t, backend) + + // Create a few resources before starting the watch + rv1, _ := writeEvent(ctx, backend, "item1", resource.WatchEvent_ADDED) + require.Greater(t, rv1, int64(0)) + rv2, _ := writeEvent(ctx, backend, "item2", resource.WatchEvent_ADDED) // rv=2 - will be modified at rv=6 + require.Greater(t, rv2, rv1) + rv3, _ := writeEvent(ctx, backend, "item3", resource.WatchEvent_ADDED) // rv=3 - will be deleted at rv=7 + require.Greater(t, rv3, rv2) + rv4, _ := writeEvent(ctx, backend, "item4", resource.WatchEvent_ADDED) + require.Greater(t, rv4, rv3) + rv5, _ := writeEvent(ctx, backend, "item5", resource.WatchEvent_ADDED) + require.Greater(t, rv5, rv4) + rv6, _ := writeEvent(ctx, backend, "item2", resource.WatchEvent_MODIFIED) + require.Greater(t, rv6, rv5) + rv7, _ := writeEvent(ctx, backend, "item3", resource.WatchEvent_DELETED) + require.Greater(t, rv7, rv6) + rv8, _ := writeEvent(ctx, backend, "item6", resource.WatchEvent_ADDED) + require.Greater(t, rv8, rv7) + + t.Run("fetch all latest", func(t *testing.T) { + res, err := server.List(ctx, &resource.ListRequest{ + Options: &resource.ListOptions{ + Key: &resource.ResourceKey{ + Group: "group", + Resource: "resource", + }, + }, + }) + require.NoError(t, err) + require.Nil(t, res.Error) + require.Len(t, res.Items, 5) + // should be sorted by key ASC + require.Equal(t, "item1 ADDED", string(res.Items[0].Value)) + require.Equal(t, "item2 MODIFIED", string(res.Items[1].Value)) + require.Equal(t, "item4 ADDED", string(res.Items[2].Value)) + require.Equal(t, "item5 ADDED", string(res.Items[3].Value)) + require.Equal(t, "item6 ADDED", string(res.Items[4].Value)) + + require.Empty(t, res.NextPageToken) + }) + + t.Run("list latest first page ", func(t *testing.T) { + res, err := server.List(ctx, &resource.ListRequest{ + Limit: 3, + Options: &resource.ListOptions{ + Key: &resource.ResourceKey{ + Group: "group", + Resource: "resource", + }, + }, + }) + require.NoError(t, err) + require.Nil(t, res.Error) + require.Len(t, res.Items, 3) + continueToken, err := sql.GetContinueToken(res.NextPageToken) + require.NoError(t, err) + require.Equal(t, "item1 ADDED", string(res.Items[0].Value)) + require.Equal(t, "item2 MODIFIED", string(res.Items[1].Value)) + require.Equal(t, "item4 ADDED", string(res.Items[2].Value)) + require.Equal(t, rv8, continueToken.ResourceVersion) + }) + + t.Run("list at revision", func(t *testing.T) { + res, err := server.List(ctx, &resource.ListRequest{ + ResourceVersion: rv4, + Options: &resource.ListOptions{ + Key: &resource.ResourceKey{ + Group: "group", + Resource: "resource", + }, + }, + }) + require.NoError(t, err) + require.Nil(t, res.Error) + require.Len(t, res.Items, 4) + require.Equal(t, "item1 ADDED", string(res.Items[0].Value)) + require.Equal(t, "item2 ADDED", string(res.Items[1].Value)) + require.Equal(t, "item3 ADDED", string(res.Items[2].Value)) + require.Equal(t, "item4 ADDED", string(res.Items[3].Value)) + require.Empty(t, res.NextPageToken) + }) + + t.Run("fetch first page at revision with limit", func(t *testing.T) { + res, err := server.List(ctx, &resource.ListRequest{ + Limit: 3, + ResourceVersion: rv7, + Options: &resource.ListOptions{ + Key: &resource.ResourceKey{ + Group: "group", + Resource: "resource", + }, + }, + }) + require.NoError(t, err) + require.NoError(t, err) + require.Nil(t, res.Error) + require.Len(t, res.Items, 3) + t.Log(res.Items) + require.Equal(t, "item1 ADDED", string(res.Items[0].Value)) + require.Equal(t, "item2 MODIFIED", string(res.Items[1].Value)) + require.Equal(t, "item4 ADDED", string(res.Items[2].Value)) + + continueToken, err := sql.GetContinueToken(res.NextPageToken) + require.NoError(t, err) + require.Equal(t, rv7, continueToken.ResourceVersion) + }) + + t.Run("fetch second page at revision", func(t *testing.T) { + continueToken := &sql.ContinueToken{ + ResourceVersion: rv8, + StartOffset: 2, + } + res, err := server.List(ctx, &resource.ListRequest{ + NextPageToken: continueToken.String(), + Limit: 2, + Options: &resource.ListOptions{ + Key: &resource.ResourceKey{ + Group: "group", + Resource: "resource", + }, + }, + }) + require.NoError(t, err) + require.Nil(t, res.Error) + require.Len(t, res.Items, 2) + t.Log(res.Items) + require.Equal(t, "item4 ADDED", string(res.Items[0].Value)) + require.Equal(t, "item5 ADDED", string(res.Items[1].Value)) + + continueToken, err = sql.GetContinueToken(res.NextPageToken) + require.NoError(t, err) + require.Equal(t, rv8, continueToken.ResourceVersion) + require.Equal(t, int64(4), continueToken.StartOffset) + }) + + // add 5 events for item1 - should be saved to history + rvHistory1, err := writeEvent(ctx, backend, "item1", resource.WatchEvent_MODIFIED) + require.NoError(t, err) + require.Greater(t, rvHistory1, rv1) + rvHistory2, err := writeEvent(ctx, backend, "item1", resource.WatchEvent_MODIFIED) + require.NoError(t, err) + require.Greater(t, rvHistory2, rvHistory1) + rvHistory3, err := writeEvent(ctx, backend, "item1", resource.WatchEvent_MODIFIED) + require.NoError(t, err) + require.Greater(t, rvHistory3, rvHistory2) + rvHistory4, err := writeEvent(ctx, backend, "item1", resource.WatchEvent_MODIFIED) + require.NoError(t, err) + require.Greater(t, rvHistory4, rvHistory3) + rvHistory5, err := writeEvent(ctx, backend, "item1", resource.WatchEvent_MODIFIED) + require.NoError(t, err) + require.Greater(t, rvHistory5, rvHistory4) + + t.Run("fetch first history page at revision with limit", func(t *testing.T) { + res, err := server.List(ctx, &resource.ListRequest{ + Limit: 3, + Source: resource.ListRequest_HISTORY, + Options: &resource.ListOptions{ + Key: &resource.ResourceKey{ + Namespace: "namespace", + Group: "group", + Resource: "resource", + Name: "item1", + }, + }, + }) + require.NoError(t, err) + require.NoError(t, err) + require.Nil(t, res.Error) + require.Len(t, res.Items, 3) + t.Log(res.Items) + // should be in desc order, so the newest RVs are returned first + require.Equal(t, "item1 MODIFIED", string(res.Items[0].Value)) + require.Equal(t, rvHistory5, res.Items[0].ResourceVersion) + require.Equal(t, "item1 MODIFIED", string(res.Items[1].Value)) + require.Equal(t, rvHistory4, res.Items[1].ResourceVersion) + require.Equal(t, "item1 MODIFIED", string(res.Items[2].Value)) + require.Equal(t, rvHistory3, res.Items[2].ResourceVersion) + + continueToken, err := sql.GetContinueToken(res.NextPageToken) + require.NoError(t, err) + // should return the furthest back RV as the next page token + require.Equal(t, rvHistory3, continueToken.ResourceVersion) + }) + + t.Run("fetch second page of history at revision", func(t *testing.T) { + continueToken := &sql.ContinueToken{ + ResourceVersion: rvHistory3, + StartOffset: 2, + } + res, err := server.List(ctx, &resource.ListRequest{ + NextPageToken: continueToken.String(), + Limit: 2, + Source: resource.ListRequest_HISTORY, + Options: &resource.ListOptions{ + Key: &resource.ResourceKey{ + Namespace: "namespace", + Group: "group", + Resource: "resource", + Name: "item1", + }, + }, + }) + require.NoError(t, err) + require.Nil(t, res.Error) + require.Len(t, res.Items, 2) + t.Log(res.Items) + require.Equal(t, "item1 MODIFIED", string(res.Items[0].Value)) + require.Equal(t, rvHistory2, res.Items[0].ResourceVersion) + require.Equal(t, "item1 MODIFIED", string(res.Items[1].Value)) + require.Equal(t, rvHistory1, res.Items[1].ResourceVersion) + }) +} + +func runTestIntegrationBlobSupport(t *testing.T, backend resource.StorageBackend) { + if testing.Short() { + t.Skip("skipping integration test") + } + + ctx := testutil.NewTestContext(t, time.Now().Add(5*time.Second)) + server := newServer(t, backend) + store, ok := backend.(resource.BlobSupport) + require.True(t, ok) + + t.Run("put and fetch blob", func(t *testing.T) { + key := &resource.ResourceKey{ + Namespace: "ns", + Group: "g", + Resource: "r", + Name: "n", + } + + b1, err := server.PutBlob(ctx, &resource.PutBlobRequest{ + Resource: key, + Method: resource.PutBlobRequest_GRPC, + ContentType: "plain/text", + Value: []byte("hello 11111"), + }) + require.NoError(t, err) + require.Nil(t, b1.Error) + require.Equal(t, "c894ae57bd227b8f8c63f38a2ddf458b", b1.Hash) + + b2, err := server.PutBlob(ctx, &resource.PutBlobRequest{ + Resource: key, + Method: resource.PutBlobRequest_GRPC, + ContentType: "plain/text", + Value: []byte("hello 22222"), // the most recent + }) + require.NoError(t, err) + require.Nil(t, b2.Error) + require.Equal(t, "b0da48de4ff92e0ad0d836de4d746937", b2.Hash) + + // Check that we can still access both values + found, err := store.GetResourceBlob(ctx, key, &utils.BlobInfo{UID: b1.Uid}, true) + require.NoError(t, err) + require.Equal(t, []byte("hello 11111"), found.Value) + + found, err = store.GetResourceBlob(ctx, key, &utils.BlobInfo{UID: b2.Uid}, true) + require.NoError(t, err) + require.Equal(t, []byte("hello 22222"), found.Value) + }) +} + +func writeEvent(ctx context.Context, store resource.StorageBackend, name string, action resource.WatchEvent_Type) (int64, error) { + res := &unstructured.Unstructured{ + Object: map[string]any{}, + } + meta, err := utils.MetaAccessor(res) + if err != nil { + return 0, err + } + meta.SetFolder("folderuid") + return store.WriteEvent(ctx, resource.WriteEvent{ + Type: action, + Value: []byte(name + " " + resource.WatchEvent_Type_name[int32(action)]), + Key: &resource.ResourceKey{ + Namespace: "namespace", + Group: "group", + Resource: "resource", + Name: name, + }, + Object: meta, + }) +} + +func resourceKey(name string) *resource.ResourceKey { + return &resource.ResourceKey{ + Namespace: "namespace", + Group: "group", + Resource: "resource", + Name: name, + } +} + +func newServer(t *testing.T, b resource.StorageBackend) resource.ResourceServer { + t.Helper() + + server, err := resource.NewResourceServer(resource.ResourceServerOptions{ + Backend: b, + }) + require.NoError(t, err) + require.NotNil(t, server) + + return server +} diff --git a/pkg/tests/api/alerting/api_notification_channel_test.go b/pkg/tests/api/alerting/api_notification_channel_test.go index 0774f90c30b..4753d915446 100644 --- a/pkg/tests/api/alerting/api_notification_channel_test.go +++ b/pkg/tests/api/alerting/api_notification_channel_test.go @@ -2525,7 +2525,7 @@ var expNonEmailNotifications = map[string][]string{ "attachments": [ { "title": "Integration Test [FIRING:1] SlackAlert1 (default)", - "title_link": "http://localhost:3000/alerting/grafana/UID_SlackAlert1/view", + "title_link": "http://localhost:3000/alerting/grafana/UID_SlackAlert1/view?orgId=1", "text": "Integration Test ", "fallback": "Integration Test [FIRING:1] SlackAlert1 (default)", "footer": "Grafana v", @@ -2545,7 +2545,7 @@ var expNonEmailNotifications = map[string][]string{ "attachments": [ { "title": "[FIRING:1] SlackAlert2 (default)", - "title_link": "http://localhost:3000/alerting/grafana/UID_SlackAlert2/view", + "title_link": "http://localhost:3000/alerting/grafana/UID_SlackAlert2/view?orgId=1", "text": "**Firing**\n\nValue: A=1\nLabels:\n - alertname = SlackAlert2\n - grafana_folder = default\nAnnotations:\nSource: http://localhost:3000/alerting/grafana/UID_SlackAlert2/view?orgId=1\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=__alert_rule_uid__%%3DUID_SlackAlert2&orgId=1\n", "fallback": "[FIRING:1] SlackAlert2 (default)", "footer": "Grafana v", diff --git a/pkg/tsdb/influxdb/flux/flux.go b/pkg/tsdb/influxdb/flux/flux.go index 18d2a6f725a..cab5c0d1067 100644 --- a/pkg/tsdb/influxdb/flux/flux.go +++ b/pkg/tsdb/influxdb/flux/flux.go @@ -31,7 +31,10 @@ func Query(ctx context.Context, dsInfo *models.DatasourceInfo, tsdbQuery backend for _, query := range tsdbQuery.Queries { qm, err := getQueryModel(query, timeRange, dsInfo) if err != nil { - tRes.Responses[query.RefID] = backend.DataResponse{Error: err} + tRes.Responses[query.RefID] = backend.DataResponse{ + Error: err, + ErrorSource: backend.ErrorSourceDownstream, + } continue } diff --git a/pkg/tsdb/influxdb/influxql/influxql.go b/pkg/tsdb/influxdb/influxql/influxql.go index e7de3267e6f..651df59e3c7 100644 --- a/pkg/tsdb/influxdb/influxql/influxql.go +++ b/pkg/tsdb/influxdb/influxql/influxql.go @@ -30,6 +30,7 @@ const ( var ( ErrInvalidHttpMode = errors.New("'httpMode' should be either 'GET' or 'POST'") + ErrInvalidUrl = errors.New("URL must contain scheme and host") glog = log.New("tsdb.influx_influxql") ) @@ -51,13 +52,17 @@ func Query(ctx context.Context, tracer trace.Tracer, dsInfo *models.DatasourceIn reqQuery := req.Queries[idx] query, err := models.QueryParse(reqQuery, logger) if err != nil { - return err + responseLock.Lock() + response.Responses[query.RefID] = backend.DataResponse{ + Error: err, + ErrorSource: backend.ErrorSourceDownstream, + } + responseLock.Unlock() + return nil } - rawQuery, err := query.Build(req) - if err != nil { - return err - } + // query.Build() unconditionally returns nil for error. + rawQuery, _ := query.Build(req) query.RefID = reqQuery.RefID query.RawQuery = rawQuery @@ -68,7 +73,13 @@ func Query(ctx context.Context, tracer trace.Tracer, dsInfo *models.DatasourceIn request, err := createRequest(ctx, logger, dsInfo, rawQuery, query.Policy) if err != nil { - return err + responseLock.Lock() + response.Responses[query.RefID] = backend.DataResponse{ + Error: err, + ErrorSource: backend.ErrorSourceDownstream, + } + responseLock.Unlock() + return nil } resp, err := execute(ctx, tracer, dsInfo, logger, query, request, features.IsEnabled(ctx, featuremgmt.FlagInfluxqlStreamingParser)) @@ -90,13 +101,15 @@ func Query(ctx context.Context, tracer trace.Tracer, dsInfo *models.DatasourceIn for _, reqQuery := range req.Queries { query, err := models.QueryParse(reqQuery, logger) if err != nil { - return &backend.QueryDataResponse{}, err + response.Responses[query.RefID] = backend.DataResponse{ + Error: err, + ErrorSource: backend.ErrorSourceDownstream, + } + continue } - rawQuery, err := query.Build(req) - if err != nil { - return &backend.QueryDataResponse{}, err - } + // query.Build() unconditionally returns nil for error. + rawQuery, _ := query.Build(req) query.RefID = reqQuery.RefID query.RawQuery = rawQuery @@ -107,7 +120,11 @@ func Query(ctx context.Context, tracer trace.Tracer, dsInfo *models.DatasourceIn request, err := createRequest(ctx, logger, dsInfo, rawQuery, query.Policy) if err != nil { - return &backend.QueryDataResponse{}, err + response.Responses[query.RefID] = backend.DataResponse{ + Error: err, + ErrorSource: backend.ErrorSourceDownstream, + } + continue } resp, err := execute(ctx, tracer, dsInfo, logger, query, request, features.IsEnabled(ctx, featuremgmt.FlagInfluxqlStreamingParser)) @@ -129,6 +146,13 @@ func createRequest(ctx context.Context, logger log.Logger, dsInfo *models.Dataso return nil, err } + // It's possible that the configuration is bad, and we'll have a URL + // without a scheme or host. This is valid from the PoV of the Go std + // library url.Parse(), but not for this data source. + if u.Host == "" || u.Scheme == "" { + return nil, ErrInvalidUrl + } + u.Path = path.Join(u.Path, "query") httpMode := dsInfo.HTTPMode diff --git a/pkg/tsdb/influxdb/models/model_parser.go b/pkg/tsdb/influxdb/models/model_parser.go index 011066b4ef9..90f8985fc75 100644 --- a/pkg/tsdb/influxdb/models/model_parser.go +++ b/pkg/tsdb/influxdb/models/model_parser.go @@ -1,6 +1,7 @@ package models import ( + "errors" "fmt" "strconv" "time" @@ -12,6 +13,10 @@ import ( "github.com/grafana/grafana/pkg/infra/log" ) +var ( + ErrInvalidQuery = errors.New("invalid InfluxDB query") +) + type InfluxdbQueryParser struct{} func QueryParse(query backend.DataQuery, logger log.Logger) (*Query, error) { @@ -33,17 +38,17 @@ func QueryParse(query backend.DataQuery, logger log.Logger) (*Query, error) { tags, err := parseTags(model) if err != nil { - return nil, err + return nil, errors.Join(ErrInvalidQuery, err) } groupBys, err := parseGroupBy(model) if err != nil { - return nil, err + return nil, errors.Join(ErrInvalidQuery, err) } selects, err := parseSelects(model) if err != nil { - return nil, err + return nil, errors.Join(ErrInvalidQuery, err) } interval := query.Interval diff --git a/public/app/core/components/AppChrome/MegaMenu/MegaMenuItem.tsx b/public/app/core/components/AppChrome/MegaMenu/MegaMenuItem.tsx index ce8b9a53d40..4379283f3c5 100644 --- a/public/app/core/components/AppChrome/MegaMenu/MegaMenuItem.tsx +++ b/public/app/core/components/AppChrome/MegaMenu/MegaMenuItem.tsx @@ -5,8 +5,9 @@ import { useLocation } from 'react-router-dom-v5-compat'; import { useLocalStorage } from 'react-use'; import { GrafanaTheme2, NavModelItem, toIconName } from '@grafana/data'; -import { useStyles2, Text, IconButton, Icon, Stack } from '@grafana/ui'; +import { useStyles2, Text, IconButton, Icon, Stack, Badge } from '@grafana/ui'; import { useGrafana } from 'app/core/context/GrafanaContext'; +import { t } from 'app/core/internationalization'; import { Indent } from '../../Indent/Indent'; @@ -35,7 +36,7 @@ export function MegaMenuItem({ link, activeItem, level = 0, onClick, onPin, isPi const isActive = link === activeItem || (level === MAX_DEPTH && hasActiveChild); const [sectionExpanded, setSectionExpanded] = useLocalStorage( `grafana.navigation.expanded[${link.text}]`, - Boolean(hasActiveChild) + Boolean(hasActiveChild || link.isNew) ); const showExpandButton = level < MAX_DEPTH && Boolean(linkHasChildren(link) || link.emptyMessage); const item = useRef(null); @@ -107,6 +108,7 @@ export function MegaMenuItem({ link, activeItem, level = 0, onClick, onPin, isPi > {level === 0 && iconElement && {iconElement}} {link.text} + {link.isNew && } diff --git a/public/app/core/components/AppChrome/MegaMenu/MegaMenuItemText.tsx b/public/app/core/components/AppChrome/MegaMenu/MegaMenuItemText.tsx index 471450c7d23..b6d18b50d9d 100644 --- a/public/app/core/components/AppChrome/MegaMenu/MegaMenuItemText.tsx +++ b/public/app/core/components/AppChrome/MegaMenu/MegaMenuItemText.tsx @@ -114,7 +114,7 @@ const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive']) => ({ position: 'relative', width: '100%', - '&:hover, &:focus-visible': { + '&:hover span, &:focus-visible span': { color: theme.colors.text.primary, textDecoration: 'underline', }, diff --git a/public/app/core/utils/navBarItem-translations.ts b/public/app/core/utils/navBarItem-translations.ts index 993a914d2c7..eb533d88b07 100644 --- a/public/app/core/utils/navBarItem-translations.ts +++ b/public/app/core/utils/navBarItem-translations.ts @@ -61,6 +61,8 @@ export function getNavTitle(navId: string | undefined) { return t('nav.scenes.title', 'Scenes'); case 'explore': return t('nav.explore.title', 'Explore'); + case 'drilldown': + return t('nav.drilldown.title', 'Drilldown'); case 'alerting': return t('nav.alerting.title', 'Alerting'); case 'plugin-page-grafana-oncall-app': diff --git a/public/app/features/dashboard/api/ResponseTransformers.ts b/public/app/features/dashboard/api/ResponseTransformers.ts index 4c25f8d44a7..b57465de211 100644 --- a/public/app/features/dashboard/api/ResponseTransformers.ts +++ b/public/app/features/dashboard/api/ResponseTransformers.ts @@ -190,13 +190,13 @@ export function ensureV2Response( export function ensureV1Response( dashboard: DashboardDTO | DashboardWithAccessInfo | DashboardWithAccessInfo ): DashboardDTO { - // if dashboard is not on v0 schema or v2 schema, return as is + // if dashboard is not on v1 schema or v2 schema, return as is if (!isDashboardResource(dashboard)) { return dashboard; } const spec = dashboard.spec; - // if dashboard is on v0 schema + // if dashboard is on v1 schema if (isDashboardV0Spec(spec)) { return { meta: { diff --git a/public/app/features/dashboard/api/dashboard_api.test.ts b/public/app/features/dashboard/api/dashboard_api.test.ts index ce234f47ae6..113158bb057 100644 --- a/public/app/features/dashboard/api/dashboard_api.test.ts +++ b/public/app/features/dashboard/api/dashboard_api.test.ts @@ -2,7 +2,7 @@ import { config } from '@grafana/runtime'; import { getDashboardAPI, setDashboardAPI } from './dashboard_api'; import { LegacyDashboardAPI } from './legacy'; -import { K8sDashboardAPI } from './v0'; +import { K8sDashboardAPI } from './v1'; import { K8sDashboardV2API } from './v2'; describe('DashboardApi', () => { @@ -29,7 +29,7 @@ describe('DashboardApi', () => { expect(getDashboardAPI()).toBeInstanceOf(LegacyDashboardAPI); }); - it('should use v0 api when and kubernetesDashboards toggle is enabled', () => { + it('should use v1 api when and kubernetesDashboards toggle is enabled', () => { config.featureToggles.kubernetesDashboards = true; expect(getDashboardAPI()).toBeInstanceOf(K8sDashboardAPI); }); @@ -55,12 +55,12 @@ describe('DashboardApi', () => { expect(getDashboardAPI()).toBeInstanceOf(LegacyDashboardAPI); }); - it('should use v0 api when kubernetesDashboards toggle is enabled', () => { + it('should use v1 api when kubernetesDashboards toggle is enabled', () => { config.featureToggles.kubernetesDashboards = true; expect(getDashboardAPI()).toBeInstanceOf(K8sDashboardAPI); }); - it('should use v0 api when kubernetesDashboards and useV2DashboardsAPI toggle is enabled', () => { + it('should use v1 api when kubernetesDashboards and useV2DashboardsAPI toggle is enabled', () => { config.featureToggles.useV2DashboardsAPI = true; config.featureToggles.kubernetesDashboards = true; expect(getDashboardAPI()).toBeInstanceOf(K8sDashboardAPI); diff --git a/public/app/features/dashboard/api/dashboard_api.ts b/public/app/features/dashboard/api/dashboard_api.ts index 390df6fb9aa..1867be90306 100644 --- a/public/app/features/dashboard/api/dashboard_api.ts +++ b/public/app/features/dashboard/api/dashboard_api.ts @@ -5,13 +5,12 @@ import { DashboardDTO } from 'app/types'; import { LegacyDashboardAPI } from './legacy'; import { DashboardAPI, DashboardWithAccessInfo } from './types'; import { getDashboardsApiVersion } from './utils'; -import { K8sDashboardAPI } from './v0'; +import { K8sDashboardAPI } from './v1'; import { K8sDashboardV2API } from './v2'; type DashboardAPIClients = { legacy: DashboardAPI; - v0: DashboardAPI; - // v1: DashboardDTO; TODO[schema]: enable v1 when available + v1: DashboardAPI; v2: DashboardAPI, DashboardV2Spec>; }; @@ -40,7 +39,7 @@ export function getDashboardAPI( if (!clients) { clients = { legacy: new LegacyDashboardAPI(), - v0: new K8sDashboardAPI(), + v1: new K8sDashboardAPI(), v2: new K8sDashboardV2API(isConvertingToV1), }; } diff --git a/public/app/features/dashboard/api/utils.test.ts b/public/app/features/dashboard/api/utils.test.ts index 0dc97fa87c8..65499339f68 100644 --- a/public/app/features/dashboard/api/utils.test.ts +++ b/public/app/features/dashboard/api/utils.test.ts @@ -7,12 +7,12 @@ describe('getDashboardsApiVersion', () => { jest.resetModules(); }); - it('should return v0 when dashboardScene is disabled and kubernetesDashboards is enabled', () => { + it('should return v1 when dashboardScene is disabled and kubernetesDashboards is enabled', () => { config.featureToggles = { dashboardScene: false, kubernetesDashboards: true, }; - expect(getDashboardsApiVersion()).toBe('v0'); + expect(getDashboardsApiVersion()).toBe('v1'); }); it('should return legacy when dashboardScene is disabled and kubernetesDashboards is disabled', () => { @@ -31,13 +31,13 @@ describe('getDashboardsApiVersion', () => { expect(getDashboardsApiVersion()).toBe('v2'); }); - it('should return v0 when dashboardScene is enabled, useV2DashboardsAPI is disabled, and kubernetesDashboards is enabled', () => { + it('should return v1 when dashboardScene is enabled, useV2DashboardsAPI is disabled, and kubernetesDashboards is enabled', () => { config.featureToggles = { dashboardScene: true, useV2DashboardsAPI: false, kubernetesDashboards: true, }; - expect(getDashboardsApiVersion()).toBe('v0'); + expect(getDashboardsApiVersion()).toBe('v1'); }); it('should return legacy when dashboardScene is enabled and both useV2DashboardsAPI and kubernetesDashboards are disabled', () => { @@ -71,7 +71,7 @@ describe('getDashboardsApiVersion', () => { kubernetesDashboards: true, }; - expect(getDashboardsApiVersion()).toBe('v0'); + expect(getDashboardsApiVersion()).toBe('v1'); }); }); }); diff --git a/public/app/features/dashboard/api/utils.ts b/public/app/features/dashboard/api/utils.ts index 89d0c9219d3..d65c35f2ff0 100644 --- a/public/app/features/dashboard/api/utils.ts +++ b/public/app/features/dashboard/api/utils.ts @@ -14,9 +14,9 @@ export function getDashboardsApiVersion() { // if dashboard scene is disabled, use legacy API response for the old architecture if (!config.featureToggles.dashboardScene || forcingOldDashboardArch) { - // for old architecture, use v0 API for k8s dashboards + // for old architecture, use v1 API for k8s dashboards if (config.featureToggles.kubernetesDashboards) { - return 'v0'; + return 'v1'; } return 'legacy'; @@ -27,20 +27,20 @@ export function getDashboardsApiVersion() { } if (config.featureToggles.kubernetesDashboards) { - return 'v0'; + return 'v1'; } return 'legacy'; } -// This function is used to determine if the dashboard is in v2 format or also v0 format +// This function is used to determine if the dashboard is in v2 format or also v1 format export function isDashboardResource( obj?: DashboardDTO | DashboardWithAccessInfo | DashboardWithAccessInfo | null ): obj is DashboardWithAccessInfo | DashboardWithAccessInfo { if (!obj) { return false; } - // is v0 or v2 format? + // is v1 or v2 format? const isK8sDashboard = 'kind' in obj && obj.kind === 'DashboardWithAccessInfo'; return isK8sDashboard; } @@ -50,7 +50,7 @@ export function isDashboardV2Spec(obj: Dashboard | DashboardDataDTO | DashboardV } export function isDashboardV0Spec(obj: DashboardDataDTO | DashboardV2Spec): obj is DashboardDataDTO { - return !isDashboardV2Spec(obj); // not v2 spec means it's v0 spec + return !isDashboardV2Spec(obj); // not v2 spec means it's v1 spec } export function isDashboardV2Resource( diff --git a/public/app/features/dashboard/api/v0.test.ts b/public/app/features/dashboard/api/v1.test.ts similarity index 97% rename from public/app/features/dashboard/api/v0.test.ts rename to public/app/features/dashboard/api/v1.test.ts index 67ca83adc8e..0f2eadedc5f 100644 --- a/public/app/features/dashboard/api/v0.test.ts +++ b/public/app/features/dashboard/api/v1.test.ts @@ -4,11 +4,11 @@ import { AnnoKeyFolder } from 'app/features/apiserver/types'; import { DashboardDataDTO } from 'app/types'; import { DashboardWithAccessInfo } from './types'; -import { K8sDashboardAPI } from './v0'; +import { K8sDashboardAPI } from './v1'; const mockDashboardDto: DashboardWithAccessInfo = { kind: 'DashboardWithAccessInfo', - apiVersion: 'v0alpha1', + apiVersion: 'v1alpha1', metadata: { name: 'dash-uid', @@ -28,7 +28,7 @@ const mockDashboardDto: DashboardWithAccessInfo = { const saveDashboardResponse = { kind: 'Dashboard', - apiVersion: 'dashboard.grafana.app/v0alpha1', + apiVersion: 'dashboard.grafana.app/v1alpha1', metadata: { name: 'adh59cn', namespace: 'default', @@ -106,7 +106,7 @@ jest.mock('app/features/live/dashboard/dashboardWatcher', () => ({ ignoreNextSave: jest.fn(), })); -describe('v0 dashboard API', () => { +describe('v1 dashboard API', () => { it('should provide folder annotations', async () => { jest.spyOn(backendSrv, 'getFolderByUid').mockResolvedValue({ id: 1, diff --git a/public/app/features/dashboard/api/v0.ts b/public/app/features/dashboard/api/v1.ts similarity index 99% rename from public/app/features/dashboard/api/v0.ts rename to public/app/features/dashboard/api/v1.ts index f4d90551a66..b3383928bc9 100644 --- a/public/app/features/dashboard/api/v0.ts +++ b/public/app/features/dashboard/api/v1.ts @@ -26,7 +26,7 @@ export class K8sDashboardAPI implements DashboardAPI { constructor() { this.client = new ScopedResourceClient({ group: 'dashboard.grafana.app', - version: 'v0alpha1', + version: 'v1alpha1', resource: 'dashboards', }); } diff --git a/public/app/features/datasources/components/EditDataSource.tsx b/public/app/features/datasources/components/EditDataSource.tsx index 64cbc49ddfd..e948bad2013 100644 --- a/public/app/features/datasources/components/EditDataSource.tsx +++ b/public/app/features/datasources/components/EditDataSource.tsx @@ -1,4 +1,5 @@ import { AnyAction } from '@reduxjs/toolkit'; +import { cloneDeep } from 'lodash'; import { useMemo } from 'react'; import * as React from 'react'; @@ -210,7 +211,7 @@ export function EditDataSourceView({
diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index 28ad370648e..4927fc5be4a 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -23,14 +23,18 @@ import { getDataSourceSrv, reportInteraction } from '@grafana/runtime'; import { DataQuery } from '@grafana/schema'; import { AdHocFilterItem, + Alert, ErrorBoundaryAlert, + LinkButton, PanelContainer, ScrollContainer, + Stack, Themeable2, withTheme2, } from '@grafana/ui'; import { FILTER_FOR_OPERATOR, FILTER_OUT_OPERATOR } from '@grafana/ui/src/components/Table/types'; import { supportedFeatures } from 'app/core/history/richHistoryStorageProvider'; +import { t, Trans } from 'app/core/internationalization'; import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource'; import { StoreState } from 'app/types'; @@ -564,6 +568,9 @@ export class Explore extends PureComponent { if (showCorrelationHelper && correlationEditorHelperData !== undefined) { correlationsBox = ; } + const isDsCompatibleWithDrilldown = ['prometheus', 'loki', 'tempo', 'grafana-pyroscope-datasource'].includes( + datasourceInstance?.type || '' + ); return ( @@ -593,6 +600,27 @@ export class Explore extends PureComponent { <> + {isDsCompatibleWithDrilldown && ( + + + + + Looking for the Grafana Explore apps? They are now called the Grafana Drilldown apps + and can be found under Menu > Drilldown + + + + Go to Grafana Drilldown + + + + )} {correlationsBox} ({ limitPerPlugin, extensionPointId, -}: UsePluginComponentOptions): UsePluginComponentsResult { +}: UsePluginComponentsOptions): UsePluginComponentsResult { const registry = useAddedComponentsRegistry(); const registryState = useObservable(registry.asObservable()); const pluginContext = usePluginContext(); diff --git a/public/app/features/plugins/extensions/usePluginLinks.tsx b/public/app/features/plugins/extensions/usePluginLinks.tsx index 58efcc4e3e6..6ecaf6b71b9 100644 --- a/public/app/features/plugins/extensions/usePluginLinks.tsx +++ b/public/app/features/plugins/extensions/usePluginLinks.tsx @@ -3,10 +3,7 @@ import { useMemo } from 'react'; import { useObservable } from 'react-use'; import { PluginExtensionLink, PluginExtensionTypes, usePluginContext } from '@grafana/data'; -import { - UsePluginLinksOptions, - UsePluginLinksResult, -} from '@grafana/runtime/src/services/pluginExtensions/getPluginExtensions'; +import { UsePluginLinksOptions, UsePluginLinksResult } from '@grafana/runtime'; import { useAddedLinksRegistry } from './ExtensionRegistriesContext'; import * as errors from './errors'; diff --git a/public/app/features/trails/Integrations/dashboardIntegration.ts b/public/app/features/trails/Integrations/dashboardIntegration.ts index 4ff039f377b..dd52e73fe44 100644 --- a/public/app/features/trails/Integrations/dashboardIntegration.ts +++ b/public/app/features/trails/Integrations/dashboardIntegration.ts @@ -61,7 +61,7 @@ export async function addDataTrailPanelAction(dashboard: DashboardScene, panel: if (subMenu.length > 0) { items.push({ - text: 'Explore metrics', + text: 'Metrics drilldown', iconClassName: 'code-branch', subMenu: getUnique(subMenu), }); @@ -109,7 +109,7 @@ function createCommonEmbeddedTrailStateProps(item: QueryMetric, dashboard: Dashb const commonProps = { scene: embeddedTrail, - title: 'Explore metrics', + title: 'Metrics drilldown', }; return commonProps; diff --git a/public/app/features/trails/MetricSelect/AddToExplorationsButton.tsx b/public/app/features/trails/MetricSelect/AddToExplorationsButton.tsx index da46b7635d0..bbdd7110530 100644 --- a/public/app/features/trails/MetricSelect/AddToExplorationsButton.tsx +++ b/public/app/features/trails/MetricSelect/AddToExplorationsButton.tsx @@ -80,7 +80,7 @@ export class AddToExplorationButton extends SceneObjectBase i }, ...Array.from(rootGroup?.groups.keys() ?? []).map((g) => ({ label: `${g}_`, value: g })), ]} - className="explore-metrics-metric-prefix-select" + className="metrics-drilldown-metric-prefix-select" /> {!metric && hasOtelResources && ( diff --git a/public/app/features/trails/MetricSelect/api.ts b/public/app/features/trails/MetricSelect/api.ts index bb807ee31e9..c36cbfe3e2c 100644 --- a/public/app/features/trails/MetricSelect/api.ts +++ b/public/app/features/trails/MetricSelect/api.ts @@ -57,7 +57,7 @@ export async function getMetricNamesWithoutScopes( ...(limit ? { limit } : {}), }; - const response = await getBackendSrv().get(url, params, 'explore-metrics-names'); + const response = await getBackendSrv().get(url, params, 'metrics-drilldown-names'); if (limit && response.warnings?.includes(LIMIT_REACHED)) { return { ...response, limitReached: true, missingOtelTargets }; @@ -82,7 +82,7 @@ export async function getMetricNamesWithScopes( filters, '__name__', limit, - 'explore-metrics-names' + 'metrics-drilldown-names' ); if (jobs.length > 0 && instances.length > 0) { diff --git a/public/app/features/trails/helpers/MetricDatasourceHelper.ts b/public/app/features/trails/helpers/MetricDatasourceHelper.ts index 5a64feb9584..ff7d1fa62f2 100644 --- a/public/app/features/trails/helpers/MetricDatasourceHelper.ts +++ b/public/app/features/trails/helpers/MetricDatasourceHelper.ts @@ -154,7 +154,7 @@ export class MetricDatasourceHelper { } /** - * Used for additional filtering for adhoc vars labels in Explore metrics. + * Used for additional filtering for adhoc vars labels in Metrics Drilldown. * @param options * @returns */ @@ -170,7 +170,7 @@ export class MetricDatasourceHelper { } /** - * Used for additional filtering for adhoc vars label values in Explore metrics. + * Used for additional filtering for adhoc vars label values in Metrics Drilldown. * @param options * @returns */ diff --git a/public/app/features/trails/otel/api.test.ts b/public/app/features/trails/otel/api.test.ts index 97e5f4a01ae..9ed900fa8be 100644 --- a/public/app/features/trails/otel/api.test.ts +++ b/public/app/features/trails/otel/api.test.ts @@ -30,10 +30,10 @@ jest.mock('@grafana/runtime', () => ({ requestId?: string, options?: Partial ) => { - // explore-metrics-otel-resources + // metrics-drilldown-otel-resources if ( - requestId === 'explore-metrics-otel-check-total-count(target_info{}) by (job, instance)' || - requestId === 'explore-metrics-otel-check-total-count(metric) by (job, instance)' + requestId === 'metrics-drilldown-otel-check-total-count(target_info{}) by (job, instance)' || + requestId === 'metrics-drilldown-otel-check-total-count(metric) by (job, instance)' ) { return Promise.resolve({ data: { @@ -43,17 +43,17 @@ jest.mock('@grafana/runtime', () => ({ ], }, }); - } else if (requestId === 'explore-metrics-otel-resources-deployment-env') { + } else if (requestId === 'metrics-drilldown-otel-resources-deployment-env') { return Promise.resolve({ data: ['env1', 'env2'] }); } else if ( requestId === - 'explore-metrics-otel-resources-metric-job-instance-metric{job=~"job1|job2",instance=~"instance1|instance2"}' + 'metrics-drilldown-otel-resources-metric-job-instance-metric{job=~"job1|job2",instance=~"instance1|instance2"}' ) { // part of getFilteredResourceAttributes to get metric labels. We prioritize metric labels over resource attributes so we use these to filter return Promise.resolve({ data: ['promotedResourceAttribute'] }); } else if ( requestId === - 'explore-metrics-otel-resources-metric-job-instance-target_info{job=~"job1|job2",instance=~"instance1|instance2"}' + 'metrics-drilldown-otel-resources-metric-job-instance-target_info{job=~"job1|job2",instance=~"instance1|instance2"}' ) { // part of getFilteredResourceAttributes to get instance labels return Promise.resolve({ data: ['promotedResourceAttribute', 'resourceAttribute'] }); diff --git a/public/app/features/trails/otel/api.ts b/public/app/features/trails/otel/api.ts index 542e9b2cd70..3e1bf5bb293 100644 --- a/public/app/features/trails/otel/api.ts +++ b/public/app/features/trails/otel/api.ts @@ -56,7 +56,7 @@ export async function totalOtelResources( const responseTotal = await getBackendSrv().get( url, paramsTotalTargets, - `explore-metrics-otel-check-total-${query}` + `metrics-drilldown-otel-check-total-${query}` ); let jobs: string[] = []; @@ -127,7 +127,7 @@ export async function getDeploymentEnvironmentsWithoutScopes( const response = await getBackendSrv().get( url, params, - 'explore-metrics-otel-resources-deployment-env' + 'metrics-drilldown-otel-resources-deployment-env' ); // exclude __name__ or previously chosen filters @@ -162,7 +162,7 @@ export async function getDeploymentEnvironmentsWithScopes( ], 'deployment_environment', undefined, - 'explore-metrics-otel-resources-deployment-env' + 'metrics-drilldown-otel-resources-deployment-env' ); // exclude __name__ or previously chosen filters return response.data.data; @@ -230,7 +230,7 @@ export async function getFilteredResourceAttributes( const metricResponse = await getBackendSrv().get( url, metricParams, - `explore-metrics-otel-resources-metric-job-instance-${metricMatchParam}` + `metrics-drilldown-otel-resources-metric-job-instance-${metricMatchParam}` ); // the metric labels here const metricLabels = metricResponse.data ?? []; @@ -249,7 +249,7 @@ export async function getFilteredResourceAttributes( const targetInfoResponse = await getBackendSrv().get( url, targetInfoParams, - `explore-metrics-otel-resources-metric-job-instance-${targetInfoMatchParam}` + `metrics-drilldown-otel-resources-metric-job-instance-${targetInfoMatchParam}` ); const targetInfoAttributes = targetInfoResponse.data ?? []; @@ -294,7 +294,7 @@ export async function getNonPromotedOtelResources(datasourceUid: string, timeRan const targetInfoResponse = getBackendSrv().get( url, targetInfoParams, - `explore-metrics-all-otel-resources-on-target_info` + `metrics-drilldown-all-otel-resources-on-target_info` ); // all labels in all metrics @@ -311,7 +311,7 @@ export async function getNonPromotedOtelResources(datasourceUid: string, timeRan const metricResponse = await getBackendSrv().get( url, metricParams, - `explore-metrics-all-metric-labels-not-otel-resource-attributes` + `metrics-drilldown-all-metric-labels-not-otel-resource-attributes` ); const promResponses = await Promise.all([targetInfoResponse, metricResponse]); // otel resource attributes diff --git a/public/app/features/trails/otel/util.ts b/public/app/features/trails/otel/util.ts index 166d089ff4e..8b1d4e29ca5 100644 --- a/public/app/features/trails/otel/util.ts +++ b/public/app/features/trails/otel/util.ts @@ -548,7 +548,7 @@ function checkLabelPromotion(filters: AdHocVariableFilter[], nonPromotedOtelReso * Once we know this, we can add the selected filter to either the * VAR_OTEL_RESOURCES or VAR_FILTERS variable. * - * When the correct variable is updated, the rest of the explore metrics behavior will remain the same. + * When the correct variable is updated, the rest of the Metrics Drilldown behavior will remain the same. * * @param newStateFilters * @param prevStateFilters diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index 0847577522f..fc9b1cf2ca0 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -163,6 +163,10 @@ export function getAppRoutes(): RouteDescriptor[] { : import(/* webpackChunkName: "explore-feature-toggle-page" */ 'app/features/explore/FeatureTogglePage') ), }, + { + path: '/drilldown', + component: () => , + }, { path: '/apps', component: () => , diff --git a/public/img/icons/unicons/drilldown.svg b/public/img/icons/unicons/drilldown.svg new file mode 100644 index 00000000000..27f627ce19f --- /dev/null +++ b/public/img/icons/unicons/drilldown.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index e80a59d9129..d7cba3f8990 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -1419,6 +1419,11 @@ }, "explore": { "add-to-dashboard": "Add to dashboard", + "drilldownInfo": { + "action": "Go to Grafana Drilldown", + "description": "Looking for the Grafana Explore apps? They are now called the Grafana Drilldown apps and can be found under <1>Menu > Drilldown", + "title": "Explore Metrics, Logs, Traces and Profiles have moved!" + }, "logs": { "logs-volume": { "add-filters": "Add more labels to your query to narrow down your search.", @@ -2499,6 +2504,9 @@ "detect": { "title": "Detect" }, + "drilldown": { + "title": "Drilldown" + }, "explore": { "title": "Explore" }, @@ -2714,6 +2722,7 @@ "close": "Close menu", "dock": "Dock menu", "list-label": "Navigation", + "new": "New!", "open": "Open menu", "undock": "Undock menu" }, diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index 43096c5ce25..2dc0f928c44 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -1419,6 +1419,11 @@ }, "explore": { "add-to-dashboard": "Åđđ ŧő đäşĥþőäřđ", + "drilldownInfo": { + "action": "Ğő ŧő Ğřäƒäʼnä Đřįľľđőŵʼn", + "description": "Ŀőőĸįʼnģ ƒőř ŧĥę Ğřäƒäʼnä Ēχpľőřę äppş? Ŧĥęy äřę ʼnőŵ čäľľęđ ŧĥę Ğřäƒäʼnä Đřįľľđőŵʼn äppş äʼnđ čäʼn þę ƒőūʼnđ ūʼnđęř <1>Męʼnū > Đřįľľđőŵʼn", + "title": "Ēχpľőřę Męŧřįčş, Ŀőģş, Ŧřäčęş äʼnđ Přőƒįľęş ĥävę mővęđ!" + }, "logs": { "logs-volume": { "add-filters": "Åđđ mőřę ľäþęľş ŧő yőūř qūęřy ŧő ʼnäřřőŵ đőŵʼn yőūř şęäřčĥ.", @@ -2499,6 +2504,9 @@ "detect": { "title": "Đęŧęčŧ" }, + "drilldown": { + "title": "Đřįľľđőŵʼn" + }, "explore": { "title": "Ēχpľőřę" }, @@ -2714,6 +2722,7 @@ "close": "Cľőşę męʼnū", "dock": "Đőčĸ męʼnū", "list-label": "Ńävįģäŧįőʼn", + "new": "Ńęŵ!", "open": "Øpęʼn męʼnū", "undock": "Ůʼnđőčĸ męʼnū" }, diff --git a/scripts/build/publish.sh b/scripts/build/publish.sh deleted file mode 100755 index 5da55e36b49..00000000000 --- a/scripts/build/publish.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/sh - -# no relation to publish.go - -# shellcheck disable=SC2124 - -EXTRA_OPTS="$@" - -# Right now we hack this in into the publish script. -# Eventually we might want to keep a list of all previous releases somewhere. -_releaseNoteUrl="https://community.grafana.com/t/release-notes-v7-0-x/29381" -_whatsNewUrl="https://grafana.com/docs/grafana/latest/guides/whats-new-in-v7-0/" - -./scripts/build/release_publisher/release_publisher \ - --wn "${_whatsNewUrl}" \ - --rn "${_releaseNoteUrl}" \ - --version "${CIRCLE_TAG}" \ - --apikey "${GRAFANA_COM_API_KEY}" "${EXTRA_OPTS}" diff --git a/scripts/build/release_publisher/externalrelease.go b/scripts/build/release_publisher/externalrelease.go deleted file mode 100644 index 31c2daf15ca..00000000000 --- a/scripts/build/release_publisher/externalrelease.go +++ /dev/null @@ -1,71 +0,0 @@ -package main - -import ( - "fmt" - "io" - "net/http" - "strings" - "time" -) - -type releaseFromExternalContent struct { - getter urlGetter - rawVersion string - artifactConfigurations []buildArtifact -} - -func (re releaseFromExternalContent) prepareRelease(baseArchiveURL, whatsNewURL string, releaseNotesURL string, nightly bool) (*release, error) { - version := re.rawVersion[1:] - beta := strings.Contains(version, "beta") - var rt releaseType - if beta { - rt = BETA - } else if nightly { - rt = NIGHTLY - } else { - rt = STABLE - } - - builds := []build{} - for _, ba := range re.artifactConfigurations { - url := ba.getURL(baseArchiveURL, version, rt) - sha256, err := re.getter.getContents(fmt.Sprintf("%s.sha256", url)) - if err != nil { - return nil, err - } - builds = append(builds, newBuild(url, ba, sha256)) - } - - r := release{ - Version: version, - ReleaseDate: time.Now().UTC(), - Stable: rt.stable(), - Beta: rt.beta(), - Nightly: rt.nightly(), - WhatsNewURL: whatsNewURL, - ReleaseNotesURL: releaseNotesURL, - Builds: builds, - } - return &r, nil -} - -type urlGetter interface { - getContents(url string) (string, error) -} - -type getHTTPContents struct{} - -func (getHTTPContents) getContents(url string) (string, error) { - response, err := http.Get(url) - if err != nil { - return "", err - } - - defer response.Body.Close() - all, err := io.ReadAll(response.Body) - if err != nil { - return "", err - } - - return string(all), nil -} diff --git a/scripts/build/release_publisher/main.go b/scripts/build/release_publisher/main.go deleted file mode 100644 index 97e70f5465f..00000000000 --- a/scripts/build/release_publisher/main.go +++ /dev/null @@ -1,80 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "log" - "os" -) - -func main() { - var version string - var whatsNewURL string - var releaseNotesURL string - var dryRun bool - var enterprise bool - var nightly bool - var apiKey string - - flag.StringVar(&version, "version", "", "Grafana version (ex: --version v5.2.0-beta1)") - flag.StringVar(&whatsNewURL, "wn", "", "What's new url (ex: --wn http://docs.grafana.org/guides/whats-new-in-v5-2/)") - flag.StringVar(&releaseNotesURL, "rn", "", "Grafana version (ex: --rn https://community.grafana.com/t/release-notes-v5-2-x/7894)") - flag.StringVar(&apiKey, "apikey", "", "Grafana.com API key (ex: --apikey ABCDEF)") - flag.BoolVar(&dryRun, "dry-run", false, "--dry-run") - flag.BoolVar(&enterprise, "enterprise", false, "--enterprise") - flag.BoolVar(&nightly, "nightly", false, "--nightly (default: false)") - flag.Parse() - - if len(os.Args) == 1 { - fmt.Println("Usage: go run publisher.go main.go --version --wn --rn --apikey --dry-run false --enterprise false --nightly false") - fmt.Println("example: go run publisher.go main.go --version v5.2.0-beta2 --wn http://docs.grafana.org/guides/whats-new-in-v5-2/ --rn https://community.grafana.com/t/release-notes-v5-2-x/7894 --apikey ASDF123 --dry-run --enterprise") - os.Exit(1) - } - - if dryRun { - log.Println("Dry-run has been enabled.") - } - var baseURL string - var builder releaseBuilder - var product string - - archiveProviderRoot := "https://dl.grafana.com" - buildArtifacts := completeBuildArtifactConfigurations - - if enterprise { - product = "grafana-enterprise" - baseURL = createBaseURL(archiveProviderRoot, "enterprise", product, nightly) - } else { - product = "grafana" - baseURL = createBaseURL(archiveProviderRoot, "oss", product, nightly) - } - - builder = releaseFromExternalContent{ - getter: getHTTPContents{}, - rawVersion: version, - artifactConfigurations: buildArtifacts, - } - - p := publisher{ - apiKey: apiKey, - apiURI: "https://grafana.com/api", - product: product, - dryRun: dryRun, - enterprise: enterprise, - baseArchiveURL: baseURL, - builder: builder, - } - if err := p.doRelease(whatsNewURL, releaseNotesURL, nightly); err != nil { - log.Fatalf("error: %v", err) - } -} -func createBaseURL(root string, bucketName string, product string, nightly bool) string { - var subPath string - if nightly { - subPath = "main" - } else { - subPath = "release" - } - - return fmt.Sprintf("%s/%s/%s/%s", root, bucketName, subPath, product) -} diff --git a/scripts/build/release_publisher/publisher_test.go b/scripts/build/release_publisher/publisher_test.go deleted file mode 100644 index d31d161d21a..00000000000 --- a/scripts/build/release_publisher/publisher_test.go +++ /dev/null @@ -1,201 +0,0 @@ -package main - -import ( - "reflect" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestPreparingReleaseFromRemote(t *testing.T) { - cases := []struct { - version string - expectedVersion string - whatsNewURL string - relNotesURL string - nightly bool - expectedBeta bool - expectedStable bool - expectedArch string - expectedOs string - expectedURL string - baseArchiveURL string - buildArtifacts []buildArtifact - }{ - { - version: "v5.2.0-beta1", - expectedVersion: "5.2.0-beta1", - whatsNewURL: "https://whatsnews.foo/", - relNotesURL: "https://relnotes.foo/", - nightly: false, - expectedBeta: true, - expectedStable: false, - expectedArch: "amd64", - expectedOs: "linux", - expectedURL: "https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.2.0-beta1.linux-amd64.tar.gz", - baseArchiveURL: "https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana", - buildArtifacts: []buildArtifact{{"linux", "amd64", ".linux-amd64.tar.gz", ""}}, - }, - { - version: "v5.2.3", - expectedVersion: "5.2.3", - whatsNewURL: "https://whatsnews.foo/", - relNotesURL: "https://relnotes.foo/", - nightly: false, - expectedBeta: false, - expectedStable: true, - expectedArch: "amd64", - expectedOs: "rhel", - expectedURL: "https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.2.3-1.x86_64.rpm", - baseArchiveURL: "https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana", - buildArtifacts: []buildArtifact{{"rhel", "amd64", ".x86_64.rpm", ""}}, - }, - { - version: "v5.4.0-pre1asdf", - expectedVersion: "5.4.0-pre1asdf", - whatsNewURL: "https://whatsnews.foo/", - relNotesURL: "https://relnotes.foo/", - nightly: true, - expectedBeta: false, - expectedStable: false, - expectedArch: "amd64", - expectedOs: "rhel", - expectedURL: "https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.4.0~pre1asdf-1.x86_64.rpm", - baseArchiveURL: "https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana", - buildArtifacts: []buildArtifact{{"rhel", "amd64", ".x86_64.rpm", ""}}, - }, - { - version: "v5.4.0-pre1asdf", - expectedVersion: "5.4.0-pre1asdf", - whatsNewURL: "https://whatsnews.foo/", - relNotesURL: "https://relnotes.foo/", - nightly: true, - expectedBeta: false, - expectedStable: false, - expectedArch: "armv6", - expectedOs: "deb", - expectedURL: "https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-rpi_5.4.0~pre1asdf_armhf.deb", - baseArchiveURL: "https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana", - buildArtifacts: []buildArtifact{ - {os: "deb", arch: "armv6", urlPostfix: "_armhf.deb", packagePostfix: "-rpi"}, - }, - }, - { - version: "v5.4.0-pre1asdf", - expectedVersion: "5.4.0-pre1asdf", - whatsNewURL: "https://whatsnews.foo/", - relNotesURL: "https://relnotes.foo/", - nightly: true, - expectedBeta: false, - expectedStable: false, - expectedArch: "amd64", - expectedOs: "win-installer", - expectedURL: "https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.4.0-pre1asdf.windows-amd64.msi", - baseArchiveURL: "https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana", - buildArtifacts: []buildArtifact{{"win-installer", "amd64", ".windows-amd64.msi", ""}}, - }, - { - version: "v5.4.0-pre1asdf", - expectedVersion: "5.4.0-pre1asdf", - whatsNewURL: "https://whatsnews.foo/", - relNotesURL: "https://relnotes.foo/", - nightly: true, - expectedBeta: false, - expectedStable: false, - expectedArch: "amd64", - expectedOs: "win", - expectedURL: "https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.4.0-pre1asdf.windows-amd64.zip", - baseArchiveURL: "https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana", - buildArtifacts: []buildArtifact{{"win", "amd64", ".windows-amd64.zip", ""}}, - }, - } - - for _, test := range cases { - builder := releaseFromExternalContent{ - getter: mockHTTPGetter{}, - rawVersion: test.version, - artifactConfigurations: test.buildArtifacts, - } - - t.Log("Preparing release", "baseArchiveURL", test.baseArchiveURL, "nightly", test.nightly) - rel, err := builder.prepareRelease(test.baseArchiveURL, test.whatsNewURL, test.relNotesURL, test.nightly) - require.NoError(t, err) - - assert.Equal(t, test.expectedBeta, rel.Beta) - assert.Equal(t, test.expectedStable, rel.Stable) - assert.Equal(t, test.expectedVersion, rel.Version) - - assert.Len(t, rel.Builds, len(test.buildArtifacts)) - - build := rel.Builds[0] - assert.Equal(t, test.expectedArch, build.Arch) - assert.Equal(t, test.expectedOs, build.Os) - assert.Equal(t, test.expectedURL, build.URL) - } -} - -type mockHTTPGetter struct{} - -func (mockHTTPGetter) getContents(url string) (string, error) { - return url, nil -} - -func TestFilterBuildArtifacts(t *testing.T) { - buildArtifacts, _ := filterBuildArtifacts(completeBuildArtifactConfigurations, Add, []artifactFilter{ - {os: "deb", arch: "amd64"}, - {os: "rhel", arch: "amd64"}, - {os: "linux", arch: "amd64"}, - {os: "win", arch: "amd64"}, - }) - - if len(buildArtifacts) != 4 { - t.Errorf("Expected 4 build artifacts after filtering, but was %v", len(buildArtifacts)) - } - - buildArtifacts, err := filterBuildArtifacts([]buildArtifact{ - { - os: "linux", - arch: "amd64", - }, - { - os: "arm", - arch: "amd64", - }, - { - os: "darwin", - arch: "amd64", - }, - }, Remove, []artifactFilter{ - {os: "darwin", arch: "amd64"}, - }) - - if err != nil { - t.Error() - } - - if len(buildArtifacts) != 2 { - t.Errorf("Expected 2 artifacts, was %v", len(buildArtifacts)) - } - - for _, ba := range buildArtifacts { - if ba.arch == "amd64" && ba.os == "darwin" { - t.Errorf("darwin/amd64 should be gone due to filtering") - } - } - - left := []buildArtifact{ - { - os: "linux", - arch: "amd64", - }, - { - os: "arm", - arch: "amd64", - }, - } - - if !reflect.DeepEqual(left, buildArtifacts) { - t.Errorf("Lists should have been equal but was, expected=%v, actual=%v", left, buildArtifacts) - } -} diff --git a/scripts/build/release_publisher/testdata/grafana-enterprise-5.4.0-123pre1.linux-amd64.tar.gz b/scripts/build/release_publisher/testdata/grafana-enterprise-5.4.0-123pre1.linux-amd64.tar.gz deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/scripts/build/release_publisher/testdata/grafana-enterprise-5.4.0-123pre1.linux-amd64.tar.gz.sha256 b/scripts/build/release_publisher/testdata/grafana-enterprise-5.4.0-123pre1.linux-amd64.tar.gz.sha256 deleted file mode 100644 index c3068040269..00000000000 --- a/scripts/build/release_publisher/testdata/grafana-enterprise-5.4.0-123pre1.linux-amd64.tar.gz.sha256 +++ /dev/null @@ -1 +0,0 @@ -e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 diff --git a/scripts/build/release_publisher/testdata/grafana-enterprise-5.4.0-123pre1.windows-amd64.msi b/scripts/build/release_publisher/testdata/grafana-enterprise-5.4.0-123pre1.windows-amd64.msi deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/scripts/build/release_publisher/testdata/grafana-enterprise-5.4.0-123pre1.windows-amd64.msi.sha256 b/scripts/build/release_publisher/testdata/grafana-enterprise-5.4.0-123pre1.windows-amd64.msi.sha256 deleted file mode 100644 index c3068040269..00000000000 --- a/scripts/build/release_publisher/testdata/grafana-enterprise-5.4.0-123pre1.windows-amd64.msi.sha256 +++ /dev/null @@ -1 +0,0 @@ -e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 diff --git a/scripts/build/release_publisher/testdata/grafana-enterprise-5.4.0-123pre1.windows-amd64.zip b/scripts/build/release_publisher/testdata/grafana-enterprise-5.4.0-123pre1.windows-amd64.zip deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/scripts/build/release_publisher/testdata/grafana-enterprise-5.4.0-123pre1.windows-amd64.zip.sha256 b/scripts/build/release_publisher/testdata/grafana-enterprise-5.4.0-123pre1.windows-amd64.zip.sha256 deleted file mode 100644 index c3068040269..00000000000 --- a/scripts/build/release_publisher/testdata/grafana-enterprise-5.4.0-123pre1.windows-amd64.zip.sha256 +++ /dev/null @@ -1 +0,0 @@ -e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 diff --git a/scripts/build/release_publisher/testdata/grafana-enterprise-5.4.0-123pre1.x86_64.rpm b/scripts/build/release_publisher/testdata/grafana-enterprise-5.4.0-123pre1.x86_64.rpm deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/scripts/build/release_publisher/testdata/grafana-enterprise-5.4.0-123pre1.x86_64.rpm.sha256 b/scripts/build/release_publisher/testdata/grafana-enterprise-5.4.0-123pre1.x86_64.rpm.sha256 deleted file mode 100644 index c3068040269..00000000000 --- a/scripts/build/release_publisher/testdata/grafana-enterprise-5.4.0-123pre1.x86_64.rpm.sha256 +++ /dev/null @@ -1 +0,0 @@ -e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 diff --git a/scripts/build/release_publisher/testdata/grafana-enterprise_5.4.0-123pre1_amd64.deb b/scripts/build/release_publisher/testdata/grafana-enterprise_5.4.0-123pre1_amd64.deb deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/scripts/build/release_publisher/testdata/grafana-enterprise_5.4.0-123pre1_amd64.deb.sha256 b/scripts/build/release_publisher/testdata/grafana-enterprise_5.4.0-123pre1_amd64.deb.sha256 deleted file mode 100644 index c3068040269..00000000000 --- a/scripts/build/release_publisher/testdata/grafana-enterprise_5.4.0-123pre1_amd64.deb.sha256 +++ /dev/null @@ -1 +0,0 @@ -e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 diff --git a/scripts/drone/pipelines/whats_new_checker.star b/scripts/drone/pipelines/whats_new_checker.star deleted file mode 100644 index 2c56be573ce..00000000000 --- a/scripts/drone/pipelines/whats_new_checker.star +++ /dev/null @@ -1,42 +0,0 @@ -""" -This module contains logic for checking if the package.json whats new url matches with the in-flight tag. -""" - -load( - "scripts/drone/steps/lib.star", - "compile_build_cmd", -) -load( - "scripts/drone/utils/images.star", - "images", -) -load( - "scripts/drone/utils/utils.star", - "pipeline", -) - -def whats_new_checker_step(): - return { - "name": "whats-new-checker", - "image": images["go"], - "depends_on": [ - "compile-build-cmd", - ], - "commands": [ - "./bin/build whatsnew-checker", - ], - } - -def whats_new_checker_pipeline(trigger): - environment = {"EDITION": "oss"} - steps = [ - compile_build_cmd(), - whats_new_checker_step(), - ] - return pipeline( - name = "release-whatsnew-checker", - trigger = trigger, - services = [], - steps = steps, - environment = environment, - ) diff --git a/scripts/drone/rgm.star b/scripts/drone/rgm.star index 61694556c37..f81effc892a 100644 --- a/scripts/drone/rgm.star +++ b/scripts/drone/rgm.star @@ -16,10 +16,6 @@ load( "scripts/drone/pipelines/test_frontend.star", "test_frontend", ) -load( - "scripts/drone/pipelines/whats_new_checker.star", - "whats_new_checker_pipeline", -) load( "scripts/drone/steps/github.star", "github_app_generate_token_step", @@ -291,7 +287,6 @@ def rgm_tag_pipeline(): return [ build, - whats_new_checker_pipeline(tag_trigger), verify_release_pipeline( trigger = tag_trigger, name = "rgm-tag-verify-prerelease-assets",