Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c730b5b8b | |||
| 4aaf8ef714 |
@@ -42,11 +42,9 @@ Usage: "Fixes #<issue number>", or "Fixes (paste link of issue)"
|
||||
|
||||
Fixes #
|
||||
|
||||
**Checklist:**
|
||||
|
||||
Both author and reviewer should ensure the following:
|
||||
**Special notes for your reviewer:**
|
||||
|
||||
Please check that:
|
||||
- [ ] It works as expected from a user's perspective.
|
||||
- [ ] If this is a pre-GA feature, it is behind a feature toggle.
|
||||
- [ ] The docs are updated, and if this is a [notable improvement](https://grafana.com/docs/writers-toolkit/contribute/release-notes/#how-to-determine-if-content-belongs-in-whats-new), it's added to our [What's New](https://grafana.com/docs/writers-toolkit/contribute/release-notes/) doc.
|
||||
- [ ] Tests are added and passing, both for new functionality and for any bug fixes.
|
||||
|
||||
@@ -25,7 +25,7 @@ Plugin signature verification, also known as _signing_, is a security measure to
|
||||
|
||||
Learn more at [plugin policies](https://grafana.com/legal/plugins/).
|
||||
|
||||
## How does verification work?
|
||||
## How does verifiction work?
|
||||
|
||||
At startup, Grafana verifies the signatures of every plugin in the plugin directory.
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general-
|
||||
| `sharingDashboardImage` | Enables image sharing functionality for dashboards | Yes |
|
||||
| `tabularNumbers` | Use fixed-width numbers globally in the UI | |
|
||||
| `azureResourcePickerUpdates` | Enables the updated Azure Monitor resource picker | Yes |
|
||||
| `tempoSearchBackendMigration` | Run search queries through the tempo backend | |
|
||||
| `opentsdbBackendMigration` | Run queries through the data source backend | |
|
||||
|
||||
## Public preview feature toggles
|
||||
|
||||
@@ -1021,6 +1021,11 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"public/app/core/actions/index.ts": {
|
||||
"no-barrel-files/no-barrel-files": {
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"public/app/core/components/AccessControl/PermissionList.tsx": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 1
|
||||
@@ -2605,6 +2610,11 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"public/app/features/explore/hooks/useStateSync/index.ts": {
|
||||
"no-barrel-files/no-barrel-files": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/features/explore/spec/helper/setup.tsx": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
@@ -4020,6 +4030,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/plugins/datasource/parca/webpack.config.ts": {
|
||||
"no-barrel-files/no-barrel-files": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/plugins/datasource/prometheus/configuration/AzureAuthSettings.tsx": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 1
|
||||
@@ -4088,7 +4103,7 @@
|
||||
"count": 1
|
||||
},
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"public/app/plugins/datasource/tempo/resultTransformer.ts": {
|
||||
|
||||
@@ -585,8 +585,6 @@ module.exports = [
|
||||
// FIXME: Remove once all enterprise issues are fixed -
|
||||
// we don't have a suppressions file/approach for enterprise code yet
|
||||
...enterpriseIgnores,
|
||||
// Ignore decoupled plugin webpack configs
|
||||
'public/app/**/webpack.config.ts',
|
||||
],
|
||||
rules: {
|
||||
'no-barrel-files/no-barrel-files': 'error',
|
||||
|
||||
-6
@@ -246,8 +246,6 @@ const injectedRtkApi = api
|
||||
facetLimit: queryArg.facetLimit,
|
||||
tags: queryArg.tags,
|
||||
libraryPanel: queryArg.libraryPanel,
|
||||
panelType: queryArg.panelType,
|
||||
dataSourceType: queryArg.dataSourceType,
|
||||
permission: queryArg.permission,
|
||||
sort: queryArg.sort,
|
||||
limit: queryArg.limit,
|
||||
@@ -676,10 +674,6 @@ export type SearchDashboardsAndFoldersApiArg = {
|
||||
tags?: string[];
|
||||
/** find dashboards that reference a given libraryPanel */
|
||||
libraryPanel?: string;
|
||||
/** find dashboards using panels of a given plugin type */
|
||||
panelType?: string;
|
||||
/** find dashboards using datasources of a given plugin type */
|
||||
dataSourceType?: string;
|
||||
/** permission needed for the resource (view, edit, admin) */
|
||||
permission?: 'view' | 'edit' | 'admin';
|
||||
/** sortable field */
|
||||
|
||||
@@ -9,7 +9,6 @@ import { FieldColorModeId } from '../types/fieldColor';
|
||||
import { FieldConfigPropertyItem, FieldConfigSource } from '../types/fieldOverrides';
|
||||
import { InterpolateFunction } from '../types/panel';
|
||||
import { ThresholdsMode } from '../types/thresholds';
|
||||
import { MappingType } from '../types/valueMapping';
|
||||
import { Registry } from '../utils/Registry';
|
||||
import { locationUtil } from '../utils/location';
|
||||
import { mockStandardProperties } from '../utils/tests/mockStandardProperties';
|
||||
@@ -1000,45 +999,6 @@ describe('setDynamicConfigValue', () => {
|
||||
expect(config.custom.property3).toEqual({});
|
||||
expect(config.displayName).toBeUndefined();
|
||||
});
|
||||
|
||||
it('works correctly with multiple value mappings in the same override', () => {
|
||||
const config: FieldConfig = {
|
||||
mappings: [{ type: MappingType.ValueToText, options: { existing: { text: 'existing' } } }],
|
||||
};
|
||||
|
||||
setDynamicConfigValue(
|
||||
config,
|
||||
{
|
||||
id: 'mappings',
|
||||
value: [{ type: MappingType.ValueToText, options: { first: { text: 'first' } } }],
|
||||
},
|
||||
{
|
||||
fieldConfigRegistry: customFieldRegistry,
|
||||
data: [],
|
||||
field: { type: FieldType.number } as Field,
|
||||
dataFrameIndex: 0,
|
||||
}
|
||||
);
|
||||
|
||||
setDynamicConfigValue(
|
||||
config,
|
||||
{
|
||||
id: 'mappings',
|
||||
value: [{ type: MappingType.ValueToText, options: { second: { text: 'second' } } }],
|
||||
},
|
||||
{
|
||||
fieldConfigRegistry: customFieldRegistry,
|
||||
data: [],
|
||||
field: { type: FieldType.number } as Field,
|
||||
dataFrameIndex: 0,
|
||||
}
|
||||
);
|
||||
|
||||
expect(config.mappings).toHaveLength(3);
|
||||
expect(config.mappings![0]).toEqual({ type: MappingType.ValueToText, options: { existing: { text: 'existing' } } });
|
||||
expect(config.mappings![1]).toEqual({ type: MappingType.ValueToText, options: { first: { text: 'first' } } });
|
||||
expect(config.mappings![2]).toEqual({ type: MappingType.ValueToText, options: { second: { text: 'second' } } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLinksSupplier', () => {
|
||||
|
||||
@@ -341,7 +341,7 @@ export function setDynamicConfigValue(config: FieldConfig, value: DynamicConfigV
|
||||
return;
|
||||
}
|
||||
|
||||
let val = item.process(value.value, context, item.settings);
|
||||
const val = item.process(value.value, context, item.settings);
|
||||
|
||||
const remove = val === undefined || val === null;
|
||||
|
||||
@@ -352,15 +352,6 @@ export function setDynamicConfigValue(config: FieldConfig, value: DynamicConfigV
|
||||
unset(config, item.path);
|
||||
}
|
||||
} else {
|
||||
// Merge arrays (e.g. mappings) when multiple overrides target the same field
|
||||
if (Array.isArray(val)) {
|
||||
const existingValue = item.isCustom ? get(config.custom, item.path) : get(config, item.path);
|
||||
|
||||
if (Array.isArray(existingValue)) {
|
||||
val = [...existingValue, ...val];
|
||||
}
|
||||
}
|
||||
|
||||
if (item.isCustom) {
|
||||
if (!config.custom) {
|
||||
config.custom = {};
|
||||
|
||||
+14
-14
@@ -527,6 +527,10 @@ export interface FeatureToggles {
|
||||
*/
|
||||
dashboardTemplates?: boolean;
|
||||
/**
|
||||
* Sets the logs table as default visualisation in logs explore
|
||||
*/
|
||||
logsExploreTableDefaultVisualization?: boolean;
|
||||
/**
|
||||
* Enables the new alert list view design
|
||||
*/
|
||||
alertingListViewV2?: boolean;
|
||||
@@ -653,6 +657,10 @@ export interface FeatureToggles {
|
||||
*/
|
||||
rolePickerDrawer?: boolean;
|
||||
/**
|
||||
* Enable unified storage search
|
||||
*/
|
||||
unifiedStorageSearch?: boolean;
|
||||
/**
|
||||
* Enable sprinkles on unified storage search
|
||||
*/
|
||||
unifiedStorageSearchSprinkles?: boolean;
|
||||
@@ -949,8 +957,7 @@ export interface FeatureToggles {
|
||||
*/
|
||||
alertingBulkActionsInUI?: boolean;
|
||||
/**
|
||||
* Deprecated: Use kubernetesAuthzCoreRolesApi, kubernetesAuthzRolesApi, and kubernetesAuthzRoleBindingsApi instead
|
||||
* @deprecated
|
||||
* Registers AuthZ /apis endpoint
|
||||
*/
|
||||
kubernetesAuthzApis?: boolean;
|
||||
/**
|
||||
@@ -966,18 +973,6 @@ export interface FeatureToggles {
|
||||
*/
|
||||
kubernetesAuthzZanzanaSync?: boolean;
|
||||
/**
|
||||
* Registers AuthZ Core Roles /apis endpoint
|
||||
*/
|
||||
kubernetesAuthzCoreRolesApi?: boolean;
|
||||
/**
|
||||
* Registers AuthZ Roles /apis endpoint
|
||||
*/
|
||||
kubernetesAuthzRolesApi?: boolean;
|
||||
/**
|
||||
* Registers AuthZ Role Bindings /apis endpoint
|
||||
*/
|
||||
kubernetesAuthzRoleBindingsApi?: boolean;
|
||||
/**
|
||||
* Enables create, delete, and update mutations for resources owned by IAM identity
|
||||
*/
|
||||
kubernetesAuthnMutation?: boolean;
|
||||
@@ -1129,6 +1124,11 @@ export interface FeatureToggles {
|
||||
*/
|
||||
pluginContainers?: boolean;
|
||||
/**
|
||||
* Run search queries through the tempo backend
|
||||
* @default false
|
||||
*/
|
||||
tempoSearchBackendMigration?: boolean;
|
||||
/**
|
||||
* Prioritize loading plugins from the CDN before other sources
|
||||
* @default false
|
||||
*/
|
||||
|
||||
@@ -52,7 +52,6 @@ export const availableIconsIndex = {
|
||||
bookmark: true,
|
||||
'book-open': true,
|
||||
'brackets-curly': true,
|
||||
brain: true,
|
||||
'browser-alt': true,
|
||||
bug: true,
|
||||
building: true,
|
||||
|
||||
@@ -153,10 +153,6 @@ interface BaseProps<TableData extends object> {
|
||||
* Optional way to set how the table is sorted from the beginning. Must be memoized.
|
||||
*/
|
||||
initialSortBy?: Array<SortingRule<TableData>>;
|
||||
/**
|
||||
* Disable the ability to remove sorting on columns (none -> asc -> desc -> asc)
|
||||
*/
|
||||
disableSortRemove?: boolean;
|
||||
}
|
||||
|
||||
interface WithExpandableRow<TableData extends object> extends BaseProps<TableData> {
|
||||
@@ -195,7 +191,6 @@ export function InteractiveTable<TableData extends object>({
|
||||
showExpandAll = false,
|
||||
fetchData,
|
||||
initialSortBy = [],
|
||||
disableSortRemove,
|
||||
}: Props<TableData>) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const tableColumns = useMemo(() => {
|
||||
@@ -227,7 +222,6 @@ export function InteractiveTable<TableData extends object>({
|
||||
disableMultiSort: true,
|
||||
// If fetchData is provided, we disable client-side sorting
|
||||
manualSortBy: Boolean(fetchData),
|
||||
disableSortRemove,
|
||||
getRowId,
|
||||
initialState: {
|
||||
hiddenColumns: [
|
||||
|
||||
@@ -26,8 +26,4 @@ export interface Column<TableData extends object> {
|
||||
* If the provided function returns `false` the column will be hidden.
|
||||
*/
|
||||
visible?: (data: TableData[]) => boolean;
|
||||
/**
|
||||
* Determines starting sort direction when the column header is clicked.
|
||||
*/
|
||||
sortDescFirst?: boolean;
|
||||
}
|
||||
|
||||
@@ -33,7 +33,6 @@ export function getColumns<K extends object>(
|
||||
disableSortBy: !Boolean(column.sortType),
|
||||
width: column.disableGrow ? 0 : undefined,
|
||||
visible: column.visible,
|
||||
...(column.sortDescFirst !== undefined && { sortDescFirst: column.sortDescFirst }),
|
||||
...(column.cell && { Cell: column.cell }),
|
||||
})),
|
||||
];
|
||||
|
||||
@@ -1,55 +1,26 @@
|
||||
package generic
|
||||
|
||||
import (
|
||||
"k8s.io/apimachinery/pkg/fields"
|
||||
"k8s.io/apimachinery/pkg/labels"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apiserver/pkg/registry/generic"
|
||||
"k8s.io/apiserver/pkg/registry/generic/registry"
|
||||
"k8s.io/apiserver/pkg/storage"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
)
|
||||
|
||||
// SelectableFieldsOptions allows customizing field selector behavior for a resource.
|
||||
type SelectableFieldsOptions struct {
|
||||
// GetAttrs returns labels and fields for the object.
|
||||
// If nil, the default GetAttrs is used which only exposes metadata.name.
|
||||
GetAttrs func(obj runtime.Object) (labels.Set, fields.Set, error)
|
||||
}
|
||||
|
||||
func NewRegistryStore(scheme *runtime.Scheme, resourceInfo utils.ResourceInfo, optsGetter generic.RESTOptionsGetter) (*registry.Store, error) {
|
||||
return NewRegistryStoreWithSelectableFields(scheme, resourceInfo, optsGetter, SelectableFieldsOptions{})
|
||||
}
|
||||
|
||||
// NewRegistryStoreWithSelectableFields creates a registry store with custom selectable fields support.
|
||||
// Use this when you need to filter resources by custom fields like spec.connection.name.
|
||||
func NewRegistryStoreWithSelectableFields(scheme *runtime.Scheme, resourceInfo utils.ResourceInfo, optsGetter generic.RESTOptionsGetter, fieldOpts SelectableFieldsOptions) (*registry.Store, error) {
|
||||
gv := resourceInfo.GroupVersion()
|
||||
gv.Version = runtime.APIVersionInternal
|
||||
strategy := NewStrategy(scheme, gv)
|
||||
if resourceInfo.IsClusterScoped() {
|
||||
strategy = strategy.WithClusterScope()
|
||||
}
|
||||
|
||||
// Use custom GetAttrs if provided, otherwise use default
|
||||
var attrFunc storage.AttrFunc
|
||||
var predicateFunc func(label labels.Selector, field fields.Selector) storage.SelectionPredicate
|
||||
if fieldOpts.GetAttrs != nil {
|
||||
attrFunc = fieldOpts.GetAttrs
|
||||
// Pass nil predicateFunc to use default behavior with custom attrFunc
|
||||
predicateFunc = nil
|
||||
} else {
|
||||
attrFunc = GetAttrs
|
||||
predicateFunc = Matcher
|
||||
}
|
||||
|
||||
store := ®istry.Store{
|
||||
NewFunc: resourceInfo.NewFunc,
|
||||
NewListFunc: resourceInfo.NewListFunc,
|
||||
KeyRootFunc: KeyRootFunc(resourceInfo.GroupResource()),
|
||||
KeyFunc: NamespaceKeyFunc(resourceInfo.GroupResource()),
|
||||
PredicateFunc: predicateFunc,
|
||||
PredicateFunc: Matcher,
|
||||
DefaultQualifiedResource: resourceInfo.GroupResource(),
|
||||
SingularQualifiedResource: resourceInfo.SingularGroupResource(),
|
||||
TableConvertor: resourceInfo.TableConverter(),
|
||||
@@ -57,7 +28,7 @@ func NewRegistryStoreWithSelectableFields(scheme *runtime.Scheme, resourceInfo u
|
||||
UpdateStrategy: strategy,
|
||||
DeleteStrategy: strategy,
|
||||
}
|
||||
options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: attrFunc}
|
||||
options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: GetAttrs}
|
||||
if err := store.CompleteWithOptions(options); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
## Build artifacts
|
||||
|
||||
Put the resulting tar in your `grafana` OSS path:
|
||||
```sh
|
||||
go -C grafana run ./pkg/build/cmd artifacts -a targz:enterprise:linux/amd64 --alpine-base=alpine:3.22 --tag-format='{{ .version }}-{{ .buildID }}-{{ .arch }}' --grafana-dir="${PWD}/grafana" --enterprise-dir="${PWD}/grafana-enterprise"
|
||||
```
|
||||
|
||||
Also build the e2e test runner:
|
||||
```sh
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o ./e2e-runner ./e2e/
|
||||
```
|
||||
|
||||
And then `chmod +x ./e2e-runner`.
|
||||
|
||||
## Running tests
|
||||
|
||||
Reporting tests with Image Renderer:
|
||||
```sh
|
||||
go run ./pkg/build/e2e --suite=e2e/extensions/enterprise/smtp-suite --license=e2e/extensions/enterprise/license.jwt --image-renderer
|
||||
```
|
||||
@@ -138,10 +138,6 @@ func run(ctx context.Context, cmd *cli.Command) error {
|
||||
}
|
||||
|
||||
if code != 0 {
|
||||
if stdout, _ := c.Stdout(ctx); len(stdout) > 0 {
|
||||
log.Printf("e2e test suite stdout:\n%s", stdout)
|
||||
}
|
||||
|
||||
return fmt.Errorf("e2e tests failed with exit code %d", code)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,10 +8,10 @@ import (
|
||||
|
||||
func RunSuite(d *dagger.Client, svc *dagger.Service, src *dagger.Directory, cache *dagger.CacheVolume, suite, runnerFlags string) *dagger.Container {
|
||||
command := fmt.Sprintf(
|
||||
"./e2e-runner cypress --browser=electron --start-grafana=false --cypress-video"+
|
||||
"./e2e-runner cypress --start-grafana=false --cypress-video"+
|
||||
" --grafana-base-url http://grafana:3001 --suite %s %s", suite, runnerFlags)
|
||||
|
||||
return WithYarnCache(WithGrafanaFrontend(d.Container().From("cypress/included:14.3.2"), src), cache).
|
||||
return WithYarnCache(WithGrafanaFrontend(d.Container().From("cypress/included:13.1.0"), src), cache).
|
||||
WithWorkdir("/src").
|
||||
WithServiceBinding("grafana", svc).
|
||||
WithExec([]string{"yarn", "install", "--immutable"}).
|
||||
|
||||
@@ -99,15 +99,13 @@ func GrafanaService(ctx context.Context, d *dagger.Client, opts GrafanaServiceOp
|
||||
}
|
||||
|
||||
if opts.StartImageRenderer {
|
||||
imageRendererSvc := d.Container().From("grafana/grafana-image-renderer:" + opts.ImageRendererVersion).
|
||||
WithExposedPort(8081).
|
||||
AsService()
|
||||
|
||||
container = container.WithServiceBinding("image-renderer", imageRendererSvc).
|
||||
container = container.WithEnvVariable("START_IMAGE_RENDERER", "true").
|
||||
WithExec([]string{"apt-get", "update"}).
|
||||
WithExec([]string{"apt-get", "install", "-y", "ca-certificates"}).
|
||||
WithEnvVariable("GF_RENDERING_CALLBACK_URL", "http://grafana:3001/").
|
||||
WithEnvVariable("GF_RENDERING_SERVER_URL", "http://image-renderer:8081/render")
|
||||
WithExec([]string{"apt-get", "install", "-y", "ca-certificates"})
|
||||
|
||||
if opts.ImageRendererVersion != "" {
|
||||
container = container.WithEnvVariable("IMAGE_RENDERER_VERSION", opts.ImageRendererVersion)
|
||||
}
|
||||
}
|
||||
|
||||
// We add all GF_ environment variables to allow for overriding Grafana configuration.
|
||||
|
||||
@@ -142,24 +142,6 @@ func (s *SearchHandler) GetAPIRoutes(defs map[string]common.OpenAPIDefinition) *
|
||||
Schema: spec.StringProperty(),
|
||||
},
|
||||
},
|
||||
{
|
||||
ParameterProps: spec3.ParameterProps{
|
||||
Name: "panelType",
|
||||
In: "query",
|
||||
Description: "find dashboards using panels of a given plugin type",
|
||||
Required: false,
|
||||
Schema: spec.StringProperty(),
|
||||
},
|
||||
},
|
||||
{
|
||||
ParameterProps: spec3.ParameterProps{
|
||||
Name: "dataSourceType",
|
||||
In: "query",
|
||||
Description: "find dashboards using datasources of a given plugin type",
|
||||
Required: false,
|
||||
Schema: spec.StringProperty(),
|
||||
},
|
||||
},
|
||||
{
|
||||
ParameterProps: spec3.ParameterProps{
|
||||
Name: "permission",
|
||||
@@ -448,11 +430,14 @@ func convertHttpSearchRequestToResourceSearchRequest(queryParams url.Values, use
|
||||
}
|
||||
}
|
||||
|
||||
// Apply facet terms
|
||||
// The facet term fields
|
||||
if facets, ok := queryParams["facet"]; ok {
|
||||
if queryParams.Has("facetLimit") {
|
||||
if parsed, err := strconv.Atoi(queryParams.Get("facetLimit")); err == nil && parsed > 0 {
|
||||
facetLimit = min(parsed, 1000)
|
||||
facetLimit = parsed
|
||||
if facetLimit > 1000 {
|
||||
facetLimit = 1000
|
||||
}
|
||||
}
|
||||
}
|
||||
searchRequest.Facet = make(map[string]*resourcepb.ResourceSearchRequest_Facet)
|
||||
@@ -464,35 +449,21 @@ func convertHttpSearchRequestToResourceSearchRequest(queryParams url.Values, use
|
||||
}
|
||||
}
|
||||
|
||||
if v, ok := queryParams["tag"]; ok {
|
||||
// The tags filter
|
||||
if tags, ok := queryParams["tag"]; ok {
|
||||
searchRequest.Options.Fields = append(searchRequest.Options.Fields, &resourcepb.Requirement{
|
||||
Key: "tags",
|
||||
Operator: "=",
|
||||
Values: v,
|
||||
Values: tags,
|
||||
})
|
||||
}
|
||||
|
||||
if v, ok := queryParams["panelType"]; ok {
|
||||
searchRequest.Options.Fields = append(searchRequest.Options.Fields, &resourcepb.Requirement{
|
||||
Key: resource.SEARCH_FIELD_PREFIX + builders.DASHBOARD_PANEL_TYPES,
|
||||
Operator: "=",
|
||||
Values: v,
|
||||
})
|
||||
}
|
||||
|
||||
if v, ok := queryParams["dataSourceType"]; ok {
|
||||
searchRequest.Options.Fields = append(searchRequest.Options.Fields, &resourcepb.Requirement{
|
||||
Key: resource.SEARCH_FIELD_PREFIX + builders.DASHBOARD_DS_TYPES,
|
||||
Operator: "=",
|
||||
Values: v,
|
||||
})
|
||||
}
|
||||
|
||||
if v, ok := queryParams["libraryPanel"]; ok {
|
||||
// The libraryPanel filter
|
||||
if libraryPanel, ok := queryParams["libraryPanel"]; ok {
|
||||
searchRequest.Options.Fields = append(searchRequest.Options.Fields, &resourcepb.Requirement{
|
||||
Key: builders.DASHBOARD_LIBRARY_PANEL_REFERENCE,
|
||||
Operator: "=",
|
||||
Values: v,
|
||||
Values: libraryPanel,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,7 @@ import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/open-feature/go-sdk/openfeature"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
@@ -211,16 +209,8 @@ func (b *IdentityAccessManagementAPIBuilder) GetGroupVersion() schema.GroupVersi
|
||||
}
|
||||
|
||||
func (b *IdentityAccessManagementAPIBuilder) InstallSchema(scheme *runtime.Scheme) error {
|
||||
client := openfeature.NewDefaultClient()
|
||||
ctx, cancelFn := context.WithTimeout(context.Background(), time.Second*5)
|
||||
defer cancelFn()
|
||||
|
||||
// Check if any of the AuthZ APIs are enabled
|
||||
enableCoreRolesApi := client.Boolean(ctx, featuremgmt.FlagKubernetesAuthzCoreRolesApi, false, openfeature.TransactionContext(ctx))
|
||||
enableRolesApi := client.Boolean(ctx, featuremgmt.FlagKubernetesAuthzRolesApi, false, openfeature.TransactionContext(ctx))
|
||||
enableRoleBindingsApi := client.Boolean(ctx, featuremgmt.FlagKubernetesAuthzRoleBindingsApi, false, openfeature.TransactionContext(ctx))
|
||||
|
||||
if enableCoreRolesApi || enableRolesApi || enableRoleBindingsApi {
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if b.features.IsEnabledGlobally(featuremgmt.FlagKubernetesAuthzApis) {
|
||||
if err := iamv0.AddAuthZKnownTypes(scheme); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -254,16 +244,10 @@ func (b *IdentityAccessManagementAPIBuilder) AllowedV0Alpha1Resources() []string
|
||||
func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions) error {
|
||||
storage := map[string]rest.Storage{}
|
||||
|
||||
client := openfeature.NewDefaultClient()
|
||||
ctx, cancelFn := context.WithTimeout(context.Background(), time.Second*5)
|
||||
defer cancelFn()
|
||||
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
enableZanzanaSync := b.features.IsEnabledGlobally(featuremgmt.FlagKubernetesAuthzZanzanaSync)
|
||||
|
||||
enableCoreRolesApi := client.Boolean(ctx, featuremgmt.FlagKubernetesAuthzCoreRolesApi, false, openfeature.TransactionContext(ctx))
|
||||
enableRolesApi := client.Boolean(ctx, featuremgmt.FlagKubernetesAuthzRolesApi, false, openfeature.TransactionContext(ctx))
|
||||
enableRoleBindingsApi := client.Boolean(ctx, featuremgmt.FlagKubernetesAuthzRoleBindingsApi, false, openfeature.TransactionContext(ctx))
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
enableAuthzApis := b.features.IsEnabledGlobally(featuremgmt.FlagKubernetesAuthzApis)
|
||||
|
||||
// teams + users must have shorter names because they are often used as part of another name
|
||||
opts.StorageOptsRegister(iamv0.TeamResourceInfo.GroupResource(), apistore.StorageOptions{
|
||||
@@ -299,21 +283,17 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge
|
||||
return err
|
||||
}
|
||||
|
||||
if enableCoreRolesApi {
|
||||
if enableAuthzApis {
|
||||
// v0alpha1
|
||||
if err := b.UpdateCoreRolesAPIGroup(apiGroupInfo, opts, storage, enableZanzanaSync); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if enableRolesApi {
|
||||
// Role registration is delegated to the RoleApiInstaller
|
||||
if err := b.roleApiInstaller.RegisterStorage(apiGroupInfo, &opts, storage); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if enableRoleBindingsApi {
|
||||
if err := b.UpdateRoleBindingsAPIGroup(apiGroupInfo, opts, storage, enableZanzanaSync); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ var groupVersion = schema.GroupVersion{
|
||||
}
|
||||
|
||||
type APIBuilder struct {
|
||||
providerType string
|
||||
providerType setting.OpenFeatureProviderType
|
||||
url *url.URL
|
||||
insecure bool
|
||||
caFile string
|
||||
@@ -52,7 +52,7 @@ type APIBuilder struct {
|
||||
logger log.Logger
|
||||
}
|
||||
|
||||
func NewAPIBuilder(providerType string, url *url.URL, insecure bool, caFile string, staticEvaluator featuremgmt.StaticFlagEvaluator) *APIBuilder {
|
||||
func NewAPIBuilder(providerType setting.OpenFeatureProviderType, url *url.URL, insecure bool, caFile string, staticEvaluator featuremgmt.StaticFlagEvaluator) *APIBuilder {
|
||||
return &APIBuilder{
|
||||
providerType: providerType,
|
||||
url: url,
|
||||
|
||||
@@ -71,98 +71,6 @@ func (_c *MockJobProgressRecorder_Complete_Call) RunAndReturn(run func(context.C
|
||||
return _c
|
||||
}
|
||||
|
||||
// HasDirPathFailedDeletion provides a mock function with given fields: folderPath
|
||||
func (_m *MockJobProgressRecorder) HasDirPathFailedDeletion(folderPath string) bool {
|
||||
ret := _m.Called(folderPath)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for HasDirPathFailedDeletion")
|
||||
}
|
||||
|
||||
var r0 bool
|
||||
if rf, ok := ret.Get(0).(func(string) bool); ok {
|
||||
r0 = rf(folderPath)
|
||||
} else {
|
||||
r0 = ret.Get(0).(bool)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// MockJobProgressRecorder_HasDirPathFailedDeletion_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HasDirPathFailedDeletion'
|
||||
type MockJobProgressRecorder_HasDirPathFailedDeletion_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// HasDirPathFailedDeletion is a helper method to define mock.On call
|
||||
// - folderPath string
|
||||
func (_e *MockJobProgressRecorder_Expecter) HasDirPathFailedDeletion(folderPath interface{}) *MockJobProgressRecorder_HasDirPathFailedDeletion_Call {
|
||||
return &MockJobProgressRecorder_HasDirPathFailedDeletion_Call{Call: _e.mock.On("HasDirPathFailedDeletion", folderPath)}
|
||||
}
|
||||
|
||||
func (_c *MockJobProgressRecorder_HasDirPathFailedDeletion_Call) Run(run func(folderPath string)) *MockJobProgressRecorder_HasDirPathFailedDeletion_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(string))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockJobProgressRecorder_HasDirPathFailedDeletion_Call) Return(_a0 bool) *MockJobProgressRecorder_HasDirPathFailedDeletion_Call {
|
||||
_c.Call.Return(_a0)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockJobProgressRecorder_HasDirPathFailedDeletion_Call) RunAndReturn(run func(string) bool) *MockJobProgressRecorder_HasDirPathFailedDeletion_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// HasDirPathFailedCreation provides a mock function with given fields: path
|
||||
func (_m *MockJobProgressRecorder) HasDirPathFailedCreation(path string) bool {
|
||||
ret := _m.Called(path)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for HasDirPathFailedCreation")
|
||||
}
|
||||
|
||||
var r0 bool
|
||||
if rf, ok := ret.Get(0).(func(string) bool); ok {
|
||||
r0 = rf(path)
|
||||
} else {
|
||||
r0 = ret.Get(0).(bool)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// MockJobProgressRecorder_HasDirPathFailedCreation_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HasDirPathFailedCreation'
|
||||
type MockJobProgressRecorder_HasDirPathFailedCreation_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// HasDirPathFailedCreation is a helper method to define mock.On call
|
||||
// - path string
|
||||
func (_e *MockJobProgressRecorder_Expecter) HasDirPathFailedCreation(path interface{}) *MockJobProgressRecorder_HasDirPathFailedCreation_Call {
|
||||
return &MockJobProgressRecorder_HasDirPathFailedCreation_Call{Call: _e.mock.On("HasDirPathFailedCreation", path)}
|
||||
}
|
||||
|
||||
func (_c *MockJobProgressRecorder_HasDirPathFailedCreation_Call) Run(run func(path string)) *MockJobProgressRecorder_HasDirPathFailedCreation_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(string))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockJobProgressRecorder_HasDirPathFailedCreation_Call) Return(_a0 bool) *MockJobProgressRecorder_HasDirPathFailedCreation_Call {
|
||||
_c.Call.Return(_a0)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockJobProgressRecorder_HasDirPathFailedCreation_Call) RunAndReturn(run func(string) bool) *MockJobProgressRecorder_HasDirPathFailedCreation_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// Record provides a mock function with given fields: ctx, result
|
||||
func (_m *MockJobProgressRecorder) Record(ctx context.Context, result JobResourceResult) {
|
||||
_m.Called(ctx, result)
|
||||
|
||||
@@ -2,7 +2,6 @@ package jobs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -10,8 +9,6 @@ import (
|
||||
"github.com/grafana/grafana-app-sdk/logging"
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/safepath"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
|
||||
)
|
||||
|
||||
// maybeNotifyProgress will only notify if a certain amount of time has passed
|
||||
@@ -61,8 +58,6 @@ type jobProgressRecorder struct {
|
||||
notifyImmediatelyFn ProgressFn
|
||||
maybeNotifyFn ProgressFn
|
||||
summaries map[string]*provisioning.JobResourceSummary
|
||||
failedCreations []string // Tracks folder paths that failed to be created
|
||||
failedDeletions []string // Tracks resource paths that failed to be deleted
|
||||
}
|
||||
|
||||
func newJobProgressRecorder(ProgressFn ProgressFn) JobProgressRecorder {
|
||||
@@ -89,26 +84,10 @@ func (r *jobProgressRecorder) Record(ctx context.Context, result JobResourceResu
|
||||
if result.Error != nil {
|
||||
shouldLogError = true
|
||||
logErr = result.Error
|
||||
|
||||
// Don't count ignored actions as errors in error count or error list
|
||||
if result.Action != repository.FileActionIgnored {
|
||||
if len(r.errors) < 20 {
|
||||
r.errors = append(r.errors, result.Error.Error())
|
||||
}
|
||||
r.errorCount++
|
||||
}
|
||||
|
||||
// Automatically track failed operations based on error type and action
|
||||
// Check if this is a PathCreationError (folder creation failure)
|
||||
var pathErr *resources.PathCreationError
|
||||
if errors.As(result.Error, &pathErr) {
|
||||
r.failedCreations = append(r.failedCreations, pathErr.Path)
|
||||
}
|
||||
|
||||
// Track failed deletions, any deletion will stop the deletion of the parent folder (as it won't be empty)
|
||||
if result.Action == repository.FileActionDeleted {
|
||||
r.failedDeletions = append(r.failedDeletions, result.Path)
|
||||
if len(r.errors) < 20 {
|
||||
r.errors = append(r.errors, result.Error.Error())
|
||||
}
|
||||
r.errorCount++
|
||||
}
|
||||
|
||||
r.updateSummary(result)
|
||||
@@ -133,8 +112,6 @@ func (r *jobProgressRecorder) ResetResults() {
|
||||
r.errorCount = 0
|
||||
r.errors = nil
|
||||
r.summaries = make(map[string]*provisioning.JobResourceSummary)
|
||||
r.failedCreations = nil
|
||||
r.failedDeletions = nil
|
||||
}
|
||||
|
||||
func (r *jobProgressRecorder) SetMessage(ctx context.Context, msg string) {
|
||||
@@ -332,29 +309,3 @@ func (r *jobProgressRecorder) Complete(ctx context.Context, err error) provision
|
||||
|
||||
return jobStatus
|
||||
}
|
||||
|
||||
// HasDirPathFailedCreation checks if a path is nested under any failed folder creation
|
||||
func (r *jobProgressRecorder) HasDirPathFailedCreation(path string) bool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
for _, failedCreation := range r.failedCreations {
|
||||
if safepath.InDir(path, failedCreation) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasDirPathFailedDeletion checks if any resource deletions failed under a folder path
|
||||
func (r *jobProgressRecorder) HasDirPathFailedDeletion(folderPath string) bool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
for _, failedDeletion := range r.failedDeletions {
|
||||
if safepath.InDir(failedDeletion, folderPath) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -253,221 +252,3 @@ func TestJobProgressRecorderWarningOnlyNoErrors(t *testing.T) {
|
||||
require.NotNil(t, finalStatus.Warnings)
|
||||
assert.Len(t, finalStatus.Warnings, 1)
|
||||
}
|
||||
|
||||
func TestJobProgressRecorderFolderFailureTracking(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a progress recorder
|
||||
mockProgressFn := func(ctx context.Context, status provisioning.JobStatus) error {
|
||||
return nil
|
||||
}
|
||||
recorder := newJobProgressRecorder(mockProgressFn).(*jobProgressRecorder)
|
||||
|
||||
// Record a folder creation failure with PathCreationError
|
||||
pathErr := &resources.PathCreationError{
|
||||
Path: "folder1/",
|
||||
Err: assert.AnError,
|
||||
}
|
||||
recorder.Record(ctx, JobResourceResult{
|
||||
Path: "folder1/file.json",
|
||||
Action: repository.FileActionCreated,
|
||||
Error: pathErr,
|
||||
})
|
||||
|
||||
// Record another PathCreationError for a different folder
|
||||
pathErr2 := &resources.PathCreationError{
|
||||
Path: "folder2/subfolder/",
|
||||
Err: assert.AnError,
|
||||
}
|
||||
recorder.Record(ctx, JobResourceResult{
|
||||
Path: "folder2/subfolder/file.json",
|
||||
Action: repository.FileActionCreated,
|
||||
Error: pathErr2,
|
||||
})
|
||||
|
||||
// Record a deletion failure
|
||||
recorder.Record(ctx, JobResourceResult{
|
||||
Path: "folder3/file1.json",
|
||||
Action: repository.FileActionDeleted,
|
||||
Error: assert.AnError,
|
||||
})
|
||||
|
||||
// Record another deletion failure
|
||||
recorder.Record(ctx, JobResourceResult{
|
||||
Path: "folder4/subfolder/file2.json",
|
||||
Action: repository.FileActionDeleted,
|
||||
Error: assert.AnError,
|
||||
})
|
||||
|
||||
// Verify failed creations are tracked
|
||||
recorder.mu.RLock()
|
||||
assert.Len(t, recorder.failedCreations, 2)
|
||||
assert.Contains(t, recorder.failedCreations, "folder1/")
|
||||
assert.Contains(t, recorder.failedCreations, "folder2/subfolder/")
|
||||
|
||||
// Verify failed deletions are tracked
|
||||
assert.Len(t, recorder.failedDeletions, 2)
|
||||
assert.Contains(t, recorder.failedDeletions, "folder3/file1.json")
|
||||
assert.Contains(t, recorder.failedDeletions, "folder4/subfolder/file2.json")
|
||||
recorder.mu.RUnlock()
|
||||
}
|
||||
|
||||
func TestJobProgressRecorderHasDirPathFailedCreation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a progress recorder
|
||||
mockProgressFn := func(ctx context.Context, status provisioning.JobStatus) error {
|
||||
return nil
|
||||
}
|
||||
recorder := newJobProgressRecorder(mockProgressFn).(*jobProgressRecorder)
|
||||
|
||||
// Add failed creations via Record
|
||||
pathErr1 := &resources.PathCreationError{
|
||||
Path: "folder1/",
|
||||
Err: assert.AnError,
|
||||
}
|
||||
recorder.Record(ctx, JobResourceResult{
|
||||
Path: "folder1/file.json",
|
||||
Action: repository.FileActionCreated,
|
||||
Error: pathErr1,
|
||||
})
|
||||
|
||||
pathErr2 := &resources.PathCreationError{
|
||||
Path: "folder2/subfolder/",
|
||||
Err: assert.AnError,
|
||||
}
|
||||
recorder.Record(ctx, JobResourceResult{
|
||||
Path: "folder2/subfolder/file.json",
|
||||
Action: repository.FileActionCreated,
|
||||
Error: pathErr2,
|
||||
})
|
||||
|
||||
// Test nested paths
|
||||
assert.True(t, recorder.HasDirPathFailedCreation("folder1/file.json"))
|
||||
assert.True(t, recorder.HasDirPathFailedCreation("folder1/nested/file.json"))
|
||||
assert.True(t, recorder.HasDirPathFailedCreation("folder2/subfolder/file.json"))
|
||||
|
||||
// Test non-nested paths
|
||||
assert.False(t, recorder.HasDirPathFailedCreation("folder2/file2.json"))
|
||||
assert.False(t, recorder.HasDirPathFailedCreation("folder2/othersubfolder/inside.json"))
|
||||
assert.False(t, recorder.HasDirPathFailedCreation("other/file.json"))
|
||||
assert.False(t, recorder.HasDirPathFailedCreation("folder3/file.json"))
|
||||
assert.False(t, recorder.HasDirPathFailedCreation("file.json"))
|
||||
}
|
||||
|
||||
func TestJobProgressRecorderHasDirPathFailedDeletion(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a progress recorder
|
||||
mockProgressFn := func(ctx context.Context, status provisioning.JobStatus) error {
|
||||
return nil
|
||||
}
|
||||
recorder := newJobProgressRecorder(mockProgressFn).(*jobProgressRecorder)
|
||||
|
||||
// Add failed deletions via Record
|
||||
recorder.Record(ctx, JobResourceResult{
|
||||
Path: "folder1/file1.json",
|
||||
Action: repository.FileActionDeleted,
|
||||
Error: assert.AnError,
|
||||
})
|
||||
|
||||
recorder.Record(ctx, JobResourceResult{
|
||||
Path: "folder2/subfolder/file2.json",
|
||||
Action: repository.FileActionDeleted,
|
||||
Error: assert.AnError,
|
||||
})
|
||||
|
||||
recorder.Record(ctx, JobResourceResult{
|
||||
Path: "folder3/nested/deep/file3.json",
|
||||
Action: repository.FileActionDeleted,
|
||||
Error: assert.AnError,
|
||||
})
|
||||
|
||||
// Test folder paths with failed deletions
|
||||
assert.True(t, recorder.HasDirPathFailedDeletion("folder1/"))
|
||||
assert.True(t, recorder.HasDirPathFailedDeletion("folder2/"))
|
||||
assert.True(t, recorder.HasDirPathFailedDeletion("folder2/subfolder/"))
|
||||
assert.True(t, recorder.HasDirPathFailedDeletion("folder3/"))
|
||||
assert.True(t, recorder.HasDirPathFailedDeletion("folder3/nested/"))
|
||||
assert.True(t, recorder.HasDirPathFailedDeletion("folder3/nested/deep/"))
|
||||
|
||||
// Test folder paths without failed deletions
|
||||
assert.False(t, recorder.HasDirPathFailedDeletion("other/"))
|
||||
assert.False(t, recorder.HasDirPathFailedDeletion("different/"))
|
||||
assert.False(t, recorder.HasDirPathFailedDeletion("folder2/othersubfolder/"))
|
||||
assert.False(t, recorder.HasDirPathFailedDeletion("folder2/subfolder/othersubfolder/"))
|
||||
assert.False(t, recorder.HasDirPathFailedDeletion("folder3/nested/anotherdeep/"))
|
||||
assert.False(t, recorder.HasDirPathFailedDeletion("folder3/nested/deep/insidedeep/"))
|
||||
}
|
||||
|
||||
func TestJobProgressRecorderResetResults(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a progress recorder
|
||||
mockProgressFn := func(ctx context.Context, status provisioning.JobStatus) error {
|
||||
return nil
|
||||
}
|
||||
recorder := newJobProgressRecorder(mockProgressFn).(*jobProgressRecorder)
|
||||
|
||||
// Add some data via Record
|
||||
pathErr := &resources.PathCreationError{
|
||||
Path: "folder1/",
|
||||
Err: assert.AnError,
|
||||
}
|
||||
recorder.Record(ctx, JobResourceResult{
|
||||
Path: "folder1/file.json",
|
||||
Action: repository.FileActionCreated,
|
||||
Error: pathErr,
|
||||
})
|
||||
|
||||
recorder.Record(ctx, JobResourceResult{
|
||||
Path: "folder2/file.json",
|
||||
Action: repository.FileActionDeleted,
|
||||
Error: assert.AnError,
|
||||
})
|
||||
|
||||
// Verify data is stored
|
||||
recorder.mu.RLock()
|
||||
assert.Len(t, recorder.failedCreations, 1)
|
||||
assert.Len(t, recorder.failedDeletions, 1)
|
||||
recorder.mu.RUnlock()
|
||||
|
||||
// Reset results
|
||||
recorder.ResetResults()
|
||||
|
||||
// Verify data is cleared
|
||||
recorder.mu.RLock()
|
||||
assert.Nil(t, recorder.failedCreations)
|
||||
assert.Nil(t, recorder.failedDeletions)
|
||||
recorder.mu.RUnlock()
|
||||
}
|
||||
|
||||
func TestJobProgressRecorderIgnoredActionsDontCountAsErrors(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a progress recorder
|
||||
mockProgressFn := func(ctx context.Context, status provisioning.JobStatus) error {
|
||||
return nil
|
||||
}
|
||||
recorder := newJobProgressRecorder(mockProgressFn).(*jobProgressRecorder)
|
||||
|
||||
// Record an ignored action with error
|
||||
recorder.Record(ctx, JobResourceResult{
|
||||
Path: "folder1/file1.json",
|
||||
Action: repository.FileActionIgnored,
|
||||
Error: assert.AnError,
|
||||
})
|
||||
|
||||
// Record a real error for comparison
|
||||
recorder.Record(ctx, JobResourceResult{
|
||||
Path: "folder2/file2.json",
|
||||
Action: repository.FileActionCreated,
|
||||
Error: assert.AnError,
|
||||
})
|
||||
|
||||
// Verify error count doesn't include ignored actions
|
||||
recorder.mu.RLock()
|
||||
assert.Equal(t, 1, recorder.errorCount, "ignored actions should not be counted as errors")
|
||||
assert.Len(t, recorder.errors, 1, "ignored action errors should not be in error list")
|
||||
recorder.mu.RUnlock()
|
||||
}
|
||||
|
||||
@@ -29,10 +29,6 @@ type JobProgressRecorder interface {
|
||||
StrictMaxErrors(maxErrors int)
|
||||
SetRefURLs(ctx context.Context, refURLs *provisioning.RepositoryURLs)
|
||||
Complete(ctx context.Context, err error) provisioning.JobStatus
|
||||
// HasDirPathFailedCreation checks if a path has any folder creations that failed
|
||||
HasDirPathFailedCreation(path string) bool
|
||||
// HasDirPathFailedDeletion checks if a folderPath has any folder deletions that failed
|
||||
HasDirPathFailedDeletion(folderPath string) bool
|
||||
}
|
||||
|
||||
// Worker is a worker that can process a job
|
||||
|
||||
@@ -75,47 +75,11 @@ func FullSync(
|
||||
return applyChanges(ctx, changes, clients, repositoryResources, progress, tracer, maxSyncWorkers, metrics)
|
||||
}
|
||||
|
||||
// shouldSkipChange checks if a change should be skipped based on previous failures on parent/child folders.
|
||||
// If there is a previous failure on the path, we don't need to process the change as it will fail anyway.
|
||||
func shouldSkipChange(ctx context.Context, change ResourceFileChange, progress jobs.JobProgressRecorder, tracer tracing.Tracer) bool {
|
||||
if change.Action != repository.FileActionDeleted && progress.HasDirPathFailedCreation(change.Path) {
|
||||
skipCtx, skipSpan := tracer.Start(ctx, "provisioning.sync.full.apply_changes.skip_nested_resource")
|
||||
skipSpan.SetAttributes(attribute.String("path", change.Path))
|
||||
progress.Record(skipCtx, jobs.JobResourceResult{
|
||||
Path: change.Path,
|
||||
Action: repository.FileActionIgnored,
|
||||
Warning: fmt.Errorf("resource was not processed because the parent folder could not be created"),
|
||||
})
|
||||
skipSpan.End()
|
||||
return true
|
||||
}
|
||||
|
||||
if change.Action == repository.FileActionDeleted && safepath.IsDir(change.Path) && progress.HasDirPathFailedDeletion(change.Path) {
|
||||
skipCtx, skipSpan := tracer.Start(ctx, "provisioning.sync.full.apply_changes.skip_folder_with_failed_deletions")
|
||||
skipSpan.SetAttributes(attribute.String("path", change.Path))
|
||||
progress.Record(skipCtx, jobs.JobResourceResult{
|
||||
Path: change.Path,
|
||||
Action: repository.FileActionIgnored,
|
||||
Group: resources.FolderKind.Group,
|
||||
Kind: resources.FolderKind.Kind,
|
||||
Warning: fmt.Errorf("folder was not processed because children resources in its path could not be deleted"),
|
||||
})
|
||||
skipSpan.End()
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func applyChange(ctx context.Context, change ResourceFileChange, clients resources.ResourceClients, repositoryResources resources.RepositoryResources, progress jobs.JobProgressRecorder, tracer tracing.Tracer) {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if shouldSkipChange(ctx, change, progress, tracer) {
|
||||
return
|
||||
}
|
||||
|
||||
if change.Action == repository.FileActionDeleted {
|
||||
deleteCtx, deleteSpan := tracer.Start(ctx, "provisioning.sync.full.apply_changes.delete")
|
||||
result := jobs.JobResourceResult{
|
||||
@@ -174,7 +138,6 @@ func applyChange(ctx context.Context, change ResourceFileChange, clients resourc
|
||||
ensureFolderSpan.RecordError(err)
|
||||
ensureFolderSpan.End()
|
||||
progress.Record(ctx, result)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -290,6 +253,8 @@ func applyChanges(ctx context.Context, changes []ResourceFileChange, clients res
|
||||
}
|
||||
|
||||
func applyFoldersSerially(ctx context.Context, folders []ResourceFileChange, clients resources.ResourceClients, repositoryResources resources.RepositoryResources, progress jobs.JobProgressRecorder, tracer tracing.Tracer) error {
|
||||
logger := logging.FromContext(ctx)
|
||||
|
||||
for _, folder := range folders {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
@@ -299,9 +264,23 @@ func applyFoldersSerially(ctx context.Context, folders []ResourceFileChange, cli
|
||||
return err
|
||||
}
|
||||
|
||||
wrapWithTimeout(ctx, 15*time.Second, func(timeoutCtx context.Context) {
|
||||
applyChange(timeoutCtx, folder, clients, repositoryResources, progress, tracer)
|
||||
})
|
||||
folderCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
|
||||
applyChange(folderCtx, folder, clients, repositoryResources, progress, tracer)
|
||||
|
||||
if folderCtx.Err() == context.DeadlineExceeded {
|
||||
logger.Error("operation timed out after 15 seconds", "path", folder.Path, "action", folder.Action)
|
||||
|
||||
recordCtx, recordCancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
progress.Record(recordCtx, jobs.JobResourceResult{
|
||||
Path: folder.Path,
|
||||
Action: folder.Action,
|
||||
Error: fmt.Errorf("operation timed out after 15 seconds"),
|
||||
})
|
||||
recordCancel()
|
||||
}
|
||||
|
||||
cancel()
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -339,9 +318,7 @@ loop:
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
|
||||
wrapWithTimeout(ctx, 15*time.Second, func(timeoutCtx context.Context) {
|
||||
applyChange(timeoutCtx, change, clients, repositoryResources, progress, tracer)
|
||||
})
|
||||
applyChangeWithTimeout(ctx, change, clients, repositoryResources, progress, tracer, logger)
|
||||
}(change)
|
||||
}
|
||||
|
||||
@@ -354,10 +331,21 @@ loop:
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// wrapWithTimeout wraps a function call with a timeout context
|
||||
func wrapWithTimeout(ctx context.Context, timeout time.Duration, fn func(context.Context)) {
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
func applyChangeWithTimeout(ctx context.Context, change ResourceFileChange, clients resources.ResourceClients, repositoryResources resources.RepositoryResources, progress jobs.JobProgressRecorder, tracer tracing.Tracer, logger logging.Logger) {
|
||||
changeCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
fn(timeoutCtx)
|
||||
applyChange(changeCtx, change, clients, repositoryResources, progress, tracer)
|
||||
|
||||
if changeCtx.Err() == context.DeadlineExceeded {
|
||||
logger.Error("operation timed out after 15 seconds", "path", change.Path, "action", change.Action)
|
||||
|
||||
recordCtx, recordCancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
progress.Record(recordCtx, jobs.JobResourceResult{
|
||||
Path: change.Path,
|
||||
Action: change.Action,
|
||||
Error: fmt.Errorf("operation timed out after 15 seconds"),
|
||||
})
|
||||
recordCancel()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,432 +0,0 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
dynamicfake "k8s.io/client-go/dynamic/fake"
|
||||
k8testing "k8s.io/client-go/testing"
|
||||
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
|
||||
)
|
||||
|
||||
/*
|
||||
TestFullSync_HierarchicalErrorHandling tests the hierarchical error handling behavior:
|
||||
|
||||
FOLDER CREATION FAILURES:
|
||||
- When a folder fails to be created with PathCreationError, all nested resources are skipped
|
||||
- Nested resources are recorded with FileActionIgnored and error "folder was not processed because children resources in its path could not be deleted"
|
||||
- Only the folder creation error counts toward error limits
|
||||
- Nested resource skips do NOT count toward error limits
|
||||
|
||||
FOLDER DELETION FAILURES:
|
||||
- When a file deletion fails, it's tracked in failedDeletions
|
||||
- When cleaning up folders, we check HasDirPathFailedDeletion()
|
||||
- If children failed to delete, folder deletion is skipped with FileActionIgnored
|
||||
- This prevents orphaning resources that still exist
|
||||
|
||||
DELETIONS NOT AFFECTED BY CREATION FAILURES:
|
||||
- If a folder creation fails, deletion operations for resources in that folder still proceed
|
||||
- This is because the resource might already exist from a previous sync
|
||||
- Only creations/updates/renames are affected by failed folder creation
|
||||
|
||||
AUTOMATIC TRACKING:
|
||||
- Record() automatically detects PathCreationError and adds to failedCreations
|
||||
- Record() automatically detects deletion failures and adds to failedDeletions
|
||||
- No manual calls to AddFailedCreation/AddFailedDeletion needed
|
||||
*/
|
||||
func TestFullSync_HierarchicalErrorHandling(t *testing.T) { // nolint:gocyclo
|
||||
tests := []struct {
|
||||
name string
|
||||
setupMocks func(*repository.MockRepository, *resources.MockRepositoryResources, *resources.MockResourceClients, *jobs.MockJobProgressRecorder, *dynamicfake.FakeDynamicClient)
|
||||
changes []ResourceFileChange
|
||||
description string
|
||||
expectError bool
|
||||
errorContains string
|
||||
}{
|
||||
{
|
||||
name: "folder creation fails, nested file skipped",
|
||||
description: "When folder1/ fails to create, folder1/file.json should be skipped with FileActionIgnored",
|
||||
changes: []ResourceFileChange{
|
||||
{Path: "folder1/file.json", Action: repository.FileActionCreated},
|
||||
},
|
||||
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, _ *dynamicfake.FakeDynamicClient) {
|
||||
// First, check if nested under failed creation - not yet
|
||||
progress.On("HasDirPathFailedCreation", "folder1/file.json").Return(false).Once()
|
||||
|
||||
// WriteResourceFromFile fails with PathCreationError for folder1/
|
||||
folderErr := &resources.PathCreationError{Path: "folder1/", Err: fmt.Errorf("permission denied")}
|
||||
repoResources.On("WriteResourceFromFile", mock.Anything, "folder1/file.json", "").
|
||||
Return("", schema.GroupVersionKind{}, folderErr).Once()
|
||||
|
||||
// File will be recorded with error, triggering automatic tracking of folder1/ failure
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == "folder1/file.json" && r.Error != nil && r.Action == repository.FileActionCreated
|
||||
})).Return().Once()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "folder creation fails, multiple nested resources skipped",
|
||||
description: "When folder1/ fails to create, all nested resources (subfolder, files) are skipped",
|
||||
changes: []ResourceFileChange{
|
||||
{Path: "folder1/file1.json", Action: repository.FileActionCreated},
|
||||
{Path: "folder1/subfolder/file2.json", Action: repository.FileActionCreated},
|
||||
{Path: "folder1/file3.json", Action: repository.FileActionCreated},
|
||||
},
|
||||
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, _ *dynamicfake.FakeDynamicClient) {
|
||||
// First file triggers folder creation failure
|
||||
progress.On("HasDirPathFailedCreation", "folder1/file1.json").Return(false).Once()
|
||||
folderErr := &resources.PathCreationError{Path: "folder1/", Err: fmt.Errorf("permission denied")}
|
||||
repoResources.On("WriteResourceFromFile", mock.Anything, "folder1/file1.json", "").
|
||||
Return("", schema.GroupVersionKind{}, folderErr).Once()
|
||||
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == "folder1/file1.json" && r.Error != nil
|
||||
})).Return().Once()
|
||||
|
||||
// Subsequent files in same folder are skipped
|
||||
progress.On("HasDirPathFailedCreation", "folder1/subfolder/file2.json").Return(true).Once()
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == "folder1/subfolder/file2.json" &&
|
||||
r.Action == repository.FileActionIgnored &&
|
||||
r.Warning != nil &&
|
||||
r.Warning.Error() == "resource was not processed because the parent folder could not be created"
|
||||
})).Return().Once()
|
||||
|
||||
progress.On("HasDirPathFailedCreation", "folder1/file3.json").Return(true).Once()
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == "folder1/file3.json" &&
|
||||
r.Action == repository.FileActionIgnored &&
|
||||
r.Warning != nil &&
|
||||
r.Warning.Error() == "resource was not processed because the parent folder could not be created"
|
||||
})).Return().Once()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "file deletion failure tracked",
|
||||
description: "When a file deletion fails, it's automatically tracked in failedDeletions",
|
||||
changes: []ResourceFileChange{
|
||||
{
|
||||
Path: "folder1/file.json",
|
||||
Action: repository.FileActionDeleted,
|
||||
Existing: &provisioning.ResourceListItem{
|
||||
Name: "file1",
|
||||
Group: "dashboard.grafana.app",
|
||||
Resource: "dashboards",
|
||||
},
|
||||
},
|
||||
},
|
||||
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, dynamicClient *dynamicfake.FakeDynamicClient) {
|
||||
gvk := schema.GroupVersionKind{Group: "dashboard.grafana.app", Kind: "Dashboard", Version: "v1"}
|
||||
gvr := schema.GroupVersionResource{Group: "dashboard.grafana.app", Resource: "dashboards", Version: "v1"}
|
||||
|
||||
clients.On("ForResource", mock.Anything, mock.MatchedBy(func(gvr schema.GroupVersionResource) bool {
|
||||
return gvr.Group == "dashboard.grafana.app"
|
||||
})).Return(dynamicClient.Resource(gvr), gvk, nil)
|
||||
|
||||
// File deletion fails
|
||||
dynamicClient.PrependReactor("delete", "dashboards", func(action k8testing.Action) (bool, runtime.Object, error) {
|
||||
return true, nil, fmt.Errorf("permission denied")
|
||||
})
|
||||
|
||||
// File deletion recorded with error, automatically tracked in failedDeletions
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == "folder1/file.json" &&
|
||||
r.Action == repository.FileActionDeleted &&
|
||||
r.Error != nil
|
||||
})).Return().Once()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "deletion proceeds despite creation failure",
|
||||
description: "When folder1/ fails to create, deletion of folder1/file2.json still proceeds (resource might exist from previous sync)",
|
||||
changes: []ResourceFileChange{
|
||||
{Path: "folder1/file1.json", Action: repository.FileActionCreated},
|
||||
{
|
||||
Path: "folder1/file2.json",
|
||||
Action: repository.FileActionDeleted,
|
||||
Existing: &provisioning.ResourceListItem{
|
||||
Name: "file2",
|
||||
Group: "dashboard.grafana.app",
|
||||
Resource: "dashboards",
|
||||
},
|
||||
},
|
||||
},
|
||||
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, dynamicClient *dynamicfake.FakeDynamicClient) {
|
||||
// Creation fails
|
||||
progress.On("HasDirPathFailedCreation", "folder1/file1.json").Return(false).Once()
|
||||
folderErr := &resources.PathCreationError{Path: "folder1/", Err: fmt.Errorf("permission denied")}
|
||||
repoResources.On("WriteResourceFromFile", mock.Anything, "folder1/file1.json", "").
|
||||
Return("", schema.GroupVersionKind{}, folderErr).Once()
|
||||
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == "folder1/file1.json" && r.Error != nil
|
||||
})).Return().Once()
|
||||
|
||||
// Deletion proceeds (NOT checking HasDirPathFailedCreation for deletions)
|
||||
// Note: deletion will fail because resource doesn't exist, but that's fine for this test
|
||||
gvk := schema.GroupVersionKind{Group: "dashboard.grafana.app", Kind: "Dashboard", Version: "v1"}
|
||||
gvr := schema.GroupVersionResource{Group: "dashboard.grafana.app", Resource: "dashboards", Version: "v1"}
|
||||
|
||||
clients.On("ForResource", mock.Anything, mock.MatchedBy(func(gvr schema.GroupVersionResource) bool {
|
||||
return gvr.Group == "dashboard.grafana.app"
|
||||
})).Return(dynamicClient.Resource(gvr), gvk, nil)
|
||||
|
||||
// Record deletion attempt (will have error since resource doesn't exist, but that's ok)
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == "folder1/file2.json" &&
|
||||
r.Action == repository.FileActionDeleted
|
||||
// Not checking r.Error because resource doesn't exist in fake client
|
||||
})).Return().Once()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multi-level nesting - all skipped",
|
||||
description: "When level1/ fails, level1/level2/level3/file.json is also skipped",
|
||||
changes: []ResourceFileChange{
|
||||
{Path: "level1/file1.json", Action: repository.FileActionCreated},
|
||||
{Path: "level1/level2/file2.json", Action: repository.FileActionCreated},
|
||||
{Path: "level1/level2/level3/file3.json", Action: repository.FileActionCreated},
|
||||
},
|
||||
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, _ *dynamicfake.FakeDynamicClient) {
|
||||
// First file triggers level1/ failure
|
||||
progress.On("HasDirPathFailedCreation", "level1/file1.json").Return(false).Once()
|
||||
folderErr := &resources.PathCreationError{Path: "level1/", Err: fmt.Errorf("permission denied")}
|
||||
repoResources.On("WriteResourceFromFile", mock.Anything, "level1/file1.json", "").
|
||||
Return("", schema.GroupVersionKind{}, folderErr).Once()
|
||||
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == "level1/file1.json" && r.Error != nil
|
||||
})).Return().Once()
|
||||
|
||||
// All nested files are skipped
|
||||
for _, path := range []string{"level1/level2/file2.json", "level1/level2/level3/file3.json"} {
|
||||
progress.On("HasDirPathFailedCreation", path).Return(true).Once()
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == path && r.Action == repository.FileActionIgnored
|
||||
})).Return().Once()
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mixed success and failure",
|
||||
description: "When success/ works and failure/ fails, only failure/* are skipped",
|
||||
changes: []ResourceFileChange{
|
||||
{Path: "success/file1.json", Action: repository.FileActionCreated},
|
||||
{Path: "failure/file2.json", Action: repository.FileActionCreated},
|
||||
{Path: "failure/nested/file3.json", Action: repository.FileActionCreated},
|
||||
},
|
||||
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, _ *dynamicfake.FakeDynamicClient) {
|
||||
// Success path works
|
||||
progress.On("HasDirPathFailedCreation", "success/file1.json").Return(false).Once()
|
||||
repoResources.On("WriteResourceFromFile", mock.Anything, "success/file1.json", "").
|
||||
Return("resource1", schema.GroupVersionKind{Kind: "Dashboard"}, nil).Once()
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == "success/file1.json" && r.Error == nil
|
||||
})).Return().Once()
|
||||
|
||||
// Failure path fails
|
||||
progress.On("HasDirPathFailedCreation", "failure/file2.json").Return(false).Once()
|
||||
folderErr := &resources.PathCreationError{Path: "failure/", Err: fmt.Errorf("disk full")}
|
||||
repoResources.On("WriteResourceFromFile", mock.Anything, "failure/file2.json", "").
|
||||
Return("", schema.GroupVersionKind{}, folderErr).Once()
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == "failure/file2.json" && r.Error != nil
|
||||
})).Return().Once()
|
||||
|
||||
// Nested file in failure path is skipped
|
||||
progress.On("HasDirPathFailedCreation", "failure/nested/file3.json").Return(true).Once()
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == "failure/nested/file3.json" && r.Action == repository.FileActionIgnored
|
||||
})).Return().Once()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "folder creation fails with explicit folder in changes",
|
||||
description: "When folder1/ is explicitly in changes and fails to create, all nested resources (subfolders and files) are skipped",
|
||||
changes: []ResourceFileChange{
|
||||
{Path: "folder1/", Action: repository.FileActionCreated},
|
||||
{Path: "folder1/subfolder/", Action: repository.FileActionCreated},
|
||||
{Path: "folder1/file1.json", Action: repository.FileActionCreated},
|
||||
{Path: "folder1/subfolder/file2.json", Action: repository.FileActionCreated},
|
||||
},
|
||||
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, _ *dynamicfake.FakeDynamicClient) {
|
||||
progress.On("HasDirPathFailedCreation", "folder1/").Return(false).Once()
|
||||
folderErr := &resources.PathCreationError{Path: "folder1/", Err: fmt.Errorf("permission denied")}
|
||||
repoResources.On("EnsureFolderPathExist", mock.Anything, "folder1/").Return("", folderErr).Once()
|
||||
|
||||
progress.On("HasDirPathFailedCreation", "folder1/subfolder/").Return(true).Once()
|
||||
progress.On("HasDirPathFailedCreation", "folder1/file1.json").Return(true).Once()
|
||||
progress.On("HasDirPathFailedCreation", "folder1/subfolder/file2.json").Return(true).Once()
|
||||
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == "folder1/" && r.Error != nil
|
||||
})).Return().Once()
|
||||
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == "folder1/subfolder/" && r.Action == repository.FileActionIgnored
|
||||
})).Return().Once()
|
||||
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == "folder1/file1.json" && r.Action == repository.FileActionIgnored
|
||||
})).Return().Once()
|
||||
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == "folder1/subfolder/file2.json" && r.Action == repository.FileActionIgnored
|
||||
})).Return().Once()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "folder deletion prevented when child deletion fails",
|
||||
description: "When a file deletion fails, folder deletion is skipped with FileActionIgnored to prevent orphaning resources",
|
||||
changes: []ResourceFileChange{
|
||||
{
|
||||
Path: "folder1/file1.json",
|
||||
Action: repository.FileActionDeleted,
|
||||
Existing: &provisioning.ResourceListItem{Name: "file1", Group: "dashboard.grafana.app", Resource: "dashboards"},
|
||||
},
|
||||
{Path: "folder1/", Action: repository.FileActionDeleted, Existing: &provisioning.ResourceListItem{Name: "folder1", Group: "folder.grafana.app", Resource: "Folder"}},
|
||||
},
|
||||
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, dynamicClient *dynamicfake.FakeDynamicClient) {
|
||||
gvk := schema.GroupVersionKind{Group: "dashboard.grafana.app", Kind: "Dashboard", Version: "v1"}
|
||||
gvr := schema.GroupVersionResource{Group: "dashboard.grafana.app", Resource: "dashboards", Version: "v1"}
|
||||
|
||||
clients.On("ForResource", mock.Anything, mock.MatchedBy(func(gvr schema.GroupVersionResource) bool {
|
||||
return gvr.Group == "dashboard.grafana.app"
|
||||
})).Return(dynamicClient.Resource(gvr), gvk, nil)
|
||||
|
||||
dynamicClient.PrependReactor("delete", "dashboards", func(action k8testing.Action) (bool, runtime.Object, error) {
|
||||
return true, nil, fmt.Errorf("permission denied")
|
||||
})
|
||||
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == "folder1/file1.json" && r.Error != nil
|
||||
})).Return().Once()
|
||||
|
||||
progress.On("HasDirPathFailedDeletion", "folder1/").Return(true).Once()
|
||||
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == "folder1/" && r.Action == repository.FileActionIgnored
|
||||
})).Return().Once()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple folder deletion failures",
|
||||
description: "When multiple independent folders have child deletion failures, all folder deletions are skipped",
|
||||
changes: []ResourceFileChange{
|
||||
{Path: "folder1/file1.json", Action: repository.FileActionDeleted, Existing: &provisioning.ResourceListItem{Name: "file1", Group: "dashboard.grafana.app", Resource: "dashboards"}},
|
||||
{Path: "folder1/", Action: repository.FileActionDeleted},
|
||||
{Path: "folder2/file2.json", Action: repository.FileActionDeleted, Existing: &provisioning.ResourceListItem{Name: "file2", Group: "dashboard.grafana.app", Resource: "dashboards"}},
|
||||
{Path: "folder2/", Action: repository.FileActionDeleted},
|
||||
},
|
||||
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, dynamicClient *dynamicfake.FakeDynamicClient) {
|
||||
gvk := schema.GroupVersionKind{Group: "dashboard.grafana.app", Kind: "Dashboard", Version: "v1"}
|
||||
gvr := schema.GroupVersionResource{Group: "dashboard.grafana.app", Resource: "dashboards", Version: "v1"}
|
||||
clients.On("ForResource", mock.Anything, mock.MatchedBy(func(gvr schema.GroupVersionResource) bool {
|
||||
return gvr.Group == "dashboard.grafana.app"
|
||||
})).Return(dynamicClient.Resource(gvr), gvk, nil)
|
||||
|
||||
dynamicClient.PrependReactor("delete", "dashboards", func(action k8testing.Action) (bool, runtime.Object, error) {
|
||||
return true, nil, fmt.Errorf("permission denied")
|
||||
})
|
||||
|
||||
for _, path := range []string{"folder1/file1.json", "folder2/file2.json"} {
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == path && r.Error != nil
|
||||
})).Return().Once()
|
||||
}
|
||||
|
||||
progress.On("HasDirPathFailedDeletion", "folder1/").Return(true).Once()
|
||||
progress.On("HasDirPathFailedDeletion", "folder2/").Return(true).Once()
|
||||
|
||||
for _, path := range []string{"folder1/", "folder2/"} {
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == path && r.Action == repository.FileActionIgnored
|
||||
})).Return().Once()
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nested subfolder deletion failure",
|
||||
description: "When a file deletion fails in a nested subfolder, both the subfolder and parent folder deletions are skipped",
|
||||
changes: []ResourceFileChange{
|
||||
{Path: "parent/subfolder/file.json", Action: repository.FileActionDeleted, Existing: &provisioning.ResourceListItem{Name: "file1", Group: "dashboard.grafana.app", Resource: "dashboards"}},
|
||||
{Path: "parent/subfolder/", Action: repository.FileActionDeleted, Existing: &provisioning.ResourceListItem{Name: "subfolder", Group: "folder.grafana.app", Resource: "Folder"}},
|
||||
{Path: "parent/", Action: repository.FileActionDeleted, Existing: &provisioning.ResourceListItem{Name: "parent", Group: "folder.grafana.app", Resource: "Folder"}},
|
||||
},
|
||||
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, dynamicClient *dynamicfake.FakeDynamicClient) {
|
||||
gvk := schema.GroupVersionKind{Group: "dashboard.grafana.app", Kind: "Dashboard", Version: "v1"}
|
||||
gvr := schema.GroupVersionResource{Group: "dashboard.grafana.app", Resource: "dashboards", Version: "v1"}
|
||||
clients.On("ForResource", mock.Anything, mock.MatchedBy(func(gvr schema.GroupVersionResource) bool {
|
||||
return gvr.Group == "dashboard.grafana.app"
|
||||
})).Return(dynamicClient.Resource(gvr), gvk, nil)
|
||||
|
||||
dynamicClient.PrependReactor("delete", "dashboards", func(action k8testing.Action) (bool, runtime.Object, error) {
|
||||
return true, nil, fmt.Errorf("permission denied")
|
||||
})
|
||||
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == "parent/subfolder/file.json" && r.Error != nil
|
||||
})).Return().Once()
|
||||
|
||||
progress.On("HasDirPathFailedDeletion", "parent/subfolder/").Return(true).Once()
|
||||
progress.On("HasDirPathFailedDeletion", "parent/").Return(true).Once()
|
||||
|
||||
for _, path := range []string{"parent/subfolder/", "parent/"} {
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == path && r.Action == repository.FileActionIgnored
|
||||
})).Return().Once()
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
scheme := runtime.NewScheme()
|
||||
dynamicClient := dynamicfake.NewSimpleDynamicClient(scheme)
|
||||
|
||||
repo := repository.NewMockRepository(t)
|
||||
repoResources := resources.NewMockRepositoryResources(t)
|
||||
clients := resources.NewMockResourceClients(t)
|
||||
progress := jobs.NewMockJobProgressRecorder(t)
|
||||
compareFn := NewMockCompareFn(t)
|
||||
|
||||
repo.On("Config").Return(&provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-repo"},
|
||||
Spec: provisioning.RepositorySpec{Title: "Test Repo"},
|
||||
})
|
||||
|
||||
tt.setupMocks(repo, repoResources, clients, progress, dynamicClient)
|
||||
|
||||
compareFn.On("Execute", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(tt.changes, nil)
|
||||
progress.On("SetTotal", mock.Anything, len(tt.changes)).Return()
|
||||
progress.On("TooManyErrors").Return(nil).Maybe()
|
||||
|
||||
err := FullSync(context.Background(), repo, compareFn.Execute, clients, "ref", repoResources, progress, tracing.NewNoopTracerService(), 10, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
|
||||
|
||||
if tt.expectError {
|
||||
require.Error(t, err)
|
||||
if tt.errorContains != "" {
|
||||
require.Contains(t, err.Error(), tt.errorContains)
|
||||
}
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
progress.AssertExpectations(t)
|
||||
repoResources.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -213,10 +213,6 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo
|
||||
return nil
|
||||
})
|
||||
|
||||
progress.On("HasDirPathFailedCreation", mock.MatchedBy(func(path string) bool {
|
||||
return path == "dashboards/one.json" || path == "dashboards/two.json" || path == "dashboards/three.json"
|
||||
})).Return(false).Maybe()
|
||||
|
||||
repoResources.On("WriteResourceFromFile", mock.Anything, mock.MatchedBy(func(path string) bool {
|
||||
return path == "dashboards/one.json" || path == "dashboards/two.json" || path == "dashboards/three.json"
|
||||
}), "").Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil).Maybe()
|
||||
@@ -239,7 +235,6 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo
|
||||
},
|
||||
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
|
||||
progress.On("TooManyErrors").Return(nil)
|
||||
progress.On("HasDirPathFailedCreation", "dashboards/test.json").Return(false)
|
||||
|
||||
repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", "").
|
||||
Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil)
|
||||
@@ -264,7 +259,6 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo
|
||||
},
|
||||
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
|
||||
progress.On("TooManyErrors").Return(nil)
|
||||
progress.On("HasDirPathFailedCreation", "dashboards/test.json").Return(false)
|
||||
|
||||
repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", "").
|
||||
Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, fmt.Errorf("write error"))
|
||||
@@ -291,7 +285,6 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo
|
||||
},
|
||||
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
|
||||
progress.On("TooManyErrors").Return(nil)
|
||||
progress.On("HasDirPathFailedCreation", "dashboards/test.json").Return(false)
|
||||
|
||||
repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", "").
|
||||
Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil)
|
||||
@@ -316,7 +309,6 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo
|
||||
},
|
||||
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
|
||||
progress.On("TooManyErrors").Return(nil)
|
||||
progress.On("HasDirPathFailedCreation", "dashboards/test.json").Return(false)
|
||||
|
||||
repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", "").
|
||||
Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, fmt.Errorf("write error"))
|
||||
@@ -343,7 +335,6 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo
|
||||
},
|
||||
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
|
||||
progress.On("TooManyErrors").Return(nil)
|
||||
progress.On("HasDirPathFailedCreation", "one/two/three/").Return(false)
|
||||
|
||||
repoResources.On("EnsureFolderPathExist", mock.Anything, "one/two/three/").Return("some-folder", nil)
|
||||
progress.On("Record", mock.Anything, jobs.JobResourceResult{
|
||||
@@ -366,7 +357,6 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo
|
||||
},
|
||||
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
|
||||
progress.On("TooManyErrors").Return(nil)
|
||||
progress.On("HasDirPathFailedCreation", "one/two/three/").Return(false)
|
||||
|
||||
repoResources.On(
|
||||
"EnsureFolderPathExist",
|
||||
@@ -591,7 +581,6 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo
|
||||
},
|
||||
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
|
||||
progress.On("TooManyErrors").Return(nil)
|
||||
progress.On("HasDirPathFailedDeletion", "to-be-deleted/").Return(false)
|
||||
|
||||
scheme := runtime.NewScheme()
|
||||
require.NoError(t, metav1.AddMetaToScheme(scheme))
|
||||
@@ -651,7 +640,6 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo
|
||||
},
|
||||
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
|
||||
progress.On("TooManyErrors").Return(nil)
|
||||
progress.On("HasDirPathFailedDeletion", "to-be-deleted/").Return(false)
|
||||
|
||||
scheme := runtime.NewScheme()
|
||||
require.NoError(t, metav1.AddMetaToScheme(scheme))
|
||||
@@ -707,7 +695,6 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo
|
||||
},
|
||||
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
|
||||
progress.On("TooManyErrors").Return(nil)
|
||||
progress.On("HasDirPathFailedCreation", "dashboards/slow.json").Return(false)
|
||||
|
||||
repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/slow.json", "").
|
||||
Run(func(args mock.Arguments) {
|
||||
@@ -721,13 +708,19 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo
|
||||
}).
|
||||
Return("", schema.GroupVersionKind{}, context.DeadlineExceeded)
|
||||
|
||||
// applyChange records the error from WriteResourceFromFile
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
|
||||
return result.Action == repository.FileActionCreated &&
|
||||
result.Path == "dashboards/slow.json" &&
|
||||
result.Error != nil &&
|
||||
result.Error.Error() == "writing resource from file dashboards/slow.json: context deadline exceeded"
|
||||
})).Return().Once()
|
||||
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
|
||||
return result.Action == repository.FileActionCreated &&
|
||||
result.Path == "dashboards/slow.json" &&
|
||||
result.Error != nil &&
|
||||
result.Error.Error() == "operation timed out after 15 seconds"
|
||||
})).Return().Once()
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ func IncrementalSync(ctx context.Context, repo repository.Versioned, previousRef
|
||||
if len(affectedFolders) > 0 {
|
||||
cleanupStart := time.Now()
|
||||
span.AddEvent("checking if impacted folders should be deleted", trace.WithAttributes(attribute.Int("affected_folders", len(affectedFolders))))
|
||||
err := cleanupOrphanedFolders(ctx, repo, affectedFolders, repositoryResources, tracer, progress)
|
||||
err := cleanupOrphanedFolders(ctx, repo, affectedFolders, repositoryResources, tracer)
|
||||
metrics.RecordIncrementalSyncPhase(jobs.IncrementalSyncPhaseCleanup, time.Since(cleanupStart))
|
||||
if err != nil {
|
||||
return tracing.Error(span, fmt.Errorf("cleanup orphaned folders: %w", err))
|
||||
@@ -85,20 +85,6 @@ func applyIncrementalChanges(ctx context.Context, diff []repository.VersionedFil
|
||||
return nil, tracing.Error(span, err)
|
||||
}
|
||||
|
||||
// Check if this resource is nested under a failed folder creation
|
||||
// This only applies to creation/update/rename operations, not deletions
|
||||
if change.Action != repository.FileActionDeleted && progress.HasDirPathFailedCreation(change.Path) {
|
||||
// Skip this resource since its parent folder failed to be created
|
||||
skipCtx, skipSpan := tracer.Start(ctx, "provisioning.sync.incremental.skip_nested_resource")
|
||||
progress.Record(skipCtx, jobs.JobResourceResult{
|
||||
Path: change.Path,
|
||||
Action: repository.FileActionIgnored,
|
||||
Warning: fmt.Errorf("resource was not processed because the parent folder could not be created"),
|
||||
})
|
||||
skipSpan.End()
|
||||
continue
|
||||
}
|
||||
|
||||
if err := resources.IsPathSupported(change.Path); err != nil {
|
||||
ensureFolderCtx, ensureFolderSpan := tracer.Start(ctx, "provisioning.sync.incremental.ensure_folder_path_exist")
|
||||
// Maintain the safe segment for empty folders
|
||||
@@ -112,15 +98,7 @@ func applyIncrementalChanges(ctx context.Context, diff []repository.VersionedFil
|
||||
if err != nil {
|
||||
ensureFolderSpan.RecordError(err)
|
||||
ensureFolderSpan.End()
|
||||
|
||||
progress.Record(ensureFolderCtx, jobs.JobResourceResult{
|
||||
Path: change.Path,
|
||||
Action: repository.FileActionIgnored,
|
||||
Group: resources.FolderKind.Group,
|
||||
Kind: resources.FolderKind.Kind,
|
||||
Error: err,
|
||||
})
|
||||
continue
|
||||
return nil, tracing.Error(span, fmt.Errorf("unable to create empty file folder: %w", err))
|
||||
}
|
||||
|
||||
progress.Record(ensureFolderCtx, jobs.JobResourceResult{
|
||||
@@ -207,7 +185,6 @@ func cleanupOrphanedFolders(
|
||||
affectedFolders map[string]string,
|
||||
repositoryResources resources.RepositoryResources,
|
||||
tracer tracing.Tracer,
|
||||
progress jobs.JobProgressRecorder,
|
||||
) error {
|
||||
ctx, span := tracer.Start(ctx, "provisioning.sync.incremental.cleanup_orphaned_folders")
|
||||
defer span.End()
|
||||
@@ -221,12 +198,6 @@ func cleanupOrphanedFolders(
|
||||
for path, folderName := range affectedFolders {
|
||||
span.SetAttributes(attribute.String("folder", folderName))
|
||||
|
||||
// Check if any resources under this folder failed to delete
|
||||
if progress.HasDirPathFailedDeletion(path) {
|
||||
span.AddEvent("skipping orphaned folder cleanup: a child resource in its path failed to be deleted")
|
||||
continue
|
||||
}
|
||||
|
||||
// if we can no longer find the folder in git, then we can delete it from grafana
|
||||
_, err := readerRepo.Read(ctx, path, "")
|
||||
if err != nil && (errors.Is(err, repository.ErrFileNotFound) || apierrors.IsNotFound(err)) {
|
||||
|
||||
@@ -1,623 +0,0 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
|
||||
)
|
||||
|
||||
/*
|
||||
TestIncrementalSync_HierarchicalErrorHandling tests the hierarchical error handling behavior:
|
||||
|
||||
FOLDER CREATION FAILURES:
|
||||
- When EnsureFolderPathExist fails with PathCreationError, the path is tracked
|
||||
- Subsequent resources under that path are skipped with FileActionIgnored
|
||||
- Only the initial folder creation error counts toward error limits
|
||||
- WriteResourceFromFile can also return PathCreationError for implicit folder creation
|
||||
|
||||
FOLDER DELETION FAILURES (cleanupOrphanedFolders):
|
||||
- When RemoveResourceFromFile fails, path is tracked in failedDeletions
|
||||
- In cleanupOrphanedFolders, HasDirPathFailedDeletion() is checked before RemoveFolder
|
||||
- If children failed to delete, folder cleanup is skipped with a span event
|
||||
|
||||
DELETIONS NOT AFFECTED BY CREATION FAILURES:
|
||||
- HasDirPathFailedCreation is NOT checked for FileActionDeleted
|
||||
- Deletions proceed even if their parent folder failed to be created
|
||||
- This handles cleanup of resources that exist from previous syncs
|
||||
|
||||
RENAME OPERATIONS:
|
||||
- RenameResourceFile can return PathCreationError for the destination folder
|
||||
- Renames are affected by failed destination folder creation
|
||||
- Renames are NOT skipped due to source folder creation failures
|
||||
|
||||
AUTOMATIC TRACKING:
|
||||
- Record() automatically detects PathCreationError via errors.As() and adds to failedCreations
|
||||
- Record() automatically detects FileActionDeleted with error and adds to failedDeletions
|
||||
- No manual tracking calls needed
|
||||
*/
|
||||
func TestIncrementalSync_HierarchicalErrorHandling(t *testing.T) { // nolint:gocyclo
|
||||
tests := []struct {
|
||||
name string
|
||||
setupMocks func(*repository.MockVersioned, *resources.MockRepositoryResources, *jobs.MockJobProgressRecorder)
|
||||
changes []repository.VersionedFileChange
|
||||
previousRef string
|
||||
currentRef string
|
||||
description string
|
||||
expectError bool
|
||||
errorContains string
|
||||
}{
|
||||
{
|
||||
name: "folder creation fails, nested file skipped",
|
||||
description: "When unsupported/ fails to create via EnsureFolderPathExist, nested file is skipped",
|
||||
previousRef: "old-ref",
|
||||
currentRef: "new-ref",
|
||||
changes: []repository.VersionedFileChange{
|
||||
{Action: repository.FileActionCreated, Path: "unsupported/file.txt", Ref: "new-ref"},
|
||||
{Action: repository.FileActionCreated, Path: "unsupported/nested/file2.txt", Ref: "new-ref"},
|
||||
},
|
||||
setupMocks: func(repo *repository.MockVersioned, repoResources *resources.MockRepositoryResources, progress *jobs.MockJobProgressRecorder) {
|
||||
// First file triggers folder creation which fails
|
||||
progress.On("HasDirPathFailedCreation", "unsupported/file.txt").Return(false).Once()
|
||||
folderErr := &resources.PathCreationError{Path: "unsupported/", Err: fmt.Errorf("permission denied")}
|
||||
repoResources.On("EnsureFolderPathExist", mock.Anything, "unsupported/").Return("", folderErr).Once()
|
||||
|
||||
// First file recorded with error (note: error is from folder creation, but recorded against file)
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == "unsupported/file.txt" &&
|
||||
r.Action == repository.FileActionIgnored &&
|
||||
r.Error != nil
|
||||
})).Return().Once()
|
||||
|
||||
// Second file is skipped because parent folder failed
|
||||
progress.On("HasDirPathFailedCreation", "unsupported/nested/file2.txt").Return(true).Once()
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == "unsupported/nested/file2.txt" &&
|
||||
r.Action == repository.FileActionIgnored &&
|
||||
r.Warning != nil &&
|
||||
r.Warning.Error() == "resource was not processed because the parent folder could not be created"
|
||||
})).Return().Once()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "WriteResourceFromFile returns PathCreationError, nested resources skipped",
|
||||
description: "When WriteResourceFromFile implicitly creates a folder and fails, nested resources are skipped",
|
||||
previousRef: "old-ref",
|
||||
currentRef: "new-ref",
|
||||
changes: []repository.VersionedFileChange{
|
||||
{Action: repository.FileActionCreated, Path: "folder1/file1.json", Ref: "new-ref"},
|
||||
{Action: repository.FileActionCreated, Path: "folder1/file2.json", Ref: "new-ref"},
|
||||
{Action: repository.FileActionCreated, Path: "folder1/nested/file3.json", Ref: "new-ref"},
|
||||
},
|
||||
setupMocks: func(repo *repository.MockVersioned, repoResources *resources.MockRepositoryResources, progress *jobs.MockJobProgressRecorder) {
|
||||
// First file write fails with PathCreationError
|
||||
progress.On("HasDirPathFailedCreation", "folder1/file1.json").Return(false).Once()
|
||||
folderErr := &resources.PathCreationError{Path: "folder1/", Err: fmt.Errorf("permission denied")}
|
||||
repoResources.On("WriteResourceFromFile", mock.Anything, "folder1/file1.json", "new-ref").
|
||||
Return("", schema.GroupVersionKind{}, folderErr).Once()
|
||||
|
||||
// First file recorded with error, automatically tracked
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == "folder1/file1.json" &&
|
||||
r.Action == repository.FileActionCreated &&
|
||||
r.Error != nil
|
||||
})).Return().Once()
|
||||
|
||||
// Subsequent files are skipped
|
||||
progress.On("HasDirPathFailedCreation", "folder1/file2.json").Return(true).Once()
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == "folder1/file2.json" && r.Action == repository.FileActionIgnored && r.Warning != nil
|
||||
})).Return().Once()
|
||||
|
||||
progress.On("HasDirPathFailedCreation", "folder1/nested/file3.json").Return(true).Once()
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == "folder1/nested/file3.json" && r.Action == repository.FileActionIgnored && r.Warning != nil
|
||||
})).Return().Once()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "file deletion fails, folder cleanup skipped",
|
||||
description: "When RemoveResourceFromFile fails, cleanupOrphanedFolders skips folder removal",
|
||||
previousRef: "old-ref",
|
||||
currentRef: "new-ref",
|
||||
changes: []repository.VersionedFileChange{
|
||||
{Action: repository.FileActionDeleted, Path: "dashboards/file1.json", PreviousRef: "old-ref"},
|
||||
},
|
||||
setupMocks: func(repo *repository.MockVersioned, repoResources *resources.MockRepositoryResources, progress *jobs.MockJobProgressRecorder) {
|
||||
// File deletion fails (deletions don't check HasDirPathFailedCreation)
|
||||
repoResources.On("RemoveResourceFromFile", mock.Anything, "dashboards/file1.json", "old-ref").
|
||||
Return("dashboard-1", "folder-uid", schema.GroupVersionKind{Kind: "Dashboard"}, fmt.Errorf("permission denied")).Once()
|
||||
|
||||
// Error recorded, automatically tracked in failedDeletions
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == "dashboards/file1.json" &&
|
||||
r.Action == repository.FileActionDeleted &&
|
||||
r.Error != nil
|
||||
})).Return().Once()
|
||||
|
||||
// During cleanup, folder deletion is skipped
|
||||
progress.On("HasDirPathFailedDeletion", "dashboards/").Return(true).Once()
|
||||
|
||||
// Note: RemoveFolder should NOT be called (verified via AssertNotCalled in test)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "deletion proceeds despite creation failure",
|
||||
description: "When folder1/ creation fails, deletion of folder1/old.json still proceeds",
|
||||
previousRef: "old-ref",
|
||||
currentRef: "new-ref",
|
||||
changes: []repository.VersionedFileChange{
|
||||
{Action: repository.FileActionCreated, Path: "folder1/new.json", Ref: "new-ref"},
|
||||
{Action: repository.FileActionDeleted, Path: "folder1/old.json", PreviousRef: "old-ref"},
|
||||
},
|
||||
setupMocks: func(repo *repository.MockVersioned, repoResources *resources.MockRepositoryResources, progress *jobs.MockJobProgressRecorder) {
|
||||
// Creation fails
|
||||
progress.On("HasDirPathFailedCreation", "folder1/new.json").Return(false).Once()
|
||||
folderErr := &resources.PathCreationError{Path: "folder1/", Err: fmt.Errorf("permission denied")}
|
||||
repoResources.On("WriteResourceFromFile", mock.Anything, "folder1/new.json", "new-ref").
|
||||
Return("", schema.GroupVersionKind{}, folderErr).Once()
|
||||
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == "folder1/new.json" && r.Error != nil
|
||||
})).Return().Once()
|
||||
|
||||
// Deletion proceeds (NOT checking HasDirPathFailedCreation for deletions)
|
||||
repoResources.On("RemoveResourceFromFile", mock.Anything, "folder1/old.json", "old-ref").
|
||||
Return("old-resource", "", schema.GroupVersionKind{Kind: "Dashboard"}, nil).Once()
|
||||
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == "folder1/old.json" &&
|
||||
r.Action == repository.FileActionDeleted &&
|
||||
r.Error == nil // Deletion succeeds!
|
||||
})).Return().Once()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multi-level nesting cascade",
|
||||
description: "When level1/ fails, level1/level2/level3/file.json is also skipped",
|
||||
previousRef: "old-ref",
|
||||
currentRef: "new-ref",
|
||||
changes: []repository.VersionedFileChange{
|
||||
{Action: repository.FileActionCreated, Path: "level1/file.txt", Ref: "new-ref"},
|
||||
{Action: repository.FileActionCreated, Path: "level1/level2/file.txt", Ref: "new-ref"},
|
||||
{Action: repository.FileActionCreated, Path: "level1/level2/level3/file.txt", Ref: "new-ref"},
|
||||
},
|
||||
setupMocks: func(repo *repository.MockVersioned, repoResources *resources.MockRepositoryResources, progress *jobs.MockJobProgressRecorder) {
|
||||
// First file triggers level1/ failure
|
||||
progress.On("HasDirPathFailedCreation", "level1/file.txt").Return(false).Once()
|
||||
folderErr := &resources.PathCreationError{Path: "level1/", Err: fmt.Errorf("permission denied")}
|
||||
repoResources.On("EnsureFolderPathExist", mock.Anything, "level1/").Return("", folderErr).Once()
|
||||
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == "level1/file.txt" && r.Action == repository.FileActionIgnored
|
||||
})).Return().Once()
|
||||
|
||||
// All nested files are skipped
|
||||
for _, path := range []string{"level1/level2/file.txt", "level1/level2/level3/file.txt"} {
|
||||
progress.On("HasDirPathFailedCreation", path).Return(true).Once()
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == path && r.Action == repository.FileActionIgnored
|
||||
})).Return().Once()
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mixed success and failure",
|
||||
description: "When success/ works and failure/ fails, only failure/* are skipped",
|
||||
previousRef: "old-ref",
|
||||
currentRef: "new-ref",
|
||||
changes: []repository.VersionedFileChange{
|
||||
{Action: repository.FileActionCreated, Path: "success/file1.json", Ref: "new-ref"},
|
||||
{Action: repository.FileActionCreated, Path: "success/nested/file2.json", Ref: "new-ref"},
|
||||
{Action: repository.FileActionCreated, Path: "failure/file3.txt", Ref: "new-ref"},
|
||||
{Action: repository.FileActionCreated, Path: "failure/nested/file4.txt", Ref: "new-ref"},
|
||||
},
|
||||
setupMocks: func(repo *repository.MockVersioned, repoResources *resources.MockRepositoryResources, progress *jobs.MockJobProgressRecorder) {
|
||||
// Success path works
|
||||
progress.On("HasDirPathFailedCreation", "success/file1.json").Return(false).Once()
|
||||
repoResources.On("WriteResourceFromFile", mock.Anything, "success/file1.json", "new-ref").
|
||||
Return("resource-1", schema.GroupVersionKind{Kind: "Dashboard"}, nil).Once()
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == "success/file1.json" && r.Error == nil
|
||||
})).Return().Once()
|
||||
|
||||
progress.On("HasDirPathFailedCreation", "success/nested/file2.json").Return(false).Once()
|
||||
repoResources.On("WriteResourceFromFile", mock.Anything, "success/nested/file2.json", "new-ref").
|
||||
Return("resource-2", schema.GroupVersionKind{Kind: "Dashboard"}, nil).Once()
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == "success/nested/file2.json" && r.Error == nil
|
||||
})).Return().Once()
|
||||
|
||||
// Failure path fails
|
||||
progress.On("HasDirPathFailedCreation", "failure/file3.txt").Return(false).Once()
|
||||
folderErr := &resources.PathCreationError{Path: "failure/", Err: fmt.Errorf("disk full")}
|
||||
repoResources.On("EnsureFolderPathExist", mock.Anything, "failure/").Return("", folderErr).Once()
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == "failure/file3.txt" && r.Action == repository.FileActionIgnored
|
||||
})).Return().Once()
|
||||
|
||||
// Nested file in failure path is skipped
|
||||
progress.On("HasDirPathFailedCreation", "failure/nested/file4.txt").Return(true).Once()
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == "failure/nested/file4.txt" && r.Action == repository.FileActionIgnored
|
||||
})).Return().Once()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "rename with failed destination folder",
|
||||
description: "When RenameResourceFile fails with PathCreationError for destination, rename is skipped",
|
||||
previousRef: "old-ref",
|
||||
currentRef: "new-ref",
|
||||
changes: []repository.VersionedFileChange{
|
||||
{
|
||||
Action: repository.FileActionRenamed,
|
||||
Path: "newfolder/file.json",
|
||||
PreviousPath: "oldfolder/file.json",
|
||||
Ref: "new-ref",
|
||||
PreviousRef: "old-ref",
|
||||
},
|
||||
},
|
||||
setupMocks: func(repo *repository.MockVersioned, repoResources *resources.MockRepositoryResources, progress *jobs.MockJobProgressRecorder) {
|
||||
// Rename fails with PathCreationError for destination folder
|
||||
progress.On("HasDirPathFailedCreation", "newfolder/file.json").Return(false).Once()
|
||||
folderErr := &resources.PathCreationError{Path: "newfolder/", Err: fmt.Errorf("permission denied")}
|
||||
repoResources.On("RenameResourceFile", mock.Anything, "oldfolder/file.json", "old-ref", "newfolder/file.json", "new-ref").
|
||||
Return("", "", schema.GroupVersionKind{}, folderErr).Once()
|
||||
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == "newfolder/file.json" &&
|
||||
r.Action == repository.FileActionRenamed &&
|
||||
r.Error != nil
|
||||
})).Return().Once()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "renamed file still checked, subsequent nested resources skipped",
|
||||
description: "After rename fails for folder1/file.json, other folder1/* files are skipped",
|
||||
previousRef: "old-ref",
|
||||
currentRef: "new-ref",
|
||||
changes: []repository.VersionedFileChange{
|
||||
{Action: repository.FileActionRenamed, Path: "folder1/file1.json", PreviousPath: "old/file1.json", Ref: "new-ref", PreviousRef: "old-ref"},
|
||||
{Action: repository.FileActionCreated, Path: "folder1/file2.json", Ref: "new-ref"},
|
||||
},
|
||||
setupMocks: func(repo *repository.MockVersioned, repoResources *resources.MockRepositoryResources, progress *jobs.MockJobProgressRecorder) {
|
||||
// Rename is NOT skipped for creation failures (it's checking the destination path)
|
||||
progress.On("HasDirPathFailedCreation", "folder1/file1.json").Return(true).Once()
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == "folder1/file1.json" &&
|
||||
r.Action == repository.FileActionIgnored &&
|
||||
r.Warning != nil && r.Warning.Error() == "resource was not processed because the parent folder could not be created"
|
||||
})).Return().Once()
|
||||
|
||||
// Second file also skipped
|
||||
progress.On("HasDirPathFailedCreation", "folder1/file2.json").Return(true).Once()
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == "folder1/file2.json" && r.Action == repository.FileActionIgnored && r.Warning != nil
|
||||
})).Return().Once()
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
runHierarchicalErrorHandlingTest(t, tt)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type compositeRepoForTest struct {
|
||||
*repository.MockVersioned
|
||||
*repository.MockReader
|
||||
}
|
||||
|
||||
func runHierarchicalErrorHandlingTest(t *testing.T, tt struct {
|
||||
name string
|
||||
setupMocks func(*repository.MockVersioned, *resources.MockRepositoryResources, *jobs.MockJobProgressRecorder)
|
||||
changes []repository.VersionedFileChange
|
||||
previousRef string
|
||||
currentRef string
|
||||
description string
|
||||
expectError bool
|
||||
errorContains string
|
||||
}) {
|
||||
var repo repository.Versioned
|
||||
mockVersioned := repository.NewMockVersioned(t)
|
||||
repoResources := resources.NewMockRepositoryResources(t)
|
||||
progress := jobs.NewMockJobProgressRecorder(t)
|
||||
|
||||
// For tests that need cleanup (folder deletion), use composite repo
|
||||
if tt.name == "file deletion fails, folder cleanup skipped" {
|
||||
mockReader := repository.NewMockReader(t)
|
||||
repo = &compositeRepoForTest{
|
||||
MockVersioned: mockVersioned,
|
||||
MockReader: mockReader,
|
||||
}
|
||||
} else {
|
||||
repo = mockVersioned
|
||||
}
|
||||
|
||||
mockVersioned.On("CompareFiles", mock.Anything, tt.previousRef, tt.currentRef).Return(tt.changes, nil)
|
||||
progress.On("SetTotal", mock.Anything, len(tt.changes)).Return()
|
||||
progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return()
|
||||
progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return()
|
||||
progress.On("TooManyErrors").Return(nil).Maybe()
|
||||
|
||||
tt.setupMocks(mockVersioned, repoResources, progress)
|
||||
|
||||
err := IncrementalSync(context.Background(), repo, tt.previousRef, tt.currentRef, repoResources, progress, tracing.NewNoopTracerService(), jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
|
||||
|
||||
if tt.expectError {
|
||||
require.Error(t, err)
|
||||
if tt.errorContains != "" {
|
||||
require.Contains(t, err.Error(), tt.errorContains)
|
||||
}
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
progress.AssertExpectations(t)
|
||||
repoResources.AssertExpectations(t)
|
||||
// For deletion tests, verify RemoveFolder was NOT called
|
||||
if tt.name == "file deletion fails, folder cleanup skipped" {
|
||||
repoResources.AssertNotCalled(t, "RemoveFolder", mock.Anything, mock.Anything)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIncrementalSync_HierarchicalErrorHandling_FailedFolderCreation tests nested resource skipping
|
||||
func TestIncrementalSync_HierarchicalErrorHandling_FailedFolderCreation(t *testing.T) {
|
||||
repo := repository.NewMockVersioned(t)
|
||||
repoResources := resources.NewMockRepositoryResources(t)
|
||||
progress := jobs.NewMockJobProgressRecorder(t)
|
||||
|
||||
changes := []repository.VersionedFileChange{
|
||||
{Action: repository.FileActionCreated, Path: "unsupported/file.txt", Ref: "new-ref"},
|
||||
{Action: repository.FileActionCreated, Path: "unsupported/subfolder/file2.txt", Ref: "new-ref"},
|
||||
{Action: repository.FileActionCreated, Path: "unsupported/file3.json", Ref: "new-ref"},
|
||||
{Action: repository.FileActionCreated, Path: "other/file.json", Ref: "new-ref"},
|
||||
}
|
||||
|
||||
repo.On("CompareFiles", mock.Anything, "old-ref", "new-ref").Return(changes, nil)
|
||||
progress.On("SetTotal", mock.Anything, 4).Return()
|
||||
progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return()
|
||||
progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return()
|
||||
progress.On("TooManyErrors").Return(nil).Maybe()
|
||||
|
||||
folderErr := &resources.PathCreationError{Path: "unsupported/", Err: fmt.Errorf("permission denied")}
|
||||
// First check is before it fails.
|
||||
progress.On("HasDirPathFailedCreation", "unsupported/file.txt").Return(false).Once()
|
||||
repoResources.On("EnsureFolderPathExist", mock.Anything, "unsupported/").Return("", folderErr).Once()
|
||||
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == "unsupported/file.txt" && r.Action == repository.FileActionIgnored && r.Error != nil
|
||||
})).Return().Once()
|
||||
|
||||
progress.On("HasDirPathFailedCreation", "unsupported/subfolder/file2.txt").Return(true).Once()
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == "unsupported/subfolder/file2.txt" && r.Action == repository.FileActionIgnored &&
|
||||
r.Warning != nil && r.Warning.Error() == "resource was not processed because the parent folder could not be created"
|
||||
})).Return().Once()
|
||||
|
||||
progress.On("HasDirPathFailedCreation", "unsupported/file3.json").Return(true).Once()
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == "unsupported/file3.json" && r.Action == repository.FileActionIgnored &&
|
||||
r.Warning != nil && r.Warning.Error() == "resource was not processed because the parent folder could not be created"
|
||||
})).Return().Once()
|
||||
|
||||
progress.On("HasDirPathFailedCreation", "other/file.json").Return(false).Once()
|
||||
repoResources.On("WriteResourceFromFile", mock.Anything, "other/file.json", "new-ref").
|
||||
Return("test-resource", schema.GroupVersionKind{Kind: "Dashboard"}, nil).Once()
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == "other/file.json" && r.Action == repository.FileActionCreated && r.Error == nil
|
||||
})).Return().Once()
|
||||
|
||||
err := IncrementalSync(context.Background(), repo, "old-ref", "new-ref", repoResources, progress, tracing.NewNoopTracerService(), jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
|
||||
require.NoError(t, err)
|
||||
progress.AssertExpectations(t)
|
||||
}
|
||||
|
||||
// TestIncrementalSync_HierarchicalErrorHandling_FailedFileDeletion tests folder cleanup prevention
|
||||
func TestIncrementalSync_HierarchicalErrorHandling_FailedFileDeletion(t *testing.T) {
|
||||
mockVersioned := repository.NewMockVersioned(t)
|
||||
mockReader := repository.NewMockReader(t)
|
||||
repo := &compositeRepoForTest{MockVersioned: mockVersioned, MockReader: mockReader}
|
||||
repoResources := resources.NewMockRepositoryResources(t)
|
||||
progress := jobs.NewMockJobProgressRecorder(t)
|
||||
|
||||
changes := []repository.VersionedFileChange{
|
||||
{Action: repository.FileActionDeleted, Path: "dashboards/file1.json", PreviousRef: "old-ref"},
|
||||
}
|
||||
|
||||
mockVersioned.On("CompareFiles", mock.Anything, "old-ref", "new-ref").Return(changes, nil)
|
||||
progress.On("SetTotal", mock.Anything, 1).Return()
|
||||
progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return()
|
||||
progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return()
|
||||
progress.On("TooManyErrors").Return(nil).Maybe()
|
||||
|
||||
// Deletions don't check HasDirPathFailedCreation, they go straight to removal
|
||||
repoResources.On("RemoveResourceFromFile", mock.Anything, "dashboards/file1.json", "old-ref").
|
||||
Return("dashboard-1", "folder-uid", schema.GroupVersionKind{Kind: "Dashboard"}, fmt.Errorf("permission denied")).Once()
|
||||
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == "dashboards/file1.json" && r.Action == repository.FileActionDeleted &&
|
||||
r.Error != nil && r.Error.Error() == "removing resource from file dashboards/file1.json: permission denied"
|
||||
})).Return().Once()
|
||||
|
||||
progress.On("HasDirPathFailedDeletion", "dashboards/").Return(true).Once()
|
||||
|
||||
err := IncrementalSync(context.Background(), repo, "old-ref", "new-ref", repoResources, progress, tracing.NewNoopTracerService(), jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
|
||||
require.NoError(t, err)
|
||||
progress.AssertExpectations(t)
|
||||
repoResources.AssertNotCalled(t, "RemoveFolder", mock.Anything, mock.Anything)
|
||||
}
|
||||
|
||||
// TestIncrementalSync_HierarchicalErrorHandling_DeletionNotAffectedByCreationFailure tests deletions proceed despite creation failures
|
||||
func TestIncrementalSync_HierarchicalErrorHandling_DeletionNotAffectedByCreationFailure(t *testing.T) {
|
||||
repo := repository.NewMockVersioned(t)
|
||||
repoResources := resources.NewMockRepositoryResources(t)
|
||||
progress := jobs.NewMockJobProgressRecorder(t)
|
||||
|
||||
changes := []repository.VersionedFileChange{
|
||||
{Action: repository.FileActionCreated, Path: "folder1/file.json", Ref: "new-ref"},
|
||||
{Action: repository.FileActionDeleted, Path: "folder1/old.json", PreviousRef: "old-ref"},
|
||||
}
|
||||
|
||||
repo.On("CompareFiles", mock.Anything, "old-ref", "new-ref").Return(changes, nil)
|
||||
progress.On("SetTotal", mock.Anything, 2).Return()
|
||||
progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return()
|
||||
progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return()
|
||||
progress.On("TooManyErrors").Return(nil).Maybe()
|
||||
|
||||
// Creation fails
|
||||
progress.On("HasDirPathFailedCreation", "folder1/file.json").Return(false).Once()
|
||||
repoResources.On("WriteResourceFromFile", mock.Anything, "folder1/file.json", "new-ref").
|
||||
Return("", schema.GroupVersionKind{}, &resources.PathCreationError{Path: "folder1/", Err: fmt.Errorf("permission denied")}).Once()
|
||||
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == "folder1/file.json" && r.Error != nil
|
||||
})).Return().Once()
|
||||
|
||||
// Deletion should NOT be skipped (not checking HasDirPathFailedCreation for deletions)
|
||||
// Deletions don't check HasDirPathFailedCreation, they go straight to removal
|
||||
repoResources.On("RemoveResourceFromFile", mock.Anything, "folder1/old.json", "old-ref").
|
||||
Return("old-resource", "", schema.GroupVersionKind{Kind: "Dashboard"}, nil).Once()
|
||||
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == "folder1/old.json" && r.Action == repository.FileActionDeleted && r.Error == nil
|
||||
})).Return().Once()
|
||||
|
||||
err := IncrementalSync(context.Background(), repo, "old-ref", "new-ref", repoResources, progress, tracing.NewNoopTracerService(), jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
|
||||
require.NoError(t, err)
|
||||
progress.AssertExpectations(t)
|
||||
}
|
||||
|
||||
// TestIncrementalSync_HierarchicalErrorHandling_MultiLevelNesting tests multi-level cascade
|
||||
func TestIncrementalSync_HierarchicalErrorHandling_MultiLevelNesting(t *testing.T) {
|
||||
repo := repository.NewMockVersioned(t)
|
||||
repoResources := resources.NewMockRepositoryResources(t)
|
||||
progress := jobs.NewMockJobProgressRecorder(t)
|
||||
|
||||
changes := []repository.VersionedFileChange{
|
||||
{Action: repository.FileActionCreated, Path: "level1/file.txt", Ref: "new-ref"},
|
||||
{Action: repository.FileActionCreated, Path: "level1/level2/file.txt", Ref: "new-ref"},
|
||||
{Action: repository.FileActionCreated, Path: "level1/level2/level3/file.txt", Ref: "new-ref"},
|
||||
}
|
||||
|
||||
repo.On("CompareFiles", mock.Anything, "old-ref", "new-ref").Return(changes, nil)
|
||||
progress.On("SetTotal", mock.Anything, 3).Return()
|
||||
progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return()
|
||||
progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return()
|
||||
progress.On("TooManyErrors").Return(nil).Maybe()
|
||||
|
||||
folderErr := &resources.PathCreationError{Path: "level1/", Err: fmt.Errorf("permission denied")}
|
||||
// First check is before it fails.
|
||||
progress.On("HasDirPathFailedCreation", "level1/file.txt").Return(false).Once()
|
||||
repoResources.On("EnsureFolderPathExist", mock.Anything, "level1/").Return("", folderErr).Once()
|
||||
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == "level1/file.txt" && r.Action == repository.FileActionIgnored && r.Error != nil
|
||||
})).Return().Once()
|
||||
|
||||
progress.On("HasDirPathFailedCreation", "level1/level2/file.txt").Return(true).Once()
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == "level1/level2/file.txt" && r.Action == repository.FileActionIgnored &&
|
||||
r.Warning != nil && r.Warning.Error() == "resource was not processed because the parent folder could not be created"
|
||||
})).Return().Once()
|
||||
|
||||
progress.On("HasDirPathFailedCreation", "level1/level2/level3/file.txt").Return(true).Once()
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == "level1/level2/level3/file.txt" && r.Action == repository.FileActionIgnored &&
|
||||
r.Warning != nil && r.Warning.Error() == "resource was not processed because the parent folder could not be created"
|
||||
})).Return().Once()
|
||||
|
||||
err := IncrementalSync(context.Background(), repo, "old-ref", "new-ref", repoResources, progress, tracing.NewNoopTracerService(), jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
|
||||
require.NoError(t, err)
|
||||
progress.AssertExpectations(t)
|
||||
}
|
||||
|
||||
// TestIncrementalSync_HierarchicalErrorHandling_MixedSuccessAndFailure tests partial failures
|
||||
func TestIncrementalSync_HierarchicalErrorHandling_MixedSuccessAndFailure(t *testing.T) {
|
||||
repo := repository.NewMockVersioned(t)
|
||||
repoResources := resources.NewMockRepositoryResources(t)
|
||||
progress := jobs.NewMockJobProgressRecorder(t)
|
||||
|
||||
changes := []repository.VersionedFileChange{
|
||||
{Action: repository.FileActionCreated, Path: "success/file1.json", Ref: "new-ref"},
|
||||
{Action: repository.FileActionCreated, Path: "success/nested/file2.json", Ref: "new-ref"},
|
||||
{Action: repository.FileActionCreated, Path: "failure/file3.txt", Ref: "new-ref"},
|
||||
{Action: repository.FileActionCreated, Path: "failure/nested/file4.txt", Ref: "new-ref"},
|
||||
}
|
||||
|
||||
repo.On("CompareFiles", mock.Anything, "old-ref", "new-ref").Return(changes, nil)
|
||||
progress.On("SetTotal", mock.Anything, 4).Return()
|
||||
progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return()
|
||||
progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return()
|
||||
progress.On("TooManyErrors").Return(nil).Maybe()
|
||||
|
||||
progress.On("HasDirPathFailedCreation", "success/file1.json").Return(false).Once()
|
||||
repoResources.On("WriteResourceFromFile", mock.Anything, "success/file1.json", "new-ref").
|
||||
Return("resource-1", schema.GroupVersionKind{Kind: "Dashboard"}, nil).Once()
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == "success/file1.json" && r.Action == repository.FileActionCreated && r.Error == nil
|
||||
})).Return().Once()
|
||||
|
||||
progress.On("HasDirPathFailedCreation", "success/nested/file2.json").Return(false).Once()
|
||||
repoResources.On("WriteResourceFromFile", mock.Anything, "success/nested/file2.json", "new-ref").
|
||||
Return("resource-2", schema.GroupVersionKind{Kind: "Dashboard"}, nil).Once()
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == "success/nested/file2.json" && r.Action == repository.FileActionCreated && r.Error == nil
|
||||
})).Return().Once()
|
||||
|
||||
folderErr := &resources.PathCreationError{Path: "failure/", Err: fmt.Errorf("disk full")}
|
||||
progress.On("HasDirPathFailedCreation", "failure/file3.txt").Return(false).Once()
|
||||
repoResources.On("EnsureFolderPathExist", mock.Anything, "failure/").Return("", folderErr).Once()
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == "failure/file3.txt" && r.Action == repository.FileActionIgnored
|
||||
})).Return().Once()
|
||||
|
||||
progress.On("HasDirPathFailedCreation", "failure/nested/file4.txt").Return(true).Once()
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == "failure/nested/file4.txt" && r.Action == repository.FileActionIgnored &&
|
||||
r.Warning != nil && r.Warning.Error() == "resource was not processed because the parent folder could not be created"
|
||||
})).Return().Once()
|
||||
|
||||
err := IncrementalSync(context.Background(), repo, "old-ref", "new-ref", repoResources, progress, tracing.NewNoopTracerService(), jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
|
||||
require.NoError(t, err)
|
||||
progress.AssertExpectations(t)
|
||||
repoResources.AssertExpectations(t)
|
||||
}
|
||||
|
||||
// TestIncrementalSync_HierarchicalErrorHandling_RenameWithFailedFolderCreation tests rename operations affected by folder failures
|
||||
func TestIncrementalSync_HierarchicalErrorHandling_RenameWithFailedFolderCreation(t *testing.T) {
|
||||
repo := repository.NewMockVersioned(t)
|
||||
repoResources := resources.NewMockRepositoryResources(t)
|
||||
progress := jobs.NewMockJobProgressRecorder(t)
|
||||
|
||||
changes := []repository.VersionedFileChange{
|
||||
{Action: repository.FileActionRenamed, Path: "newfolder/file.json", PreviousPath: "oldfolder/file.json", Ref: "new-ref", PreviousRef: "old-ref"},
|
||||
}
|
||||
|
||||
repo.On("CompareFiles", mock.Anything, "old-ref", "new-ref").Return(changes, nil)
|
||||
progress.On("SetTotal", mock.Anything, 1).Return()
|
||||
progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return()
|
||||
progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return()
|
||||
progress.On("TooManyErrors").Return(nil).Maybe()
|
||||
|
||||
progress.On("HasDirPathFailedCreation", "newfolder/file.json").Return(true).Once()
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Path == "newfolder/file.json" && r.Action == repository.FileActionIgnored &&
|
||||
r.Warning != nil && r.Warning.Error() == "resource was not processed because the parent folder could not be created"
|
||||
})).Return().Once()
|
||||
|
||||
err := IncrementalSync(context.Background(), repo, "old-ref", "new-ref", repoResources, progress, tracing.NewNoopTracerService(), jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
|
||||
require.NoError(t, err)
|
||||
progress.AssertExpectations(t)
|
||||
}
|
||||
@@ -92,10 +92,6 @@ func TestIncrementalSync(t *testing.T) {
|
||||
progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return()
|
||||
progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return()
|
||||
|
||||
// Mock HasDirPathFailedCreation checks
|
||||
progress.On("HasDirPathFailedCreation", "dashboards/test.json").Return(false)
|
||||
progress.On("HasDirPathFailedCreation", "alerts/alert.yaml").Return(false)
|
||||
|
||||
// Mock successful resource writes
|
||||
repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", "new-ref").
|
||||
Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil)
|
||||
@@ -131,9 +127,6 @@ func TestIncrementalSync(t *testing.T) {
|
||||
progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return()
|
||||
progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return()
|
||||
|
||||
// Mock HasDirPathFailedCreation check
|
||||
progress.On("HasDirPathFailedCreation", "unsupported/path/file.txt").Return(false)
|
||||
|
||||
// Mock folder creation
|
||||
repoResources.On("EnsureFolderPathExist", mock.Anything, "unsupported/path/").
|
||||
Return("test-folder", nil)
|
||||
@@ -168,9 +161,6 @@ func TestIncrementalSync(t *testing.T) {
|
||||
progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return()
|
||||
progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return()
|
||||
|
||||
// Mock HasDirPathFailedCreation check
|
||||
progress.On("HasDirPathFailedCreation", ".unsupported/path/file.txt").Return(false)
|
||||
|
||||
progress.On("Record", mock.Anything, jobs.JobResourceResult{
|
||||
Action: repository.FileActionIgnored,
|
||||
Path: ".unsupported/path/file.txt",
|
||||
@@ -232,9 +222,6 @@ func TestIncrementalSync(t *testing.T) {
|
||||
progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return()
|
||||
progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return()
|
||||
|
||||
// Mock HasDirPathFailedCreation check
|
||||
progress.On("HasDirPathFailedCreation", "dashboards/new.json").Return(false)
|
||||
|
||||
// Mock resource rename
|
||||
repoResources.On("RenameResourceFile", mock.Anything, "dashboards/old.json", "old-ref", "dashboards/new.json", "new-ref").
|
||||
Return("renamed-dashboard", "", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil)
|
||||
@@ -267,10 +254,6 @@ func TestIncrementalSync(t *testing.T) {
|
||||
progress.On("SetTotal", mock.Anything, 1).Return()
|
||||
progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return()
|
||||
progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return()
|
||||
|
||||
// Mock HasDirPathFailedCreation check
|
||||
progress.On("HasDirPathFailedCreation", "dashboards/ignored.json").Return(false)
|
||||
|
||||
progress.On("Record", mock.Anything, jobs.JobResourceResult{
|
||||
Action: repository.FileActionIgnored,
|
||||
Path: "dashboards/ignored.json",
|
||||
@@ -294,28 +277,16 @@ func TestIncrementalSync(t *testing.T) {
|
||||
repo.On("CompareFiles", mock.Anything, "old-ref", "new-ref").Return(changes, nil)
|
||||
progress.On("SetTotal", mock.Anything, 1).Return()
|
||||
progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return()
|
||||
progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return()
|
||||
|
||||
// Mock HasDirPathFailedCreation check
|
||||
progress.On("HasDirPathFailedCreation", "unsupported/path/file.txt").Return(false)
|
||||
|
||||
// Mock folder creation error
|
||||
repoResources.On("EnsureFolderPathExist", mock.Anything, "unsupported/path/").
|
||||
Return("", fmt.Errorf("failed to create folder"))
|
||||
|
||||
// Mock progress recording with error
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
|
||||
return result.Action == repository.FileActionIgnored &&
|
||||
result.Path == "unsupported/path/file.txt" &&
|
||||
result.Error != nil &&
|
||||
result.Error.Error() == "failed to create folder"
|
||||
})).Return()
|
||||
|
||||
progress.On("TooManyErrors").Return(nil)
|
||||
},
|
||||
previousRef: "old-ref",
|
||||
currentRef: "new-ref",
|
||||
expectedCalls: 1,
|
||||
expectedError: "unable to create empty file folder: failed to create folder",
|
||||
},
|
||||
{
|
||||
name: "error writing resource",
|
||||
@@ -332,9 +303,6 @@ func TestIncrementalSync(t *testing.T) {
|
||||
progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return()
|
||||
progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return()
|
||||
|
||||
// Mock HasDirPathFailedCreation check
|
||||
progress.On("HasDirPathFailedCreation", "dashboards/test.json").Return(false)
|
||||
|
||||
// Mock resource write error
|
||||
repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", "new-ref").
|
||||
Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, fmt.Errorf("write failed"))
|
||||
@@ -404,8 +372,7 @@ func TestIncrementalSync(t *testing.T) {
|
||||
repo.On("CompareFiles", mock.Anything, "old-ref", "new-ref").Return(changes, nil)
|
||||
progress.On("SetTotal", mock.Anything, 1).Return()
|
||||
progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return()
|
||||
|
||||
// Mock too many errors - this is checked before processing files, so HasDirPathFailedCreation won't be called
|
||||
// Mock too many errors
|
||||
progress.On("TooManyErrors").Return(fmt.Errorf("too many errors occurred"))
|
||||
},
|
||||
previousRef: "old-ref",
|
||||
@@ -461,9 +428,6 @@ func TestIncrementalSync_CleanupOrphanedFolders(t *testing.T) {
|
||||
repoResources.On("RemoveResourceFromFile", mock.Anything, "dashboards/old.json", "old-ref").
|
||||
Return("old-dashboard", "folder-uid", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil)
|
||||
|
||||
// Mock HasDirPathFailedDeletion check for cleanup
|
||||
progress.On("HasDirPathFailedDeletion", "dashboards/").Return(false)
|
||||
|
||||
// if the folder is not found in git, there should be a call to remove the folder from grafana
|
||||
repo.MockReader.On("Read", mock.Anything, "dashboards/", "").
|
||||
Return((*repository.FileInfo)(nil), repository.ErrFileNotFound)
|
||||
@@ -489,10 +453,6 @@ func TestIncrementalSync_CleanupOrphanedFolders(t *testing.T) {
|
||||
progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return()
|
||||
repoResources.On("RemoveResourceFromFile", mock.Anything, "dashboards/old.json", "old-ref").
|
||||
Return("old-dashboard", "folder-uid", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil)
|
||||
|
||||
// Mock HasDirPathFailedDeletion check for cleanup
|
||||
progress.On("HasDirPathFailedDeletion", "dashboards/").Return(false)
|
||||
|
||||
// if the folder still exists in git, there should not be a call to delete it from grafana
|
||||
repo.MockReader.On("Read", mock.Anything, "dashboards/", "").
|
||||
Return(&repository.FileInfo{}, nil)
|
||||
@@ -525,13 +485,6 @@ func TestIncrementalSync_CleanupOrphanedFolders(t *testing.T) {
|
||||
repoResources.On("RemoveResourceFromFile", mock.Anything, "alerts/old-alert.yaml", "old-ref").
|
||||
Return("old-alert", "folder-uid-2", schema.GroupVersionKind{Kind: "Alert", Group: "alerts"}, nil)
|
||||
|
||||
progress.On("Record", mock.Anything, mock.Anything).Return()
|
||||
progress.On("TooManyErrors").Return(nil)
|
||||
|
||||
// Mock HasDirPathFailedDeletion checks for cleanup
|
||||
progress.On("HasDirPathFailedDeletion", "dashboards/").Return(false)
|
||||
progress.On("HasDirPathFailedDeletion", "alerts/").Return(false)
|
||||
|
||||
// both not found in git, both should be deleted
|
||||
repo.MockReader.On("Read", mock.Anything, "dashboards/", "").
|
||||
Return((*repository.FileInfo)(nil), repository.ErrFileNotFound)
|
||||
@@ -539,6 +492,9 @@ func TestIncrementalSync_CleanupOrphanedFolders(t *testing.T) {
|
||||
Return((*repository.FileInfo)(nil), repository.ErrFileNotFound)
|
||||
repoResources.On("RemoveFolder", mock.Anything, "folder-uid-1").Return(nil)
|
||||
repoResources.On("RemoveFolder", mock.Anything, "folder-uid-2").Return(nil)
|
||||
|
||||
progress.On("Record", mock.Anything, mock.Anything).Return()
|
||||
progress.On("TooManyErrors").Return(nil)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -559,22 +559,6 @@ func (b *APIBuilder) InstallSchema(scheme *runtime.Scheme) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Register custom field label conversion for Repository to enable field selectors like spec.connection.name
|
||||
err = scheme.AddFieldLabelConversionFunc(
|
||||
provisioning.SchemeGroupVersion.WithKind("Repository"),
|
||||
func(label, value string) (string, string, error) {
|
||||
switch label {
|
||||
case "metadata.name", "metadata.namespace", "spec.connection.name":
|
||||
return label, value, nil
|
||||
default:
|
||||
return "", "", fmt.Errorf("field label not supported for Repository: %s", label)
|
||||
}
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
metav1.AddToGroupVersion(scheme, provisioning.SchemeGroupVersion)
|
||||
// Only 1 version (for now?)
|
||||
return scheme.SetVersionPriority(provisioning.SchemeGroupVersion)
|
||||
@@ -585,19 +569,10 @@ func (b *APIBuilder) AllowedV0Alpha1Resources() []string {
|
||||
}
|
||||
|
||||
func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions) error {
|
||||
// Create repository storage with custom field selectors (e.g., spec.connection.name)
|
||||
repositoryStorage, err := grafanaregistry.NewRegistryStoreWithSelectableFields(
|
||||
opts.Scheme,
|
||||
provisioning.RepositoryResourceInfo,
|
||||
opts.OptsGetter,
|
||||
grafanaregistry.SelectableFieldsOptions{
|
||||
GetAttrs: RepositoryGetAttrs,
|
||||
},
|
||||
)
|
||||
repositoryStorage, err := grafanaregistry.NewRegistryStore(opts.Scheme, provisioning.RepositoryResourceInfo, opts.OptsGetter)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create repository storage: %w", err)
|
||||
}
|
||||
|
||||
repositoryStatusStorage := grafanaregistry.NewRegistryStatusStore(opts.Scheme, repositoryStorage)
|
||||
b.store = repositoryStorage
|
||||
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
package provisioning
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"k8s.io/apimachinery/pkg/fields"
|
||||
"k8s.io/apimachinery/pkg/labels"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apiserver/pkg/registry/generic"
|
||||
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
)
|
||||
|
||||
// RepositoryToSelectableFields returns a field set that can be used for field selectors.
|
||||
// This includes standard metadata fields plus custom fields like spec.connection.name.
|
||||
func RepositoryToSelectableFields(obj *provisioning.Repository) fields.Set {
|
||||
objectMetaFields := generic.ObjectMetaFieldsSet(&obj.ObjectMeta, true)
|
||||
|
||||
// Add custom selectable fields
|
||||
specificFields := fields.Set{
|
||||
"spec.connection.name": getConnectionName(obj),
|
||||
}
|
||||
|
||||
return generic.MergeFieldsSets(objectMetaFields, specificFields)
|
||||
}
|
||||
|
||||
// getConnectionName safely extracts the connection name from a Repository.
|
||||
// Returns empty string if no connection is configured.
|
||||
func getConnectionName(obj *provisioning.Repository) string {
|
||||
if obj == nil || obj.Spec.Connection == nil {
|
||||
return ""
|
||||
}
|
||||
return obj.Spec.Connection.Name
|
||||
}
|
||||
|
||||
// RepositoryGetAttrs returns labels and fields of a Repository object.
|
||||
// This is used by the storage layer for filtering.
|
||||
func RepositoryGetAttrs(obj runtime.Object) (labels.Set, fields.Set, error) {
|
||||
repo, ok := obj.(*provisioning.Repository)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("given object is not a Repository")
|
||||
}
|
||||
return labels.Set(repo.Labels), RepositoryToSelectableFields(repo), nil
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
package provisioning
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
)
|
||||
|
||||
func TestGetConnectionName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
repo *provisioning.Repository
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "nil repository returns empty string",
|
||||
repo: nil,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "repository without connection returns empty string",
|
||||
repo: &provisioning.Repository{
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Title: "test-repo",
|
||||
},
|
||||
},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "repository with connection returns connection name",
|
||||
repo: &provisioning.Repository{
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Title: "test-repo",
|
||||
Connection: &provisioning.ConnectionInfo{
|
||||
Name: "my-connection",
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: "my-connection",
|
||||
},
|
||||
{
|
||||
name: "repository with empty connection name returns empty string",
|
||||
repo: &provisioning.Repository{
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Title: "test-repo",
|
||||
Connection: &provisioning.ConnectionInfo{
|
||||
Name: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := getConnectionName(tt.repo)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepositoryToSelectableFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
repo *provisioning.Repository
|
||||
expectedFields map[string]string
|
||||
}{
|
||||
{
|
||||
name: "includes metadata.name and metadata.namespace",
|
||||
repo: &provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Title: "Test Repository",
|
||||
},
|
||||
},
|
||||
expectedFields: map[string]string{
|
||||
"metadata.name": "test-repo",
|
||||
"metadata.namespace": "default",
|
||||
"spec.connection.name": "",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "includes spec.connection.name when set",
|
||||
repo: &provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "repo-with-connection",
|
||||
Namespace: "org-1",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Title: "Repo With Connection",
|
||||
Connection: &provisioning.ConnectionInfo{
|
||||
Name: "github-connection",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedFields: map[string]string{
|
||||
"metadata.name": "repo-with-connection",
|
||||
"metadata.namespace": "org-1",
|
||||
"spec.connection.name": "github-connection",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fields := RepositoryToSelectableFields(tt.repo)
|
||||
|
||||
for key, expectedValue := range tt.expectedFields {
|
||||
actualValue, exists := fields[key]
|
||||
assert.True(t, exists, "field %s should exist", key)
|
||||
assert.Equal(t, expectedValue, actualValue, "field %s should have correct value", key)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepositoryGetAttrs(t *testing.T) {
|
||||
t.Run("returns error for non-Repository object", func(t *testing.T) {
|
||||
// Pass a different runtime.Object type instead of a Repository
|
||||
connection := &provisioning.Connection{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "not-a-repository",
|
||||
},
|
||||
}
|
||||
_, _, err := RepositoryGetAttrs(connection)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not a Repository")
|
||||
})
|
||||
|
||||
t.Run("returns labels and fields for valid Repository", func(t *testing.T) {
|
||||
repo := &provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
Namespace: "default",
|
||||
Labels: map[string]string{
|
||||
"app": "grafana",
|
||||
"env": "test",
|
||||
},
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Title: "Test Repository",
|
||||
Connection: &provisioning.ConnectionInfo{
|
||||
Name: "my-connection",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
labels, fields, err := RepositoryGetAttrs(repo)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check labels
|
||||
assert.Equal(t, "grafana", labels["app"])
|
||||
assert.Equal(t, "test", labels["env"])
|
||||
|
||||
// Check fields
|
||||
assert.Equal(t, "test-repo", fields["metadata.name"])
|
||||
assert.Equal(t, "default", fields["metadata.namespace"])
|
||||
assert.Equal(t, "my-connection", fields["spec.connection.name"])
|
||||
})
|
||||
|
||||
t.Run("returns empty connection name when not set", func(t *testing.T) {
|
||||
repo := &provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Title: "Test Repository",
|
||||
},
|
||||
}
|
||||
|
||||
_, fields, err := RepositoryGetAttrs(repo)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "", fields["spec.connection.name"])
|
||||
})
|
||||
}
|
||||
@@ -20,21 +20,6 @@ import (
|
||||
|
||||
const MaxNumberOfFolders = 10000
|
||||
|
||||
// PathCreationError represents an error that occurred while creating a folder path.
|
||||
// It contains the path that failed and the underlying error.
|
||||
type PathCreationError struct {
|
||||
Path string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *PathCreationError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
func (e *PathCreationError) Error() string {
|
||||
return fmt.Sprintf("failed to create path %s: %v", e.Path, e.Err)
|
||||
}
|
||||
|
||||
type FolderManager struct {
|
||||
repo repository.ReaderWriter
|
||||
tree FolderTree
|
||||
@@ -88,11 +73,7 @@ func (fm *FolderManager) EnsureFolderPathExist(ctx context.Context, filePath str
|
||||
}
|
||||
|
||||
if err := fm.EnsureFolderExists(ctx, f, parent); err != nil {
|
||||
// Wrap in PathCreationError to indicate which path failed
|
||||
return &PathCreationError{
|
||||
Path: f.Path,
|
||||
Err: fmt.Errorf("ensure folder exists: %w", err),
|
||||
}
|
||||
return fmt.Errorf("ensure folder exists: %w", err)
|
||||
}
|
||||
|
||||
fm.tree.Add(f, parent)
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
package resources_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPathCreationError(t *testing.T) {
|
||||
t.Run("Error method returns formatted message", func(t *testing.T) {
|
||||
underlyingErr := fmt.Errorf("underlying error")
|
||||
pathErr := &resources.PathCreationError{
|
||||
Path: "grafana/folder-1",
|
||||
Err: underlyingErr,
|
||||
}
|
||||
|
||||
expectedMsg := "failed to create path grafana/folder-1: underlying error"
|
||||
require.Equal(t, expectedMsg, pathErr.Error())
|
||||
})
|
||||
|
||||
t.Run("Unwrap returns underlying error", func(t *testing.T) {
|
||||
underlyingErr := fmt.Errorf("underlying error")
|
||||
pathErr := &resources.PathCreationError{
|
||||
Path: "grafana/folder-1",
|
||||
Err: underlyingErr,
|
||||
}
|
||||
|
||||
unwrapped := pathErr.Unwrap()
|
||||
require.Equal(t, underlyingErr, unwrapped)
|
||||
require.EqualError(t, unwrapped, "underlying error")
|
||||
})
|
||||
|
||||
t.Run("errors.Is finds underlying error", func(t *testing.T) {
|
||||
underlyingErr := fmt.Errorf("underlying error")
|
||||
pathErr := &resources.PathCreationError{
|
||||
Path: "grafana/folder-1",
|
||||
Err: underlyingErr,
|
||||
}
|
||||
|
||||
require.True(t, errors.Is(pathErr, underlyingErr))
|
||||
require.False(t, errors.Is(pathErr, fmt.Errorf("different error")))
|
||||
})
|
||||
|
||||
t.Run("errors.As extracts PathCreationError", func(t *testing.T) {
|
||||
underlyingErr := fmt.Errorf("underlying error")
|
||||
pathErr := &resources.PathCreationError{
|
||||
Path: "grafana/folder-1",
|
||||
Err: underlyingErr,
|
||||
}
|
||||
|
||||
var extractedErr *resources.PathCreationError
|
||||
require.True(t, errors.As(pathErr, &extractedErr))
|
||||
require.NotNil(t, extractedErr)
|
||||
require.Equal(t, "grafana/folder-1", extractedErr.Path)
|
||||
require.Equal(t, underlyingErr, extractedErr.Err)
|
||||
})
|
||||
|
||||
t.Run("errors.As returns false for non-PathCreationError", func(t *testing.T) {
|
||||
regularErr := fmt.Errorf("regular error")
|
||||
|
||||
var extractedErr *resources.PathCreationError
|
||||
require.False(t, errors.As(regularErr, &extractedErr))
|
||||
require.Nil(t, extractedErr)
|
||||
})
|
||||
}
|
||||
@@ -20,7 +20,7 @@ const (
|
||||
// OpenFeatureConfig holds configuration for initializing OpenFeature
|
||||
type OpenFeatureConfig struct {
|
||||
// ProviderType is either "static", "features-service", or "ofrep"
|
||||
ProviderType string
|
||||
ProviderType setting.OpenFeatureProviderType
|
||||
// URL is the remote provider's URL (required for features-service + OFREP providers)
|
||||
URL *url.URL
|
||||
// HTTPClient is a pre-configured HTTP client (optional, used by features-service + OFREP providers)
|
||||
@@ -98,7 +98,7 @@ func InitOpenFeatureWithCfg(cfg *setting.Cfg) error {
|
||||
}
|
||||
|
||||
func createProvider(
|
||||
providerType string,
|
||||
providerType setting.OpenFeatureProviderType,
|
||||
u *url.URL,
|
||||
staticFlags map[string]bool,
|
||||
httpClient *http.Client,
|
||||
|
||||
@@ -872,6 +872,13 @@ var (
|
||||
Owner: grafanaSharingSquad,
|
||||
FrontendOnly: false,
|
||||
},
|
||||
{
|
||||
Name: "logsExploreTableDefaultVisualization",
|
||||
Description: "Sets the logs table as default visualisation in logs explore",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaObservabilityLogsSquad,
|
||||
FrontendOnly: true,
|
||||
},
|
||||
{
|
||||
Name: "alertingListViewV2",
|
||||
Description: "Enables the new alert list view design",
|
||||
@@ -1080,6 +1087,13 @@ var (
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: identityAccessTeam,
|
||||
},
|
||||
{
|
||||
Name: "unifiedStorageSearch",
|
||||
Description: "Enable unified storage search",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaSearchAndStorageSquad,
|
||||
HideFromDocs: true,
|
||||
},
|
||||
{
|
||||
Name: "unifiedStorageSearchSprinkles",
|
||||
Description: "Enable sprinkles on unified storage search",
|
||||
@@ -1571,8 +1585,8 @@ var (
|
||||
},
|
||||
{
|
||||
Name: "kubernetesAuthzApis",
|
||||
Description: "Deprecated: Use kubernetesAuthzCoreRolesApi, kubernetesAuthzRolesApi, and kubernetesAuthzRoleBindingsApi instead",
|
||||
Stage: FeatureStageDeprecated,
|
||||
Description: "Registers AuthZ /apis endpoint",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: identityAccessTeam,
|
||||
HideFromDocs: true,
|
||||
},
|
||||
@@ -1597,27 +1611,6 @@ var (
|
||||
Owner: identityAccessTeam,
|
||||
HideFromDocs: true,
|
||||
},
|
||||
{
|
||||
Name: "kubernetesAuthzCoreRolesApi",
|
||||
Description: "Registers AuthZ Core Roles /apis endpoint",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: identityAccessTeam,
|
||||
HideFromDocs: true,
|
||||
},
|
||||
{
|
||||
Name: "kubernetesAuthzRolesApi",
|
||||
Description: "Registers AuthZ Roles /apis endpoint",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: identityAccessTeam,
|
||||
HideFromDocs: true,
|
||||
},
|
||||
{
|
||||
Name: "kubernetesAuthzRoleBindingsApi",
|
||||
Description: "Registers AuthZ Role Bindings /apis endpoint",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: identityAccessTeam,
|
||||
HideFromDocs: true,
|
||||
},
|
||||
{
|
||||
Name: "kubernetesAuthnMutation",
|
||||
Description: "Enables create, delete, and update mutations for resources owned by IAM identity",
|
||||
@@ -1866,6 +1859,14 @@ var (
|
||||
Expression: "false",
|
||||
RequiresRestart: true,
|
||||
},
|
||||
{
|
||||
Name: "tempoSearchBackendMigration",
|
||||
Description: "Run search queries through the tempo backend",
|
||||
Stage: FeatureStageGeneralAvailability,
|
||||
Owner: grafanaOSSBigTent,
|
||||
Expression: "false",
|
||||
RequiresRestart: true,
|
||||
},
|
||||
{
|
||||
Name: "cdnPluginsLoadFirst",
|
||||
Description: "Prioritize loading plugins from the CDN before other sources",
|
||||
|
||||
Generated
+4
-4
@@ -120,6 +120,7 @@ queryLibrary,preview,@grafana/sharing-squad,false,false,false
|
||||
dashboardLibrary,experimental,@grafana/sharing-squad,false,false,false
|
||||
suggestedDashboards,experimental,@grafana/sharing-squad,false,false,false
|
||||
dashboardTemplates,preview,@grafana/sharing-squad,false,false,false
|
||||
logsExploreTableDefaultVisualization,experimental,@grafana/observability-logs,false,false,true
|
||||
alertingListViewV2,privatePreview,@grafana/alerting-squad,false,false,true
|
||||
alertingSavedSearches,experimental,@grafana/alerting-squad,false,false,true
|
||||
alertingDisableSendAlertsExternal,experimental,@grafana/alerting-squad,false,false,false
|
||||
@@ -149,6 +150,7 @@ alertingQueryAndExpressionsStepMode,GA,@grafana/alerting-squad,false,false,true
|
||||
improvedExternalSessionHandling,GA,@grafana/identity-access-team,false,false,false
|
||||
useSessionStorageForRedirection,GA,@grafana/identity-access-team,false,false,false
|
||||
rolePickerDrawer,experimental,@grafana/identity-access-team,false,false,false
|
||||
unifiedStorageSearch,experimental,@grafana/search-and-storage,false,false,false
|
||||
unifiedStorageSearchSprinkles,experimental,@grafana/search-and-storage,false,false,false
|
||||
managedDualWriter,experimental,@grafana/search-and-storage,false,false,false
|
||||
pluginsSriChecks,GA,@grafana/plugins-platform-backend,false,false,false
|
||||
@@ -215,13 +217,10 @@ pluginsAutoUpdate,experimental,@grafana/plugins-platform-backend,false,false,fal
|
||||
alertingListViewV2PreviewToggle,privatePreview,@grafana/alerting-squad,false,false,true
|
||||
alertRuleUseFiredAtForStartsAt,experimental,@grafana/alerting-squad,false,false,false
|
||||
alertingBulkActionsInUI,GA,@grafana/alerting-squad,false,false,true
|
||||
kubernetesAuthzApis,deprecated,@grafana/identity-access-team,false,false,false
|
||||
kubernetesAuthzApis,experimental,@grafana/identity-access-team,false,false,false
|
||||
kubernetesAuthZHandlerRedirect,experimental,@grafana/identity-access-team,false,false,false
|
||||
kubernetesAuthzResourcePermissionApis,experimental,@grafana/identity-access-team,false,false,false
|
||||
kubernetesAuthzZanzanaSync,experimental,@grafana/identity-access-team,false,false,false
|
||||
kubernetesAuthzCoreRolesApi,experimental,@grafana/identity-access-team,false,false,false
|
||||
kubernetesAuthzRolesApi,experimental,@grafana/identity-access-team,false,false,false
|
||||
kubernetesAuthzRoleBindingsApi,experimental,@grafana/identity-access-team,false,false,false
|
||||
kubernetesAuthnMutation,experimental,@grafana/identity-access-team,false,false,false
|
||||
kubernetesExternalGroupMapping,experimental,@grafana/identity-access-team,false,false,false
|
||||
restoreDashboards,experimental,@grafana/grafana-search-navigate-organise,false,false,false
|
||||
@@ -254,6 +253,7 @@ graphiteBackendMode,privatePreview,@grafana/partner-datasources,false,false,fals
|
||||
azureResourcePickerUpdates,GA,@grafana/partner-datasources,false,false,true
|
||||
prometheusTypeMigration,experimental,@grafana/partner-datasources,false,true,false
|
||||
pluginContainers,privatePreview,@grafana/plugins-platform-backend,false,true,false
|
||||
tempoSearchBackendMigration,GA,@grafana/oss-big-tent,false,true,false
|
||||
cdnPluginsLoadFirst,experimental,@grafana/plugins-platform-backend,false,false,false
|
||||
cdnPluginsUrls,experimental,@grafana/plugins-platform-backend,false,false,false
|
||||
pluginInstallAPISync,experimental,@grafana/plugins-platform-backend,false,false,false
|
||||
|
||||
|
Generated
+9
-13
@@ -455,6 +455,10 @@ const (
|
||||
// Enables the new role picker drawer design
|
||||
FlagRolePickerDrawer = "rolePickerDrawer"
|
||||
|
||||
// FlagUnifiedStorageSearch
|
||||
// Enable unified storage search
|
||||
FlagUnifiedStorageSearch = "unifiedStorageSearch"
|
||||
|
||||
// FlagUnifiedStorageSearchSprinkles
|
||||
// Enable sprinkles on unified storage search
|
||||
FlagUnifiedStorageSearchSprinkles = "unifiedStorageSearchSprinkles"
|
||||
@@ -627,7 +631,7 @@ const (
|
||||
FlagAlertRuleUseFiredAtForStartsAt = "alertRuleUseFiredAtForStartsAt"
|
||||
|
||||
// FlagKubernetesAuthzApis
|
||||
// Deprecated: Use kubernetesAuthzCoreRolesApi, kubernetesAuthzRolesApi, and kubernetesAuthzRoleBindingsApi instead
|
||||
// Registers AuthZ /apis endpoint
|
||||
FlagKubernetesAuthzApis = "kubernetesAuthzApis"
|
||||
|
||||
// FlagKubernetesAuthZHandlerRedirect
|
||||
@@ -642,18 +646,6 @@ const (
|
||||
// Enable sync of Zanzana authorization store on AuthZ CRD mutations
|
||||
FlagKubernetesAuthzZanzanaSync = "kubernetesAuthzZanzanaSync"
|
||||
|
||||
// FlagKubernetesAuthzCoreRolesApi
|
||||
// Registers AuthZ Core Roles /apis endpoint
|
||||
FlagKubernetesAuthzCoreRolesApi = "kubernetesAuthzCoreRolesApi"
|
||||
|
||||
// FlagKubernetesAuthzRolesApi
|
||||
// Registers AuthZ Roles /apis endpoint
|
||||
FlagKubernetesAuthzRolesApi = "kubernetesAuthzRolesApi"
|
||||
|
||||
// FlagKubernetesAuthzRoleBindingsApi
|
||||
// Registers AuthZ Role Bindings /apis endpoint
|
||||
FlagKubernetesAuthzRoleBindingsApi = "kubernetesAuthzRoleBindingsApi"
|
||||
|
||||
// FlagKubernetesAuthnMutation
|
||||
// Enables create, delete, and update mutations for resources owned by IAM identity
|
||||
FlagKubernetesAuthnMutation = "kubernetesAuthnMutation"
|
||||
@@ -738,6 +730,10 @@ const (
|
||||
// Enables running plugins in containers
|
||||
FlagPluginContainers = "pluginContainers"
|
||||
|
||||
// FlagTempoSearchBackendMigration
|
||||
// Run search queries through the tempo backend
|
||||
FlagTempoSearchBackendMigration = "tempoSearchBackendMigration"
|
||||
|
||||
// FlagCdnPluginsLoadFirst
|
||||
// Prioritize loading plugins from the CDN before other sources
|
||||
FlagCdnPluginsLoadFirst = "cdnPluginsLoadFirst"
|
||||
|
||||
+5
-49
@@ -1951,27 +1951,11 @@
|
||||
{
|
||||
"metadata": {
|
||||
"name": "kubernetesAuthzApis",
|
||||
"resourceVersion": "1767954559317",
|
||||
"creationTimestamp": "2025-06-18T07:43:01Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2026-01-09 10:29:19.317164 +0000 UTC"
|
||||
}
|
||||
"resourceVersion": "1764664939750",
|
||||
"creationTimestamp": "2025-06-18T07:43:01Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Deprecated: Use kubernetesAuthzCoreRolesApi, kubernetesAuthzRolesApi, and kubernetesAuthzRoleBindingsApi instead",
|
||||
"stage": "deprecated",
|
||||
"codeowner": "@grafana/identity-access-team",
|
||||
"hideFromDocs": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "kubernetesAuthzCoreRolesApi",
|
||||
"resourceVersion": "1767954459090",
|
||||
"creationTimestamp": "2026-01-09T10:27:39Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Registers AuthZ Core Roles /apis endpoint",
|
||||
"description": "Registers AuthZ /apis endpoint",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/identity-access-team",
|
||||
"hideFromDocs": true
|
||||
@@ -1991,32 +1975,6 @@
|
||||
"hideFromDocs": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "kubernetesAuthzRoleBindingsApi",
|
||||
"resourceVersion": "1767954459090",
|
||||
"creationTimestamp": "2026-01-09T10:27:39Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Registers AuthZ Role Bindings /apis endpoint",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/identity-access-team",
|
||||
"hideFromDocs": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "kubernetesAuthzRolesApi",
|
||||
"resourceVersion": "1767954459090",
|
||||
"creationTimestamp": "2026-01-09T10:27:39Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Registers AuthZ Roles /apis endpoint",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/identity-access-team",
|
||||
"hideFromDocs": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "kubernetesAuthzZanzanaSync",
|
||||
@@ -2246,8 +2204,7 @@
|
||||
"metadata": {
|
||||
"name": "logsExploreTableDefaultVisualization",
|
||||
"resourceVersion": "1764664939750",
|
||||
"creationTimestamp": "2024-05-02T15:28:15Z",
|
||||
"deletionTimestamp": "2026-01-12T14:11:46Z"
|
||||
"creationTimestamp": "2024-05-02T15:28:15Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Sets the logs table as default visualisation in logs explore",
|
||||
@@ -3698,8 +3655,7 @@
|
||||
"metadata": {
|
||||
"name": "unifiedStorageSearch",
|
||||
"resourceVersion": "1764664939750",
|
||||
"creationTimestamp": "2024-09-30T19:46:14Z",
|
||||
"deletionTimestamp": "2026-01-12T10:02:12Z"
|
||||
"creationTimestamp": "2024-09-30T19:46:14Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enable unified storage search",
|
||||
|
||||
@@ -100,9 +100,6 @@ func (d *DsLookup) ByRef(ref *DataSourceRef) *DataSourceRef {
|
||||
if ref == nil {
|
||||
return d.defaultDS
|
||||
}
|
||||
if ref.UID == "default" && ref.Type == "" {
|
||||
return d.defaultDS
|
||||
}
|
||||
|
||||
key := ""
|
||||
if ref.UID != "" {
|
||||
@@ -120,13 +117,7 @@ func (d *DsLookup) ByRef(ref *DataSourceRef) *DataSourceRef {
|
||||
return ds
|
||||
}
|
||||
|
||||
ds, ok = d.byName[key]
|
||||
if ok {
|
||||
return ds
|
||||
}
|
||||
|
||||
// With nothing was found (or configured), use the original reference
|
||||
return ref
|
||||
return d.byName[key]
|
||||
}
|
||||
|
||||
func (d *DsLookup) ByType(dsType string) []DataSourceRef {
|
||||
|
||||
+4
-4
@@ -4,8 +4,8 @@
|
||||
"tags": null,
|
||||
"datasource": [
|
||||
{
|
||||
"uid": "000000001",
|
||||
"type": "graphite"
|
||||
"uid": "default.uid",
|
||||
"type": "default.type"
|
||||
}
|
||||
],
|
||||
"panels": [
|
||||
@@ -16,8 +16,8 @@
|
||||
"libraryPanel": "dfkljg98345dkf",
|
||||
"datasource": [
|
||||
{
|
||||
"uid": "000000001",
|
||||
"type": "graphite"
|
||||
"uid": "default.uid",
|
||||
"type": "default.type"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package dashboard
|
||||
|
||||
import "iter"
|
||||
|
||||
type PanelSummaryInfo struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
@@ -32,20 +30,3 @@ type DashboardSummaryInfo struct {
|
||||
Refresh string `json:"refresh,omitempty"`
|
||||
ReadOnly bool `json:"readOnly,omitempty"` // editable = false
|
||||
}
|
||||
|
||||
func (d *DashboardSummaryInfo) PanelIterator() iter.Seq[PanelSummaryInfo] {
|
||||
return func(yield func(PanelSummaryInfo) bool) {
|
||||
for _, p := range d.Panels {
|
||||
if len(p.Collapsed) > 0 {
|
||||
for _, c := range p.Collapsed {
|
||||
if !yield(c) { // NOTE, rows can only be one level deep!
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if !yield(p) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,15 +5,17 @@ import (
|
||||
"net/url"
|
||||
)
|
||||
|
||||
type OpenFeatureProviderType string
|
||||
|
||||
const (
|
||||
StaticProviderType = "static"
|
||||
FeaturesServiceProviderType = "features-service"
|
||||
OFREPProviderType = "ofrep"
|
||||
StaticProviderType OpenFeatureProviderType = "static"
|
||||
FeaturesServiceProviderType OpenFeatureProviderType = "features-service"
|
||||
OFREPProviderType OpenFeatureProviderType = "ofrep"
|
||||
)
|
||||
|
||||
type OpenFeatureSettings struct {
|
||||
APIEnabled bool
|
||||
ProviderType string
|
||||
ProviderType OpenFeatureProviderType
|
||||
URL *url.URL
|
||||
TargetingKey string
|
||||
ContextAttrs map[string]string
|
||||
@@ -24,7 +26,27 @@ func (cfg *Cfg) readOpenFeatureSettings() error {
|
||||
|
||||
config := cfg.Raw.Section("feature_toggles.openfeature")
|
||||
cfg.OpenFeature.APIEnabled = config.Key("enable_api").MustBool(true)
|
||||
cfg.OpenFeature.ProviderType = config.Key("provider").MustString(StaticProviderType)
|
||||
|
||||
providerType := config.Key("provider").Validate(func(in string) string {
|
||||
if in == "" {
|
||||
return string(StaticProviderType)
|
||||
}
|
||||
|
||||
switch in {
|
||||
case string(StaticProviderType):
|
||||
return string(StaticProviderType)
|
||||
case string(FeaturesServiceProviderType):
|
||||
return string(FeaturesServiceProviderType)
|
||||
case string(OFREPProviderType):
|
||||
return string(OFREPProviderType)
|
||||
default:
|
||||
cfg.Logger.Warn("invalid provider type", "provider", in)
|
||||
cfg.Logger.Info("using static provider for openfeature")
|
||||
return string(StaticProviderType)
|
||||
}
|
||||
})
|
||||
|
||||
cfg.OpenFeature.ProviderType = OpenFeatureProviderType(providerType)
|
||||
strURL := config.Key("url").MustString("")
|
||||
|
||||
defaultTargetingKey := "default"
|
||||
|
||||
@@ -236,6 +236,7 @@ kubernetesDashboards = true
|
||||
kubernetesFolders = true
|
||||
unifiedStorage = true
|
||||
unifiedStorageHistoryPruner = true
|
||||
unifiedStorageSearch = true
|
||||
unifiedStorageSearchPermissionFiltering = false
|
||||
unifiedStorageSearchSprinkles = false
|
||||
|
||||
|
||||
@@ -863,7 +863,7 @@ func newRebuildRequest(key NamespacedResource, minBuildTime, lastImportTime time
|
||||
|
||||
func (s *searchSupport) getOrCreateIndex(ctx context.Context, stats *SearchStats, key NamespacedResource, reason string) (ResourceIndex, error) {
|
||||
if s == nil || s.search == nil {
|
||||
return nil, fmt.Errorf("search is not configured properly (missing enable_search config?)")
|
||||
return nil, fmt.Errorf("search is not configured properly (missing unifiedStorageSearch feature toggle?)")
|
||||
}
|
||||
|
||||
ctx, span := tracer.Start(ctx, "resource.searchSupport.getOrCreateIndex")
|
||||
|
||||
@@ -1253,23 +1253,21 @@ func (b *bleveIndex) toBleveSearchRequest(ctx context.Context, req *resourcepb.R
|
||||
queryExact.SetField(resource.SEARCH_FIELD_TITLE)
|
||||
queryExact.Analyzer = keyword.Name // don't analyze the query input - treat it as a single token
|
||||
queryExact.Operator = query.MatchQueryOperatorAnd // This doesn't make a difference for keyword analyzer, we add it just to be explicit.
|
||||
searchQuery := bleve.NewDisjunctionQuery(queryExact)
|
||||
|
||||
// Query 2: Phrase query with standard analyzer
|
||||
queryPhrase := bleve.NewMatchPhraseQuery(req.Query)
|
||||
queryPhrase.SetBoost(5.0)
|
||||
queryPhrase.SetField(resource.SEARCH_FIELD_TITLE)
|
||||
queryPhrase.Analyzer = standard.Name
|
||||
searchQuery.AddQuery(queryPhrase)
|
||||
|
||||
// Query 3: Match query with standard analyzer
|
||||
queryAnalyzed := bleve.NewMatchQuery(removeSmallTerms(req.Query))
|
||||
queryAnalyzed.SetField(resource.SEARCH_FIELD_TITLE)
|
||||
queryAnalyzed.SetBoost(2.0)
|
||||
queryAnalyzed.Analyzer = standard.Name
|
||||
queryAnalyzed.Operator = query.MatchQueryOperatorAnd // Make sure all terms from the query are matched
|
||||
searchQuery.AddQuery(queryAnalyzed)
|
||||
|
||||
// At least one of the queries must match
|
||||
searchQuery := bleve.NewDisjunctionQuery(queryExact, queryAnalyzed, queryPhrase)
|
||||
queries = append(queries, searchQuery)
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"go.uber.org/goleak"
|
||||
|
||||
authlib "github.com/grafana/authlib/types"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
@@ -19,7 +18,6 @@ import (
|
||||
const DASHBOARD_SCHEMA_VERSION = "schema_version"
|
||||
const DASHBOARD_LINK_COUNT = "link_count"
|
||||
const DASHBOARD_PANEL_TYPES = "panel_types"
|
||||
const DASHBOARD_PANEL_TITLE = "panel_title"
|
||||
const DASHBOARD_DS_TYPES = "ds_types"
|
||||
const DASHBOARD_TRANSFORMATIONS = "transformation"
|
||||
const DASHBOARD_LIBRARY_PANEL_REFERENCE = "reference.LibraryPanel"
|
||||
@@ -55,21 +53,11 @@ func DashboardBuilder(namespaced resource.NamespacedDocumentSupplier) (resource.
|
||||
Type: resourcepb.ResourceTableColumnDefinition_INT32,
|
||||
Description: "How many links appear on the page",
|
||||
},
|
||||
{
|
||||
Name: DASHBOARD_PANEL_TITLE,
|
||||
Type: resourcepb.ResourceTableColumnDefinition_STRING,
|
||||
IsArray: true,
|
||||
Description: "The panel title text",
|
||||
Properties: &resourcepb.ResourceTableColumnDefinition_Properties{
|
||||
Filterable: false, // full text
|
||||
FreeText: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: DASHBOARD_PANEL_TYPES,
|
||||
Type: resourcepb.ResourceTableColumnDefinition_STRING,
|
||||
IsArray: true,
|
||||
Description: "The panel types used in this dashboard",
|
||||
Description: "How many links appear on the page",
|
||||
Properties: &resourcepb.ResourceTableColumnDefinition_Properties{
|
||||
Filterable: true,
|
||||
},
|
||||
@@ -281,22 +269,14 @@ func (s *DashboardDocumentBuilder) BuildDocument(ctx context.Context, key *resou
|
||||
doc.Description = summary.Description
|
||||
doc.Tags = summary.Tags
|
||||
|
||||
panelTitles := []string{}
|
||||
panelTypes := []string{}
|
||||
transformations := []string{}
|
||||
dsTypes := []string{}
|
||||
|
||||
for p := range summary.PanelIterator() {
|
||||
switch p.Type {
|
||||
case "": // ignore
|
||||
case "row": // row should map to a layout type when we support v2 constructs
|
||||
default:
|
||||
for _, p := range summary.Panels {
|
||||
if p.Type != "" {
|
||||
panelTypes = append(panelTypes, p.Type)
|
||||
}
|
||||
|
||||
if len(p.Title) > 0 {
|
||||
panelTitles = append(panelTitles, p.Title)
|
||||
}
|
||||
if len(p.Transformer) > 0 {
|
||||
transformations = append(transformations, p.Transformer...)
|
||||
}
|
||||
@@ -329,20 +309,17 @@ func (s *DashboardDocumentBuilder) BuildDocument(ctx context.Context, key *resou
|
||||
resource.SEARCH_FIELD_LEGACY_ID: summary.ID,
|
||||
}
|
||||
|
||||
if len(panelTitles) > 0 {
|
||||
doc.Fields[DASHBOARD_PANEL_TITLE] = panelTitles
|
||||
}
|
||||
if len(panelTypes) > 0 {
|
||||
sort.Strings(panelTypes)
|
||||
doc.Fields[DASHBOARD_PANEL_TYPES] = slices.Compact(panelTypes) // distinct values
|
||||
doc.Fields[DASHBOARD_PANEL_TYPES] = panelTypes
|
||||
}
|
||||
if len(dsTypes) > 0 {
|
||||
sort.Strings(dsTypes)
|
||||
doc.Fields[DASHBOARD_DS_TYPES] = slices.Compact(dsTypes) // distinct values
|
||||
doc.Fields[DASHBOARD_DS_TYPES] = dsTypes
|
||||
}
|
||||
if len(transformations) > 0 {
|
||||
sort.Strings(transformations)
|
||||
doc.Fields[DASHBOARD_TRANSFORMATIONS] = slices.Compact(transformations) // distinct values
|
||||
doc.Fields[DASHBOARD_TRANSFORMATIONS] = transformations
|
||||
}
|
||||
|
||||
for k, v := range s.Stats[summary.UID] {
|
||||
|
||||
@@ -32,16 +32,10 @@
|
||||
"errors_last_7_days": 1,
|
||||
"grafana.app/deprecatedInternalID": 141,
|
||||
"link_count": 0,
|
||||
"panel_title": [
|
||||
"green pie",
|
||||
"red pie",
|
||||
"blue pie",
|
||||
"collapsed row"
|
||||
],
|
||||
"panel_types": [
|
||||
"barchart",
|
||||
"graph",
|
||||
"pie"
|
||||
"row"
|
||||
],
|
||||
"schema_version": 38
|
||||
},
|
||||
@@ -52,12 +46,6 @@
|
||||
"kind": "DataSource",
|
||||
"name": "DSUID"
|
||||
},
|
||||
{
|
||||
"relation": "depends-on",
|
||||
"group": "dashboards.grafana.app",
|
||||
"kind": "LibraryPanel",
|
||||
"name": "l3d2s634-fdgf-75u4-3fg3-67j966ii7jur"
|
||||
},
|
||||
{
|
||||
"relation": "depends-on",
|
||||
"group": "dashboards.grafana.app",
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
"name": "red pie",
|
||||
"uid": "e1d5f519-dabd-47c6-9ad7-83d181ce1cee"
|
||||
},
|
||||
"title": "red pie"
|
||||
"title": "green pie"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
@@ -78,14 +78,6 @@
|
||||
"id": 8,
|
||||
"type": "graph"
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"type": "graph"
|
||||
},
|
||||
{
|
||||
"id": 30,
|
||||
"type": "graph"
|
||||
},
|
||||
{
|
||||
"collapsed": true,
|
||||
"gridPos": {
|
||||
@@ -109,10 +101,6 @@
|
||||
"uid": "l3d2s634-fdgf-75u4-3fg3-67j966ii7jur"
|
||||
},
|
||||
"title": "blue pie"
|
||||
},
|
||||
{
|
||||
"id": 40,
|
||||
"type": "pie"
|
||||
}
|
||||
],
|
||||
"title": "collapsed row",
|
||||
|
||||
@@ -19,7 +19,7 @@ func NewSearchOptions(
|
||||
ownsIndexFn func(key resource.NamespacedResource) (bool, error),
|
||||
) (resource.SearchOptions, error) {
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if cfg.EnableSearch || features.IsEnabledGlobally(featuremgmt.FlagProvisioning) {
|
||||
if cfg.EnableSearch || features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorageSearch) || features.IsEnabledGlobally(featuremgmt.FlagProvisioning) {
|
||||
root := cfg.IndexPath
|
||||
if root == "" {
|
||||
root = filepath.Join(cfg.DataPath, "unified-search", "bleve")
|
||||
|
||||
+1
-11
@@ -71,18 +71,11 @@
|
||||
"description": "How many links appear on the page",
|
||||
"priority": 0
|
||||
},
|
||||
{
|
||||
"name": "panel_title",
|
||||
"type": "string",
|
||||
"format": "",
|
||||
"description": "The panel title text",
|
||||
"priority": 0
|
||||
},
|
||||
{
|
||||
"name": "panel_types",
|
||||
"type": "string",
|
||||
"format": "",
|
||||
"description": "The panel types used in this dashboard",
|
||||
"description": "How many links appear on the page",
|
||||
"priority": 0
|
||||
},
|
||||
{
|
||||
@@ -221,7 +214,6 @@
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
],
|
||||
"object": {
|
||||
@@ -247,7 +239,6 @@
|
||||
"repo",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
[
|
||||
"timeseries"
|
||||
],
|
||||
@@ -291,7 +282,6 @@
|
||||
"repo",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
[
|
||||
"timeseries",
|
||||
"table"
|
||||
|
||||
@@ -4,15 +4,10 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/client-go/dynamic"
|
||||
@@ -21,167 +16,12 @@ import (
|
||||
dashboardV0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"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/util/testutil"
|
||||
)
|
||||
|
||||
func TestIntegrationSearchDevDashboards(t *testing.T) {
|
||||
testutil.SkipIntegrationTestInShortMode(t)
|
||||
ctx := context.Background()
|
||||
|
||||
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
|
||||
DisableDataMigrations: true,
|
||||
AppModeProduction: true,
|
||||
DisableAnonymous: true,
|
||||
APIServerStorageType: "unified",
|
||||
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
|
||||
"dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode5},
|
||||
"folders.folder.grafana.app": {DualWriterMode: rest.Mode5},
|
||||
},
|
||||
UnifiedStorageEnableSearch: true,
|
||||
})
|
||||
defer helper.Shutdown()
|
||||
|
||||
// Create devenv dashboards from legacy API
|
||||
cfg := dynamic.ConfigFor(helper.Org1.Admin.NewRestConfig())
|
||||
cfg.GroupVersion = &dashboardV0.GroupVersion
|
||||
adminClient, err := k8srest.RESTClientFor(cfg)
|
||||
require.NoError(t, err)
|
||||
adminClient.Get()
|
||||
|
||||
fileCount := 0
|
||||
devenv := "../../../../devenv/dev-dashboards/panel-timeseries"
|
||||
err = filepath.WalkDir(devenv, func(p string, d fs.DirEntry, e error) error {
|
||||
require.NoError(t, err)
|
||||
if d.IsDir() || filepath.Ext(d.Name()) != ".json" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// use the filename as UID
|
||||
uid := strings.TrimSuffix(d.Name(), ".json")
|
||||
if len(uid) > 40 {
|
||||
uid = uid[:40] // avoid uid too long, max 40 characters
|
||||
}
|
||||
|
||||
// nolint:gosec
|
||||
data, err := os.ReadFile(p)
|
||||
require.NoError(t, err)
|
||||
|
||||
cmd := dashboards.SaveDashboardCommand{
|
||||
Dashboard: &simplejson.Json{},
|
||||
Overwrite: true,
|
||||
}
|
||||
err = cmd.Dashboard.FromDB(data)
|
||||
require.NoError(t, err)
|
||||
cmd.Dashboard.Set("id", nil)
|
||||
cmd.Dashboard.Set("uid", uid)
|
||||
data, err = json.Marshal(cmd)
|
||||
require.NoError(t, err)
|
||||
|
||||
var statusCode int
|
||||
result := adminClient.Post().AbsPath("api", "dashboards", "db").
|
||||
Body(data).
|
||||
SetHeader("Content-type", "application/json").
|
||||
Do(ctx).
|
||||
StatusCode(&statusCode)
|
||||
require.NoError(t, result.Error(), "file: [%d] %s [status:%d]", fileCount, d.Name(), statusCode)
|
||||
require.Equal(t, int(http.StatusOK), statusCode)
|
||||
fileCount++
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 16, fileCount, "file count from %s", devenv)
|
||||
|
||||
// Helper to call search
|
||||
callSearch := func(user apis.User, params string) dashboardV0.SearchResults {
|
||||
require.NotNil(t, user)
|
||||
ns := user.Identity.GetNamespace()
|
||||
cfg := dynamic.ConfigFor(user.NewRestConfig())
|
||||
cfg.GroupVersion = &dashboardV0.GroupVersion
|
||||
restClient, err := k8srest.RESTClientFor(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
var statusCode int
|
||||
req := restClient.Get().AbsPath("apis", "dashboard.grafana.app", "v0alpha1", "namespaces", ns, "search").
|
||||
Param("limit", "1000").
|
||||
Param("type", "dashboard") // Only search dashboards
|
||||
|
||||
for kv := range strings.SplitSeq(params, "&") {
|
||||
if kv == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(kv, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
req = req.Param(parts[0], parts[1])
|
||||
}
|
||||
}
|
||||
res := req.Do(ctx).StatusCode(&statusCode)
|
||||
require.NoError(t, res.Error())
|
||||
require.Equal(t, int(http.StatusOK), statusCode)
|
||||
var sr dashboardV0.SearchResults
|
||||
raw, err := res.Raw()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, json.Unmarshal(raw, &sr))
|
||||
|
||||
// Normalize scores and query cost for snapshot comparison
|
||||
sr.QueryCost = 0 // this depends on the hardware
|
||||
sr.MaxScore = roundTo(sr.MaxScore, 3)
|
||||
for i := range sr.Hits {
|
||||
sr.Hits[i].Score = roundTo(sr.Hits[i].Score, 3) // 0.6250571494814442 -> 0.625
|
||||
}
|
||||
return sr
|
||||
}
|
||||
|
||||
// Compare a results to snapshots
|
||||
testCases := []struct {
|
||||
name string
|
||||
user apis.User
|
||||
params string
|
||||
}{
|
||||
{
|
||||
name: "all",
|
||||
user: helper.Org1.Admin,
|
||||
params: "", // only dashboards
|
||||
},
|
||||
{
|
||||
name: "simple-query",
|
||||
user: helper.Org1.Admin,
|
||||
params: "query=stacking",
|
||||
},
|
||||
{
|
||||
name: "with-text-panel",
|
||||
user: helper.Org1.Admin,
|
||||
params: "field=panel_types&panelType=text",
|
||||
},
|
||||
}
|
||||
for i, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
res := callSearch(tc.user, tc.params)
|
||||
jj, err := json.MarshalIndent(res, "", " ")
|
||||
require.NoError(t, err)
|
||||
|
||||
fname := fmt.Sprintf("testdata/searchV0/t%02d-%s.json", i, tc.name)
|
||||
// nolint:gosec
|
||||
snapshot, err := os.ReadFile(fname)
|
||||
if err != nil {
|
||||
assert.Failf(t, "Failed to read snapshot", "file: %s", fname)
|
||||
err = os.WriteFile(fname, jj, 0o644)
|
||||
require.NoErrorf(t, err, "Failed to write snapshot file %s", fname)
|
||||
return
|
||||
}
|
||||
|
||||
if !assert.JSONEq(t, string(snapshot), string(jj)) {
|
||||
err = os.WriteFile(fname, jj, 0o644)
|
||||
require.NoErrorf(t, err, "Failed to write snapshot file %s", fname)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationSearchPermissionFiltering(t *testing.T) {
|
||||
testutil.SkipIntegrationTestInShortMode(t)
|
||||
|
||||
@@ -445,11 +285,3 @@ func setFolderPermissions(t *testing.T, helper *apis.K8sTestHelper, actingUser a
|
||||
|
||||
require.Equal(t, http.StatusOK, resp.Response.StatusCode, "Failed to set permissions for folder %s", folderUID)
|
||||
}
|
||||
|
||||
// roundTo rounds a float64 to a specified number of decimal places.
|
||||
func roundTo(n float64, decimals uint32) float64 {
|
||||
// Calculate the power of 10 for the desired number of decimals
|
||||
scale := math.Pow(10, float64(decimals))
|
||||
// Multiply, round to the nearest integer, and then divide back
|
||||
return math.Round(n*scale) / scale
|
||||
}
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
{
|
||||
"totalHits": 16,
|
||||
"hits": [
|
||||
{
|
||||
"resource": "dashboards",
|
||||
"name": "timeseries",
|
||||
"title": "Panel Tests - Graph NG",
|
||||
"tags": [
|
||||
"gdev",
|
||||
"panel-tests",
|
||||
"graph-ng"
|
||||
]
|
||||
},
|
||||
{
|
||||
"resource": "dashboards",
|
||||
"name": "timeseries-by-value-color-schemes",
|
||||
"title": "Panel Tests - Graph NG - By value color schemes",
|
||||
"tags": [
|
||||
"gdev",
|
||||
"panel-tests",
|
||||
"graph-ng"
|
||||
]
|
||||
},
|
||||
{
|
||||
"resource": "dashboards",
|
||||
"name": "timeseries-nulls",
|
||||
"title": "Panel Tests - Graph NG - Discrete panels",
|
||||
"tags": [
|
||||
"gdev",
|
||||
"panel-tests",
|
||||
"graph-ng",
|
||||
"timeseries",
|
||||
"trend",
|
||||
"state-timeline",
|
||||
"transform"
|
||||
]
|
||||
},
|
||||
{
|
||||
"resource": "dashboards",
|
||||
"name": "timeseries-gradient-area",
|
||||
"title": "Panel Tests - Graph NG - Gradient Area Fills",
|
||||
"tags": [
|
||||
"gdev",
|
||||
"panel-tests",
|
||||
"graph-ng"
|
||||
]
|
||||
},
|
||||
{
|
||||
"resource": "dashboards",
|
||||
"name": "timeseries-soft-limits",
|
||||
"title": "Panel Tests - Graph NG - softMin/softMax",
|
||||
"tags": [
|
||||
"gdev",
|
||||
"panel-tests",
|
||||
"graph-ng"
|
||||
]
|
||||
},
|
||||
{
|
||||
"resource": "dashboards",
|
||||
"name": "timeseries-yaxis-ticks",
|
||||
"title": "Panel Tests - Graph NG - Y axis ticks",
|
||||
"tags": [
|
||||
"gdev",
|
||||
"panel-tests",
|
||||
"graph-ng"
|
||||
]
|
||||
},
|
||||
{
|
||||
"resource": "dashboards",
|
||||
"name": "timeseries-hue-gradients",
|
||||
"title": "Panel Tests - GraphNG - Hue Gradients",
|
||||
"tags": [
|
||||
"gdev",
|
||||
"panel-tests",
|
||||
"graph-ng"
|
||||
]
|
||||
},
|
||||
{
|
||||
"resource": "dashboards",
|
||||
"name": "timeseries-time",
|
||||
"title": "Panel Tests - GraphNG - Time Axis",
|
||||
"tags": [
|
||||
"gdev",
|
||||
"panel-tests",
|
||||
"graph-ng"
|
||||
]
|
||||
},
|
||||
{
|
||||
"resource": "dashboards",
|
||||
"name": "timeseries-thresholds",
|
||||
"title": "Panel Tests - GraphNG Thresholds",
|
||||
"tags": [
|
||||
"gdev",
|
||||
"panel-tests",
|
||||
"graph-ng"
|
||||
]
|
||||
},
|
||||
{
|
||||
"resource": "dashboards",
|
||||
"name": "timeseries-shared-tooltip-cursor-positio",
|
||||
"title": "Panel Tests - shared tooltips cursor positioning",
|
||||
"tags": [
|
||||
"gdev",
|
||||
"panel-tests",
|
||||
"graph-ng"
|
||||
]
|
||||
},
|
||||
{
|
||||
"resource": "dashboards",
|
||||
"name": "timeseries-bars-high-density",
|
||||
"title": "Panel Tests - TimeSeries - bars high density (stroke + fill)",
|
||||
"tags": [
|
||||
"gdev",
|
||||
"panel-tests",
|
||||
"graph-ng"
|
||||
]
|
||||
},
|
||||
{
|
||||
"resource": "dashboards",
|
||||
"name": "timeseries-out-of-rage",
|
||||
"title": "Panel Tests - Timeseries - Out of range",
|
||||
"tags": [
|
||||
"gdev",
|
||||
"panel-tests",
|
||||
"graph-ng"
|
||||
]
|
||||
},
|
||||
{
|
||||
"resource": "dashboards",
|
||||
"name": "timeseries-stacking",
|
||||
"title": "Panel Tests - TimeSeries - stacking",
|
||||
"tags": [
|
||||
"gdev",
|
||||
"panel-tests",
|
||||
"graph-ng"
|
||||
]
|
||||
},
|
||||
{
|
||||
"resource": "dashboards",
|
||||
"name": "timeseries-formats",
|
||||
"title": "Panel Tests - Timeseries - Supported input formats"
|
||||
},
|
||||
{
|
||||
"resource": "dashboards",
|
||||
"name": "timeseries-stacking2",
|
||||
"title": "TimeSeries \u0026 BarChart Stacking",
|
||||
"tags": [
|
||||
"gdev",
|
||||
"panel-tests",
|
||||
"graph-ng"
|
||||
]
|
||||
},
|
||||
{
|
||||
"resource": "dashboards",
|
||||
"name": "timeseries-y-ticks-zero-decimals",
|
||||
"title": "Zero Decimals Y Ticks",
|
||||
"tags": [
|
||||
"gdev",
|
||||
"panel-tests",
|
||||
"graph-ng"
|
||||
]
|
||||
}
|
||||
],
|
||||
"maxScore": 1
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"totalHits": 2,
|
||||
"hits": [
|
||||
{
|
||||
"resource": "dashboards",
|
||||
"name": "timeseries-stacking",
|
||||
"title": "Panel Tests - TimeSeries - stacking",
|
||||
"tags": [
|
||||
"gdev",
|
||||
"panel-tests",
|
||||
"graph-ng"
|
||||
],
|
||||
"score": 0.658
|
||||
},
|
||||
{
|
||||
"resource": "dashboards",
|
||||
"name": "timeseries-stacking2",
|
||||
"title": "TimeSeries \u0026 BarChart Stacking",
|
||||
"tags": [
|
||||
"gdev",
|
||||
"panel-tests",
|
||||
"graph-ng"
|
||||
],
|
||||
"score": 0.625
|
||||
}
|
||||
],
|
||||
"maxScore": 0.658
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"totalHits": 1,
|
||||
"hits": [
|
||||
{
|
||||
"resource": "dashboards",
|
||||
"name": "timeseries-formats",
|
||||
"title": "Panel Tests - Timeseries - Supported input formats",
|
||||
"field": {
|
||||
"panel_types": [
|
||||
"table",
|
||||
"text",
|
||||
"timeseries"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"maxScore": 1.778
|
||||
}
|
||||
@@ -2054,7 +2054,9 @@ func TestIntegrationDeleteFolderWithProvisionedDashboards(t *testing.T) {
|
||||
DualWriterMode: modeDw,
|
||||
},
|
||||
},
|
||||
UnifiedStorageEnableSearch: true,
|
||||
EnableFeatureToggles: []string{
|
||||
featuremgmt.FlagUnifiedStorageSearch,
|
||||
},
|
||||
}
|
||||
|
||||
setupProvisioningDir(t, &ops)
|
||||
@@ -2161,7 +2163,9 @@ func TestIntegrationProvisionedFolderPropagatesLabelsAndAnnotations(t *testing.T
|
||||
DualWriterMode: mode3,
|
||||
},
|
||||
},
|
||||
UnifiedStorageEnableSearch: true,
|
||||
EnableFeatureToggles: []string{
|
||||
featuremgmt.FlagUnifiedStorageSearch,
|
||||
},
|
||||
}
|
||||
|
||||
setupProvisioningDir(t, &ops)
|
||||
|
||||
@@ -1830,22 +1830,6 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "panelType",
|
||||
"in": "query",
|
||||
"description": "find dashboards using panels of a given plugin type",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "dataSourceType",
|
||||
"in": "query",
|
||||
"description": "find dashboards using datasources of a given plugin type",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "permission",
|
||||
"in": "query",
|
||||
|
||||
@@ -559,175 +559,3 @@ func TestIntegrationConnectionController_HealthCheckUpdates(t *testing.T) {
|
||||
assert.True(t, final.Status.Health.Healthy, "connection should remain healthy")
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegrationProvisioning_RepositoryFieldSelectorByConnection(t *testing.T) {
|
||||
testutil.SkipIntegrationTestInShortMode(t)
|
||||
|
||||
helper := runGrafana(t)
|
||||
ctx := context.Background()
|
||||
createOptions := metav1.CreateOptions{FieldValidation: "Strict"}
|
||||
|
||||
// Create a connection first
|
||||
connection := &unstructured.Unstructured{Object: map[string]any{
|
||||
"apiVersion": "provisioning.grafana.app/v0alpha1",
|
||||
"kind": "Connection",
|
||||
"metadata": map[string]any{
|
||||
"name": "test-conn-for-field-selector",
|
||||
"namespace": "default",
|
||||
},
|
||||
"spec": map[string]any{
|
||||
"type": "github",
|
||||
"github": map[string]any{
|
||||
"appID": "123456",
|
||||
"installationID": "789012",
|
||||
},
|
||||
},
|
||||
"secure": map[string]any{
|
||||
"privateKey": map[string]any{
|
||||
"create": "test-private-key",
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
|
||||
require.NoError(t, err, "failed to create connection")
|
||||
|
||||
t.Cleanup(func() {
|
||||
// Clean up repositories first
|
||||
_ = helper.Repositories.Resource.Delete(ctx, "repo-with-connection", metav1.DeleteOptions{})
|
||||
_ = helper.Repositories.Resource.Delete(ctx, "repo-without-connection", metav1.DeleteOptions{})
|
||||
_ = helper.Repositories.Resource.Delete(ctx, "repo-with-different-connection", metav1.DeleteOptions{})
|
||||
// Then clean up the connection
|
||||
_ = helper.Connections.Resource.Delete(ctx, "test-conn-for-field-selector", metav1.DeleteOptions{})
|
||||
})
|
||||
|
||||
// Create a repository WITH the connection
|
||||
repoWithConnection := &unstructured.Unstructured{Object: map[string]any{
|
||||
"apiVersion": "provisioning.grafana.app/v0alpha1",
|
||||
"kind": "Repository",
|
||||
"metadata": map[string]any{
|
||||
"name": "repo-with-connection",
|
||||
"namespace": "default",
|
||||
},
|
||||
"spec": map[string]any{
|
||||
"title": "Repo With Connection",
|
||||
"type": "local",
|
||||
"sync": map[string]any{
|
||||
"enabled": false,
|
||||
"target": "folder",
|
||||
},
|
||||
"local": map[string]any{
|
||||
"path": helper.ProvisioningPath,
|
||||
},
|
||||
"connection": map[string]any{
|
||||
"name": "test-conn-for-field-selector",
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
_, err = helper.Repositories.Resource.Create(ctx, repoWithConnection, createOptions)
|
||||
require.NoError(t, err, "failed to create repository with connection")
|
||||
|
||||
// Create a repository WITHOUT the connection
|
||||
repoWithoutConnection := &unstructured.Unstructured{Object: map[string]any{
|
||||
"apiVersion": "provisioning.grafana.app/v0alpha1",
|
||||
"kind": "Repository",
|
||||
"metadata": map[string]any{
|
||||
"name": "repo-without-connection",
|
||||
"namespace": "default",
|
||||
},
|
||||
"spec": map[string]any{
|
||||
"title": "Repo Without Connection",
|
||||
"type": "local",
|
||||
"sync": map[string]any{
|
||||
"enabled": false,
|
||||
"target": "folder",
|
||||
},
|
||||
"local": map[string]any{
|
||||
"path": helper.ProvisioningPath,
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
_, err = helper.Repositories.Resource.Create(ctx, repoWithoutConnection, createOptions)
|
||||
require.NoError(t, err, "failed to create repository without connection")
|
||||
|
||||
// Create a repository with a DIFFERENT connection name (non-existent)
|
||||
repoWithDifferentConnection := &unstructured.Unstructured{Object: map[string]any{
|
||||
"apiVersion": "provisioning.grafana.app/v0alpha1",
|
||||
"kind": "Repository",
|
||||
"metadata": map[string]any{
|
||||
"name": "repo-with-different-connection",
|
||||
"namespace": "default",
|
||||
},
|
||||
"spec": map[string]any{
|
||||
"title": "Repo With Different Connection",
|
||||
"type": "local",
|
||||
"sync": map[string]any{
|
||||
"enabled": false,
|
||||
"target": "folder",
|
||||
},
|
||||
"local": map[string]any{
|
||||
"path": helper.ProvisioningPath,
|
||||
},
|
||||
"connection": map[string]any{
|
||||
"name": "some-other-connection",
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
_, err = helper.Repositories.Resource.Create(ctx, repoWithDifferentConnection, createOptions)
|
||||
require.NoError(t, err, "failed to create repository with different connection")
|
||||
|
||||
t.Run("filter repositories by spec.connection.name", func(t *testing.T) {
|
||||
// List repositories with field selector for the specific connection
|
||||
list, err := helper.Repositories.Resource.List(ctx, metav1.ListOptions{
|
||||
FieldSelector: "spec.connection.name=test-conn-for-field-selector",
|
||||
})
|
||||
require.NoError(t, err, "failed to list repositories with field selector")
|
||||
|
||||
// Should only return the repository with the matching connection
|
||||
assert.Len(t, list.Items, 1, "should return exactly one repository")
|
||||
assert.Equal(t, "repo-with-connection", list.Items[0].GetName(), "should return the correct repository")
|
||||
})
|
||||
|
||||
t.Run("filter repositories by non-existent connection returns empty", func(t *testing.T) {
|
||||
// List repositories with field selector for a non-existent connection
|
||||
list, err := helper.Repositories.Resource.List(ctx, metav1.ListOptions{
|
||||
FieldSelector: "spec.connection.name=non-existent-connection",
|
||||
})
|
||||
require.NoError(t, err, "failed to list repositories with field selector")
|
||||
|
||||
// Should return empty list
|
||||
assert.Len(t, list.Items, 0, "should return no repositories for non-existent connection")
|
||||
})
|
||||
|
||||
t.Run("filter repositories by empty connection name", func(t *testing.T) {
|
||||
// List repositories with field selector for empty connection (repos without connection)
|
||||
list, err := helper.Repositories.Resource.List(ctx, metav1.ListOptions{
|
||||
FieldSelector: "spec.connection.name=",
|
||||
})
|
||||
require.NoError(t, err, "failed to list repositories with empty connection field selector")
|
||||
|
||||
// Should return the repository without a connection
|
||||
assert.Len(t, list.Items, 1, "should return exactly one repository without connection")
|
||||
assert.Equal(t, "repo-without-connection", list.Items[0].GetName(), "should return the repository without connection")
|
||||
})
|
||||
|
||||
t.Run("list all repositories without field selector", func(t *testing.T) {
|
||||
// List all repositories without field selector
|
||||
list, err := helper.Repositories.Resource.List(ctx, metav1.ListOptions{})
|
||||
require.NoError(t, err, "failed to list all repositories")
|
||||
|
||||
// Should return all three repositories
|
||||
assert.Len(t, list.Items, 3, "should return all three repositories")
|
||||
|
||||
names := make([]string, len(list.Items))
|
||||
for i, item := range list.Items {
|
||||
names[i] = item.GetName()
|
||||
}
|
||||
assert.Contains(t, names, "repo-with-connection")
|
||||
assert.Contains(t, names, "repo-without-connection")
|
||||
assert.Contains(t, names, "repo-with-different-connection")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { generatedAPI } from '@grafana/api-clients/rtkq/collections/v1alpha1';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { notifyApp } from 'app/core/actions';
|
||||
import { createSuccessNotification, createErrorNotification } from 'app/core/copy/appNotification';
|
||||
import { notifyApp } from 'app/core/reducers/appNotification';
|
||||
|
||||
export const collectionsAPIv1alpha1 = generatedAPI.enhanceEndpoints({
|
||||
endpoints: {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { generatedAPI, type Playlist, type PlaylistSpec } from '@grafana/api-clients/rtkq/playlist/v0alpha1';
|
||||
import { getBackendSrv } from '@grafana/runtime';
|
||||
|
||||
import { notifyApp } from '../../../../core/actions';
|
||||
import { createSuccessNotification } from '../../../../core/copy/appNotification';
|
||||
import { notifyApp } from '../../../../core/reducers/appNotification';
|
||||
import { contextSrv } from '../../../../core/services/context_srv';
|
||||
import { handleError } from '../../../utils';
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ import { isFetchError } from '@grafana/runtime';
|
||||
import { clearFolders } from 'app/features/browse-dashboards/state/slice';
|
||||
import { getState } from 'app/store/store';
|
||||
|
||||
import { notifyApp } from '../../../../core/actions';
|
||||
import { createSuccessNotification, createErrorNotification } from '../../../../core/copy/appNotification';
|
||||
import { notifyApp } from '../../../../core/reducers/appNotification';
|
||||
import { PAGE_SIZE } from '../../../../features/browse-dashboards/api/services';
|
||||
import { refetchChildren } from '../../../../features/browse-dashboards/state/actions';
|
||||
import { handleError } from '../../../utils';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { normalizeError } from '@grafana/api-clients';
|
||||
import { ThunkDispatch } from 'app/types/store';
|
||||
|
||||
import { notifyApp } from '../core/actions';
|
||||
import { createErrorNotification } from '../core/copy/appNotification';
|
||||
import { notifyApp } from '../core/reducers/appNotification';
|
||||
|
||||
/**
|
||||
* Handle an error from a k8s API call
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { hideAppNotification, notifyApp } from '../reducers/appNotification';
|
||||
import { updateNavIndex, updateConfigurationSubtitle } from '../reducers/navModel';
|
||||
|
||||
export { updateNavIndex, updateConfigurationSubtitle, notifyApp, hideAppNotification };
|
||||
@@ -4,9 +4,10 @@ import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { AlertErrorPayload, AlertPayload, AppEvents, GrafanaTheme2 } from '@grafana/data';
|
||||
import { useStyles2, Stack } from '@grafana/ui';
|
||||
import { notifyApp, hideAppNotification } from 'app/core/actions';
|
||||
import { appEvents } from 'app/core/app_events';
|
||||
import { useGrafana } from 'app/core/context/GrafanaContext';
|
||||
import { hideAppNotification, notifyApp, selectVisible } from 'app/core/reducers/appNotification';
|
||||
import { selectVisible } from 'app/core/reducers/appNotification';
|
||||
import { useSelector, useDispatch } from 'app/types/store';
|
||||
|
||||
import {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { dispatch as storeDispatch } from 'app/store/store';
|
||||
import { AppNotificationSeverity, AppNotification } from 'app/types/appNotifications';
|
||||
import { useDispatch } from 'app/types/store';
|
||||
|
||||
import { notifyApp } from '../reducers/appNotification';
|
||||
import { notifyApp } from '../actions';
|
||||
|
||||
const defaultSuccessNotification = {
|
||||
title: '',
|
||||
|
||||
@@ -29,7 +29,6 @@
|
||||
"unicons/bookmark",
|
||||
"unicons/book-open",
|
||||
"unicons/brackets-curly",
|
||||
"unicons/brain",
|
||||
"unicons/bug",
|
||||
"unicons/building",
|
||||
"unicons/calculator-alt",
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { getDataSourceSrv } from '@grafana/runtime';
|
||||
import { notifyApp } from 'app/core/actions';
|
||||
import { createErrorNotification, createWarningNotification } from 'app/core/copy/appNotification';
|
||||
import { dispatch } from 'app/store/store';
|
||||
import { RichHistoryQuery } from 'app/types/explore';
|
||||
@@ -22,7 +23,6 @@ import {
|
||||
} from '../history/RichHistoryStorage';
|
||||
import { createRetentionPeriodBoundary } from '../history/richHistoryLocalStorageUtils';
|
||||
import { getLocalRichHistoryStorage, getRichHistoryStorage } from '../history/richHistoryStorageProvider';
|
||||
import { notifyApp } from '../reducers/appNotification';
|
||||
import { contextSrv } from '../services/context_srv';
|
||||
|
||||
import {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { t } from '@grafana/i18n';
|
||||
import { getBackendSrv, config, locationService } from '@grafana/runtime';
|
||||
import { sceneGraph, SceneTimeRangeLike, VizPanel } from '@grafana/scenes';
|
||||
import { shortURLAPIv1beta1 } from 'app/api/clients/shorturl/v1beta1';
|
||||
import { notifyApp } from 'app/core/actions';
|
||||
import { createErrorNotification, createSuccessNotification } from 'app/core/copy/appNotification';
|
||||
import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene';
|
||||
import { getDashboardUrl } from 'app/features/dashboard-scene/utils/getDashboardUrl';
|
||||
@@ -13,7 +14,6 @@ import { dispatch } from 'app/store/store';
|
||||
import { ShortURL } from '../../../../apps/shorturl/plugin/src/generated/shorturl/v1beta1/shorturl_object_gen';
|
||||
import { extractErrorMessage } from '../../api/utils';
|
||||
import { ShareLinkConfiguration } from '../../features/dashboard-scene/sharing/ShareButton/utils';
|
||||
import { notifyApp } from '../reducers/appNotification';
|
||||
|
||||
import { copyStringToClipboard } from './explore';
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { UrlQueryMap, getTimeZone, getDefaultTimeRange, dateMath } from '@grafana/data';
|
||||
import { locationService } from '@grafana/runtime';
|
||||
import { getFolderByUidFacade } from 'app/api/clients/folder/v1beta1/hooks';
|
||||
import { updateNavIndex } from 'app/core/reducers/navModel';
|
||||
import { updateNavIndex } from 'app/core/actions';
|
||||
import { buildNavModel } from 'app/features/folders/state/navModel';
|
||||
import { store } from 'app/store/store';
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
sceneGraph,
|
||||
SceneTimeRangeLike,
|
||||
} from '@grafana/scenes';
|
||||
import { notifyApp } from 'app/core/actions';
|
||||
import { createErrorNotification } from 'app/core/copy/appNotification';
|
||||
import { notifyApp } from 'app/core/reducers/appNotification';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { getMessageFromError } from 'app/core/utils/errors';
|
||||
import { alertRuleApi } from 'app/features/alerting/unified/api/alertRuleApi';
|
||||
|
||||
@@ -12,9 +12,9 @@ import {
|
||||
LibraryPanelRef,
|
||||
LibraryPanelKind,
|
||||
} from '@grafana/schema/dist/esm/schema/dashboard/v2';
|
||||
import { notifyApp } from 'app/core/actions';
|
||||
import config from 'app/core/config';
|
||||
import { createErrorNotification } from 'app/core/copy/appNotification';
|
||||
import { notifyApp } from 'app/core/reducers/appNotification';
|
||||
import { buildPanelKind } from 'app/features/dashboard/api/ResponseTransformers';
|
||||
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
|
||||
import { PanelModel, GridPos } from 'app/features/dashboard/state/PanelModel';
|
||||
|
||||
@@ -9,8 +9,8 @@ import { Trans, t } from '@grafana/i18n';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { SceneComponentProps } from '@grafana/scenes';
|
||||
import { Button, ClipboardButton, CodeEditor, Label, Spinner, Stack, Switch, useStyles2 } from '@grafana/ui';
|
||||
import { notifyApp } from 'app/core/actions';
|
||||
import { createSuccessNotification } from 'app/core/copy/appNotification';
|
||||
import { notifyApp } from 'app/core/reducers/appNotification';
|
||||
import { dispatch } from 'app/store/store';
|
||||
|
||||
import { ShareExportTab } from '../ShareExportTab';
|
||||
|
||||
@@ -6,8 +6,8 @@ import { Trans, t } from '@grafana/i18n';
|
||||
import { SceneComponentProps, sceneGraph, SceneObjectBase, SceneObjectRef, VizPanel } from '@grafana/scenes';
|
||||
import { Dashboard } from '@grafana/schema';
|
||||
import { Button, ClipboardButton, Field, Input, Modal, RadioButtonGroup, Stack } from '@grafana/ui';
|
||||
import { notifyApp } from 'app/core/actions';
|
||||
import { createSuccessNotification } from 'app/core/copy/appNotification';
|
||||
import { notifyApp } from 'app/core/reducers/appNotification';
|
||||
import { getTrackingSource, shareDashboardType } from 'app/features/dashboard/components/ShareModal/utils';
|
||||
import { getDashboardSnapshotSrv, SnapshotSharingOptions } from 'app/features/dashboard/services/SnapshotSrv';
|
||||
import { dispatch } from 'app/store/store';
|
||||
|
||||
@@ -3,8 +3,8 @@ import { createApi } from '@reduxjs/toolkit/query/react';
|
||||
import { createBaseQuery } from '@grafana/api-clients/rtkq';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { config, FetchError, isFetchError } from '@grafana/runtime';
|
||||
import { notifyApp } from 'app/core/actions';
|
||||
import { createErrorNotification, createSuccessNotification } from 'app/core/copy/appNotification';
|
||||
import { notifyApp } from 'app/core/reducers/appNotification';
|
||||
import {
|
||||
PublicDashboard,
|
||||
PublicDashboardSettings,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
ToolbarButtonRow,
|
||||
ConfirmModal,
|
||||
} from '@grafana/ui';
|
||||
import { updateNavIndex } from 'app/core/actions';
|
||||
import { appEvents } from 'app/core/app_events';
|
||||
import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate';
|
||||
import { NavToolbarSeparator } from 'app/core/components/AppChrome/NavToolbar/NavToolbarSeparator';
|
||||
@@ -21,7 +22,7 @@ import config from 'app/core/config';
|
||||
import { useAppNotification } from 'app/core/copy/appNotification';
|
||||
import { useBusEvent } from 'app/core/hooks/useBusEvent';
|
||||
import { ID_PREFIX, setStarred } from 'app/core/reducers/navBarTree';
|
||||
import { removeNavIndex, updateNavIndex } from 'app/core/reducers/navModel';
|
||||
import { removeNavIndex } from 'app/core/reducers/navModel';
|
||||
import AddPanelButton from 'app/features/dashboard/components/AddPanelButton/AddPanelButton';
|
||||
import { SaveDashboardDrawer } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardDrawer';
|
||||
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
|
||||
|
||||
@@ -24,7 +24,6 @@ import { appEvents } from 'app/core/app_events';
|
||||
import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
import { SplitPaneWrapper } from 'app/core/components/SplitPaneWrapper/SplitPaneWrapper';
|
||||
import { notifyApp } from 'app/core/reducers/appNotification';
|
||||
import { SubMenuItems } from 'app/features/dashboard/components/SubMenu/SubMenuItems';
|
||||
import { SaveLibraryPanelModal } from 'app/features/library-panels/components/SaveLibraryPanelModal/SaveLibraryPanelModal';
|
||||
import { PanelModelWithLibraryPanel } from 'app/features/library-panels/types';
|
||||
@@ -33,6 +32,7 @@ import { updateTimeZoneForSession } from 'app/features/profile/state/reducers';
|
||||
import { PanelOptionsChangedEvent, ShowModalReactEvent } from 'app/types/events';
|
||||
import { StoreState } from 'app/types/store';
|
||||
|
||||
import { notifyApp } from '../../../../core/actions';
|
||||
import { UnlinkModal } from '../../../dashboard-scene/scene/UnlinkModal';
|
||||
import { isPanelModelLibraryPanel } from '../../../library-panels/guard';
|
||||
import { getVariablesByKey } from '../../../variables/state/selectors';
|
||||
|
||||
@@ -8,10 +8,10 @@ import { createTheme } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { config, setDataSourceSrv } from '@grafana/runtime';
|
||||
import { Dashboard } from '@grafana/schema';
|
||||
import { notifyApp } from 'app/core/actions';
|
||||
import { AppChrome } from 'app/core/components/AppChrome/AppChrome';
|
||||
import { getRouteComponentProps } from 'app/core/navigation/mocks/routeProps';
|
||||
import { RouteDescriptor } from 'app/core/navigation/types';
|
||||
import { notifyApp } from 'app/core/reducers/appNotification';
|
||||
import { HOME_NAV_ID } from 'app/core/reducers/navModel';
|
||||
import { DashboardInitPhase, DashboardMeta, DashboardRoutes } from 'app/types/dashboard';
|
||||
|
||||
|
||||
@@ -6,13 +6,13 @@ import { NavModel, NavModelItem, TimeRange, PageLayoutType, locationUtil, Grafan
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { locationService } from '@grafana/runtime';
|
||||
import { Themeable2, withTheme2 } from '@grafana/ui';
|
||||
import { notifyApp } from 'app/core/actions';
|
||||
import { ScrollRefElement } from 'app/core/components/NativeScrollbar';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
import { GrafanaContext, GrafanaContextType } from 'app/core/context/GrafanaContext';
|
||||
import { createErrorNotification } from 'app/core/copy/appNotification';
|
||||
import { getKioskMode } from 'app/core/navigation/kiosk';
|
||||
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
|
||||
import { notifyApp } from 'app/core/reducers/appNotification';
|
||||
import { ID_PREFIX } from 'app/core/reducers/navBarTree';
|
||||
import { getNavModel } from 'app/core/selectors/navModel';
|
||||
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
|
||||
|
||||
+1
-1
@@ -1,8 +1,8 @@
|
||||
import { PanelModel } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { locationService } from '@grafana/runtime';
|
||||
import { notifyApp } from 'app/core/actions';
|
||||
import { createErrorNotification } from 'app/core/copy/appNotification';
|
||||
import { notifyApp } from 'app/core/reducers/appNotification';
|
||||
import { DataSourceInput } from 'app/features/manage-dashboards/state/reducers';
|
||||
import { DashboardJson } from 'app/features/manage-dashboards/types';
|
||||
import { dispatch } from 'app/types/store';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { TimeZone } from '@grafana/data';
|
||||
import { getBackendSrv } from '@grafana/runtime';
|
||||
import { WeekStart } from '@grafana/ui';
|
||||
import { notifyApp } from 'app/core/actions';
|
||||
import { createSuccessNotification } from 'app/core/copy/appNotification';
|
||||
import { notifyApp } from 'app/core/reducers/appNotification';
|
||||
import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api';
|
||||
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
|
||||
import { removeAllPanels } from 'app/features/panel/state/reducers';
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { DataQuery, locationUtil, setWeekStart, DashboardLoadedEvent } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { config, isFetchError, locationService } from '@grafana/runtime';
|
||||
import { notifyApp } from 'app/core/actions';
|
||||
import { appEvents } from 'app/core/app_events';
|
||||
import { createErrorNotification } from 'app/core/copy/appNotification';
|
||||
import { notifyApp } from 'app/core/reducers/appNotification';
|
||||
import { backendSrv } from 'app/core/services/backend_srv';
|
||||
import { KeybindingSrv } from 'app/core/services/keybindingSrv';
|
||||
import store from 'app/core/store';
|
||||
|
||||
@@ -17,8 +17,8 @@ import {
|
||||
isFetchError,
|
||||
locationService,
|
||||
} from '@grafana/runtime';
|
||||
import { updateNavIndex } from 'app/core/actions';
|
||||
import { appEvents } from 'app/core/app_events';
|
||||
import { updateNavIndex } from 'app/core/reducers/navModel';
|
||||
import { getBackendSrv } from 'app/core/services/backend_srv';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { DatasourceAPIVersions } from 'app/features/apiserver/client';
|
||||
|
||||
@@ -73,7 +73,7 @@ import {
|
||||
contentOutlineTrackUnpinClicked,
|
||||
} from '../ContentOutline/ContentOutlineAnalyticEvents';
|
||||
import { useContentOutlineContext } from '../ContentOutline/ContentOutlineContext';
|
||||
import { getUrlStateFromPaneState } from '../hooks/useStateSync/external.utils';
|
||||
import { getUrlStateFromPaneState } from '../hooks/useStateSync';
|
||||
import { changePanelState } from '../state/explorePane';
|
||||
import { changeQueries, runQueries } from '../state/query';
|
||||
|
||||
@@ -149,6 +149,9 @@ const getDefaultVisualisationType = (): LogsVisualisationType => {
|
||||
if (visualisationType === 'logs') {
|
||||
return 'logs';
|
||||
}
|
||||
if (config.featureToggles.logsExploreTableDefaultVisualization) {
|
||||
return 'table';
|
||||
}
|
||||
return 'logs';
|
||||
};
|
||||
|
||||
@@ -444,6 +447,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
reportInteraction('grafana_explore_logs_visualisation_changed', {
|
||||
newVisualizationType: visualisation,
|
||||
datasourceType: props.datasourceType ?? 'unknown',
|
||||
defaultVisualisationType: config.featureToggles.logsExploreTableDefaultVisualization ? 'table' : 'logs',
|
||||
});
|
||||
},
|
||||
[panelState?.logs, props.datasourceType, updatePanelState]
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { t } from '@grafana/i18n';
|
||||
import { ClipboardButton, CustomCellRendererProps, IconButton, Modal, useTheme2 } from '@grafana/ui';
|
||||
import { getLogsPermalinkRange } from 'app/core/utils/shortLinks';
|
||||
import { getUrlStateFromPaneState } from 'app/features/explore/hooks/useStateSync/external.utils';
|
||||
import { getUrlStateFromPaneState } from 'app/features/explore/hooks/useStateSync';
|
||||
import { LogsFrame, DATAPLANE_ID_NAME } from 'app/features/logs/logsFrame';
|
||||
import { getState } from 'app/store/store';
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ import { Trans, t } from '@grafana/i18n';
|
||||
import { config, reportInteraction, getAppEvents } from '@grafana/runtime';
|
||||
import { DataQuery } from '@grafana/schema';
|
||||
import { TextArea, Button, IconButton, useStyles2 } from '@grafana/ui';
|
||||
import { notifyApp } from 'app/core/actions';
|
||||
import { createSuccessNotification } from 'app/core/copy/appNotification';
|
||||
import { notifyApp } from 'app/core/reducers/appNotification';
|
||||
import { copyStringToClipboard } from 'app/core/utils/explore';
|
||||
import { createUrlFromRichHistory, createQueryText } from 'app/core/utils/richHistory';
|
||||
import { createAndCopyShortLink } from 'app/core/utils/shortLinks';
|
||||
|
||||
@@ -4,9 +4,9 @@ import { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { getAppEvents } from '@grafana/runtime';
|
||||
import { useStyles2, Select, Button, Field, InlineField, InlineSwitch, Alert } from '@grafana/ui';
|
||||
import { notifyApp } from 'app/core/actions';
|
||||
import { createSuccessNotification } from 'app/core/copy/appNotification';
|
||||
import { MAX_HISTORY_ITEMS } from 'app/core/history/RichHistoryLocalStorage';
|
||||
import { notifyApp } from 'app/core/reducers/appNotification';
|
||||
import { dispatch } from 'app/store/store';
|
||||
|
||||
import { supportedFeatures } from '../../../core/history/richHistoryStorageProvider';
|
||||
|
||||
@@ -3,19 +3,9 @@ import { Unsubscribable } from 'rxjs';
|
||||
|
||||
import { getAppEvents } from '@grafana/runtime';
|
||||
import { useGrafana } from 'app/core/context/GrafanaContext';
|
||||
import { getState } from 'app/store/store';
|
||||
import {
|
||||
AbsoluteTimeEvent,
|
||||
CopyTimeEvent,
|
||||
PasteTimeEvent,
|
||||
RunQueriesEvent,
|
||||
ShiftTimeEvent,
|
||||
ZoomOutEvent,
|
||||
} from 'app/types/events';
|
||||
import { AbsoluteTimeEvent, CopyTimeEvent, PasteTimeEvent, ShiftTimeEvent, ZoomOutEvent } from 'app/types/events';
|
||||
import { useDispatch } from 'app/types/store';
|
||||
|
||||
import { runQueries } from '../state/query';
|
||||
import { selectPanesEntries } from '../state/selectors';
|
||||
import {
|
||||
copyTimeRangeToClipboard,
|
||||
makeAbsoluteTime,
|
||||
@@ -31,23 +21,8 @@ export function useKeyboardShortcuts() {
|
||||
useEffect(() => {
|
||||
keybindings.setupTimeRangeBindings(false);
|
||||
|
||||
// Explore-specific: run queries shortcut
|
||||
keybindings.bind('e r', () => {
|
||||
getAppEvents().publish(new RunQueriesEvent());
|
||||
});
|
||||
|
||||
const tearDown: Unsubscribable[] = [];
|
||||
|
||||
tearDown.push(
|
||||
getAppEvents().subscribe(RunQueriesEvent, () => {
|
||||
// Read panes at event time to avoid re-subscribing when panes change
|
||||
const panes = selectPanesEntries(getState());
|
||||
panes.forEach(([exploreId]) => {
|
||||
dispatch(runQueries({ exploreId }));
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
tearDown.push(
|
||||
getAppEvents().subscribe(AbsoluteTimeEvent, () => {
|
||||
dispatch(makeAbsoluteTime());
|
||||
@@ -79,7 +54,6 @@ export function useKeyboardShortcuts() {
|
||||
);
|
||||
|
||||
return () => {
|
||||
keybindings.unbind('e r');
|
||||
tearDown.forEach((u) => u.unsubscribe());
|
||||
};
|
||||
}, [dispatch, keybindings]);
|
||||
|
||||
@@ -12,6 +12,8 @@ import { syncFromURL } from './synchronizer/fromURL';
|
||||
import { initializeFromURL } from './synchronizer/init';
|
||||
import { syncToURL, syncToURLPredicate } from './synchronizer/toURL';
|
||||
|
||||
export { getUrlStateFromPaneState } from './external.utils';
|
||||
|
||||
/**
|
||||
* Bi-directionally syncs URL changes with Explore's state.
|
||||
*/
|
||||
|
||||
@@ -11,7 +11,7 @@ import { withUniqueRefIds } from 'app/features/explore/utils/queries';
|
||||
import { ExploreItemState } from 'app/types/explore';
|
||||
import { ThunkDispatch } from 'app/types/store';
|
||||
|
||||
import { getUrlStateFromPaneState } from '../external.utils';
|
||||
import { getUrlStateFromPaneState } from '../index';
|
||||
import { urlDiff } from '../internal.utils';
|
||||
import { ExploreURLV1 } from '../migrators/v1';
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { withUniqueRefIds } from 'app/features/explore/utils/queries';
|
||||
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
|
||||
import { ThunkDispatch } from 'app/types/store';
|
||||
|
||||
import { getUrlStateFromPaneState } from '../external.utils';
|
||||
import { getUrlStateFromPaneState } from '../index';
|
||||
import {
|
||||
getDefaultQuery,
|
||||
getPaneDatasource,
|
||||
|
||||
@@ -11,7 +11,7 @@ import { runQueries } from 'app/features/explore/state/query';
|
||||
import { changeRangeAction } from 'app/features/explore/state/time';
|
||||
import { ExploreState } from 'app/types/explore';
|
||||
|
||||
import { getUrlStateFromPaneState } from '../external.utils';
|
||||
import { getUrlStateFromPaneState } from '../index';
|
||||
import { InitState } from '../internal.utils';
|
||||
|
||||
/*
|
||||
|
||||
@@ -2,8 +2,8 @@ import { Observable } from 'rxjs';
|
||||
|
||||
import { DataLinkTransformationConfig } from '@grafana/data';
|
||||
import { CorrelationData, getDataSourceSrv, reportInteraction } from '@grafana/runtime';
|
||||
import { notifyApp } from 'app/core/actions';
|
||||
import { createErrorNotification } from 'app/core/copy/appNotification';
|
||||
import { notifyApp } from 'app/core/reducers/appNotification';
|
||||
import { CreateCorrelationParams } from 'app/features/correlations/types';
|
||||
import { getCorrelationsBySourceUIDs, createCorrelation, generateDefaultLabel } from 'app/features/correlations/utils';
|
||||
import { store } from 'app/store/store';
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
import { combinePanelData } from '@grafana/o11y-ds-frontend';
|
||||
import { config, getDataSourceSrv } from '@grafana/runtime';
|
||||
import { DataQuery } from '@grafana/schema';
|
||||
import { notifyApp } from 'app/core/reducers/appNotification';
|
||||
import {
|
||||
buildQueryTransaction,
|
||||
ensureQueries,
|
||||
@@ -49,6 +48,7 @@ import {
|
||||
} from 'app/types/explore';
|
||||
import { createAsyncThunk, StoreState, ThunkDispatch, ThunkResult } from 'app/types/store';
|
||||
|
||||
import { notifyApp } from '../../../core/actions';
|
||||
import { createErrorNotification } from '../../../core/copy/appNotification';
|
||||
import { runRequest } from '../../query/state/runRequest';
|
||||
import { decorateData, decorateWithLogsResult } from '../utils/decorators';
|
||||
|
||||
@@ -30,7 +30,7 @@ import { parseDataplaneLogsFrame } from 'app/features/logs/logsFrame';
|
||||
import { ExploreItemState } from 'app/types/explore';
|
||||
|
||||
import { getLinkSrv } from '../../panel/panellinks/link_srv';
|
||||
import { getUrlStateFromPaneState } from '../hooks/useStateSync/external.utils';
|
||||
import { getUrlStateFromPaneState } from '../hooks/useStateSync';
|
||||
|
||||
type DataLinkFilter = (link: DataLink, scopedVars: ScopedVars) => boolean;
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { DataFrameType, Field, FieldType, LogRowModel, MutableDataFrame } from '@grafana/data';
|
||||
import { mockTimeRange } from '@grafana/plugin-ui';
|
||||
import { setTemplateSrv } from '@grafana/runtime';
|
||||
import { ExploreFieldLinkModel, getFieldLinksForExplore } from 'app/features/explore/utils/links';
|
||||
import { TemplateSrv } from 'app/features/templating/template_srv';
|
||||
import { GetFieldLinksFn } from 'app/plugins/panel/logs/types';
|
||||
|
||||
import { getAllFields, createLogLineLinks, FieldDef, getDataframeFields } from './logParser';
|
||||
@@ -468,10 +466,6 @@ describe('logParser', () => {
|
||||
});
|
||||
|
||||
describe('getDataframeFields', () => {
|
||||
beforeEach(() => {
|
||||
setTemplateSrv(new TemplateSrv());
|
||||
});
|
||||
|
||||
it('should add row labels as variables for links', () => {
|
||||
const row = createLogRow({
|
||||
labels: { service_name: 'checkout', service_namespace: 'prod' },
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { FieldType, getDefaultTimeRange, LogsSortOrder, toDataFrame } from '@grafana/data';
|
||||
import { setTemplateSrv } from '@grafana/runtime';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { getFieldLinksForExplore } from 'app/features/explore/utils/links';
|
||||
import { TemplateSrv } from 'app/features/templating/template_srv';
|
||||
import { GetFieldLinksFn } from 'app/plugins/panel/logs/types';
|
||||
|
||||
import { createLogLine } from '../mocks/logRow';
|
||||
@@ -72,7 +70,6 @@ describe('getTempoTraceFromLinks', () => {
|
||||
wrapLogMessage: true,
|
||||
}
|
||||
);
|
||||
setTemplateSrv(new TemplateSrv());
|
||||
});
|
||||
|
||||
test('Gets the trace information from a link', () => {
|
||||
|
||||
@@ -190,22 +190,6 @@ describe('preProcessLogs', () => {
|
||||
expect(logListModel.body).not.toBe(entry);
|
||||
});
|
||||
|
||||
test('Prettifies JSON with duplicate keys', () => {
|
||||
const entry = '{"key": "value", "key": "otherValue"}';
|
||||
const logListModel = createLogLine(
|
||||
{ entry },
|
||||
{
|
||||
escape: false,
|
||||
order: LogsSortOrder.Descending,
|
||||
timeZone: 'browser',
|
||||
wrapLogMessage: true, // wrapped
|
||||
prettifyJSON: true,
|
||||
}
|
||||
);
|
||||
expect(logListModel.entry).toBe(entry);
|
||||
expect(logListModel.body).not.toBe(entry);
|
||||
});
|
||||
|
||||
test('Prettifies and escapes wrapped JSON', () => {
|
||||
const entry = '{"key": "value", "otherKey": "other\\nValue"}';
|
||||
const logListModel = createLogLine(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user