Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b910e2e39e | |||
| 8a93d5331a | |||
| eb84c5d02f | |||
| ba380fd15d | |||
| 646fb2aa35 | |||
| 34e3c20250 | |||
| 48a8d54794 | |||
| 62d83a1ba9 | |||
| 5626dc50f8 | |||
| eea50c8e9b | |||
| df2f528612 | |||
| 7b8191ba42 | |||
| ba58506ffd | |||
| f12cc5411d | |||
| 8daa228083 | |||
| cd797b6789 | |||
| c7ea3d17cc | |||
| 8e4be891c5 | |||
| 763067f8e1 | |||
| eedb613a5e | |||
| 42d3673d04 | |||
| 8e73cc2f70 | |||
| 80fc87339a | |||
| 8515bcc6b0 | |||
| f872fd7f2f | |||
| 4c869a21a4 | |||
| cffca37999 | |||
| 95174454e3 | |||
| 1c8f4a745f | |||
| fe3e2bf9cb |
+1
-1
@@ -110,7 +110,7 @@ If you believe you've found a security vulnerability, please read our [security
|
||||
|
||||
### Suggest features
|
||||
|
||||
If you have an idea of how to improve Grafana, submit a [feature request](https://github.com/grafana/grafana/issues/new?template=1-feature_requests.md).
|
||||
If you have an idea of how to improve Grafana, submit a [feature request](https://github.com/grafana/grafana/issues/new?template=1-feature_requests.md). To learn how to write an effective feature request, refer to [Create a feature request](contribute/create-feature-request.md).
|
||||
|
||||
We want to make Grafana accessible to even more people. Submit an [accessibility issue](https://github.com/grafana/grafana/issues/new?template=2-accessibility.md) to help us understand what we can improve.
|
||||
|
||||
|
||||
@@ -43,6 +43,12 @@ type RepositoryView struct {
|
||||
// For git, this is the target branch
|
||||
Branch string `json:"branch,omitempty"`
|
||||
|
||||
// For git, this is the target URL
|
||||
URL string `json:"url,omitempty"`
|
||||
|
||||
// For git, this is the target path
|
||||
Path string `json:"path,omitempty"`
|
||||
|
||||
// The supported workflows
|
||||
Workflows []Workflow `json:"workflows"`
|
||||
}
|
||||
|
||||
@@ -1690,6 +1690,20 @@ func schema_pkg_apis_provisioning_v0alpha1_RepositoryView(ref common.ReferenceCa
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"url": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "For git, this is the target URL",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"path": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "For git, this is the target path",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"workflows": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "The supported workflows",
|
||||
|
||||
@@ -5,6 +5,7 @@ We're excited that you're considering making a contribution to the Grafana proje
|
||||
These are some good resources to explore for developers:
|
||||
|
||||
- [Create a pull request](create-pull-request.md)
|
||||
- [Create a feature request](create-feature-request.md)
|
||||
- [Developer guide](developer-guide.md)
|
||||
- [Triage issues](triage-issues.md)
|
||||
- [Merge a pull request](merge-pull-request.md)
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
# Create a feature request
|
||||
|
||||
Feature requests help us understand what you need from Grafana. This document guides you through writing effective feature requests that help maintainers understand your needs and prioritize improvements.
|
||||
|
||||
## Before you begin
|
||||
|
||||
We're excited to hear your ideas! Before you submit a feature request, consider these resources:
|
||||
|
||||
- Read the [Code of Conduct](../CODE_OF_CONDUCT.md) to understand our community guidelines.
|
||||
- Search [existing feature requests](https://github.com/grafana/grafana/issues?q=is%3Aissue+is%3Aopen+label%3Atype%2Ffeature-request) to see if someone already suggested something similar.
|
||||
- Discuss your idea in the [Grafana community forums](https://community.grafana.com/) to refine it and gather feedback.
|
||||
|
||||
## Your first feature request
|
||||
|
||||
When you're ready to submit a feature request, use the [feature request template](https://github.com/grafana/grafana/issues/new?template=1-feature_requests.md). The template has three sections that help maintainers understand what you need and why.
|
||||
|
||||
Here's an [example of how all three sections work together in an actual feature request](https://github.com/grafana/grafana/issues/105298) from the Grafana community. We'll analyze each section based on this example feature request.
|
||||
|
||||
### Why is this needed
|
||||
|
||||
This section describes the real problem or limitation you're facing.
|
||||
|
||||
Explain what's difficult, inefficient, or impossible with the current implementation. Focus on the problem rather than proposing a solution. This helps maintainers understand your use case and potentially find better solutions.
|
||||
|
||||
**What to include:**
|
||||
|
||||
- The specific problem or pain point you're experiencing
|
||||
- How the current behavior falls short for your workflow
|
||||
- Why this matters to you and your work
|
||||
- A concrete example that clarifies the issue (optional but helpful)
|
||||
|
||||
**What to avoid:**
|
||||
|
||||
- Jumping directly to the solution (save that for the next section)
|
||||
- Vague statements like "it would be nice if..."
|
||||
- Assuming maintainers know your context or workflow
|
||||
|
||||
**Example of a strong answer:**
|
||||
|
||||
```
|
||||
When using a datasource variable in dashboards and using the "Export" feature in a dashboard,
|
||||
this will automatically create an input for the datasource(s) being used, but it will also
|
||||
effectively override the use of the datasource variable in all panels.
|
||||
|
||||
This makes a confusing
|
||||
experience when importing the dashboard, because users are prompted for an input, but the
|
||||
selected datasource won't be reflected in the datasource variable, and any changes to the
|
||||
datasource variable will not have any effect on the dashboard.
|
||||
```
|
||||
|
||||
**Example of a weak answer:**
|
||||
|
||||
```
|
||||
Dashboard export doesn't work well with variables.
|
||||
```
|
||||
|
||||
The first example clearly explains what's broken, why it's confusing, and what the specific consequences are. The second example is too vague and doesn't explain the actual problem.
|
||||
|
||||
### What would you like to be added
|
||||
|
||||
This section describes what you want Grafana to do differently.
|
||||
|
||||
Be specific and concrete about the expected behavior. If you're suggesting a UI change, describe the interaction or include a screenshot or sketch. If it's data or API related, provide an example query or expected output.
|
||||
|
||||
**What to include:**
|
||||
|
||||
- Exactly what behavior you expect
|
||||
- How the feature should work in practice
|
||||
- Examples, screenshots, or code snippets that illustrate your idea
|
||||
- Expected output or results
|
||||
|
||||
**What to avoid:**
|
||||
|
||||
- Vague or abstract descriptions
|
||||
- Multiple unrelated features in one request (create separate requests instead)
|
||||
- Implementation details unless they're critical to your request
|
||||
|
||||
**Example of a strong answer:**
|
||||
|
||||
```
|
||||
Ideal behavior here would be that when using the export feature, either:
|
||||
|
||||
1. No inputs section is created for datasource types that are used as datasource variables.
|
||||
2. IF an input is created, it should only be used to replace the currently selected value of
|
||||
the datasource variable, rather than override the datasource in panels.
|
||||
```
|
||||
|
||||
**Example of a weak answer:**
|
||||
|
||||
```
|
||||
Fix the dashboard export feature.
|
||||
```
|
||||
|
||||
The first example provides clear, actionable options for how the feature should work. The second example is too vague and doesn't specify what the fix should do.
|
||||
|
||||
### Who is this feature for?
|
||||
|
||||
This section describes who benefits from this feature and in what context.
|
||||
|
||||
Help maintainers understand the scope and impact of your request. Be specific about user types, workflows, or scenarios where this feature matters.
|
||||
|
||||
**What to include:**
|
||||
|
||||
- The type of user who needs this (for example, Tempo users, dashboard editors, plugin developers)
|
||||
- Whether this affects all Grafana users or only those using specific features or data sources
|
||||
- The workflow or use case this feature improves (optional but helpful)
|
||||
|
||||
**What to avoid:**
|
||||
|
||||
- Saying "everyone" without clarifying who actually needs it
|
||||
- Being overly narrow if the feature has broader appeal
|
||||
|
||||
**Example of a strong answer:**
|
||||
|
||||
```
|
||||
Any Grafana Dashboard users or authors that use datasource variables.
|
||||
```
|
||||
|
||||
**Example of a weak answer:**
|
||||
|
||||
```
|
||||
Dashboard users.
|
||||
```
|
||||
|
||||
The first example identifies the specific users and the feature they use (datasource variables). The second example is too generic and doesn't clarify which users or workflow are affected.
|
||||
|
||||
## Best practices for feature requests
|
||||
|
||||
Follow these guidelines to increase the chances of your feature request being accepted:
|
||||
|
||||
### Keep it focused
|
||||
|
||||
Request one feature at a time. If you have multiple ideas, create separate feature requests for each one. This makes it easier to discuss, prioritize, and implement each feature independently.
|
||||
|
||||
### Research first
|
||||
|
||||
Before submitting, search for similar requests. If you find an existing request that's close to your idea, add your use case and context to that discussion instead of creating a duplicate.
|
||||
|
||||
### Provide context
|
||||
|
||||
The more context you provide, the better maintainers can understand your needs. Include:
|
||||
|
||||
- Your environment or setup (which data sources, plugins, or features you're using)
|
||||
- Your workflow or process
|
||||
- Why this matters to you
|
||||
- Any workarounds you've tried
|
||||
|
||||
### Be open to alternatives
|
||||
|
||||
Maintainers might suggest different approaches to solve your problem. Be open to these alternatives as they might be easier to implement or more maintainable in the long term.
|
||||
|
||||
### Stay engaged
|
||||
|
||||
After submitting your feature request, monitor the discussion. Answer questions from maintainers and provide clarification when needed. This helps move your request forward.
|
||||
|
||||
## Contributing the feature yourself
|
||||
|
||||
If you want to implement the feature yourself, feel free to create a pull request following the [pull request guidelines](create-pull-request.md).
|
||||
|
||||
We welcome community contributions and appreciate your help making Grafana better!
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
---
|
||||
aliases:
|
||||
- ../../panels-visualizations/query-transform-data/sql-expressions/ # /docs/grafana/next/panels-visualizations/query-transform-data/sql-expressions/
|
||||
- ../../../panels-visualizations/query-transform-data/sql-expressions/ # /docs/grafana/next/panels-visualizations/query-transform-data/sql-expressions/
|
||||
labels:
|
||||
products:
|
||||
- cloud
|
||||
|
||||
+51
-1
@@ -4,6 +4,8 @@ import { test, expect, E2ESelectorGroups, DashboardPage, DashboardPageArgs } fro
|
||||
|
||||
import testDashboard from '../dashboards/DashboardWithAllConditionalRendering.json';
|
||||
|
||||
import { checkRepeatedPanelTitles } from './utils';
|
||||
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: true,
|
||||
@@ -93,7 +95,7 @@ test.describe('Dashboard - Conditional Rendering - Load and Change', { tag: ['@d
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (uid) {
|
||||
await request.delete(`/apis/dashboard.grafana.app/v1beta1/namespaces/default/dashboards/${uid}`);
|
||||
await request.delete(`/apis/dashboard.grafana.app/v1beta1/namespaces/stacks-12345/dashboards/${uid}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -407,4 +409,52 @@ test.describe('Dashboard - Conditional Rendering - Load and Change', { tag: ['@d
|
||||
await expect(getTabShowNotMatches(dashboardPage, selectors)).toBeVisible();
|
||||
await expect(getTabHideNotMatches(dashboardPage, selectors)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test.describe('Variable repeat', () => {
|
||||
const repeatOptions = ['a', 'b', 'c'];
|
||||
|
||||
async function failTestDataRequestForOption(page: Page, option: string) {
|
||||
await page.route(/\/api\/ds\/query\?.*\bds_type=grafana-testdata-datasource/, async (route) => {
|
||||
const rawPostData = route.request().postData();
|
||||
if (!rawPostData) {
|
||||
return;
|
||||
}
|
||||
|
||||
// the first panel query has a label set to the current variable value
|
||||
if (JSON.parse(rawPostData).queries[0].labels === `key=${option}`) {
|
||||
await route.fulfill({ status: 500, body: '{}' });
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('Hide when equals, hide when no data', async ({ page, gotoDashboardPage, selectors }) => {
|
||||
const dashboardPage = await loadDashboard(page, gotoDashboardPage);
|
||||
|
||||
await getTab(dashboardPage, selectors, 'repeated items').click();
|
||||
|
||||
const optionForHiddenPanels = repeatOptions[0];
|
||||
|
||||
await failTestDataRequestForOption(page, optionForHiddenPanels);
|
||||
|
||||
await checkRepeatedPanelTitles(
|
||||
dashboardPage,
|
||||
selectors,
|
||||
'Hide panel - ',
|
||||
[
|
||||
`custom variable equals ${optionForHiddenPanels} (current = ${optionForHiddenPanels})`,
|
||||
`no data (current = ${optionForHiddenPanels})`,
|
||||
],
|
||||
true
|
||||
);
|
||||
|
||||
const optionsForVisiblePanels = repeatOptions.slice(1);
|
||||
|
||||
await checkRepeatedPanelTitles(dashboardPage, selectors, 'Hide panel - ', [
|
||||
...optionsForVisiblePanels.map((o) => `custom variable equals ${optionForHiddenPanels} (current = ${o})`),
|
||||
...optionsForVisiblePanels.map((o) => `no data (current = ${o})`),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,12 +97,18 @@ export async function checkRepeatedPanelTitles(
|
||||
dashboardPage: DashboardPage,
|
||||
selectors: E2ESelectorGroups,
|
||||
title: string,
|
||||
options: Array<string | number>
|
||||
options: Array<string | number>,
|
||||
expectHidden = false
|
||||
) {
|
||||
for (const option of options) {
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(`${title}${option}`))
|
||||
).toBeVisible();
|
||||
const titleLocator = dashboardPage.getByGrafanaSelector(
|
||||
selectors.components.Panels.Panel.title(`${title}${option}`)
|
||||
);
|
||||
if (expectHidden) {
|
||||
await expect(titleLocator).toBeHidden();
|
||||
} else {
|
||||
await expect(titleLocator).toBeVisible();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ const NUM_NESTED_DASHBOARDS = 60;
|
||||
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ import testDashboard from '../dashboards/TestDashboard.json';
|
||||
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ test.use({
|
||||
featureToggles: {
|
||||
scenes: true,
|
||||
sharingDashboardImage: true, // Enable the export image feature
|
||||
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@ import { test, expect } from '@grafana/plugin-e2e';
|
||||
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@ import { test, expect } from '@grafana/plugin-e2e';
|
||||
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ import testDashboard from '../dashboards/DataLinkWithoutSlugTest.json';
|
||||
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ import testDashboard from '../dashboards/DashboardLiveTest.json';
|
||||
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@ import { test, expect } from '@grafana/plugin-e2e';
|
||||
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardScene: false, // this test is for the old sharing modal only used when scenes is turned off
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,7 +2,8 @@ import { test, expect } from '@grafana/plugin-e2e';
|
||||
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardScene: false, // this test is for the old sharing modal only used when scenes is turned off
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3,7 +3,8 @@ import { test, expect } from '@grafana/plugin-e2e';
|
||||
test.use({
|
||||
featureToggles: {
|
||||
scenes: true,
|
||||
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ import { test, expect } from '@grafana/plugin-e2e';
|
||||
test.use({
|
||||
featureToggles: {
|
||||
scenes: true,
|
||||
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ import { SnapshotCreateResponse } from '../../public/app/features/dashboard/serv
|
||||
test.use({
|
||||
featureToggles: {
|
||||
scenes: true,
|
||||
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ const DASHBOARD_UID = 'HYaGDGIMk';
|
||||
test.use({
|
||||
timezoneId: 'Pacific/Easter',
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ const TIMEZONE_DASHBOARD_UID = 'd41dbaa2-a39e-4536-ab2b-caca52f1a9c8';
|
||||
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@ test.use({
|
||||
origins: [],
|
||||
},
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@ import { test, expect } from '@grafana/plugin-e2e';
|
||||
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ const PAGE_UNDER_TEST = 'edediimbjhdz4b/a-tall-dashboard';
|
||||
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ import testDashboard from '../dashboards/TestDashboard.json';
|
||||
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ const PAGE_UNDER_TEST = '-Y-tnEDWk/templating-nested-template-variables';
|
||||
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ const DASHBOARD_NAME = 'Test variable output';
|
||||
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -52,7 +52,8 @@ async function assertPreviewValues(
|
||||
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ const DASHBOARD_NAME = 'Test variable output';
|
||||
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -18,7 +18,8 @@ async function assertPreviewValues(
|
||||
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ const DASHBOARD_NAME = 'Templating - Nested Template Variables';
|
||||
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ const DASHBOARD_NAME = 'Test variable output';
|
||||
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ const PAGE_UNDER_TEST = 'WVpf2jp7z/repeating-a-panel-horizontally';
|
||||
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ const PAGE_UNDER_TEST = 'OY8Ghjt7k/repeating-a-panel-vertically';
|
||||
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ const PAGE_UNDER_TEST = 'dtpl2Ctnk/repeating-an-empty-row';
|
||||
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ const PAGE_UNDER_TEST = '-Y-tnEDWk/templating-nested-template-variables';
|
||||
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ const DASHBOARD_UID = 'ZqZnVvFZz';
|
||||
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardScene: false, // this test is for the old sharing modal only used when scenes is turned off
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,7 +4,8 @@ const DASHBOARD_UID = 'yBCC3aKGk';
|
||||
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ const PAGE_UNDER_TEST = 'AejrN1AMz';
|
||||
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -3308,6 +3308,170 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel-37": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"data": {
|
||||
"kind": "QueryGroup",
|
||||
"spec": {
|
||||
"queries": [
|
||||
{
|
||||
"kind": "PanelQuery",
|
||||
"spec": {
|
||||
"hidden": false,
|
||||
"query": {
|
||||
"group": "",
|
||||
"kind": "DataQuery",
|
||||
"spec": {},
|
||||
"version": "v0"
|
||||
},
|
||||
"refId": "A"
|
||||
}
|
||||
}
|
||||
],
|
||||
"queryOptions": {},
|
||||
"transformations": []
|
||||
}
|
||||
},
|
||||
"description": "",
|
||||
"id": 37,
|
||||
"links": [],
|
||||
"title": "Hide panel - custom variable equals a (current = ${myCustomVariable})",
|
||||
"vizConfig": {
|
||||
"group": "text",
|
||||
"kind": "VizConfig",
|
||||
"spec": {
|
||||
"fieldConfig": {
|
||||
"defaults": {},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"code": {
|
||||
"language": "plaintext",
|
||||
"showLineNumbers": false,
|
||||
"showMiniMap": false
|
||||
},
|
||||
"content": "",
|
||||
"mode": "markdown"
|
||||
}
|
||||
},
|
||||
"version": "12.2.0-pre"
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel-38": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"data": {
|
||||
"kind": "QueryGroup",
|
||||
"spec": {
|
||||
"queries": [
|
||||
{
|
||||
"kind": "PanelQuery",
|
||||
"spec": {
|
||||
"hidden": false,
|
||||
"query": {
|
||||
"datasource": {
|
||||
"name": "PD8C576611E62080A"
|
||||
},
|
||||
"group": "grafana-testdata-datasource",
|
||||
"kind": "DataQuery",
|
||||
"spec": {
|
||||
"labels": "key=$myCustomVariable",
|
||||
"scenarioId": "random_walk",
|
||||
"seriesCount": 1
|
||||
},
|
||||
"version": "v0"
|
||||
},
|
||||
"refId": "A"
|
||||
}
|
||||
}
|
||||
],
|
||||
"queryOptions": {},
|
||||
"transformations": []
|
||||
}
|
||||
},
|
||||
"description": "",
|
||||
"id": 38,
|
||||
"links": [],
|
||||
"title": "Hide panel - no data (current = ${myCustomVariable})",
|
||||
"vizConfig": {
|
||||
"group": "timeseries",
|
||||
"kind": "VizConfig",
|
||||
"spec": {
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"barWidthFactor": 0.6,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "auto",
|
||||
"showValues": false,
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": 0
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"hideZeros": false,
|
||||
"mode": "single",
|
||||
"sort": "none"
|
||||
}
|
||||
}
|
||||
},
|
||||
"version": "12.2.0-pre"
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel-4": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
@@ -5091,6 +5255,80 @@
|
||||
},
|
||||
"title": "Tab - hide - time range <7d"
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "TabsLayoutTab",
|
||||
"spec": {
|
||||
"layout": {
|
||||
"kind": "AutoGridLayout",
|
||||
"spec": {
|
||||
"columnWidthMode": "standard",
|
||||
"items": [
|
||||
{
|
||||
"kind": "AutoGridLayoutItem",
|
||||
"spec": {
|
||||
"conditionalRendering": {
|
||||
"kind": "ConditionalRenderingGroup",
|
||||
"spec": {
|
||||
"condition": "and",
|
||||
"items": [
|
||||
{
|
||||
"kind": "ConditionalRenderingVariable",
|
||||
"spec": {
|
||||
"operator": "equals",
|
||||
"value": "a",
|
||||
"variable": "myCustomVariable"
|
||||
}
|
||||
}
|
||||
],
|
||||
"visibility": "hide"
|
||||
}
|
||||
},
|
||||
"element": {
|
||||
"kind": "ElementReference",
|
||||
"name": "panel-37"
|
||||
},
|
||||
"repeat": {
|
||||
"mode": "variable",
|
||||
"value": "myCustomVariable"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "AutoGridLayoutItem",
|
||||
"spec": {
|
||||
"conditionalRendering": {
|
||||
"kind": "ConditionalRenderingGroup",
|
||||
"spec": {
|
||||
"condition": "and",
|
||||
"items": [
|
||||
{
|
||||
"kind": "ConditionalRenderingData",
|
||||
"spec": {
|
||||
"value": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"visibility": "hide"
|
||||
}
|
||||
},
|
||||
"element": {
|
||||
"kind": "ElementReference",
|
||||
"name": "panel-38"
|
||||
},
|
||||
"repeat": {
|
||||
"mode": "variable",
|
||||
"value": "myCustomVariable"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"maxColumnCount": 3,
|
||||
"rowHeightMode": "standard"
|
||||
}
|
||||
},
|
||||
"title": "Tab - repeated items"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -5122,6 +5360,39 @@
|
||||
"query": "",
|
||||
"skipUrlSync": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "CustomVariable",
|
||||
"spec": {
|
||||
"allowCustomValue": false,
|
||||
"current": {
|
||||
"text": "All",
|
||||
"value": "$__all"
|
||||
},
|
||||
"hide": "dontHide",
|
||||
"includeAll": true,
|
||||
"multi": false,
|
||||
"name": "myCustomVariable",
|
||||
"options": [
|
||||
{
|
||||
"selected": false,
|
||||
"text": "a",
|
||||
"value": "a"
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "b",
|
||||
"value": "b"
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "c",
|
||||
"value": "c"
|
||||
}
|
||||
],
|
||||
"query": "a, b, c",
|
||||
"skipUrlSync": false
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
// @ts-check
|
||||
// TODO: Migrate to Typescript.
|
||||
// @ts-nocheck
|
||||
const emotionPlugin = require('@emotion/eslint-plugin');
|
||||
const restrictedGlobals = require('confusing-browser-globals');
|
||||
const importPlugin = require('eslint-plugin-import');
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
"themes-generate": "yarn themes-schema && esbuild --target=es6 ./scripts/cli/generateSassVariableFiles.ts --bundle --platform=node --tsconfig=./scripts/cli/tsconfig.json | node",
|
||||
"themes:usage": "eslint . --ignore-pattern '*.test.ts*' --ignore-pattern '*.spec.ts*' --cache --plugin '@grafana' --rule '{ @grafana/theme-token-usage: \"error\" }'",
|
||||
"typecheck": "tsc --noEmit && yarn run packages:typecheck",
|
||||
"typecheck:tsgo": "tsgo --noEmit",
|
||||
"plugins:build-bundled": "echo 'bundled plugins are no longer supported'",
|
||||
"watch": "yarn start -d watch,start core:start --watchTheme",
|
||||
"i18n:stats": "node ./scripts/cli/reportI18nStats.mjs",
|
||||
@@ -166,6 +167,7 @@
|
||||
"@types/yargs": "17.0.33",
|
||||
"@typescript-eslint/eslint-plugin": "8.38.0",
|
||||
"@typescript-eslint/parser": "8.38.0",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20251128.1",
|
||||
"autoprefixer": "10.4.21",
|
||||
"babel-loader": "10.0.0",
|
||||
"blob-polyfill": "9.0.20240710",
|
||||
|
||||
@@ -110,10 +110,9 @@ export function getInheritedProperties<T extends Route>(
|
||||
...propertiesParentInherited,
|
||||
} as const;
|
||||
|
||||
// @ts-expect-error we're using "keyof" for the property so the type checker can help us out but this makes the
|
||||
// reduce function signature unhappy
|
||||
const inherited = reduce(
|
||||
inheritableProperties,
|
||||
// @ts-expect-error we're using "keyof" for the property so the type checker can help us out but this makes the
|
||||
(inheritedProperties: InheritableProperties, parentValue, property: keyof InheritableProperties) => {
|
||||
const parentHasValue = parentValue != null;
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"exclude": ["dist", "node_modules", "tests", "**/*.test.ts*", "**/*.story.tsx"],
|
||||
"extends": "./tsconfig.json"
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src"
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -1581,6 +1581,8 @@ export type RepositoryView = {
|
||||
branch?: string;
|
||||
/** The k8s name for this repository */
|
||||
name: string;
|
||||
/** For git, this is the target path */
|
||||
path?: string;
|
||||
/** When syncing, where values are saved
|
||||
|
||||
Possible enum values:
|
||||
@@ -1598,6 +1600,8 @@ export type RepositoryView = {
|
||||
- `"gitlab"`
|
||||
- `"local"` */
|
||||
type: 'bitbucket' | 'git' | 'github' | 'gitlab' | 'local';
|
||||
/** For git, this is the target URL */
|
||||
url?: string;
|
||||
/** The supported workflows */
|
||||
workflows: ('branch' | 'write')[];
|
||||
};
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["dist", "node_modules", "**/*.test.ts*"]
|
||||
"exclude": ["dist", "node_modules", "**/*.test.ts*"],
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,8 +91,10 @@ describe('EventBus', () => {
|
||||
it('Supports legacy events', () => {
|
||||
const bus = new EventBusSrv();
|
||||
const events: LegacyEventPayload[] = [];
|
||||
const handler = (event: LegacyEventPayload) => {
|
||||
events.push(event);
|
||||
const handler = (event?: LegacyEventPayload) => {
|
||||
if (event) {
|
||||
events.push(event);
|
||||
}
|
||||
};
|
||||
|
||||
bus.on(legacyEvent, handler);
|
||||
@@ -111,7 +113,9 @@ describe('EventBus', () => {
|
||||
const newEvents: AlertSuccessEvent[] = [];
|
||||
|
||||
bus.on(legacyEvent, (event) => {
|
||||
legacyEvents.push(event);
|
||||
if (event) {
|
||||
legacyEvents.push(event);
|
||||
}
|
||||
});
|
||||
|
||||
bus.subscribe(AlertSuccessEvent, (event) => {
|
||||
|
||||
@@ -133,12 +133,12 @@ export interface LegacyEmitter {
|
||||
/**
|
||||
* @deprecated use $on
|
||||
*/
|
||||
off<T>(event: AppEvent<T> | string, handler: (payload?: T) => void): void;
|
||||
off<T>(event: AppEvent<T> | string, handler: LegacyEventHandler<T>): void;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface LegacyEventHandler<T> {
|
||||
(payload: T): void;
|
||||
(payload?: T): void;
|
||||
wrapper?: (event: BusEvent) => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -896,6 +896,42 @@ describe('getHistogramFields', () => {
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should prevent excessive densification when sparse histogram has large gaps', () => {
|
||||
const result = getHistogramFields(
|
||||
toDataFrame({
|
||||
meta: {
|
||||
type: DataFrameType.HeatmapCells,
|
||||
},
|
||||
fields: [
|
||||
{ name: 'yMin', type: FieldType.number, values: [0.001, 1000] },
|
||||
{ name: 'yMax', type: FieldType.number, values: [0.00101, 1010] },
|
||||
{ name: 'count', type: FieldType.number, values: [10, 20] },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.counts[0].values.length).toBeLessThanOrEqual(1001);
|
||||
});
|
||||
|
||||
it('should handle multiple observed buckets when hitting densification limit', () => {
|
||||
const result = getHistogramFields(
|
||||
toDataFrame({
|
||||
meta: {
|
||||
type: DataFrameType.HeatmapCells,
|
||||
},
|
||||
fields: [
|
||||
{ name: 'yMin', type: FieldType.number, values: [0.001, 1000, 2000] },
|
||||
{ name: 'yMax', type: FieldType.number, values: [0.00101, 1010, 2020] },
|
||||
{ name: 'count', type: FieldType.number, values: [10, 20, 30] },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.counts[0].values.every((v) => !isNaN(v))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('joinHistograms', () => {
|
||||
|
||||
@@ -210,6 +210,8 @@ export function getHistogramFields(frame: DataFrame): HistogramFields | undefine
|
||||
let denseMins: number[] = [];
|
||||
let denseMaxs: number[] = [];
|
||||
|
||||
const MAX_DENSIFIED_BUCKETS = 1000;
|
||||
|
||||
for (let i = 0; i < uniqueMaxs.length; i++) {
|
||||
let curMax = uniqueMaxs[i];
|
||||
let curMin = uniqueMins[i];
|
||||
@@ -223,13 +225,17 @@ export function getHistogramFields(frame: DataFrame): HistogramFields | undefine
|
||||
curMax = curMax * bucketFactor;
|
||||
curMin = curMin * bucketFactor;
|
||||
|
||||
while (curMax < nextMax * 0.999999) {
|
||||
while (curMax < nextMax * 0.999999 && denseMaxs.length < MAX_DENSIFIED_BUCKETS) {
|
||||
denseMaxs.push(curMax);
|
||||
denseMins.push(curMin);
|
||||
|
||||
curMax = curMax * bucketFactor;
|
||||
curMin = curMin * bucketFactor;
|
||||
}
|
||||
|
||||
if (denseMaxs.length >= MAX_DENSIFIED_BUCKETS) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,7 +244,10 @@ export function getHistogramFields(frame: DataFrame): HistogramFields | undefine
|
||||
|
||||
for (let i = 0; i < yMaxField.values.length; i++) {
|
||||
let max = yMaxField.values[i];
|
||||
countsByMax.set(max, countsByMax.get(max) + countField.values[i]);
|
||||
let currentCount = countsByMax.get(max);
|
||||
if (currentCount !== undefined) {
|
||||
countsByMax.set(max, currentCount + countField.values[i]);
|
||||
}
|
||||
}
|
||||
|
||||
let fields = {
|
||||
|
||||
@@ -643,6 +643,7 @@ export interface MetricFindValue {
|
||||
value?: string | number;
|
||||
group?: string;
|
||||
expandable?: boolean;
|
||||
properties?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface DataSourceGetDrilldownsApplicabilityOptions<TQuery extends DataQuery = DataQuery> {
|
||||
|
||||
@@ -361,6 +361,10 @@ export interface FeatureToggles {
|
||||
*/
|
||||
dashboardNewLayouts?: boolean;
|
||||
/**
|
||||
* Use the v2 kubernetes API in the frontend for dashboards
|
||||
*/
|
||||
kubernetesDashboardsV2?: boolean;
|
||||
/**
|
||||
* Enables undo/redo in dynamic dashboards
|
||||
*/
|
||||
dashboardUndoRedo?: boolean;
|
||||
@@ -1189,4 +1193,8 @@ export interface FeatureToggles {
|
||||
* @default false
|
||||
*/
|
||||
rudderstackUpgrade?: boolean;
|
||||
/**
|
||||
* Adds support for Kubernetes alerting historian APIs
|
||||
*/
|
||||
kubernetesAlertingHistorian?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"exclude": ["dist", "node_modules", "test", "**/*.test.ts*"],
|
||||
"extends": "./tsconfig.json"
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"exclude": ["dist", "node_modules", "**/*.test.ts*"],
|
||||
"extends": "./tsconfig.json"
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@emotion/core": ["./src/types/emotion-core-stub.d.ts"]
|
||||
}
|
||||
},
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"exclude": ["**/*.test.ts*"],
|
||||
"extends": "./tsconfig.json"
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"exclude": ["dist", "node_modules", "test", "**/*.test.ts*"],
|
||||
"extends": "./tsconfig.json"
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
"emitDeclarationOnly": true,
|
||||
"isolatedModules": true,
|
||||
"allowJs": true,
|
||||
"rootDirs": ["."]
|
||||
"rootDirs": ["."],
|
||||
"rootDir": "../.."
|
||||
},
|
||||
"exclude": ["dist/**/*"],
|
||||
"include": [
|
||||
|
||||
@@ -8,5 +8,8 @@
|
||||
"src/querybuilder/testUtils.ts",
|
||||
"../../public/test/setupTests.ts"
|
||||
],
|
||||
"extends": "./tsconfig.json"
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"exclude": ["dist", "node_modules", "**/*.test.ts*", "../../public/test/setupTests.ts"],
|
||||
"extends": "./tsconfig.json"
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,18 +312,7 @@ export const handyTestingSchema: Spec = {
|
||||
label: 'Custom Variable',
|
||||
multi: true,
|
||||
name: 'customVar',
|
||||
options: [
|
||||
{
|
||||
selected: true,
|
||||
text: 'option1',
|
||||
value: 'option1',
|
||||
},
|
||||
{
|
||||
selected: false,
|
||||
text: 'option2',
|
||||
value: 'option2',
|
||||
},
|
||||
],
|
||||
options: [],
|
||||
query: 'option1, option2',
|
||||
skipUrlSync: false,
|
||||
allowCustomValue: true,
|
||||
@@ -490,5 +479,18 @@ export const handyTestingSchema: Spec = {
|
||||
allowCustomValue: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'SwitchVariable',
|
||||
spec: {
|
||||
name: 'switchVar',
|
||||
label: 'Switch Variable',
|
||||
description: 'A switch variable',
|
||||
current: 'false',
|
||||
enabledValue: 'true',
|
||||
disabledValue: 'false',
|
||||
hide: 'dontHide',
|
||||
skipUrlSync: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"exclude": ["dist/**/*", "**/*.test.ts*"],
|
||||
"extends": "./tsconfig.json"
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMeasure } from 'react-use';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import AutoSizer, { type Size } from 'react-virtualized-auto-sizer';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
@@ -61,7 +61,7 @@ export function RawEditor({ db, query, onChange, onRunQuery, onValidate, queryTo
|
||||
const renderEditor = (standalone = false) => {
|
||||
return standalone ? (
|
||||
<AutoSizer>
|
||||
{({ width, height }) => {
|
||||
{({ width, height }: Size) => {
|
||||
return renderQueryEditor(width, height);
|
||||
}}
|
||||
</AutoSizer>
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
"emitDeclarationOnly": true,
|
||||
"isolatedModules": true,
|
||||
"strict": true,
|
||||
"rootDirs": ["."]
|
||||
"rootDirs": ["."],
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"exclude": ["dist/**/*"],
|
||||
"include": ["src/**/*.ts*", "../../public/app/types/*.d.ts", "../grafana-ui/src/types/*.d.ts"]
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
"emitDeclarationOnly": true,
|
||||
"isolatedModules": true,
|
||||
"rootDirs": ["."],
|
||||
"moduleResolution": "bundler"
|
||||
"moduleResolution": "bundler",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"ts-node": {
|
||||
"compilerOptions": {
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
"paths": {
|
||||
"@emotion/core": ["./src/types/emotion-core-stub.d.ts"],
|
||||
"@grafana/ui": ["."]
|
||||
}
|
||||
},
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"exclude": [
|
||||
"**/*.story.tsx",
|
||||
|
||||
@@ -198,7 +198,7 @@ func NewAPIService(ac authlib.AccessClient, features featuremgmt.FeatureToggles,
|
||||
}
|
||||
|
||||
func (b *DashboardsAPIBuilder) GetGroupVersions() []schema.GroupVersion {
|
||||
if featuremgmt.AnyEnabled(b.features, featuremgmt.FlagDashboardNewLayouts) {
|
||||
if featuremgmt.AnyEnabled(b.features, featuremgmt.FlagDashboardNewLayouts, featuremgmt.FlagKubernetesDashboardsV2) {
|
||||
// If dashboards v2 is enabled, we want to use v2beta1 as the default API version.
|
||||
return []schema.GroupVersion{
|
||||
dashv2beta1.DashboardResourceInfo.GroupVersion(),
|
||||
|
||||
@@ -346,6 +346,12 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if enableZanzanaSync {
|
||||
b.logger.Info("Enabling hooks for RoleBinding to sync to Zanzana")
|
||||
roleBindingStore.AfterCreate = b.AfterRoleBindingCreate
|
||||
roleBindingStore.AfterDelete = b.AfterRoleBindingDelete
|
||||
roleBindingStore.BeginUpdate = b.BeginRoleBindingUpdate
|
||||
}
|
||||
storage[iamv0.RoleBindingInfo.StoragePath()] = roleBindingStore
|
||||
}
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
package iam
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apiserver/pkg/registry/generic/registry"
|
||||
|
||||
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
|
||||
v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
|
||||
)
|
||||
|
||||
const resourceType = "rolebinding"
|
||||
|
||||
// AfterRoleBindingCreate is a post-create hook that writes the role binding to Zanzana (openFGA)
|
||||
func (b *IdentityAccessManagementAPIBuilder) AfterRoleBindingCreate(obj runtime.Object, _ *metav1.CreateOptions) {
|
||||
if b.zClient == nil {
|
||||
return
|
||||
}
|
||||
|
||||
rb, ok := obj.(*iamv0.RoleBinding)
|
||||
if !ok {
|
||||
b.logger.Error("failed to convert object to RoleBinding type", "object", obj)
|
||||
return
|
||||
}
|
||||
|
||||
operation := "create"
|
||||
|
||||
// Grab a ticket to write to Zanzana
|
||||
// This limits the amount of concurrent connections to Zanzana
|
||||
wait := time.Now()
|
||||
b.zTickets <- true
|
||||
hooksWaitHistogram.WithLabelValues(resourceType, operation).Observe(time.Since(wait).Seconds())
|
||||
|
||||
go func(rb *iamv0.RoleBinding) {
|
||||
start := time.Now()
|
||||
status := "success"
|
||||
|
||||
defer func() {
|
||||
// Release the ticket after write is done
|
||||
<-b.zTickets
|
||||
// Record operation duration and count
|
||||
hooksDurationHistogram.WithLabelValues(resourceType, operation, status).Observe(time.Since(start).Seconds())
|
||||
}()
|
||||
|
||||
b.logger.Debug("writing role binding to zanzana",
|
||||
"namespace", rb.Namespace,
|
||||
"name", rb.Name,
|
||||
"subject", rb.Spec.Subject.Name,
|
||||
"roleRefs", rb.Spec.RoleRefs,
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout)
|
||||
defer cancel()
|
||||
|
||||
operations := make([]*v1.MutateOperation, 0, len(rb.Spec.RoleRefs))
|
||||
for _, roleRef := range rb.Spec.RoleRefs {
|
||||
operations = append(operations, &v1.MutateOperation{
|
||||
Operation: &v1.MutateOperation_CreateRoleBinding{
|
||||
CreateRoleBinding: &v1.CreateRoleBindingOperation{
|
||||
SubjectKind: string(rb.Spec.Subject.Kind),
|
||||
SubjectName: rb.Spec.Subject.Name,
|
||||
RoleKind: string(roleRef.Kind),
|
||||
RoleName: roleRef.Name,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if len(operations) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
err := b.zClient.Mutate(ctx, &v1.MutateRequest{
|
||||
Namespace: rb.Namespace,
|
||||
Operations: operations,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
status = "failure"
|
||||
b.logger.Error("failed to write role binding to zanzana",
|
||||
"err", err,
|
||||
"namespace", rb.Namespace,
|
||||
"name", rb.Name,
|
||||
"subject", rb.Spec.Subject.Name,
|
||||
"roleRefs", rb.Spec.RoleRefs,
|
||||
)
|
||||
}
|
||||
}(rb.DeepCopy()) // Pass a copy of the object
|
||||
}
|
||||
|
||||
// AfterRoleBindingDelete is a post-delete hook that removes the role binding from Zanzana (openFGA)
|
||||
func (b *IdentityAccessManagementAPIBuilder) AfterRoleBindingDelete(obj runtime.Object, _ *metav1.DeleteOptions) {
|
||||
if b.zClient == nil {
|
||||
return
|
||||
}
|
||||
|
||||
rb, ok := obj.(*iamv0.RoleBinding)
|
||||
if !ok {
|
||||
b.logger.Error("failed to convert object to RoleBinding type", "object", obj)
|
||||
return
|
||||
}
|
||||
|
||||
operation := "delete"
|
||||
|
||||
// Grab a ticket to write to Zanzana
|
||||
// This limits the amount of concurrent connections to Zanzana
|
||||
wait := time.Now()
|
||||
b.zTickets <- true
|
||||
hooksWaitHistogram.WithLabelValues(resourceType, operation).Observe(time.Since(wait).Seconds())
|
||||
|
||||
go func(rb *iamv0.RoleBinding) {
|
||||
start := time.Now()
|
||||
status := "success"
|
||||
|
||||
defer func() {
|
||||
// Release the ticket after write is done
|
||||
<-b.zTickets
|
||||
// Record operation duration and count
|
||||
hooksDurationHistogram.WithLabelValues(resourceType, operation, status).Observe(time.Since(start).Seconds())
|
||||
}()
|
||||
|
||||
b.logger.Debug("deleting role binding from zanzana",
|
||||
"namespace", rb.Namespace,
|
||||
"name", rb.Name,
|
||||
"subject", rb.Spec.Subject.Name,
|
||||
"roleRefs", rb.Spec.RoleRefs,
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout)
|
||||
defer cancel()
|
||||
|
||||
operations := make([]*v1.MutateOperation, 0, len(rb.Spec.RoleRefs))
|
||||
for _, roleRef := range rb.Spec.RoleRefs {
|
||||
operations = append(operations, &v1.MutateOperation{
|
||||
Operation: &v1.MutateOperation_DeleteRoleBinding{
|
||||
DeleteRoleBinding: &v1.DeleteRoleBindingOperation{
|
||||
SubjectKind: string(rb.Spec.Subject.Kind),
|
||||
SubjectName: rb.Spec.Subject.Name,
|
||||
RoleKind: string(roleRef.Kind),
|
||||
RoleName: roleRef.Name,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if len(operations) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
err := b.zClient.Mutate(ctx, &v1.MutateRequest{
|
||||
Namespace: rb.Namespace,
|
||||
Operations: operations,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
status = "failure"
|
||||
b.logger.Error("failed to delete role binding from zanzana",
|
||||
"err", err,
|
||||
"namespace", rb.Namespace,
|
||||
"name", rb.Name,
|
||||
"subject", rb.Spec.Subject.Name,
|
||||
"roleRefs", rb.Spec.RoleRefs,
|
||||
)
|
||||
}
|
||||
}(rb.DeepCopy()) // Pass a copy of the object
|
||||
}
|
||||
|
||||
// BeginRoleBindingUpdate is a pre-update hook that prepares zanzana updates.
|
||||
// It performs the zanzana write after K8s update succeeds.
|
||||
func (b *IdentityAccessManagementAPIBuilder) BeginRoleBindingUpdate(ctx context.Context, obj, oldObj runtime.Object, options *metav1.UpdateOptions) (registry.FinishFunc, error) {
|
||||
if b.zClient == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Extract role bindings from both old and new objects
|
||||
oldRB, ok := oldObj.(*iamv0.RoleBinding)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
newRB, ok := obj.(*iamv0.RoleBinding)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if oldRB.Spec.Subject.Name == newRB.Spec.Subject.Name && roleRefsEqual(oldRB.Spec.RoleRefs, newRB.Spec.RoleRefs) {
|
||||
return nil, nil // No changes to the role binding
|
||||
}
|
||||
|
||||
if newRB.Spec.Subject.Name == "" {
|
||||
b.logger.Error("invalid role binding",
|
||||
"namespace", newRB.Namespace,
|
||||
"name", newRB.Name,
|
||||
"subject", newRB.Spec.Subject.Name,
|
||||
"roleRefs", newRB.Spec.RoleRefs,
|
||||
)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Return a finish function that performs the zanzana write only on success
|
||||
return func(ctx context.Context, success bool) {
|
||||
if !success {
|
||||
return
|
||||
}
|
||||
|
||||
wait := time.Now()
|
||||
b.zTickets <- true
|
||||
hooksWaitHistogram.WithLabelValues(resourceType, "update").Observe(time.Since(wait).Seconds())
|
||||
|
||||
go func() {
|
||||
start := time.Now()
|
||||
status := "success"
|
||||
|
||||
defer func() {
|
||||
<-b.zTickets
|
||||
// Record operation duration and count
|
||||
hooksDurationHistogram.WithLabelValues(resourceType, "update", status).Observe(time.Since(start).Seconds())
|
||||
}()
|
||||
|
||||
b.logger.Debug("updating role binding in zanzana",
|
||||
"namespace", newRB.Namespace,
|
||||
"name", newRB.Name,
|
||||
"oldSubject", oldRB.Spec.Subject.Name,
|
||||
"newSubject", newRB.Spec.Subject.Name,
|
||||
"oldRoleRefs", oldRB.Spec.RoleRefs,
|
||||
"newRoleRefs", newRB.Spec.RoleRefs,
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout)
|
||||
defer cancel()
|
||||
|
||||
operations := make([]*v1.MutateOperation, 0, len(oldRB.Spec.RoleRefs))
|
||||
for _, roleRef := range oldRB.Spec.RoleRefs {
|
||||
operations = append(operations, &v1.MutateOperation{
|
||||
Operation: &v1.MutateOperation_DeleteRoleBinding{
|
||||
DeleteRoleBinding: &v1.DeleteRoleBindingOperation{
|
||||
SubjectKind: string(oldRB.Spec.Subject.Kind),
|
||||
SubjectName: oldRB.Spec.Subject.Name,
|
||||
RoleKind: string(roleRef.Kind),
|
||||
RoleName: roleRef.Name,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
for _, roleRef := range newRB.Spec.RoleRefs {
|
||||
operations = append(operations, &v1.MutateOperation{
|
||||
Operation: &v1.MutateOperation_CreateRoleBinding{
|
||||
CreateRoleBinding: &v1.CreateRoleBindingOperation{
|
||||
SubjectKind: string(newRB.Spec.Subject.Kind),
|
||||
SubjectName: newRB.Spec.Subject.Name,
|
||||
RoleKind: string(roleRef.Kind),
|
||||
RoleName: roleRef.Name,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Only make the request if there are deletes or writes
|
||||
if len(operations) == 0 {
|
||||
b.logger.Debug("no role bindings to update in zanzana", "namespace", newRB.Namespace, "name", newRB.Name)
|
||||
return
|
||||
}
|
||||
|
||||
err := b.zClient.Mutate(ctx, &v1.MutateRequest{
|
||||
Namespace: newRB.Namespace,
|
||||
Operations: operations,
|
||||
})
|
||||
if err != nil {
|
||||
status = "failure"
|
||||
b.logger.Error("failed to update role binding in zanzana",
|
||||
"err", err,
|
||||
"namespace", newRB.Namespace,
|
||||
"name", newRB.Name,
|
||||
)
|
||||
}
|
||||
}()
|
||||
}, nil
|
||||
}
|
||||
|
||||
func roleRefsEqual(oldRoleRefs, newRoleRefs []iamv0.RoleBindingspecRoleRef) bool {
|
||||
if len(oldRoleRefs) != len(newRoleRefs) {
|
||||
return false
|
||||
}
|
||||
|
||||
oldRoleRefsMap := make(map[string]string)
|
||||
for _, roleRef := range oldRoleRefs {
|
||||
oldRoleRefsMap[roleRef.Name] = string(roleRef.Kind)
|
||||
}
|
||||
for _, roleRef := range newRoleRefs {
|
||||
refKind, ok := oldRoleRefsMap[roleRef.Name]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if refKind != string(roleRef.Kind) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
package iam
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
|
||||
)
|
||||
|
||||
func TestAfterRoleBindingCreate(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
b := &IdentityAccessManagementAPIBuilder{
|
||||
logger: log.NewNopLogger(),
|
||||
zTickets: make(chan bool, 1),
|
||||
}
|
||||
|
||||
t.Run("should create zanzana entry for role binding", func(t *testing.T) {
|
||||
wg.Add(1)
|
||||
roleBinding := iamv0.RoleBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "binding-1",
|
||||
Namespace: "org-1",
|
||||
},
|
||||
Spec: iamv0.RoleBindingSpec{
|
||||
Subject: iamv0.RoleBindingspecSubject{
|
||||
Kind: "user",
|
||||
Name: "user-1",
|
||||
},
|
||||
RoleRefs: []iamv0.RoleBindingspecRoleRef{
|
||||
{
|
||||
Kind: "role",
|
||||
Name: "role-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testRoleBinding := func(ctx context.Context, req *v1.MutateRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.NotNil(t, req.Operations)
|
||||
require.Len(t, req.Operations, 1)
|
||||
require.Equal(t, "org-1", req.Namespace)
|
||||
|
||||
expectedOperation := &v1.MutateOperation{
|
||||
Operation: &v1.MutateOperation_CreateRoleBinding{
|
||||
CreateRoleBinding: &v1.CreateRoleBindingOperation{
|
||||
SubjectKind: "user",
|
||||
SubjectName: "user-1",
|
||||
RoleKind: "role",
|
||||
RoleName: "role-1",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
actualCreate := req.Operations[0].Operation.(*v1.MutateOperation_CreateRoleBinding).CreateRoleBinding
|
||||
expectedCreate := expectedOperation.Operation.(*v1.MutateOperation_CreateRoleBinding).CreateRoleBinding
|
||||
|
||||
require.Equal(t, expectedCreate.SubjectKind, actualCreate.SubjectKind)
|
||||
require.Equal(t, expectedCreate.SubjectName, actualCreate.SubjectName)
|
||||
require.Equal(t, expectedCreate.RoleKind, actualCreate.RoleKind)
|
||||
require.Equal(t, expectedCreate.RoleName, actualCreate.RoleName)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{mutateCallback: testRoleBinding}
|
||||
b.AfterRoleBindingCreate(&roleBinding, nil)
|
||||
wg.Wait()
|
||||
})
|
||||
|
||||
t.Run("should not write to zanzana when zClient is nil", func(t *testing.T) {
|
||||
builder := &IdentityAccessManagementAPIBuilder{
|
||||
logger: log.NewNopLogger(),
|
||||
zTickets: make(chan bool, 1),
|
||||
zClient: nil,
|
||||
}
|
||||
|
||||
roleBinding := iamv0.RoleBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "binding-3",
|
||||
Namespace: "org-3",
|
||||
},
|
||||
Spec: iamv0.RoleBindingSpec{
|
||||
Subject: iamv0.RoleBindingspecSubject{
|
||||
Kind: "user",
|
||||
Name: "user-3",
|
||||
},
|
||||
RoleRefs: []iamv0.RoleBindingspecRoleRef{
|
||||
{
|
||||
Kind: "role",
|
||||
Name: "role-3",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Should not panic or error when zClient is nil
|
||||
builder.AfterRoleBindingCreate(&roleBinding, nil)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBeginRoleBindingUpdate(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
b := &IdentityAccessManagementAPIBuilder{
|
||||
logger: log.NewNopLogger(),
|
||||
zTickets: make(chan bool, 1),
|
||||
}
|
||||
|
||||
t.Run("should update zanzana entry when role binding changed", func(t *testing.T) {
|
||||
wg.Add(1)
|
||||
oldBinding := iamv0.RoleBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "binding-1",
|
||||
Namespace: "org-1",
|
||||
},
|
||||
Spec: iamv0.RoleBindingSpec{
|
||||
Subject: iamv0.RoleBindingspecSubject{
|
||||
Kind: "user",
|
||||
Name: "user-1",
|
||||
},
|
||||
RoleRefs: []iamv0.RoleBindingspecRoleRef{
|
||||
{
|
||||
Kind: "role",
|
||||
Name: "role-foo",
|
||||
},
|
||||
{
|
||||
Kind: "role",
|
||||
Name: "role-2",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
newBinding := iamv0.RoleBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "binding-1",
|
||||
Namespace: "org-1",
|
||||
},
|
||||
Spec: iamv0.RoleBindingSpec{
|
||||
Subject: iamv0.RoleBindingspecSubject{
|
||||
Kind: "user",
|
||||
Name: "user-1",
|
||||
},
|
||||
RoleRefs: []iamv0.RoleBindingspecRoleRef{
|
||||
{
|
||||
Kind: "role",
|
||||
Name: "role-bar",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testRoleBindingUpdate := func(ctx context.Context, req *v1.MutateRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.Equal(t, "org-1", req.Namespace)
|
||||
|
||||
require.NotNil(t, req.Operations)
|
||||
require.Len(t, req.Operations, 3)
|
||||
|
||||
// Should write new binding and delete old one
|
||||
require.True(t, containsOperation(req.Operations, &v1.MutateOperation{
|
||||
Operation: &v1.MutateOperation_DeleteRoleBinding{
|
||||
DeleteRoleBinding: &v1.DeleteRoleBindingOperation{
|
||||
SubjectKind: "user",
|
||||
SubjectName: "user-1",
|
||||
RoleKind: "role",
|
||||
RoleName: "role-foo",
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
require.True(t, containsOperation(req.Operations, &v1.MutateOperation{
|
||||
Operation: &v1.MutateOperation_CreateRoleBinding{
|
||||
CreateRoleBinding: &v1.CreateRoleBindingOperation{
|
||||
SubjectKind: "user",
|
||||
SubjectName: "user-1",
|
||||
RoleKind: "role",
|
||||
RoleName: "role-bar",
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{mutateCallback: testRoleBindingUpdate}
|
||||
|
||||
finishFunc, err := b.BeginRoleBindingUpdate(context.Background(), &newBinding, &oldBinding, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, finishFunc)
|
||||
|
||||
finishFunc(context.Background(), true)
|
||||
wg.Wait()
|
||||
})
|
||||
|
||||
t.Run("should return nil finish func when bindings are identical", func(t *testing.T) {
|
||||
oldBinding := iamv0.RoleBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "binding-2",
|
||||
Namespace: "org-2",
|
||||
},
|
||||
Spec: iamv0.RoleBindingSpec{
|
||||
Subject: iamv0.RoleBindingspecSubject{
|
||||
Kind: "user",
|
||||
Name: "user-1",
|
||||
},
|
||||
RoleRefs: []iamv0.RoleBindingspecRoleRef{
|
||||
{
|
||||
Kind: "role",
|
||||
Name: "role-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
newBinding := iamv0.RoleBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "binding-2",
|
||||
Namespace: "org-2",
|
||||
},
|
||||
Spec: iamv0.RoleBindingSpec{
|
||||
Subject: iamv0.RoleBindingspecSubject{
|
||||
Kind: "user",
|
||||
Name: "user-1",
|
||||
},
|
||||
RoleRefs: []iamv0.RoleBindingspecRoleRef{
|
||||
{
|
||||
Kind: "role",
|
||||
Name: "role-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
writeCalled := false
|
||||
testNoWriteOnNoChange := func(ctx context.Context, req *v1.MutateRequest) error {
|
||||
writeCalled = true
|
||||
require.Fail(t, "Write should not be called when bindings are identical")
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{mutateCallback: testNoWriteOnNoChange}
|
||||
|
||||
finishFunc, err := b.BeginRoleBindingUpdate(context.Background(), &newBinding, &oldBinding, nil)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, finishFunc) // Should return nil when bindings are identical
|
||||
|
||||
// Verify write was never called
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
require.False(t, writeCalled, "Write callback should not be called when bindings are identical")
|
||||
})
|
||||
|
||||
t.Run("should return nil finish func when new binding has empty subject name", func(t *testing.T) {
|
||||
oldBinding := iamv0.RoleBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "binding-8",
|
||||
Namespace: "org-8",
|
||||
},
|
||||
Spec: iamv0.RoleBindingSpec{
|
||||
Subject: iamv0.RoleBindingspecSubject{
|
||||
Kind: "user",
|
||||
Name: "user-1",
|
||||
},
|
||||
RoleRefs: []iamv0.RoleBindingspecRoleRef{
|
||||
{
|
||||
Kind: "role",
|
||||
Name: "role-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
newBinding := iamv0.RoleBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "binding-8",
|
||||
Namespace: "org-8",
|
||||
},
|
||||
Spec: iamv0.RoleBindingSpec{
|
||||
Subject: iamv0.RoleBindingspecSubject{
|
||||
Kind: "",
|
||||
Name: "", // Empty name - should cause early return
|
||||
},
|
||||
RoleRefs: []iamv0.RoleBindingspecRoleRef{
|
||||
{
|
||||
Kind: "role",
|
||||
Name: "role-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
writeCalled := false
|
||||
testNoWriteOnInvalidBinding := func(ctx context.Context, req *v1.MutateRequest) error {
|
||||
writeCalled = true
|
||||
require.Fail(t, "Write should not be called when new binding has empty subject name")
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{mutateCallback: testNoWriteOnInvalidBinding}
|
||||
|
||||
finishFunc, err := b.BeginRoleBindingUpdate(context.Background(), &newBinding, &oldBinding, nil)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, finishFunc) // Should return nil when new binding has empty subject name
|
||||
|
||||
// Verify write was never called
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
require.False(t, writeCalled, "Write callback should not be called when new binding has empty subject name")
|
||||
})
|
||||
}
|
||||
|
||||
func TestAfterRoleBindingDelete(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
b := &IdentityAccessManagementAPIBuilder{
|
||||
logger: log.NewNopLogger(),
|
||||
zTickets: make(chan bool, 1),
|
||||
}
|
||||
|
||||
t.Run("should delete zanzana entry for team binding with member permission", func(t *testing.T) {
|
||||
wg.Add(1)
|
||||
roleBinding := iamv0.RoleBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "binding-1",
|
||||
Namespace: "org-1",
|
||||
},
|
||||
Spec: iamv0.RoleBindingSpec{
|
||||
Subject: iamv0.RoleBindingspecSubject{
|
||||
Kind: "user",
|
||||
Name: "user-1",
|
||||
},
|
||||
RoleRefs: []iamv0.RoleBindingspecRoleRef{
|
||||
{
|
||||
Kind: "role",
|
||||
Name: "role-1",
|
||||
},
|
||||
{
|
||||
Kind: "role",
|
||||
Name: "role-2",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testRoleBindingDelete := func(ctx context.Context, req *v1.MutateRequest) error {
|
||||
defer wg.Done()
|
||||
require.NotNil(t, req)
|
||||
require.Equal(t, "org-1", req.Namespace)
|
||||
|
||||
// Should have deletes but no writes
|
||||
require.NotNil(t, req.Operations)
|
||||
require.Len(t, req.Operations, 2)
|
||||
require.True(t, containsOperation(req.Operations, &v1.MutateOperation{
|
||||
Operation: &v1.MutateOperation_DeleteRoleBinding{
|
||||
DeleteRoleBinding: &v1.DeleteRoleBindingOperation{
|
||||
SubjectKind: "user",
|
||||
SubjectName: "user-1",
|
||||
RoleKind: "role",
|
||||
RoleName: "role-1",
|
||||
},
|
||||
},
|
||||
}))
|
||||
require.True(t, containsOperation(req.Operations, &v1.MutateOperation{
|
||||
Operation: &v1.MutateOperation_DeleteRoleBinding{
|
||||
DeleteRoleBinding: &v1.DeleteRoleBindingOperation{
|
||||
SubjectKind: "user",
|
||||
SubjectName: "user-1",
|
||||
RoleKind: "role",
|
||||
RoleName: "role-2",
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{mutateCallback: testRoleBindingDelete}
|
||||
b.AfterRoleBindingDelete(&roleBinding, nil)
|
||||
wg.Wait()
|
||||
})
|
||||
|
||||
t.Run("should not delete from zanzana when zClient is nil", func(t *testing.T) {
|
||||
builder := &IdentityAccessManagementAPIBuilder{
|
||||
logger: log.NewNopLogger(),
|
||||
zTickets: make(chan bool, 1),
|
||||
zClient: nil,
|
||||
}
|
||||
|
||||
roleBinding := iamv0.RoleBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "binding-3",
|
||||
Namespace: "org-3",
|
||||
},
|
||||
Spec: iamv0.RoleBindingSpec{
|
||||
Subject: iamv0.RoleBindingspecSubject{
|
||||
Kind: "user",
|
||||
Name: "user-3",
|
||||
},
|
||||
RoleRefs: []iamv0.RoleBindingspecRoleRef{
|
||||
{
|
||||
Kind: "role",
|
||||
Name: "role-3",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Should not panic or error when zClient is nil
|
||||
builder.AfterRoleBindingDelete(&roleBinding, nil)
|
||||
})
|
||||
}
|
||||
|
||||
func containsOperation(operations []*v1.MutateOperation, operation *v1.MutateOperation) bool {
|
||||
return slices.ContainsFunc(operations, func(o *v1.MutateOperation) bool {
|
||||
switch operation.Operation.(type) {
|
||||
case *v1.MutateOperation_DeleteRoleBinding:
|
||||
deleteOperation := operation.Operation.(*v1.MutateOperation_DeleteRoleBinding)
|
||||
deleteO, ok := o.Operation.(*v1.MutateOperation_DeleteRoleBinding)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return deleteO.DeleteRoleBinding.SubjectKind == deleteOperation.DeleteRoleBinding.SubjectKind &&
|
||||
deleteO.DeleteRoleBinding.SubjectName == deleteOperation.DeleteRoleBinding.SubjectName &&
|
||||
deleteO.DeleteRoleBinding.RoleKind == deleteOperation.DeleteRoleBinding.RoleKind &&
|
||||
deleteO.DeleteRoleBinding.RoleName == deleteOperation.DeleteRoleBinding.RoleName
|
||||
case *v1.MutateOperation_CreateRoleBinding:
|
||||
createOperation := operation.Operation.(*v1.MutateOperation_CreateRoleBinding)
|
||||
createO, ok := o.Operation.(*v1.MutateOperation_CreateRoleBinding)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return createO.CreateRoleBinding.SubjectKind == createOperation.CreateRoleBinding.SubjectKind &&
|
||||
createO.CreateRoleBinding.SubjectName == createOperation.CreateRoleBinding.SubjectName &&
|
||||
createO.CreateRoleBinding.RoleKind == createOperation.CreateRoleBinding.RoleKind &&
|
||||
createO.CreateRoleBinding.RoleName == createOperation.CreateRoleBinding.RoleName
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
@@ -172,6 +172,8 @@ func (b *APIBuilder) handleSettings(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
for i, val := range all {
|
||||
branch := val.Branch()
|
||||
url := val.URL()
|
||||
path := val.Path()
|
||||
|
||||
settings.Items[i] = provisioning.RepositoryView{
|
||||
Name: val.Name,
|
||||
@@ -179,6 +181,8 @@ func (b *APIBuilder) handleSettings(w http.ResponseWriter, r *http.Request) {
|
||||
Type: val.Spec.Type,
|
||||
Target: val.Spec.Sync.Target,
|
||||
Branch: branch,
|
||||
URL: url,
|
||||
Path: path,
|
||||
Workflows: val.Spec.Workflows,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ type FakeDataSourceService struct {
|
||||
lastID int64
|
||||
DataSources []*datasources.DataSource
|
||||
SimulatePluginFailure bool
|
||||
|
||||
// UID -> Headers
|
||||
DataSourceHeaders map[string]http.Header
|
||||
}
|
||||
|
||||
var _ datasources.DataSourceService = &FakeDataSourceService{}
|
||||
@@ -152,5 +155,5 @@ func (s *FakeDataSourceService) DecryptedPassword(ctx context.Context, ds *datas
|
||||
}
|
||||
|
||||
func (s *FakeDataSourceService) CustomHeaders(ctx context.Context, ds *datasources.DataSource) (http.Header, error) {
|
||||
return nil, nil
|
||||
return s.DataSourceHeaders[ds.UID], nil
|
||||
}
|
||||
|
||||
@@ -579,6 +579,13 @@ var (
|
||||
FrontendOnly: false, // The restore backend feature changes behavior based on this flag
|
||||
Owner: grafanaDashboardsSquad,
|
||||
},
|
||||
{
|
||||
Name: "kubernetesDashboardsV2",
|
||||
Description: "Use the v2 kubernetes API in the frontend for dashboards",
|
||||
Stage: FeatureStageExperimental,
|
||||
FrontendOnly: false,
|
||||
Owner: grafanaDashboardsSquad,
|
||||
},
|
||||
{
|
||||
Name: "dashboardUndoRedo",
|
||||
Description: "Enables undo/redo in dynamic dashboards",
|
||||
@@ -1315,7 +1322,7 @@ var (
|
||||
Name: "elasticsearchImprovedParsing",
|
||||
Description: "Enables less memory intensive Elasticsearch result parsing",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: awsDatasourcesSquad,
|
||||
Owner: grafanaPartnerPluginsSquad,
|
||||
},
|
||||
{
|
||||
Name: "datasourceConnectionsTab",
|
||||
@@ -1963,6 +1970,13 @@ var (
|
||||
RequiresRestart: false,
|
||||
HideFromDocs: false,
|
||||
},
|
||||
{
|
||||
Name: "kubernetesAlertingHistorian",
|
||||
Description: "Adds support for Kubernetes alerting historian APIs",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaAlertingSquad,
|
||||
RequiresRestart: true,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
Generated
+3
-1
@@ -80,6 +80,7 @@ dashboardSceneForViewers,GA,@grafana/dashboards-squad,false,false,true
|
||||
dashboardSceneSolo,GA,@grafana/dashboards-squad,false,false,true
|
||||
dashboardScene,GA,@grafana/dashboards-squad,false,false,true
|
||||
dashboardNewLayouts,experimental,@grafana/dashboards-squad,false,false,false
|
||||
kubernetesDashboardsV2,experimental,@grafana/dashboards-squad,false,false,false
|
||||
dashboardUndoRedo,experimental,@grafana/dashboards-squad,false,false,true
|
||||
unlimitedLayoutsNesting,experimental,@grafana/dashboards-squad,false,false,true
|
||||
perPanelNonApplicableDrilldowns,experimental,@grafana/dashboards-squad,false,false,true
|
||||
@@ -181,7 +182,7 @@ k8SFolderMove,experimental,@grafana/search-and-storage,false,false,false
|
||||
improvedExternalSessionHandlingSAML,GA,@grafana/identity-access-team,false,false,false
|
||||
teamHttpHeadersTempo,experimental,@grafana/identity-access-team,false,false,false
|
||||
grafanaAdvisor,privatePreview,@grafana/plugins-platform-backend,false,false,false
|
||||
elasticsearchImprovedParsing,experimental,@grafana/aws-datasources,false,false,false
|
||||
elasticsearchImprovedParsing,experimental,@grafana/partner-datasources,false,false,false
|
||||
datasourceConnectionsTab,privatePreview,@grafana/plugins-platform-backend,false,false,true
|
||||
fetchRulesUsingPost,experimental,@grafana/alerting-squad,false,false,false
|
||||
newLogsPanel,GA,@grafana/observability-logs,false,false,true
|
||||
@@ -266,3 +267,4 @@ transformationsEmptyPlaceholder,preview,@grafana/datapro,false,false,true
|
||||
ttlPluginInstanceManager,experimental,@grafana/plugins-platform-backend,false,false,true
|
||||
lokiQueryLimitsContext,experimental,@grafana/observability-logs,false,false,true
|
||||
rudderstackUpgrade,experimental,@grafana/grafana-frontend-platform,false,false,true
|
||||
kubernetesAlertingHistorian,experimental,@grafana/alerting-squad,false,true,false
|
||||
|
||||
|
Generated
+8
@@ -259,6 +259,10 @@ const (
|
||||
// Enables experimental new dashboard layouts
|
||||
FlagDashboardNewLayouts = "dashboardNewLayouts"
|
||||
|
||||
// FlagKubernetesDashboardsV2
|
||||
// Use the v2 kubernetes API in the frontend for dashboards
|
||||
FlagKubernetesDashboardsV2 = "kubernetesDashboardsV2"
|
||||
|
||||
// FlagPdfTables
|
||||
// Enables generating table data as PDF in reporting
|
||||
FlagPdfTables = "pdfTables"
|
||||
@@ -757,4 +761,8 @@ const (
|
||||
// FlagAwsDatasourcesHttpProxy
|
||||
// Enables http proxy settings for aws datasources
|
||||
FlagAwsDatasourcesHttpProxy = "awsDatasourcesHttpProxy"
|
||||
|
||||
// FlagKubernetesAlertingHistorian
|
||||
// Adds support for Kubernetes alerting historian APIs
|
||||
FlagKubernetesAlertingHistorian = "kubernetesAlertingHistorian"
|
||||
)
|
||||
|
||||
+47
-3
@@ -1214,13 +1214,16 @@
|
||||
{
|
||||
"metadata": {
|
||||
"name": "elasticsearchImprovedParsing",
|
||||
"resourceVersion": "1763734583253",
|
||||
"creationTimestamp": "2025-01-15T17:05:54Z"
|
||||
"resourceVersion": "1764260048941",
|
||||
"creationTimestamp": "2025-01-15T17:05:54Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2025-11-27 16:14:08.941633 +0000 UTC"
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables less memory intensive Elasticsearch result parsing",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/aws-datasources"
|
||||
"codeowner": "@grafana/partner-datasources"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1790,6 +1793,19 @@
|
||||
"requiresRestart": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "kubernetesAlertingHistorian",
|
||||
"resourceVersion": "1764257713773",
|
||||
"creationTimestamp": "2025-11-27T15:35:13Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Adds support for Kubernetes alerting historian APIs",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/alerting-squad",
|
||||
"requiresRestart": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "kubernetesAlertingRules",
|
||||
@@ -1911,6 +1927,18 @@
|
||||
"expression": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "kubernetesDashboardsV2",
|
||||
"resourceVersion": "1764236054307",
|
||||
"creationTimestamp": "2025-11-27T09:34:14Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Use the v2 kubernetes API in the frontend for dashboards",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/dashboards-squad"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "kubernetesExternalGroupMapping",
|
||||
@@ -3547,6 +3575,22 @@
|
||||
"expression": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "v2DashboardAPIVersion",
|
||||
"resourceVersion": "1762457740470",
|
||||
"creationTimestamp": "2025-11-06T19:22:05Z",
|
||||
"deletionTimestamp": "2025-11-27T09:34:14Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2025-11-06 19:35:40.470587 +0000 UTC"
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables the v2 dashboard API version",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/dashboards-squad"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "vizActionsAuth",
|
||||
|
||||
@@ -239,6 +239,30 @@
|
||||
|
||||
const CHECK_INTERVAL = 1 * 1000;
|
||||
|
||||
function getCookie(name) {
|
||||
const cookies = document.cookie.split(";").map(c => c.trim());
|
||||
|
||||
for (const cookie of cookies) {
|
||||
if (cookie.startsWith(name + "=")) {
|
||||
return cookie.substring(name.length + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getSessionExpiration() {
|
||||
const value = getCookie("grafana_session_expiry") || "0";
|
||||
const realExpiresSeconds = parseInt(value, 10);
|
||||
const expiresSeconds = Math.max(realExpiresSeconds - 10, 0); // Rotate 10s before the real expiration
|
||||
const expiration = new Date(expiresSeconds * 1000);
|
||||
return expiration;
|
||||
}
|
||||
|
||||
async function rotateSession() {
|
||||
await fetch('/api/user/auth-tokens/rotate', { method: 'POST' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches boot data from the server. If it returns undefined, it should be retried later.
|
||||
* Will return a rejected promise on unrecoverable errors.
|
||||
@@ -295,6 +319,19 @@
|
||||
function loadBootData() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const attemptFetch = async () => {
|
||||
try {
|
||||
const sessionExpiration = getSessionExpiration();
|
||||
const now = new Date();
|
||||
|
||||
// If the session has expired, don't continue trying to fetch boot data
|
||||
if (now >= sessionExpiration) {
|
||||
await rotateSession();
|
||||
}
|
||||
} catch (error) {
|
||||
// Just ignore any errors in session rotation. The user can just log in again.
|
||||
console.warn("Failed to rotate session", error);
|
||||
}
|
||||
|
||||
try {
|
||||
const bootData = await fetchBootData();
|
||||
|
||||
|
||||
@@ -4,11 +4,14 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
"github.com/grafana/grafana/pkg/api/response"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/eval"
|
||||
@@ -24,55 +27,64 @@ type HistorySrv struct {
|
||||
hist Historian
|
||||
}
|
||||
|
||||
const labelQueryPrefix = "labels_"
|
||||
|
||||
func (srv *HistorySrv) RouteQueryStateHistory(c *contextmodel.ReqContext) response.Response {
|
||||
from := c.QueryInt64("from")
|
||||
to := c.QueryInt64("to")
|
||||
limit := c.QueryInt("limit")
|
||||
ruleUID := c.Query("ruleUID")
|
||||
dashUID := c.Query("dashboardUID")
|
||||
panelID := c.QueryInt64("panelID")
|
||||
|
||||
previous := c.Query("previous")
|
||||
if previous != "" {
|
||||
_, err := eval.ParseStateString(previous)
|
||||
if err != nil {
|
||||
return ErrResp(http.StatusBadRequest, fmt.Errorf("invalid previous state filter: %w", err), "")
|
||||
}
|
||||
query, err := ParseHistoryQuery(c.OrgID, c.SignedInUser, c.Req.URL.Query())
|
||||
if err != nil {
|
||||
return ErrResp(http.StatusBadRequest, err, "")
|
||||
}
|
||||
|
||||
current := c.Query("current")
|
||||
if current != "" {
|
||||
_, err := eval.ParseStateString(current)
|
||||
if err != nil {
|
||||
return ErrResp(http.StatusBadRequest, fmt.Errorf("invalid current state filter: %w", err), "")
|
||||
}
|
||||
}
|
||||
|
||||
labels := make(map[string]string)
|
||||
for k, v := range c.Req.URL.Query() {
|
||||
if strings.HasPrefix(k, labelQueryPrefix) {
|
||||
labels[k[len(labelQueryPrefix):]] = v[0]
|
||||
}
|
||||
}
|
||||
|
||||
query := models.HistoryQuery{
|
||||
RuleUID: ruleUID,
|
||||
OrgID: c.GetOrgID(),
|
||||
DashboardUID: dashUID,
|
||||
PanelID: panelID,
|
||||
Previous: previous,
|
||||
Current: current,
|
||||
SignedInUser: c.SignedInUser,
|
||||
From: time.Unix(from, 0),
|
||||
To: time.Unix(to, 0),
|
||||
Limit: limit,
|
||||
Labels: labels,
|
||||
}
|
||||
frame, err := srv.hist.Query(c.Req.Context(), query)
|
||||
if err != nil {
|
||||
return ErrResp(http.StatusInternalServerError, err, "")
|
||||
}
|
||||
return response.JSON(http.StatusOK, frame)
|
||||
}
|
||||
|
||||
const labelQueryPrefix = "labels_"
|
||||
|
||||
// ParseHistoryQuery parses a HistoryQuery from request parameters.
|
||||
func ParseHistoryQuery(orgID int64, user identity.Requester, query url.Values) (models.HistoryQuery, error) {
|
||||
from, _ := strconv.ParseInt(query.Get("from"), 10, 64)
|
||||
to, _ := strconv.ParseInt(query.Get("to"), 10, 64)
|
||||
limit, _ := strconv.Atoi(query.Get("limit"))
|
||||
ruleUID := query.Get("ruleUID")
|
||||
dashUID := query.Get("dashboardUID")
|
||||
panelID, _ := strconv.ParseInt(query.Get("panelID"), 10, 64)
|
||||
|
||||
previous := query.Get("previous")
|
||||
if previous != "" {
|
||||
_, err := eval.ParseStateString(previous)
|
||||
if err != nil {
|
||||
return models.HistoryQuery{}, fmt.Errorf("invalid previous state filter: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
current := query.Get("current")
|
||||
if current != "" {
|
||||
_, err := eval.ParseStateString(current)
|
||||
if err != nil {
|
||||
return models.HistoryQuery{}, fmt.Errorf("invalid current state filter: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
labels := make(map[string]string)
|
||||
for k, v := range query {
|
||||
if strings.HasPrefix(k, labelQueryPrefix) {
|
||||
labels[k[len(labelQueryPrefix):]] = v[0]
|
||||
}
|
||||
}
|
||||
|
||||
return models.HistoryQuery{
|
||||
RuleUID: ruleUID,
|
||||
OrgID: orgID,
|
||||
DashboardUID: dashUID,
|
||||
PanelID: panelID,
|
||||
Previous: previous,
|
||||
Current: current,
|
||||
SignedInUser: user,
|
||||
From: time.Unix(from, 0),
|
||||
To: time.Unix(to, 0),
|
||||
Limit: limit,
|
||||
Labels: labels,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -205,11 +205,23 @@ func (w *DatasourceWriter) makeWriter(ctx context.Context, orgID int64, dsUID st
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// We need to add the writer headers (valid for any data source) and any data-source-specific headers.
|
||||
headers := make(http.Header)
|
||||
for k, v := range w.cfg.CustomHeaders {
|
||||
headers.Add(k, v)
|
||||
}
|
||||
|
||||
dsHeaders, err := w.datasources.CustomHeaders(ctx, ds)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get headers for data source: %w", err)
|
||||
}
|
||||
|
||||
for k, values := range dsHeaders {
|
||||
for _, v := range values {
|
||||
headers.Add(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
var backend backendType
|
||||
if dsUID == string(grafanaCloudPromType) {
|
||||
backend = grafanaCloudPromType
|
||||
|
||||
@@ -56,13 +56,14 @@ func (m *mockHTTPClientProvider) New(options ...sdkhttpclient.Options) (*http.Cl
|
||||
type testDataSources struct {
|
||||
dsfakes.FakeDataSourceService
|
||||
|
||||
prom1, prom2, prom3 *TestRemoteWriteTarget
|
||||
prom1, prom2, prom3, prom4 *TestRemoteWriteTarget
|
||||
}
|
||||
|
||||
func (t *testDataSources) Reset() {
|
||||
t.prom1.Reset()
|
||||
t.prom2.Reset()
|
||||
t.prom3.Reset()
|
||||
t.prom4.Reset()
|
||||
}
|
||||
|
||||
func setupDataSources(t *testing.T) *testDataSources {
|
||||
@@ -70,7 +71,9 @@ func setupDataSources(t *testing.T) *testDataSources {
|
||||
prom1: NewTestRemoteWriteTarget(t),
|
||||
prom2: NewTestRemoteWriteTarget(t),
|
||||
prom3: NewTestRemoteWriteTarget(t),
|
||||
prom4: NewTestRemoteWriteTarget(t),
|
||||
}
|
||||
res.DataSourceHeaders = make(map[string]http.Header)
|
||||
|
||||
t.Cleanup(func() {
|
||||
res.prom1.Close()
|
||||
@@ -81,6 +84,9 @@ func setupDataSources(t *testing.T) *testDataSources {
|
||||
t.Cleanup(func() {
|
||||
res.prom3.Close()
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
res.prom4.Close()
|
||||
})
|
||||
|
||||
p1, _ := res.AddDataSource(context.Background(), &datasources.AddDataSourceCommand{
|
||||
Name: "prom-1",
|
||||
@@ -107,7 +113,7 @@ func setupDataSources(t *testing.T) *testDataSources {
|
||||
Type: datasources.DS_LOKI,
|
||||
})
|
||||
|
||||
// Add a third Prometheus datasource that uses PDC
|
||||
// Add a third Prometheus datasource that uses PDC.
|
||||
p3, _ := res.AddDataSource(context.Background(), &datasources.AddDataSourceCommand{
|
||||
Name: "prom-3",
|
||||
UID: "prom-3",
|
||||
@@ -123,6 +129,21 @@ func setupDataSources(t *testing.T) *testDataSources {
|
||||
|
||||
require.True(t, p3.IsSecureSocksDSProxyEnabled())
|
||||
|
||||
// Add a fourth Prometheus datasource with headers in the JSON config.
|
||||
p4, _ := res.AddDataSource(context.Background(), &datasources.AddDataSourceCommand{
|
||||
Name: "prom-4",
|
||||
UID: "prom-4",
|
||||
Type: datasources.DS_PROMETHEUS,
|
||||
JsonData: simplejson.MustJson([]byte(`{"prometheusType":"Prometheus"}`)),
|
||||
})
|
||||
p4.URL = res.prom4.srv.URL
|
||||
res.prom4.ExpectedPath = "/api/v1/write"
|
||||
res.DataSourceHeaders["prom-4"] = http.Header{
|
||||
"X-Scope-OrgID": []string{"test-user"},
|
||||
"X-Test-Header": []string{"test-value"},
|
||||
"X-Double-Header": []string{"one", "two", "three"},
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
@@ -204,6 +225,45 @@ func TestDatasourceWriter(t *testing.T) {
|
||||
assert.Equal(t, headers[header2], testDS.prom1.LastHeaders.Get(header2))
|
||||
})
|
||||
|
||||
t.Run("when data source headers are configured, they are passed to the request", func(t *testing.T) {
|
||||
testDS.Reset()
|
||||
overwrittenHeader := "X-Test-Header"
|
||||
cHeaders := map[string]string{
|
||||
"X-Custom-Header": "test-value",
|
||||
"X-Another-Header": "another-value",
|
||||
overwrittenHeader: "overwritten", // Data source headers should be overwritten by custom headers.
|
||||
}
|
||||
|
||||
cfg = DatasourceWriterConfig{
|
||||
Timeout: time.Second * 5,
|
||||
DefaultDatasourceUID: "prom-1",
|
||||
CustomHeaders: cHeaders,
|
||||
}
|
||||
writer = NewDatasourceWriter(cfg, testDS, httpclient.NewProvider(), pluginContextProvider, clock.New(), log.New("test"), met)
|
||||
|
||||
uid := "prom-4"
|
||||
err := writer.WriteDatasource(context.Background(), uid, "metric", time.Now(), frames, 1, map[string]string{})
|
||||
require.NoError(t, err)
|
||||
|
||||
dsHeaders := testDS.DataSourceHeaders[uid]
|
||||
require.Len(t, dsHeaders, 3)
|
||||
|
||||
// We're confirming we have a data source header with the same name but different value.
|
||||
// This one should not be sent in the request.
|
||||
require.NotEmpty(t, dsHeaders[overwrittenHeader])
|
||||
require.NotEqual(t, dsHeaders[overwrittenHeader], cHeaders[overwrittenHeader])
|
||||
|
||||
// All headers (except for the one that was overwritten) should have been used.
|
||||
for k, vv := range dsHeaders {
|
||||
if k != overwrittenHeader {
|
||||
assert.Equal(t, vv, testDS.prom4.LastHeaders.Values(k))
|
||||
}
|
||||
}
|
||||
for k, v := range cHeaders {
|
||||
assert.Equal(t, v, testDS.prom4.LastHeaders.Get(k))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("when PDC is enabled proxy options are passed to HTTP client provider", func(t *testing.T) {
|
||||
testDS.Reset()
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package writer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -37,7 +38,7 @@ func NewTestRemoteWriteTarget(t *testing.T) *TestRemoteWriteTarget {
|
||||
|
||||
handler := func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != target.ExpectedPath {
|
||||
require.Fail(t, "Received unexpected request for endpoint %s", r.URL.Path)
|
||||
require.Fail(t, fmt.Sprintf("Received unexpected request for endpoint %s", r.URL.Path))
|
||||
}
|
||||
|
||||
target.mtx.Lock()
|
||||
|
||||
@@ -581,10 +581,16 @@ func TestMain(m *testing.M) {
|
||||
// nolint:staticcheck
|
||||
testSQLStore.cfg.IsFeatureToggleEnabled = features.IsEnabledGlobally
|
||||
|
||||
if err := testSQLStore.dialect.TruncateDBTables(testSQLStore.GetEngine()); err != nil {
|
||||
return nil, err
|
||||
skipTruncate := false
|
||||
if skip, present := os.LookupEnv("SKIP_DB_TRUNCATE"); present {
|
||||
skipTruncate = strings.ToLower(skip) == "true"
|
||||
}
|
||||
if !skipTruncate {
|
||||
if err := testSQLStore.dialect.TruncateDBTables(testSQLStore.GetEngine()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
testSQLStore.engine.ResetSequenceGenerator()
|
||||
}
|
||||
testSQLStore.engine.ResetSequenceGenerator()
|
||||
|
||||
if err := testSQLStore.Reset(); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -615,6 +615,8 @@ type Cfg struct {
|
||||
HttpsSkipVerify bool
|
||||
ResourceServerJoinRingTimeout time.Duration
|
||||
EnableSearch bool
|
||||
OverridesFilePath string
|
||||
OverridesReloadInterval time.Duration
|
||||
|
||||
// Secrets Management
|
||||
SecretsManagement SecretsManagerSettings
|
||||
|
||||
@@ -94,6 +94,10 @@ func (cfg *Cfg) setUnifiedStorageConfig() {
|
||||
cfg.HttpsSkipVerify = section.Key("https_skip_verify").MustBool(false)
|
||||
cfg.ResourceServerJoinRingTimeout = section.Key("resource_server_join_ring_timeout").MustDuration(10 * time.Second)
|
||||
|
||||
// quotas/limits config
|
||||
cfg.OverridesFilePath = section.Key("overrides_path").String()
|
||||
cfg.OverridesReloadInterval = section.Key("overrides_reload_period").MustDuration(30 * time.Second)
|
||||
|
||||
cfg.MaxFileIndexAge = section.Key("max_file_index_age").MustDuration(0)
|
||||
cfg.MinFileIndexBuildVersion = section.Key("min_file_index_build_version").MustString("")
|
||||
}
|
||||
|
||||
@@ -54,6 +54,9 @@ func NewRESTOptionsGetterMemory(originalStorageConfig storagebackend.Config, sec
|
||||
// Create BadgerDB with in-memory mode
|
||||
db, err := badger.Open(badger.DefaultOptions("").
|
||||
WithInMemory(true).
|
||||
WithMemTableSize(256 << 10). // 256KB memtable size
|
||||
WithValueThreshold(16 << 10). // 16KB threshold for storing values in LSM vs value log
|
||||
WithNumMemtables(2). // Keep only 2 memtables in memory
|
||||
WithLogger(nil))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -211,6 +211,19 @@ func newClient(opts options.StorageOptions,
|
||||
serverOptions.QOSQueue = queue
|
||||
}
|
||||
|
||||
// only enable if an overrides file path is provided
|
||||
if cfg.OverridesFilePath != "" {
|
||||
overridesSvc, err := resource.NewOverridesService(ctx, cfg.Logger, reg, tracer, resource.ReloadOptions{
|
||||
FilePath: cfg.OverridesFilePath,
|
||||
ReloadPeriod: cfg.OverridesReloadInterval,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
serverOptions.OverridesService = overridesSvc
|
||||
}
|
||||
|
||||
server, err := sql.NewResourceServer(serverOptions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
package migrations_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
authlib "github.com/grafana/authlib/types"
|
||||
"github.com/grafana/grafana/pkg/services/folder"
|
||||
"github.com/grafana/grafana/pkg/tests/apis"
|
||||
"github.com/stretchr/testify/require"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
)
|
||||
|
||||
// foldersAndDashboardsTestCase tests the "folders-dashboards" ResourceMigration
|
||||
type foldersAndDashboardsTestCase struct {
|
||||
parentFolderUID string
|
||||
childFolderUID string
|
||||
dashboardUID string
|
||||
libPanelUID string
|
||||
}
|
||||
|
||||
// newFoldersAndDashboardsTestCase creates a test case for the compound folders+dashboards migrator
|
||||
func newFoldersAndDashboardsTestCase() resourceMigratorTestCase {
|
||||
return &foldersAndDashboardsTestCase{
|
||||
parentFolderUID: "parent-folder-uid",
|
||||
childFolderUID: "child-folder-uid",
|
||||
dashboardUID: "", // Will be generated during setup
|
||||
libPanelUID: "", // Will be generated during setup
|
||||
}
|
||||
}
|
||||
|
||||
func (tc *foldersAndDashboardsTestCase) name() string {
|
||||
return "folders-dashboards"
|
||||
}
|
||||
|
||||
func (tc *foldersAndDashboardsTestCase) resources() []schema.GroupVersionResource {
|
||||
return []schema.GroupVersionResource{
|
||||
{
|
||||
Group: "folder.grafana.app",
|
||||
Version: "v1beta1",
|
||||
Resource: "folders",
|
||||
},
|
||||
{
|
||||
Group: "dashboard.grafana.app",
|
||||
Version: "v1beta1",
|
||||
Resource: "dashboards",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (tc *foldersAndDashboardsTestCase) setup(t *testing.T, helper *apis.K8sTestHelper) {
|
||||
t.Helper()
|
||||
|
||||
// Create parent folder
|
||||
parent := createTestFolder(t, helper, tc.parentFolderUID, "parent-folder", "")
|
||||
|
||||
// Create child folder (nested under parent)
|
||||
child := createTestFolder(t, helper, tc.childFolderUID, "child-folder", parent.UID)
|
||||
|
||||
// Create library panel in child folder
|
||||
tc.libPanelUID = createTestLibraryPanel(t, helper, "Test Library Panel", child.UID)
|
||||
|
||||
// Create dashboard with library panel in child folder
|
||||
tc.dashboardUID = createTestDashboardWithLibraryPanel(t, helper, "dashboard-with-library-panel",
|
||||
tc.libPanelUID, "Test LP in dashboard", child.UID)
|
||||
}
|
||||
|
||||
func (tc *foldersAndDashboardsTestCase) verify(t *testing.T, helper *apis.K8sTestHelper, shouldExist bool) {
|
||||
t.Helper()
|
||||
|
||||
// Build maps of UIDs by resource type
|
||||
folderUIDs := []string{tc.parentFolderUID, tc.childFolderUID}
|
||||
dashboardUIDs := []string{tc.dashboardUID}
|
||||
|
||||
expectedFolderCount := 0
|
||||
if shouldExist {
|
||||
expectedFolderCount = len(folderUIDs)
|
||||
}
|
||||
orgID := helper.Org1.OrgID
|
||||
namespace := authlib.OrgNamespaceFormatter(orgID)
|
||||
|
||||
// Verify folders
|
||||
folderCli := helper.GetResourceClient(apis.ResourceClientArgs{
|
||||
User: helper.Org1.Admin,
|
||||
Namespace: namespace,
|
||||
GVR: schema.GroupVersionResource{
|
||||
Group: "folder.grafana.app",
|
||||
Version: "v1beta1",
|
||||
Resource: "folders",
|
||||
},
|
||||
})
|
||||
verifyResourceCount(t, folderCli, expectedFolderCount)
|
||||
for _, uid := range folderUIDs {
|
||||
verifyResource(t, folderCli, uid, shouldExist)
|
||||
}
|
||||
|
||||
// Verify dashboards
|
||||
expectedDashboardCount := 0
|
||||
if shouldExist {
|
||||
expectedDashboardCount = len(dashboardUIDs)
|
||||
}
|
||||
dashboardCli := helper.GetResourceClient(apis.ResourceClientArgs{
|
||||
User: helper.Org1.Admin,
|
||||
Namespace: namespace,
|
||||
GVR: schema.GroupVersionResource{
|
||||
Group: "dashboard.grafana.app",
|
||||
Version: "v1beta1",
|
||||
Resource: "dashboards",
|
||||
},
|
||||
})
|
||||
verifyResourceCount(t, dashboardCli, expectedDashboardCount)
|
||||
for _, uid := range dashboardUIDs {
|
||||
verifyResource(t, dashboardCli, uid, shouldExist)
|
||||
}
|
||||
}
|
||||
|
||||
// createTestFolder creates a folder with specified UID and optional parent
|
||||
func createTestFolder(t *testing.T, helper *apis.K8sTestHelper, uid, title, parentUID string) *folder.Folder {
|
||||
t.Helper()
|
||||
|
||||
payload := fmt.Sprintf(`{
|
||||
"title": "%s",
|
||||
"uid": "%s"`, title, uid)
|
||||
|
||||
if parentUID != "" {
|
||||
payload += fmt.Sprintf(`,
|
||||
"parentUid": "%s"`, parentUID)
|
||||
}
|
||||
|
||||
payload += "}"
|
||||
|
||||
folderCreate := apis.DoRequest(helper, apis.RequestParams{
|
||||
User: helper.Org1.Admin,
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/folders",
|
||||
Body: []byte(payload),
|
||||
}, &folder.Folder{})
|
||||
|
||||
require.NotNil(t, folderCreate.Result)
|
||||
require.Equal(t, uid, folderCreate.Result.UID)
|
||||
|
||||
return folderCreate.Result
|
||||
}
|
||||
|
||||
// createTestLibraryPanel creates a library panel in a folder
|
||||
func createTestLibraryPanel(t *testing.T, helper *apis.K8sTestHelper, name, folderUID string) string {
|
||||
t.Helper()
|
||||
|
||||
libPanelPayload := fmt.Sprintf(`{
|
||||
"kind": 1,
|
||||
"name": "%s",
|
||||
"folderUid": "%s",
|
||||
"model": {
|
||||
"type": "text",
|
||||
"title": "%s"
|
||||
}
|
||||
}`, name, folderUID, name)
|
||||
|
||||
libCreate := apis.DoRequest(helper, apis.RequestParams{
|
||||
User: helper.Org1.Admin,
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/library-elements",
|
||||
Body: []byte(libPanelPayload),
|
||||
}, &map[string]interface{}{})
|
||||
|
||||
require.NotNil(t, libCreate.Response)
|
||||
require.Equal(t, http.StatusOK, libCreate.Response.StatusCode)
|
||||
|
||||
libPanelUID := (*libCreate.Result)["result"].(map[string]interface{})["uid"].(string)
|
||||
require.NotEmpty(t, libPanelUID)
|
||||
|
||||
return libPanelUID
|
||||
}
|
||||
|
||||
// createTestDashboardWithLibraryPanel creates a dashboard that uses a library panel
|
||||
func createTestDashboardWithLibraryPanel(t *testing.T, helper *apis.K8sTestHelper, dashTitle, libPanelUID, libPanelName, folderUID string) string {
|
||||
t.Helper()
|
||||
|
||||
dashPayload := fmt.Sprintf(`{
|
||||
"dashboard": {
|
||||
"title": "%s",
|
||||
"panels": [{
|
||||
"id": 1,
|
||||
"libraryPanel": {
|
||||
"uid": "%s",
|
||||
"name": "%s"
|
||||
}
|
||||
}]
|
||||
},
|
||||
"folderUid": "%s",
|
||||
"overwrite": false
|
||||
}`, dashTitle, libPanelUID, libPanelName, folderUID)
|
||||
|
||||
dashCreate := apis.DoRequest(helper, apis.RequestParams{
|
||||
User: helper.Org1.Admin,
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/dashboards/db",
|
||||
Body: []byte(dashPayload),
|
||||
}, &map[string]interface{}{})
|
||||
|
||||
require.NotNil(t, dashCreate.Response)
|
||||
require.Equal(t, http.StatusOK, dashCreate.Response.StatusCode)
|
||||
|
||||
dashUID := (*dashCreate.Result)["uid"].(string)
|
||||
require.NotEmpty(t, dashUID)
|
||||
return dashUID
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package migrations_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/tests/apis"
|
||||
"github.com/grafana/grafana/pkg/tests/testinfra"
|
||||
"github.com/grafana/grafana/pkg/tests/testsuite"
|
||||
"github.com/grafana/grafana/pkg/util/testutil"
|
||||
"github.com/stretchr/testify/require"
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
testsuite.Run(m)
|
||||
}
|
||||
|
||||
// resourceMigratorTestCase defines the interface for testing a resource migrator.
|
||||
type resourceMigratorTestCase interface {
|
||||
// name returns the test case name
|
||||
name() string
|
||||
// resources returns the GVRs that this migrator handles
|
||||
resources() []schema.GroupVersionResource
|
||||
// setup creates test resources in legacy storage (Mode0)
|
||||
setup(t *testing.T, helper *apis.K8sTestHelper)
|
||||
// verify checks that resources exist (or don't exist) in unified storage
|
||||
verify(t *testing.T, helper *apis.K8sTestHelper, shouldExist bool)
|
||||
}
|
||||
|
||||
// TestIntegrationMigrations verifies that legacy storage data is correctly migrated to unified storage.
|
||||
// The test follows a three-step process:
|
||||
// Step 1: inserts legacy data (migration disabled at startup)
|
||||
// Step 2: verifies that the data is not in unified storage
|
||||
// Step 3: migration runs at startup, and the test verifies that the data is in unified storage
|
||||
func TestIntegrationMigrations(t *testing.T) {
|
||||
testutil.SkipIntegrationTestInShortMode(t)
|
||||
|
||||
migrationTestCases := []resourceMigratorTestCase{
|
||||
newFoldersAndDashboardsTestCase(),
|
||||
}
|
||||
|
||||
runMigrationTestSuite(t, migrationTestCases)
|
||||
}
|
||||
|
||||
// runMigrationTestSuite executes the migration test suite for the given test cases
|
||||
func runMigrationTestSuite(t *testing.T, testCases []resourceMigratorTestCase) {
|
||||
if db.IsTestDbSQLite() {
|
||||
// Share the same SQLite DB file between steps
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := tmpDir + "/shared-migration-test-suite.db"
|
||||
|
||||
oldVal := os.Getenv("SQLITE_TEST_DB")
|
||||
require.NoError(t, os.Setenv("SQLITE_TEST_DB", dbPath))
|
||||
t.Cleanup(func() {
|
||||
if oldVal == "" {
|
||||
_ = os.Unsetenv("SQLITE_TEST_DB")
|
||||
} else {
|
||||
_ = os.Setenv("SQLITE_TEST_DB", oldVal)
|
||||
}
|
||||
})
|
||||
t.Logf("Using shared database path: %s", dbPath)
|
||||
}
|
||||
|
||||
// Store UIDs created by each test case
|
||||
type testCaseState struct {
|
||||
tc resourceMigratorTestCase
|
||||
}
|
||||
testStates := make([]testCaseState, len(testCases))
|
||||
for i, tc := range testCases {
|
||||
testStates[i].tc = tc
|
||||
}
|
||||
|
||||
// reuse org users throughout the tests
|
||||
var org1 *apis.OrgUsers
|
||||
var orgB *apis.OrgUsers
|
||||
t.Run("Step 1: Create data in legacy", func(t *testing.T) {
|
||||
// Enforce Mode0 for all migrated resources
|
||||
unifiedConfig := make(map[string]setting.UnifiedStorageConfig)
|
||||
for _, tc := range testCases {
|
||||
for _, gvr := range tc.resources() {
|
||||
resourceKey := fmt.Sprintf("%s.%s", gvr.Resource, gvr.Group)
|
||||
unifiedConfig[resourceKey] = setting.UnifiedStorageConfig{
|
||||
DualWriterMode: grafanarest.Mode0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set up test environment with Mode0 (writes only to legacy)
|
||||
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
|
||||
AppModeProduction: true,
|
||||
DisableAnonymous: true,
|
||||
DisableDataMigrations: true,
|
||||
DisableDBCleanup: true,
|
||||
APIServerStorageType: "unified",
|
||||
UnifiedStorageConfig: unifiedConfig,
|
||||
})
|
||||
t.Cleanup(helper.Shutdown)
|
||||
org1 = &helper.Org1
|
||||
orgB = &helper.OrgB
|
||||
|
||||
for i := range testStates {
|
||||
state := &testStates[i]
|
||||
t.Run(state.tc.name(), func(t *testing.T) {
|
||||
state.tc.setup(t, helper)
|
||||
// Verify resources were created in legacy storage
|
||||
state.tc.verify(t, helper, true)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Set SKIP_DB_TRUNCATE to not truncate the data created in Step 1
|
||||
oldSkipTruncate := os.Getenv("SKIP_DB_TRUNCATE")
|
||||
require.NoError(t, os.Setenv("SKIP_DB_TRUNCATE", "true"))
|
||||
t.Cleanup(func() {
|
||||
if oldSkipTruncate == "" {
|
||||
_ = os.Unsetenv("SKIP_DB_TRUNCATE")
|
||||
} else {
|
||||
_ = os.Setenv("SKIP_DB_TRUNCATE", oldSkipTruncate)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Step 2: Verify data is NOT in unified storage before the migration", func(t *testing.T) {
|
||||
// Build unified storage config for Mode5
|
||||
unifiedConfig := make(map[string]setting.UnifiedStorageConfig)
|
||||
for _, tc := range testCases {
|
||||
for _, gvr := range tc.resources() {
|
||||
resourceKey := fmt.Sprintf("%s.%s", gvr.Resource, gvr.Group)
|
||||
unifiedConfig[resourceKey] = setting.UnifiedStorageConfig{
|
||||
DualWriterMode: grafanarest.Mode5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
helper := apis.NewK8sTestHelperWithOpts(t, apis.K8sTestHelperOpts{
|
||||
GrafanaOpts: testinfra.GrafanaOpts{
|
||||
AppModeProduction: true,
|
||||
DisableAnonymous: true,
|
||||
DisableDataMigrations: true,
|
||||
DisableDBCleanup: true,
|
||||
APIServerStorageType: "unified",
|
||||
UnifiedStorageConfig: unifiedConfig,
|
||||
},
|
||||
Org1Users: org1,
|
||||
OrgBUsers: orgB,
|
||||
})
|
||||
t.Cleanup(helper.Shutdown)
|
||||
|
||||
for _, state := range testStates {
|
||||
t.Run(state.tc.name(), func(t *testing.T) {
|
||||
// Verify resources don't exist in unified storage yet
|
||||
state.tc.verify(t, helper, false)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Step 3: verify data is migrated to unified storage", func(t *testing.T) {
|
||||
// Migrations will run automatically at startup and mode 5 is enforced by the config
|
||||
helper := apis.NewK8sTestHelperWithOpts(t, apis.K8sTestHelperOpts{
|
||||
GrafanaOpts: testinfra.GrafanaOpts{
|
||||
// EnableLog: true,
|
||||
AppModeProduction: true,
|
||||
DisableAnonymous: true,
|
||||
DisableDataMigrations: false, // Run migrations at startup
|
||||
APIServerStorageType: "unified",
|
||||
},
|
||||
Org1Users: org1,
|
||||
OrgBUsers: orgB,
|
||||
})
|
||||
t.Cleanup(helper.Shutdown)
|
||||
|
||||
for _, state := range testStates {
|
||||
t.Run(state.tc.name(), func(t *testing.T) {
|
||||
// Verify resources now exist in unified storage after migration
|
||||
state.tc.verify(t, helper, true)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// verifyResourceCount verifies that the expected number of resources exist in K8s storage
|
||||
func verifyResourceCount(t *testing.T, client *apis.K8sResourceClient, expectedCount int) {
|
||||
t.Helper()
|
||||
|
||||
l, err := client.Resource.List(context.Background(), metav1.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
resources, err := meta.ExtractList(l)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expectedCount, len(resources))
|
||||
}
|
||||
|
||||
// verifyResource verifies that a resource with the given UID exists in K8s storage
|
||||
func verifyResource(t *testing.T, client *apis.K8sResourceClient, uid string, shouldExist bool) {
|
||||
t.Helper()
|
||||
|
||||
_, err := client.Resource.Get(context.Background(), uid, metav1.GetOptions{})
|
||||
if shouldExist {
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
require.Error(t, err)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/grafana/dskit/backoff"
|
||||
"github.com/grafana/grafana-app-sdk/logging"
|
||||
gocache "github.com/patrickmn/go-cache"
|
||||
|
||||
@@ -13,8 +14,8 @@ import (
|
||||
|
||||
const (
|
||||
defaultLookbackPeriod = 30 * time.Second
|
||||
defaultPollInterval = 100 * time.Millisecond
|
||||
defaultEventCacheSize = 10000
|
||||
defaultMinBackoff = 100 * time.Millisecond
|
||||
defaultMaxBackoff = 5 * time.Second
|
||||
defaultBufferSize = 10000
|
||||
)
|
||||
|
||||
@@ -29,15 +30,17 @@ type notifierOptions struct {
|
||||
|
||||
type watchOptions struct {
|
||||
LookbackPeriod time.Duration // How far back to look for events
|
||||
PollInterval time.Duration // How often to poll for new events
|
||||
BufferSize int // How many events to buffer
|
||||
MinBackoff time.Duration // Minimum interval between polling requests
|
||||
MaxBackoff time.Duration // Maximum interval between polling requests
|
||||
}
|
||||
|
||||
func defaultWatchOptions() watchOptions {
|
||||
return watchOptions{
|
||||
LookbackPeriod: defaultLookbackPeriod,
|
||||
PollInterval: defaultPollInterval,
|
||||
BufferSize: defaultBufferSize,
|
||||
MinBackoff: defaultMinBackoff,
|
||||
MaxBackoff: defaultMaxBackoff,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,9 +65,13 @@ func (n *notifier) cacheKey(evt Event) string {
|
||||
}
|
||||
|
||||
func (n *notifier) Watch(ctx context.Context, opts watchOptions) <-chan Event {
|
||||
if opts.PollInterval <= 0 {
|
||||
opts.PollInterval = defaultPollInterval
|
||||
if opts.MinBackoff <= 0 {
|
||||
opts.MinBackoff = defaultMinBackoff
|
||||
}
|
||||
if opts.MaxBackoff <= 0 || opts.MaxBackoff <= opts.MinBackoff {
|
||||
opts.MaxBackoff = defaultMaxBackoff
|
||||
}
|
||||
|
||||
cacheTTL := opts.LookbackPeriod
|
||||
cacheCleanupInterval := 2 * opts.LookbackPeriod
|
||||
|
||||
@@ -81,11 +88,21 @@ func (n *notifier) Watch(ctx context.Context, opts watchOptions) <-chan Event {
|
||||
|
||||
go func() {
|
||||
defer close(events)
|
||||
// Initialize backoff with minimum backoff interval
|
||||
currentInterval := opts.MinBackoff
|
||||
backoffConfig := backoff.Config{
|
||||
MinBackoff: opts.MinBackoff,
|
||||
MaxBackoff: opts.MaxBackoff,
|
||||
MaxRetries: 0, // infinite retries
|
||||
}
|
||||
bo := backoff.New(ctx, backoffConfig)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(opts.PollInterval):
|
||||
case <-time.After(currentInterval):
|
||||
foundEvents := false
|
||||
for evt, err := range n.eventStore.ListSince(ctx, subtractDurationFromSnowflake(lastRV, opts.LookbackPeriod)) {
|
||||
if err != nil {
|
||||
n.log.Error("Failed to list events since", "error", err)
|
||||
@@ -102,6 +119,7 @@ func (n *notifier) Watch(ctx context.Context, opts watchOptions) <-chan Event {
|
||||
continue
|
||||
}
|
||||
|
||||
foundEvents = true
|
||||
if evt.ResourceVersion > lastRV {
|
||||
lastRV = evt.ResourceVersion + 1
|
||||
}
|
||||
@@ -113,6 +131,14 @@ func (n *notifier) Watch(ctx context.Context, opts watchOptions) <-chan Event {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Apply backoff logic: reset to min when events are found, increase when no events
|
||||
if foundEvents {
|
||||
bo.Reset()
|
||||
currentInterval = opts.MinBackoff
|
||||
} else {
|
||||
currentInterval = bo.NextDelay()
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -32,7 +32,6 @@ func TestDefaultWatchOptions(t *testing.T) {
|
||||
opts := defaultWatchOptions()
|
||||
|
||||
assert.Equal(t, defaultLookbackPeriod, opts.LookbackPeriod)
|
||||
assert.Equal(t, defaultPollInterval, opts.PollInterval)
|
||||
assert.Equal(t, defaultBufferSize, opts.BufferSize)
|
||||
}
|
||||
|
||||
@@ -158,8 +157,9 @@ func TestNotifier_Watch_NoEvents(t *testing.T) {
|
||||
|
||||
opts := watchOptions{
|
||||
LookbackPeriod: 100 * time.Millisecond,
|
||||
PollInterval: 50 * time.Millisecond,
|
||||
BufferSize: 10,
|
||||
MinBackoff: 50 * time.Millisecond,
|
||||
MaxBackoff: 500 * time.Millisecond,
|
||||
}
|
||||
|
||||
events := notifier.Watch(ctx, opts)
|
||||
@@ -210,8 +210,9 @@ func TestNotifier_Watch_WithExistingEvents(t *testing.T) {
|
||||
|
||||
opts := watchOptions{
|
||||
LookbackPeriod: 100 * time.Millisecond,
|
||||
PollInterval: 50 * time.Millisecond,
|
||||
BufferSize: 10,
|
||||
MinBackoff: 50 * time.Millisecond,
|
||||
MaxBackoff: 500 * time.Millisecond,
|
||||
}
|
||||
|
||||
// Start watching
|
||||
@@ -265,8 +266,9 @@ func TestNotifier_Watch_EventDeduplication(t *testing.T) {
|
||||
|
||||
opts := watchOptions{
|
||||
LookbackPeriod: time.Second,
|
||||
PollInterval: 20 * time.Millisecond,
|
||||
BufferSize: 10,
|
||||
MinBackoff: 20 * time.Millisecond,
|
||||
MaxBackoff: 200 * time.Millisecond,
|
||||
}
|
||||
|
||||
// Start watching
|
||||
@@ -326,8 +328,9 @@ func TestNotifier_Watch_ContextCancellation(t *testing.T) {
|
||||
|
||||
opts := watchOptions{
|
||||
LookbackPeriod: 100 * time.Millisecond,
|
||||
PollInterval: 20 * time.Millisecond,
|
||||
BufferSize: 10,
|
||||
MinBackoff: 20 * time.Millisecond,
|
||||
MaxBackoff: 200 * time.Millisecond,
|
||||
}
|
||||
|
||||
events := notifier.Watch(ctx, opts)
|
||||
@@ -369,8 +372,9 @@ func TestNotifier_Watch_MultipleEvents(t *testing.T) {
|
||||
|
||||
opts := watchOptions{
|
||||
LookbackPeriod: time.Second,
|
||||
PollInterval: 20 * time.Millisecond,
|
||||
BufferSize: 10,
|
||||
MinBackoff: 20 * time.Millisecond,
|
||||
MaxBackoff: 200 * time.Millisecond,
|
||||
}
|
||||
|
||||
// Start watching
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package resource
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/dskit/runtimeconfig"
|
||||
"github.com/grafana/dskit/services"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"go.yaml.in/yaml/v3"
|
||||
)
|
||||
|
||||
const DEFAULT_RESOURCE_LIMIT = 1000
|
||||
|
||||
type OverridesService struct {
|
||||
manager *runtimeconfig.Manager
|
||||
logger log.Logger
|
||||
tracer trace.Tracer
|
||||
}
|
||||
|
||||
type ReloadOptions struct {
|
||||
FilePath string
|
||||
ReloadPeriod time.Duration
|
||||
}
|
||||
|
||||
// ResourceQuota represents quota limits for a specific resource
|
||||
type ResourceQuota struct {
|
||||
Limit int `yaml:"limit"`
|
||||
}
|
||||
|
||||
// NamespaceOverrides represents all overrides for a tenant
|
||||
type NamespaceOverrides struct {
|
||||
Quotas map[string]ResourceQuota `yaml:"quotas"`
|
||||
}
|
||||
|
||||
// Overrides represents the entire overrides configuration file
|
||||
type Overrides struct {
|
||||
Namespaces map[string]NamespaceOverrides
|
||||
}
|
||||
|
||||
/*
|
||||
This service loads overrides (currently just quotas) from a YAML file with the following yaml structure:
|
||||
|
||||
"123":
|
||||
|
||||
quotas:
|
||||
grafana.dashboard.app/dashboards:
|
||||
limit: 1500
|
||||
grafana.folder.app/folders:
|
||||
limit: 1500
|
||||
*/
|
||||
func NewOverridesService(_ context.Context, logger log.Logger, reg prometheus.Registerer, tracer trace.Tracer, opts ReloadOptions) (*OverridesService, error) {
|
||||
// shouldn't be empty since we use file path existence to determine if we should enable the service
|
||||
if opts.FilePath == "" {
|
||||
return nil, fmt.Errorf("overrides file path is required")
|
||||
}
|
||||
if opts.ReloadPeriod == 0 {
|
||||
opts.ReloadPeriod = time.Second * 30
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
if _, err := os.Stat(opts.FilePath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("overrides file does not exist: %s", opts.FilePath)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to stat overrides file: %w", err)
|
||||
}
|
||||
|
||||
config := runtimeconfig.Config{
|
||||
ReloadPeriod: opts.ReloadPeriod,
|
||||
LoadPath: []string{opts.FilePath},
|
||||
Loader: func(r io.Reader) (interface{}, error) {
|
||||
var tenants map[string]NamespaceOverrides
|
||||
decoder := yaml.NewDecoder(r)
|
||||
if err := decoder.Decode(&tenants); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Overrides{Namespaces: tenants}, nil
|
||||
},
|
||||
}
|
||||
|
||||
manager, err := runtimeconfig.New(config, "tenant-overrides", reg, logger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &OverridesService{
|
||||
manager: manager,
|
||||
logger: logger,
|
||||
tracer: tracer,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (q *OverridesService) init(ctx context.Context) error {
|
||||
return services.StartAndAwaitRunning(ctx, q.manager)
|
||||
}
|
||||
|
||||
func (q *OverridesService) stop(ctx context.Context) error {
|
||||
return services.StopAndAwaitTerminated(ctx, q.manager)
|
||||
}
|
||||
|
||||
func (q *OverridesService) GetQuota(_ context.Context, nsr NamespacedResource) (ResourceQuota, error) {
|
||||
if nsr.Namespace == "" || nsr.Resource == "" || nsr.Group == "" {
|
||||
return ResourceQuota{}, fmt.Errorf("invalid namespaced resource: %+v", nsr)
|
||||
}
|
||||
|
||||
overrides, ok := q.manager.GetConfig().(*Overrides)
|
||||
if !ok {
|
||||
return ResourceQuota{}, fmt.Errorf("failed to get quota overrides from config manager")
|
||||
}
|
||||
|
||||
tenantId := strings.TrimPrefix(nsr.Namespace, "stacks-")
|
||||
groupResource := nsr.Group + "/" + nsr.Resource
|
||||
if tenantOverrides, ok := overrides.Namespaces[tenantId]; ok {
|
||||
if resourceQuota, ok := tenantOverrides.Quotas[groupResource]; ok {
|
||||
return resourceQuota, nil
|
||||
}
|
||||
}
|
||||
|
||||
return ResourceQuota{Limit: DEFAULT_RESOURCE_LIMIT}, nil
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
package resource
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewQuotaService(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
opts ReloadOptions
|
||||
setupFile func(t *testing.T) string
|
||||
expectError bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
name: "success with valid file",
|
||||
opts: ReloadOptions{},
|
||||
setupFile: func(t *testing.T) string {
|
||||
tmpFile := filepath.Join(t.TempDir(), "overrides.yaml")
|
||||
content := `"123":
|
||||
quotas:
|
||||
grafana.dashboard.app/dashboards:
|
||||
limit: 1500
|
||||
`
|
||||
require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644))
|
||||
return tmpFile
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "success with custom reload period",
|
||||
opts: ReloadOptions{
|
||||
ReloadPeriod: time.Minute,
|
||||
},
|
||||
setupFile: func(t *testing.T) string {
|
||||
tmpFile := filepath.Join(t.TempDir(), "overrides.yaml")
|
||||
require.NoError(t, os.WriteFile(tmpFile, []byte{}, 0644))
|
||||
return tmpFile
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "error when file path is empty",
|
||||
opts: ReloadOptions{
|
||||
FilePath: "",
|
||||
},
|
||||
setupFile: func(t *testing.T) string { return "" },
|
||||
expectError: true,
|
||||
errorMsg: "overrides file path is required",
|
||||
},
|
||||
{
|
||||
name: "error when file does not exist",
|
||||
opts: ReloadOptions{
|
||||
FilePath: "/nonexistent/path/overrides.yaml",
|
||||
},
|
||||
setupFile: func(t *testing.T) string { return "/nonexistent/path/overrides.yaml" },
|
||||
expectError: true,
|
||||
errorMsg: "overrides file does not exist",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
logger := log.NewNopLogger()
|
||||
reg := prometheus.NewRegistry()
|
||||
tcr := tracing.NewNoopTracerService()
|
||||
|
||||
filePath := tt.setupFile(t)
|
||||
if filePath != "" && tt.opts.FilePath == "" {
|
||||
tt.opts.FilePath = filePath
|
||||
}
|
||||
|
||||
service, err := NewOverridesService(ctx, logger, reg, tcr, tt.opts)
|
||||
|
||||
if tt.expectError {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.errorMsg)
|
||||
assert.Nil(t, service)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, service)
|
||||
assert.NotNil(t, service.manager)
|
||||
assert.NotNil(t, service.logger)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuotaService_ConfigReload(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
logger := log.NewNopLogger()
|
||||
reg := prometheus.NewRegistry()
|
||||
tcr := tracing.NewNoopTracerService()
|
||||
|
||||
// Create a temporary config file
|
||||
tmpFile := filepath.Join(t.TempDir(), "overrides.yaml")
|
||||
initialConfig := `"123":
|
||||
quotas:
|
||||
grafana.dashboard.app/dashboards:
|
||||
limit: 1500
|
||||
`
|
||||
require.NoError(t, os.WriteFile(tmpFile, []byte(initialConfig), 0644))
|
||||
|
||||
// Create service with a very short reload period
|
||||
service, err := NewOverridesService(ctx, logger, reg, tcr, ReloadOptions{
|
||||
FilePath: tmpFile,
|
||||
ReloadPeriod: 100 * time.Millisecond, // Very short reload period for testing
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, service)
|
||||
|
||||
// Initialize the service
|
||||
err = service.init(ctx)
|
||||
require.NoError(t, err)
|
||||
defer func(service *OverridesService, ctx context.Context) {
|
||||
err := service.stop(ctx)
|
||||
require.NoError(t, err)
|
||||
}(service, ctx)
|
||||
|
||||
// Verify initial config
|
||||
nsr := NamespacedResource{
|
||||
Namespace: "stacks-123",
|
||||
Group: "grafana.dashboard.app",
|
||||
Resource: "dashboards",
|
||||
}
|
||||
quota, err := service.GetQuota(ctx, nsr)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1500, quota.Limit, "initial quota should be 1500")
|
||||
|
||||
// Update the config file with new values
|
||||
updatedConfig := `"123":
|
||||
quotas:
|
||||
grafana.dashboard.app/dashboards:
|
||||
limit: 2500
|
||||
"456":
|
||||
quotas:
|
||||
grafana.folder.app/folders:
|
||||
limit: 3000
|
||||
`
|
||||
require.NoError(t, os.WriteFile(tmpFile, []byte(updatedConfig), 0644))
|
||||
|
||||
// Wait for the config to be reloaded (wait longer than reload period)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Verify the config was updated for existing tenant
|
||||
quota, err = service.GetQuota(ctx, nsr)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2500, quota.Limit, "quota should be updated to 2500")
|
||||
|
||||
// Verify new tenant config is also loaded
|
||||
nsr2 := NamespacedResource{
|
||||
Namespace: "stacks-456",
|
||||
Group: "grafana.folder.app",
|
||||
Resource: "folders",
|
||||
}
|
||||
quota2, err := service.GetQuota(ctx, nsr2)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 3000, quota2.Limit, "new tenant quota should be 3000")
|
||||
}
|
||||
|
||||
func TestQuotaService_GetQuota(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setupFile func(t *testing.T) string
|
||||
nsr NamespacedResource
|
||||
expectedLimit int
|
||||
expectError bool
|
||||
errorMsg string
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "returns custom quota for matching tenant and resource",
|
||||
setupFile: func(t *testing.T) string {
|
||||
tmpFile := filepath.Join(t.TempDir(), "overrides.yaml")
|
||||
content := `"123":
|
||||
quotas:
|
||||
grafana.dashboard.app/dashboards:
|
||||
limit: 1500
|
||||
`
|
||||
require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644))
|
||||
return tmpFile
|
||||
},
|
||||
nsr: NamespacedResource{
|
||||
Namespace: "stacks-123",
|
||||
Group: "grafana.dashboard.app",
|
||||
Resource: "dashboards",
|
||||
},
|
||||
expectedLimit: 1500,
|
||||
expectError: false,
|
||||
description: "should return custom limit for matching tenant",
|
||||
},
|
||||
{
|
||||
name: "returns default quota when tenant not found",
|
||||
setupFile: func(t *testing.T) string {
|
||||
tmpFile := filepath.Join(t.TempDir(), "overrides.yaml")
|
||||
content := `"123":
|
||||
quotas:
|
||||
grafana.dashboard.app/dashboards:
|
||||
limit: 1500
|
||||
`
|
||||
require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644))
|
||||
return tmpFile
|
||||
},
|
||||
nsr: NamespacedResource{
|
||||
Namespace: "stacks-456",
|
||||
Group: "grafana.dashboard.app",
|
||||
Resource: "dashboards",
|
||||
},
|
||||
expectedLimit: DEFAULT_RESOURCE_LIMIT,
|
||||
expectError: false,
|
||||
description: "should return default limit when tenant not found",
|
||||
},
|
||||
{
|
||||
name: "returns default quota when resource not found",
|
||||
setupFile: func(t *testing.T) string {
|
||||
tmpFile := filepath.Join(t.TempDir(), "overrides.yaml")
|
||||
content := `"123":
|
||||
quotas:
|
||||
grafana.dashboard.app/dashboards:
|
||||
limit: 1500
|
||||
`
|
||||
require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644))
|
||||
return tmpFile
|
||||
},
|
||||
nsr: NamespacedResource{
|
||||
Namespace: "stacks-123",
|
||||
Group: "grafana.folder.app",
|
||||
Resource: "folders",
|
||||
},
|
||||
expectedLimit: DEFAULT_RESOURCE_LIMIT,
|
||||
expectError: false,
|
||||
description: "should return default limit when resource not found",
|
||||
},
|
||||
{
|
||||
name: "handles namespace without stacks- prefix",
|
||||
setupFile: func(t *testing.T) string {
|
||||
tmpFile := filepath.Join(t.TempDir(), "overrides.yaml")
|
||||
content := `"123":
|
||||
quotas:
|
||||
grafana.dashboard.app/dashboards:
|
||||
limit: 1500
|
||||
`
|
||||
require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644))
|
||||
return tmpFile
|
||||
},
|
||||
nsr: NamespacedResource{
|
||||
Namespace: "123",
|
||||
Group: "grafana.dashboard.app",
|
||||
Resource: "dashboards",
|
||||
},
|
||||
expectedLimit: 1500,
|
||||
expectError: false,
|
||||
description: "should handle namespace without stacks- prefix",
|
||||
},
|
||||
{
|
||||
name: "returns default quota when config is empty",
|
||||
setupFile: func(t *testing.T) string {
|
||||
tmpFile := filepath.Join(t.TempDir(), "overrides.yaml")
|
||||
content := ""
|
||||
require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644))
|
||||
return tmpFile
|
||||
},
|
||||
nsr: NamespacedResource{
|
||||
Namespace: "stacks-123",
|
||||
Group: "grafana.dashboard.app",
|
||||
Resource: "dashboards",
|
||||
},
|
||||
expectedLimit: DEFAULT_RESOURCE_LIMIT,
|
||||
expectError: false,
|
||||
description: "should return default limit when config is empty",
|
||||
},
|
||||
{
|
||||
name: "handles multiple resources for same tenant",
|
||||
setupFile: func(t *testing.T) string {
|
||||
tmpFile := filepath.Join(t.TempDir(), "overrides.yaml")
|
||||
content := `"123":
|
||||
quotas:
|
||||
grafana.dashboard.app/dashboards:
|
||||
limit: 1500
|
||||
grafana.folder.app/folders:
|
||||
limit: 2500
|
||||
`
|
||||
require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644))
|
||||
return tmpFile
|
||||
},
|
||||
nsr: NamespacedResource{
|
||||
Namespace: "stacks-123",
|
||||
Group: "grafana.folder.app",
|
||||
Resource: "folders",
|
||||
},
|
||||
expectedLimit: 2500,
|
||||
expectError: false,
|
||||
description: "should return correct limit for specific resource",
|
||||
},
|
||||
{
|
||||
name: "returns error when namespace is empty",
|
||||
setupFile: func(t *testing.T) string {
|
||||
tmpFile := filepath.Join(t.TempDir(), "overrides.yaml")
|
||||
content := `"123":
|
||||
quotas:
|
||||
grafana.dashboard.app/dashboards:
|
||||
limit: 1500
|
||||
grafana.folder.app/folders:
|
||||
limit: 2500
|
||||
`
|
||||
require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644))
|
||||
return tmpFile
|
||||
},
|
||||
nsr: NamespacedResource{
|
||||
Namespace: "",
|
||||
Group: "grafana.dashboard.app",
|
||||
Resource: "dashboards",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "invalid namespaced resource",
|
||||
description: "should return error when namespace is empty",
|
||||
},
|
||||
{
|
||||
name: "returns error when group is empty",
|
||||
setupFile: func(t *testing.T) string {
|
||||
tmpFile := filepath.Join(t.TempDir(), "overrides.yaml")
|
||||
content := `"123":
|
||||
quotas:
|
||||
grafana.dashboard.app/dashboards:
|
||||
limit: 1500
|
||||
grafana.folder.app/folders:
|
||||
limit: 2500
|
||||
`
|
||||
require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644))
|
||||
return tmpFile
|
||||
},
|
||||
nsr: NamespacedResource{
|
||||
Namespace: "stacks-123",
|
||||
Group: "",
|
||||
Resource: "dashboards",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "invalid namespaced resource",
|
||||
description: "should return error when group is empty",
|
||||
},
|
||||
{
|
||||
name: "returns error when resource is empty",
|
||||
setupFile: func(t *testing.T) string {
|
||||
tmpFile := filepath.Join(t.TempDir(), "overrides.yaml")
|
||||
content := `"123":
|
||||
quotas:
|
||||
grafana.dashboard.app/dashboards:
|
||||
limit: 1500
|
||||
grafana.folder.app/folders:
|
||||
limit: 2500
|
||||
`
|
||||
require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644))
|
||||
return tmpFile
|
||||
},
|
||||
nsr: NamespacedResource{
|
||||
Namespace: "stacks-123",
|
||||
Group: "grafana.dashboard.app",
|
||||
Resource: "",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "invalid namespaced resource",
|
||||
description: "should return error when resource is empty",
|
||||
},
|
||||
{
|
||||
name: "returns error when all fields are empty",
|
||||
setupFile: func(t *testing.T) string {
|
||||
tmpFile := filepath.Join(t.TempDir(), "overrides.yaml")
|
||||
content := `"123":
|
||||
quotas:
|
||||
grafana.dashboard.app/dashboards:
|
||||
limit: 1500
|
||||
grafana.folder.app/folders:
|
||||
limit: 2500
|
||||
`
|
||||
require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644))
|
||||
return tmpFile
|
||||
},
|
||||
nsr: NamespacedResource{
|
||||
Namespace: "",
|
||||
Group: "",
|
||||
Resource: "",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "invalid namespaced resource",
|
||||
description: "should return error when all fields are empty",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
logger := log.NewNopLogger()
|
||||
reg := prometheus.NewRegistry()
|
||||
tcr := tracing.NewNoopTracerService()
|
||||
opts := ReloadOptions{
|
||||
FilePath: tt.setupFile(t),
|
||||
}
|
||||
|
||||
service, err := NewOverridesService(ctx, logger, reg, tcr, opts)
|
||||
require.NoError(t, err, "failed to create quota service")
|
||||
err = service.init(ctx)
|
||||
require.NoError(t, err, "failed to initialize quota service")
|
||||
|
||||
quota, err := service.GetQuota(ctx, tt.nsr)
|
||||
|
||||
if tt.expectError {
|
||||
require.Error(t, err, tt.description)
|
||||
assert.Contains(t, err.Error(), tt.errorMsg, tt.description)
|
||||
assert.Equal(t, ResourceQuota{}, quota, "should return empty quota on error")
|
||||
} else {
|
||||
require.NoError(t, err, tt.description)
|
||||
assert.Equal(t, tt.expectedLimit, quota.Limit, tt.description)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
@@ -220,6 +222,9 @@ type ResourceServerOptions struct {
|
||||
// Search options
|
||||
Search SearchOptions
|
||||
|
||||
// Quota service
|
||||
OverridesService *OverridesService
|
||||
|
||||
// Diagnostics
|
||||
Diagnostics resourcepb.DiagnosticsServer
|
||||
|
||||
@@ -342,6 +347,7 @@ func NewResourceServer(opts ResourceServerOptions) (*server, error) {
|
||||
reg: opts.Reg,
|
||||
queue: opts.QOSQueue,
|
||||
queueConfig: opts.QOSConfig,
|
||||
overridesService: opts.OverridesService,
|
||||
|
||||
artificialSuccessfulWriteDelay: opts.Search.IndexMinUpdateInterval,
|
||||
}
|
||||
@@ -366,19 +372,20 @@ func NewResourceServer(opts ResourceServerOptions) (*server, error) {
|
||||
var _ ResourceServer = &server{}
|
||||
|
||||
type server struct {
|
||||
log log.Logger
|
||||
backend StorageBackend
|
||||
blob BlobSupport
|
||||
secure secrets.InlineSecureValueSupport
|
||||
search *searchSupport
|
||||
diagnostics resourcepb.DiagnosticsServer
|
||||
access claims.AccessClient
|
||||
writeHooks WriteAccessHooks
|
||||
lifecycle LifecycleHooks
|
||||
now func() int64
|
||||
mostRecentRV atomic.Int64 // The most recent resource version seen by the server
|
||||
storageMetrics *StorageMetrics
|
||||
indexMetrics *BleveIndexMetrics
|
||||
log log.Logger
|
||||
backend StorageBackend
|
||||
blob BlobSupport
|
||||
secure secrets.InlineSecureValueSupport
|
||||
search *searchSupport
|
||||
diagnostics resourcepb.DiagnosticsServer
|
||||
access claims.AccessClient
|
||||
writeHooks WriteAccessHooks
|
||||
lifecycle LifecycleHooks
|
||||
now func() int64
|
||||
mostRecentRV atomic.Int64 // The most recent resource version seen by the server
|
||||
storageMetrics *StorageMetrics
|
||||
indexMetrics *BleveIndexMetrics
|
||||
overridesService *OverridesService
|
||||
|
||||
// Background watch task -- this has permissions for everything
|
||||
ctx context.Context
|
||||
@@ -411,6 +418,11 @@ func (s *server) Init(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
// initialize tenant overrides service
|
||||
if s.initErr == nil && s.overridesService != nil {
|
||||
s.initErr = s.overridesService.init(ctx)
|
||||
}
|
||||
|
||||
// initialize the search index
|
||||
if s.initErr == nil && s.search != nil {
|
||||
s.initErr = s.search.init(ctx)
|
||||
@@ -444,6 +456,13 @@ func (s *server) Stop(ctx context.Context) error {
|
||||
s.search.stop()
|
||||
}
|
||||
|
||||
if s.overridesService != nil {
|
||||
if err := s.overridesService.stop(ctx); err != nil {
|
||||
stopFailed = true
|
||||
s.initErr = fmt.Errorf("service stopeed with error: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Stops the streaming
|
||||
s.cancel()
|
||||
|
||||
@@ -647,6 +666,13 @@ func (s *server) Create(ctx context.Context, req *resourcepb.CreateRequest) (*re
|
||||
ctx, span := tracer.Start(ctx, "resource.server.Create")
|
||||
defer span.End()
|
||||
|
||||
// check quotas and log for now
|
||||
s.checkQuota(ctx, NamespacedResource{
|
||||
Namespace: req.Key.Namespace,
|
||||
Group: req.Key.Group,
|
||||
Resource: req.Key.Resource,
|
||||
})
|
||||
|
||||
if r := verifyRequestKey(req.Key); r != nil {
|
||||
return nil, fmt.Errorf("invalid request key: %s", r.Message)
|
||||
}
|
||||
@@ -1549,3 +1575,31 @@ func (s *server) RebuildIndexes(ctx context.Context, req *resourcepb.RebuildInde
|
||||
|
||||
return s.search.RebuildIndexes(ctx, req)
|
||||
}
|
||||
|
||||
func (s *server) checkQuota(ctx context.Context, nsr NamespacedResource) {
|
||||
span := trace.SpanFromContext(ctx)
|
||||
span.AddEvent("checkQuota", trace.WithAttributes(
|
||||
attribute.String("namespace", nsr.Namespace),
|
||||
attribute.String("group", nsr.Group),
|
||||
attribute.String("resource", nsr.Resource),
|
||||
))
|
||||
|
||||
if s.overridesService == nil {
|
||||
return
|
||||
}
|
||||
|
||||
quota, err := s.overridesService.GetQuota(ctx, nsr)
|
||||
if err != nil {
|
||||
s.log.FromContext(ctx).Error("failed to get quota for resource", "namespace", nsr.Namespace, "group", nsr.Group, "resource", nsr.Resource, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
stats, err := s.backend.GetResourceStats(ctx, nsr, 0)
|
||||
if err != nil {
|
||||
s.log.FromContext(ctx).Error("failed to get resource stats for quota checking", "namespace", nsr.Namespace, "group", nsr.Group, "resource", nsr.Resource, "error", err)
|
||||
return
|
||||
}
|
||||
if len(stats) > 0 && stats[0].Count >= int64(quota.Limit) {
|
||||
s.log.FromContext(ctx).Info("Quota exceeded on create", "namespace", nsr.Namespace, "group", nsr.Group, "resource", nsr.Resource, "quota", quota.Limit, "count", stats[0].Count, "stats_resource", stats[0].Resource)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/lib/pq"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"go.opentelemetry.io/otel/trace/noop"
|
||||
"go.uber.org/atomic"
|
||||
@@ -263,7 +264,11 @@ func (b *backend) Stop(_ context.Context) error {
|
||||
|
||||
// GetResourceStats implements Backend.
|
||||
func (b *backend) GetResourceStats(ctx context.Context, nsr resource.NamespacedResource, minCount int) ([]resource.ResourceStats, error) {
|
||||
ctx, span := b.tracer.Start(ctx, tracePrefix+"GetResourceStats")
|
||||
ctx, span := b.tracer.Start(ctx, tracePrefix+"GetResourceStats", trace.WithAttributes(
|
||||
attribute.String("namespace", nsr.Namespace),
|
||||
attribute.String("group", nsr.Group),
|
||||
attribute.String("resource", nsr.Resource),
|
||||
))
|
||||
defer span.End()
|
||||
|
||||
req := &sqlStatsRequest{
|
||||
|
||||
@@ -30,19 +30,20 @@ type QOSEnqueueDequeuer interface {
|
||||
|
||||
// ServerOptions contains the options for creating a new ResourceServer
|
||||
type ServerOptions struct {
|
||||
Backend resource.StorageBackend
|
||||
DB infraDB.DB
|
||||
Cfg *setting.Cfg
|
||||
Tracer trace.Tracer
|
||||
Reg prometheus.Registerer
|
||||
AccessClient types.AccessClient
|
||||
SearchOptions resource.SearchOptions
|
||||
StorageMetrics *resource.StorageMetrics
|
||||
IndexMetrics *resource.BleveIndexMetrics
|
||||
Features featuremgmt.FeatureToggles
|
||||
QOSQueue QOSEnqueueDequeuer
|
||||
SecureValues secrets.InlineSecureValueSupport
|
||||
OwnsIndexFn func(key resource.NamespacedResource) (bool, error)
|
||||
Backend resource.StorageBackend
|
||||
OverridesService *resource.OverridesService
|
||||
DB infraDB.DB
|
||||
Cfg *setting.Cfg
|
||||
Tracer trace.Tracer
|
||||
Reg prometheus.Registerer
|
||||
AccessClient types.AccessClient
|
||||
SearchOptions resource.SearchOptions
|
||||
StorageMetrics *resource.StorageMetrics
|
||||
IndexMetrics *resource.BleveIndexMetrics
|
||||
Features featuremgmt.FeatureToggles
|
||||
QOSQueue QOSEnqueueDequeuer
|
||||
SecureValues secrets.InlineSecureValueSupport
|
||||
OwnsIndexFn func(key resource.NamespacedResource) (bool, error)
|
||||
}
|
||||
|
||||
func NewResourceServer(opts ServerOptions) (resource.ResourceServer, error) {
|
||||
@@ -119,6 +120,7 @@ func NewResourceServer(opts ServerOptions) (resource.ResourceServer, error) {
|
||||
serverOptions.IndexMetrics = opts.IndexMetrics
|
||||
serverOptions.QOSQueue = opts.QOSQueue
|
||||
serverOptions.OwnsIndexFn = opts.OwnsIndexFn
|
||||
serverOptions.OverridesService = opts.OverridesService
|
||||
|
||||
return resource.NewResourceServer(serverOptions)
|
||||
}
|
||||
|
||||
@@ -279,6 +279,18 @@ func (s *service) starting(ctx context.Context) error {
|
||||
QOSQueue: s.queue,
|
||||
OwnsIndexFn: s.OwnsIndex,
|
||||
}
|
||||
|
||||
if s.cfg.OverridesFilePath != "" {
|
||||
overridesSvc, err := resource.NewOverridesService(context.Background(), s.log, s.reg, s.tracing, resource.ReloadOptions{
|
||||
FilePath: s.cfg.OverridesFilePath,
|
||||
ReloadPeriod: s.cfg.OverridesReloadInterval,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
serverOptions.OverridesService = overridesSvc
|
||||
}
|
||||
|
||||
server, err := NewResourceServer(serverOptions)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -93,7 +93,18 @@ type K8sTestHelper struct {
|
||||
userSvc user.Service
|
||||
}
|
||||
|
||||
type K8sTestHelperOpts struct {
|
||||
testinfra.GrafanaOpts
|
||||
// If provided, these users will be used instead of creating new ones
|
||||
Org1Users *OrgUsers
|
||||
OrgBUsers *OrgUsers
|
||||
}
|
||||
|
||||
func NewK8sTestHelper(t *testing.T, opts testinfra.GrafanaOpts) *K8sTestHelper {
|
||||
return NewK8sTestHelperWithOpts(t, K8sTestHelperOpts{GrafanaOpts: opts})
|
||||
}
|
||||
|
||||
func NewK8sTestHelperWithOpts(t *testing.T, opts K8sTestHelperOpts) *K8sTestHelper {
|
||||
t.Helper()
|
||||
|
||||
// Use GRPC server when not configured
|
||||
@@ -111,9 +122,12 @@ func NewK8sTestHelper(t *testing.T, opts testinfra.GrafanaOpts) *K8sTestHelper {
|
||||
path = opts.DirPath
|
||||
)
|
||||
if opts.Dir == "" && opts.DirPath == "" {
|
||||
dir, path = testinfra.CreateGrafDir(t, opts)
|
||||
dir, path = testinfra.CreateGrafDir(t, opts.GrafanaOpts)
|
||||
}
|
||||
listenerAddress, env, testDB := testinfra.StartGrafanaEnvWithDB(t, dir, path)
|
||||
if !opts.DisableDBCleanup {
|
||||
t.Cleanup(testDB.Cleanup)
|
||||
}
|
||||
listenerAddress, env := testinfra.StartGrafanaEnv(t, dir, path)
|
||||
|
||||
c := &K8sTestHelper{
|
||||
env: *env,
|
||||
@@ -143,8 +157,24 @@ func NewK8sTestHelper(t *testing.T, opts testinfra.GrafanaOpts) *K8sTestHelper {
|
||||
_ = c.CreateOrg(Org1)
|
||||
_ = c.CreateOrg(Org2)
|
||||
|
||||
c.Org1 = c.createTestUsers(Org1)
|
||||
c.OrgB = c.createTestUsers(Org2)
|
||||
if opts.Org1Users != nil {
|
||||
c.Org1 = *opts.Org1Users
|
||||
c.Org1.Admin.baseURL = listenerAddress
|
||||
c.Org1.Editor.baseURL = listenerAddress
|
||||
c.Org1.Viewer.baseURL = listenerAddress
|
||||
c.Org1.None.baseURL = listenerAddress
|
||||
} else {
|
||||
c.Org1 = c.createTestUsers(Org1)
|
||||
}
|
||||
if opts.OrgBUsers != nil {
|
||||
c.OrgB = *opts.OrgBUsers
|
||||
c.OrgB.Admin.baseURL = listenerAddress
|
||||
c.OrgB.Editor.baseURL = listenerAddress
|
||||
c.OrgB.Viewer.baseURL = listenerAddress
|
||||
c.OrgB.None.baseURL = listenerAddress
|
||||
} else {
|
||||
c.OrgB = c.createTestUsers(Org2)
|
||||
}
|
||||
|
||||
c.loadAPIGroups()
|
||||
|
||||
|
||||
@@ -4309,6 +4309,10 @@
|
||||
"type": "string",
|
||||
"default": ""
|
||||
},
|
||||
"path": {
|
||||
"description": "For git, this is the target path",
|
||||
"type": "string"
|
||||
},
|
||||
"target": {
|
||||
"description": "When syncing, where values are saved\n\nPossible enum values:\n - `\"folder\"` Resources will be saved into a folder managed by this repository It will contain a copy of everything from the remote The folder k8s name will be the same as the repository k8s name\n - `\"instance\"` Resources are saved in the global context Only one repository may specify the `instance` target When this exists, the UI will promote writing to the instance repo rather than the grafana database (where possible)",
|
||||
"type": "string",
|
||||
@@ -4335,6 +4339,10 @@
|
||||
"local"
|
||||
]
|
||||
},
|
||||
"url": {
|
||||
"description": "For git, this is the target URL",
|
||||
"type": "string"
|
||||
},
|
||||
"workflows": {
|
||||
"description": "The supported workflows",
|
||||
"type": "array",
|
||||
|
||||
@@ -136,6 +136,19 @@ func TestIntegrationProvisioning_CreatingAndGetting(t *testing.T) {
|
||||
return
|
||||
}
|
||||
|
||||
for _, i := range settings.Items {
|
||||
switch i.Type {
|
||||
case provisioning.LocalRepositoryType:
|
||||
assert.Equal(collect, i.Path, helper.ProvisioningPath)
|
||||
case provisioning.GitHubRepositoryType:
|
||||
assert.Equal(collect, i.URL, "https://github.com/grafana/grafana-git-sync-demo")
|
||||
assert.Equal(collect, i.Path, "grafana/")
|
||||
default:
|
||||
assert.NotEmpty(collect, i.Path)
|
||||
assert.NotEmpty(collect, i.URL)
|
||||
}
|
||||
}
|
||||
|
||||
assert.ElementsMatch(collect, []provisioning.RepositoryType{
|
||||
provisioning.LocalRepositoryType,
|
||||
provisioning.GitHubRepositoryType,
|
||||
|
||||
@@ -49,6 +49,12 @@ func StartGrafana(t *testing.T, grafDir, cfgPath string) (string, db.DB) {
|
||||
}
|
||||
|
||||
func StartGrafanaEnv(t *testing.T, grafDir, cfgPath string) (string, *server.TestEnv) {
|
||||
addr, env, testDB := StartGrafanaEnvWithDB(t, grafDir, cfgPath)
|
||||
t.Cleanup(testDB.Cleanup)
|
||||
return addr, env
|
||||
}
|
||||
|
||||
func StartGrafanaEnvWithDB(t *testing.T, grafDir, cfgPath string) (string, *server.TestEnv, *sqlutil.TestDB) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -93,7 +99,6 @@ func StartGrafanaEnv(t *testing.T, grafDir, cfgPath string) (string, *server.Tes
|
||||
// Use proper database type based on the environment variable GRAFANA_TEST_DB in tests
|
||||
testDB, err := sqlutil.GetTestDB(sqlutil.GetTestDBType())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(testDB.Cleanup)
|
||||
|
||||
dbCfg := cfg.Raw.Section("database")
|
||||
dbCfg.Key("type").SetValue(testDB.DriverName)
|
||||
@@ -169,7 +174,7 @@ func StartGrafanaEnv(t *testing.T, grafDir, cfgPath string) (string, *server.Tes
|
||||
|
||||
t.Logf("Grafana is listening on %s", addr)
|
||||
|
||||
return addr, env
|
||||
return addr, env, testDB
|
||||
}
|
||||
|
||||
// CreateGrafDir creates the Grafana directory.
|
||||
@@ -538,6 +543,12 @@ func CreateGrafDir(t *testing.T, opts GrafanaOpts) (string, string) {
|
||||
_, err = section.NewKey("max_page_size_bytes", fmt.Sprintf("%d", opts.UnifiedStorageMaxPageSizeBytes))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
if opts.DisableDataMigrations {
|
||||
section, err := getOrCreateSection("unified_storage")
|
||||
require.NoError(t, err)
|
||||
_, err = section.NewKey("disable_data_migrations", "true")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
if opts.PermittedProvisioningPaths != "" {
|
||||
_, err = pathsSect.NewKey("permitted_provisioning_paths", opts.PermittedProvisioningPaths)
|
||||
require.NoError(t, err)
|
||||
@@ -637,6 +648,8 @@ type GrafanaOpts struct {
|
||||
EnableSCIM bool
|
||||
APIServerRuntimeConfig string
|
||||
DisableControllers bool
|
||||
DisableDBCleanup bool
|
||||
DisableDataMigrations bool
|
||||
SecretsManagerEnableDBMigrations bool
|
||||
|
||||
// Allow creating grafana dir beforehand
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user