Merge remote-tracking branch 'origin/main' into dual-write-inline-secure-values
This commit is contained in:
@@ -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/).
|
Learn more at [plugin policies](https://grafana.com/legal/plugins/).
|
||||||
|
|
||||||
## How does verifiction work?
|
## How does verification work?
|
||||||
|
|
||||||
At startup, Grafana verifies the signatures of every plugin in the plugin directory.
|
At startup, Grafana verifies the signatures of every plugin in the plugin directory.
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general-
|
|||||||
| `sharingDashboardImage` | Enables image sharing functionality for dashboards | Yes |
|
| `sharingDashboardImage` | Enables image sharing functionality for dashboards | Yes |
|
||||||
| `tabularNumbers` | Use fixed-width numbers globally in the UI | |
|
| `tabularNumbers` | Use fixed-width numbers globally in the UI | |
|
||||||
| `azureResourcePickerUpdates` | Enables the updated Azure Monitor resource picker | Yes |
|
| `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 | |
|
| `opentsdbBackendMigration` | Run queries through the data source backend | |
|
||||||
|
|
||||||
## Public preview feature toggles
|
## Public preview feature toggles
|
||||||
|
|||||||
@@ -1021,11 +1021,6 @@
|
|||||||
"count": 2
|
"count": 2
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"public/app/core/actions/index.ts": {
|
|
||||||
"no-barrel-files/no-barrel-files": {
|
|
||||||
"count": 4
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"public/app/core/components/AccessControl/PermissionList.tsx": {
|
"public/app/core/components/AccessControl/PermissionList.tsx": {
|
||||||
"no-restricted-syntax": {
|
"no-restricted-syntax": {
|
||||||
"count": 1
|
"count": 1
|
||||||
@@ -4025,11 +4020,6 @@
|
|||||||
"count": 1
|
"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": {
|
"public/app/plugins/datasource/prometheus/configuration/AzureAuthSettings.tsx": {
|
||||||
"no-restricted-syntax": {
|
"no-restricted-syntax": {
|
||||||
"count": 1
|
"count": 1
|
||||||
@@ -4098,7 +4088,7 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
},
|
},
|
||||||
"@typescript-eslint/no-explicit-any": {
|
"@typescript-eslint/no-explicit-any": {
|
||||||
"count": 2
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"public/app/plugins/datasource/tempo/resultTransformer.ts": {
|
"public/app/plugins/datasource/tempo/resultTransformer.ts": {
|
||||||
|
|||||||
@@ -585,6 +585,8 @@ module.exports = [
|
|||||||
// FIXME: Remove once all enterprise issues are fixed -
|
// FIXME: Remove once all enterprise issues are fixed -
|
||||||
// we don't have a suppressions file/approach for enterprise code yet
|
// we don't have a suppressions file/approach for enterprise code yet
|
||||||
...enterpriseIgnores,
|
...enterpriseIgnores,
|
||||||
|
// Ignore decoupled plugin webpack configs
|
||||||
|
'public/app/**/webpack.config.ts',
|
||||||
],
|
],
|
||||||
rules: {
|
rules: {
|
||||||
'no-barrel-files/no-barrel-files': 'error',
|
'no-barrel-files/no-barrel-files': 'error',
|
||||||
|
|||||||
+14
-6
@@ -957,7 +957,8 @@ export interface FeatureToggles {
|
|||||||
*/
|
*/
|
||||||
alertingBulkActionsInUI?: boolean;
|
alertingBulkActionsInUI?: boolean;
|
||||||
/**
|
/**
|
||||||
* Registers AuthZ /apis endpoint
|
* Deprecated: Use kubernetesAuthzCoreRolesApi, kubernetesAuthzRolesApi, and kubernetesAuthzRoleBindingsApi instead
|
||||||
|
* @deprecated
|
||||||
*/
|
*/
|
||||||
kubernetesAuthzApis?: boolean;
|
kubernetesAuthzApis?: boolean;
|
||||||
/**
|
/**
|
||||||
@@ -973,6 +974,18 @@ export interface FeatureToggles {
|
|||||||
*/
|
*/
|
||||||
kubernetesAuthzZanzanaSync?: boolean;
|
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
|
* Enables create, delete, and update mutations for resources owned by IAM identity
|
||||||
*/
|
*/
|
||||||
kubernetesAuthnMutation?: boolean;
|
kubernetesAuthnMutation?: boolean;
|
||||||
@@ -1124,11 +1137,6 @@ export interface FeatureToggles {
|
|||||||
*/
|
*/
|
||||||
pluginContainers?: boolean;
|
pluginContainers?: boolean;
|
||||||
/**
|
/**
|
||||||
* Run search queries through the tempo backend
|
|
||||||
* @default false
|
|
||||||
*/
|
|
||||||
tempoSearchBackendMigration?: boolean;
|
|
||||||
/**
|
|
||||||
* Prioritize loading plugins from the CDN before other sources
|
* Prioritize loading plugins from the CDN before other sources
|
||||||
* @default false
|
* @default false
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -153,6 +153,10 @@ interface BaseProps<TableData extends object> {
|
|||||||
* Optional way to set how the table is sorted from the beginning. Must be memoized.
|
* Optional way to set how the table is sorted from the beginning. Must be memoized.
|
||||||
*/
|
*/
|
||||||
initialSortBy?: Array<SortingRule<TableData>>;
|
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> {
|
interface WithExpandableRow<TableData extends object> extends BaseProps<TableData> {
|
||||||
@@ -191,6 +195,7 @@ export function InteractiveTable<TableData extends object>({
|
|||||||
showExpandAll = false,
|
showExpandAll = false,
|
||||||
fetchData,
|
fetchData,
|
||||||
initialSortBy = [],
|
initialSortBy = [],
|
||||||
|
disableSortRemove,
|
||||||
}: Props<TableData>) {
|
}: Props<TableData>) {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
const tableColumns = useMemo(() => {
|
const tableColumns = useMemo(() => {
|
||||||
@@ -222,6 +227,7 @@ export function InteractiveTable<TableData extends object>({
|
|||||||
disableMultiSort: true,
|
disableMultiSort: true,
|
||||||
// If fetchData is provided, we disable client-side sorting
|
// If fetchData is provided, we disable client-side sorting
|
||||||
manualSortBy: Boolean(fetchData),
|
manualSortBy: Boolean(fetchData),
|
||||||
|
disableSortRemove,
|
||||||
getRowId,
|
getRowId,
|
||||||
initialState: {
|
initialState: {
|
||||||
hiddenColumns: [
|
hiddenColumns: [
|
||||||
|
|||||||
@@ -26,4 +26,8 @@ export interface Column<TableData extends object> {
|
|||||||
* If the provided function returns `false` the column will be hidden.
|
* If the provided function returns `false` the column will be hidden.
|
||||||
*/
|
*/
|
||||||
visible?: (data: TableData[]) => boolean;
|
visible?: (data: TableData[]) => boolean;
|
||||||
|
/**
|
||||||
|
* Determines starting sort direction when the column header is clicked.
|
||||||
|
*/
|
||||||
|
sortDescFirst?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export function getColumns<K extends object>(
|
|||||||
disableSortBy: !Boolean(column.sortType),
|
disableSortBy: !Boolean(column.sortType),
|
||||||
width: column.disableGrow ? 0 : undefined,
|
width: column.disableGrow ? 0 : undefined,
|
||||||
visible: column.visible,
|
visible: column.visible,
|
||||||
|
...(column.sortDescFirst !== undefined && { sortDescFirst: column.sortDescFirst }),
|
||||||
...(column.cell && { Cell: column.cell }),
|
...(column.cell && { Cell: column.cell }),
|
||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,26 +1,55 @@
|
|||||||
package generic
|
package generic
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"k8s.io/apimachinery/pkg/fields"
|
||||||
|
"k8s.io/apimachinery/pkg/labels"
|
||||||
"k8s.io/apimachinery/pkg/runtime"
|
"k8s.io/apimachinery/pkg/runtime"
|
||||||
"k8s.io/apiserver/pkg/registry/generic"
|
"k8s.io/apiserver/pkg/registry/generic"
|
||||||
"k8s.io/apiserver/pkg/registry/generic/registry"
|
"k8s.io/apiserver/pkg/registry/generic/registry"
|
||||||
|
"k8s.io/apiserver/pkg/storage"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
"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) {
|
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 := resourceInfo.GroupVersion()
|
||||||
gv.Version = runtime.APIVersionInternal
|
gv.Version = runtime.APIVersionInternal
|
||||||
strategy := NewStrategy(scheme, gv)
|
strategy := NewStrategy(scheme, gv)
|
||||||
if resourceInfo.IsClusterScoped() {
|
if resourceInfo.IsClusterScoped() {
|
||||||
strategy = strategy.WithClusterScope()
|
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{
|
store := ®istry.Store{
|
||||||
NewFunc: resourceInfo.NewFunc,
|
NewFunc: resourceInfo.NewFunc,
|
||||||
NewListFunc: resourceInfo.NewListFunc,
|
NewListFunc: resourceInfo.NewListFunc,
|
||||||
KeyRootFunc: KeyRootFunc(resourceInfo.GroupResource()),
|
KeyRootFunc: KeyRootFunc(resourceInfo.GroupResource()),
|
||||||
KeyFunc: NamespaceKeyFunc(resourceInfo.GroupResource()),
|
KeyFunc: NamespaceKeyFunc(resourceInfo.GroupResource()),
|
||||||
PredicateFunc: Matcher,
|
PredicateFunc: predicateFunc,
|
||||||
DefaultQualifiedResource: resourceInfo.GroupResource(),
|
DefaultQualifiedResource: resourceInfo.GroupResource(),
|
||||||
SingularQualifiedResource: resourceInfo.SingularGroupResource(),
|
SingularQualifiedResource: resourceInfo.SingularGroupResource(),
|
||||||
TableConvertor: resourceInfo.TableConverter(),
|
TableConvertor: resourceInfo.TableConverter(),
|
||||||
@@ -28,7 +57,7 @@ func NewRegistryStore(scheme *runtime.Scheme, resourceInfo utils.ResourceInfo, o
|
|||||||
UpdateStrategy: strategy,
|
UpdateStrategy: strategy,
|
||||||
DeleteStrategy: strategy,
|
DeleteStrategy: strategy,
|
||||||
}
|
}
|
||||||
options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: GetAttrs}
|
options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: attrFunc}
|
||||||
if err := store.CompleteWithOptions(options); err != nil {
|
if err := store.CompleteWithOptions(options); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
## 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,6 +138,10 @@ func run(ctx context.Context, cmd *cli.Command) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if code != 0 {
|
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)
|
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 {
|
func RunSuite(d *dagger.Client, svc *dagger.Service, src *dagger.Directory, cache *dagger.CacheVolume, suite, runnerFlags string) *dagger.Container {
|
||||||
command := fmt.Sprintf(
|
command := fmt.Sprintf(
|
||||||
"./e2e-runner cypress --start-grafana=false --cypress-video"+
|
"./e2e-runner cypress --browser=electron --start-grafana=false --cypress-video"+
|
||||||
" --grafana-base-url http://grafana:3001 --suite %s %s", suite, runnerFlags)
|
" --grafana-base-url http://grafana:3001 --suite %s %s", suite, runnerFlags)
|
||||||
|
|
||||||
return WithYarnCache(WithGrafanaFrontend(d.Container().From("cypress/included:13.1.0"), src), cache).
|
return WithYarnCache(WithGrafanaFrontend(d.Container().From("cypress/included:14.3.2"), src), cache).
|
||||||
WithWorkdir("/src").
|
WithWorkdir("/src").
|
||||||
WithServiceBinding("grafana", svc).
|
WithServiceBinding("grafana", svc).
|
||||||
WithExec([]string{"yarn", "install", "--immutable"}).
|
WithExec([]string{"yarn", "install", "--immutable"}).
|
||||||
|
|||||||
@@ -99,13 +99,15 @@ func GrafanaService(ctx context.Context, d *dagger.Client, opts GrafanaServiceOp
|
|||||||
}
|
}
|
||||||
|
|
||||||
if opts.StartImageRenderer {
|
if opts.StartImageRenderer {
|
||||||
container = container.WithEnvVariable("START_IMAGE_RENDERER", "true").
|
imageRendererSvc := d.Container().From("grafana/grafana-image-renderer:" + opts.ImageRendererVersion).
|
||||||
WithExec([]string{"apt-get", "update"}).
|
WithExposedPort(8081).
|
||||||
WithExec([]string{"apt-get", "install", "-y", "ca-certificates"})
|
AsService()
|
||||||
|
|
||||||
if opts.ImageRendererVersion != "" {
|
container = container.WithServiceBinding("image-renderer", imageRendererSvc).
|
||||||
container = container.WithEnvVariable("IMAGE_RENDERER_VERSION", opts.ImageRendererVersion)
|
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")
|
||||||
}
|
}
|
||||||
|
|
||||||
// We add all GF_ environment variables to allow for overriding Grafana configuration.
|
// We add all GF_ environment variables to allow for overriding Grafana configuration.
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/open-feature/go-sdk/openfeature"
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
@@ -209,8 +211,16 @@ func (b *IdentityAccessManagementAPIBuilder) GetGroupVersion() schema.GroupVersi
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *IdentityAccessManagementAPIBuilder) InstallSchema(scheme *runtime.Scheme) error {
|
func (b *IdentityAccessManagementAPIBuilder) InstallSchema(scheme *runtime.Scheme) error {
|
||||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
client := openfeature.NewDefaultClient()
|
||||||
if b.features.IsEnabledGlobally(featuremgmt.FlagKubernetesAuthzApis) {
|
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 {
|
||||||
if err := iamv0.AddAuthZKnownTypes(scheme); err != nil {
|
if err := iamv0.AddAuthZKnownTypes(scheme); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -244,10 +254,16 @@ func (b *IdentityAccessManagementAPIBuilder) AllowedV0Alpha1Resources() []string
|
|||||||
func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions) error {
|
func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions) error {
|
||||||
storage := map[string]rest.Storage{}
|
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
|
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||||
enableZanzanaSync := b.features.IsEnabledGlobally(featuremgmt.FlagKubernetesAuthzZanzanaSync)
|
enableZanzanaSync := b.features.IsEnabledGlobally(featuremgmt.FlagKubernetesAuthzZanzanaSync)
|
||||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
|
||||||
enableAuthzApis := b.features.IsEnabledGlobally(featuremgmt.FlagKubernetesAuthzApis)
|
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))
|
||||||
|
|
||||||
// teams + users must have shorter names because they are often used as part of another name
|
// teams + users must have shorter names because they are often used as part of another name
|
||||||
opts.StorageOptsRegister(iamv0.TeamResourceInfo.GroupResource(), apistore.StorageOptions{
|
opts.StorageOptsRegister(iamv0.TeamResourceInfo.GroupResource(), apistore.StorageOptions{
|
||||||
@@ -283,17 +299,21 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if enableAuthzApis {
|
if enableCoreRolesApi {
|
||||||
// v0alpha1
|
// v0alpha1
|
||||||
if err := b.UpdateCoreRolesAPIGroup(apiGroupInfo, opts, storage, enableZanzanaSync); err != nil {
|
if err := b.UpdateCoreRolesAPIGroup(apiGroupInfo, opts, storage, enableZanzanaSync); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if enableRolesApi {
|
||||||
// Role registration is delegated to the RoleApiInstaller
|
// Role registration is delegated to the RoleApiInstaller
|
||||||
if err := b.roleApiInstaller.RegisterStorage(apiGroupInfo, &opts, storage); err != nil {
|
if err := b.roleApiInstaller.RegisterStorage(apiGroupInfo, &opts, storage); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if enableRoleBindingsApi {
|
||||||
if err := b.UpdateRoleBindingsAPIGroup(apiGroupInfo, opts, storage, enableZanzanaSync); err != nil {
|
if err := b.UpdateRoleBindingsAPIGroup(apiGroupInfo, opts, storage, enableZanzanaSync); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,98 @@ func (_c *MockJobProgressRecorder_Complete_Call) RunAndReturn(run func(context.C
|
|||||||
return _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
|
// Record provides a mock function with given fields: ctx, result
|
||||||
func (_m *MockJobProgressRecorder) Record(ctx context.Context, result JobResourceResult) {
|
func (_m *MockJobProgressRecorder) Record(ctx context.Context, result JobResourceResult) {
|
||||||
_m.Called(ctx, result)
|
_m.Called(ctx, result)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package jobs
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -9,6 +10,8 @@ import (
|
|||||||
"github.com/grafana/grafana-app-sdk/logging"
|
"github.com/grafana/grafana-app-sdk/logging"
|
||||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
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/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
|
// maybeNotifyProgress will only notify if a certain amount of time has passed
|
||||||
@@ -58,6 +61,8 @@ type jobProgressRecorder struct {
|
|||||||
notifyImmediatelyFn ProgressFn
|
notifyImmediatelyFn ProgressFn
|
||||||
maybeNotifyFn ProgressFn
|
maybeNotifyFn ProgressFn
|
||||||
summaries map[string]*provisioning.JobResourceSummary
|
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 {
|
func newJobProgressRecorder(ProgressFn ProgressFn) JobProgressRecorder {
|
||||||
@@ -84,10 +89,26 @@ func (r *jobProgressRecorder) Record(ctx context.Context, result JobResourceResu
|
|||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
shouldLogError = true
|
shouldLogError = true
|
||||||
logErr = result.Error
|
logErr = result.Error
|
||||||
if len(r.errors) < 20 {
|
|
||||||
r.errors = append(r.errors, result.Error.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)
|
||||||
}
|
}
|
||||||
r.errorCount++
|
|
||||||
}
|
}
|
||||||
|
|
||||||
r.updateSummary(result)
|
r.updateSummary(result)
|
||||||
@@ -112,6 +133,8 @@ func (r *jobProgressRecorder) ResetResults() {
|
|||||||
r.errorCount = 0
|
r.errorCount = 0
|
||||||
r.errors = nil
|
r.errors = nil
|
||||||
r.summaries = make(map[string]*provisioning.JobResourceSummary)
|
r.summaries = make(map[string]*provisioning.JobResourceSummary)
|
||||||
|
r.failedCreations = nil
|
||||||
|
r.failedDeletions = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *jobProgressRecorder) SetMessage(ctx context.Context, msg string) {
|
func (r *jobProgressRecorder) SetMessage(ctx context.Context, msg string) {
|
||||||
@@ -309,3 +332,29 @@ func (r *jobProgressRecorder) Complete(ctx context.Context, err error) provision
|
|||||||
|
|
||||||
return jobStatus
|
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,6 +7,7 @@ import (
|
|||||||
|
|
||||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
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/repository"
|
||||||
|
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -252,3 +253,221 @@ func TestJobProgressRecorderWarningOnlyNoErrors(t *testing.T) {
|
|||||||
require.NotNil(t, finalStatus.Warnings)
|
require.NotNil(t, finalStatus.Warnings)
|
||||||
assert.Len(t, finalStatus.Warnings, 1)
|
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,6 +29,10 @@ type JobProgressRecorder interface {
|
|||||||
StrictMaxErrors(maxErrors int)
|
StrictMaxErrors(maxErrors int)
|
||||||
SetRefURLs(ctx context.Context, refURLs *provisioning.RepositoryURLs)
|
SetRefURLs(ctx context.Context, refURLs *provisioning.RepositoryURLs)
|
||||||
Complete(ctx context.Context, err error) provisioning.JobStatus
|
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
|
// Worker is a worker that can process a job
|
||||||
|
|||||||
@@ -75,11 +75,47 @@ func FullSync(
|
|||||||
return applyChanges(ctx, changes, clients, repositoryResources, progress, tracer, maxSyncWorkers, metrics)
|
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) {
|
func applyChange(ctx context.Context, change ResourceFileChange, clients resources.ResourceClients, repositoryResources resources.RepositoryResources, progress jobs.JobProgressRecorder, tracer tracing.Tracer) {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if shouldSkipChange(ctx, change, progress, tracer) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if change.Action == repository.FileActionDeleted {
|
if change.Action == repository.FileActionDeleted {
|
||||||
deleteCtx, deleteSpan := tracer.Start(ctx, "provisioning.sync.full.apply_changes.delete")
|
deleteCtx, deleteSpan := tracer.Start(ctx, "provisioning.sync.full.apply_changes.delete")
|
||||||
result := jobs.JobResourceResult{
|
result := jobs.JobResourceResult{
|
||||||
@@ -138,6 +174,7 @@ func applyChange(ctx context.Context, change ResourceFileChange, clients resourc
|
|||||||
ensureFolderSpan.RecordError(err)
|
ensureFolderSpan.RecordError(err)
|
||||||
ensureFolderSpan.End()
|
ensureFolderSpan.End()
|
||||||
progress.Record(ctx, result)
|
progress.Record(ctx, result)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,8 +290,6 @@ 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 {
|
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 {
|
for _, folder := range folders {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
@@ -264,23 +299,9 @@ func applyFoldersSerially(ctx context.Context, folders []ResourceFileChange, cli
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
folderCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
wrapWithTimeout(ctx, 15*time.Second, func(timeoutCtx context.Context) {
|
||||||
|
applyChange(timeoutCtx, folder, clients, repositoryResources, progress, tracer)
|
||||||
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
|
return nil
|
||||||
@@ -318,7 +339,9 @@ loop:
|
|||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
defer func() { <-sem }()
|
defer func() { <-sem }()
|
||||||
|
|
||||||
applyChangeWithTimeout(ctx, change, clients, repositoryResources, progress, tracer, logger)
|
wrapWithTimeout(ctx, 15*time.Second, func(timeoutCtx context.Context) {
|
||||||
|
applyChange(timeoutCtx, change, clients, repositoryResources, progress, tracer)
|
||||||
|
})
|
||||||
}(change)
|
}(change)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,21 +354,10 @@ loop:
|
|||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyChangeWithTimeout(ctx context.Context, change ResourceFileChange, clients resources.ResourceClients, repositoryResources resources.RepositoryResources, progress jobs.JobProgressRecorder, tracer tracing.Tracer, logger logging.Logger) {
|
// wrapWithTimeout wraps a function call with a timeout context
|
||||||
changeCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
func wrapWithTimeout(ctx context.Context, timeout time.Duration, fn func(context.Context)) {
|
||||||
|
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
applyChange(changeCtx, change, clients, repositoryResources, progress, tracer)
|
fn(timeoutCtx)
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,432 @@
|
|||||||
|
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,6 +213,10 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo
|
|||||||
return nil
|
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 {
|
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 path == "dashboards/one.json" || path == "dashboards/two.json" || path == "dashboards/three.json"
|
||||||
}), "").Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil).Maybe()
|
}), "").Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil).Maybe()
|
||||||
@@ -235,6 +239,7 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo
|
|||||||
},
|
},
|
||||||
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
|
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
|
||||||
progress.On("TooManyErrors").Return(nil)
|
progress.On("TooManyErrors").Return(nil)
|
||||||
|
progress.On("HasDirPathFailedCreation", "dashboards/test.json").Return(false)
|
||||||
|
|
||||||
repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", "").
|
repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", "").
|
||||||
Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil)
|
Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil)
|
||||||
@@ -259,6 +264,7 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo
|
|||||||
},
|
},
|
||||||
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
|
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
|
||||||
progress.On("TooManyErrors").Return(nil)
|
progress.On("TooManyErrors").Return(nil)
|
||||||
|
progress.On("HasDirPathFailedCreation", "dashboards/test.json").Return(false)
|
||||||
|
|
||||||
repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", "").
|
repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", "").
|
||||||
Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, fmt.Errorf("write error"))
|
Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, fmt.Errorf("write error"))
|
||||||
@@ -285,6 +291,7 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo
|
|||||||
},
|
},
|
||||||
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
|
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
|
||||||
progress.On("TooManyErrors").Return(nil)
|
progress.On("TooManyErrors").Return(nil)
|
||||||
|
progress.On("HasDirPathFailedCreation", "dashboards/test.json").Return(false)
|
||||||
|
|
||||||
repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", "").
|
repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", "").
|
||||||
Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil)
|
Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil)
|
||||||
@@ -309,6 +316,7 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo
|
|||||||
},
|
},
|
||||||
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
|
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
|
||||||
progress.On("TooManyErrors").Return(nil)
|
progress.On("TooManyErrors").Return(nil)
|
||||||
|
progress.On("HasDirPathFailedCreation", "dashboards/test.json").Return(false)
|
||||||
|
|
||||||
repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", "").
|
repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", "").
|
||||||
Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, fmt.Errorf("write error"))
|
Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, fmt.Errorf("write error"))
|
||||||
@@ -335,6 +343,7 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo
|
|||||||
},
|
},
|
||||||
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
|
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
|
||||||
progress.On("TooManyErrors").Return(nil)
|
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)
|
repoResources.On("EnsureFolderPathExist", mock.Anything, "one/two/three/").Return("some-folder", nil)
|
||||||
progress.On("Record", mock.Anything, jobs.JobResourceResult{
|
progress.On("Record", mock.Anything, jobs.JobResourceResult{
|
||||||
@@ -357,6 +366,7 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo
|
|||||||
},
|
},
|
||||||
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
|
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
|
||||||
progress.On("TooManyErrors").Return(nil)
|
progress.On("TooManyErrors").Return(nil)
|
||||||
|
progress.On("HasDirPathFailedCreation", "one/two/three/").Return(false)
|
||||||
|
|
||||||
repoResources.On(
|
repoResources.On(
|
||||||
"EnsureFolderPathExist",
|
"EnsureFolderPathExist",
|
||||||
@@ -581,6 +591,7 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo
|
|||||||
},
|
},
|
||||||
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
|
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
|
||||||
progress.On("TooManyErrors").Return(nil)
|
progress.On("TooManyErrors").Return(nil)
|
||||||
|
progress.On("HasDirPathFailedDeletion", "to-be-deleted/").Return(false)
|
||||||
|
|
||||||
scheme := runtime.NewScheme()
|
scheme := runtime.NewScheme()
|
||||||
require.NoError(t, metav1.AddMetaToScheme(scheme))
|
require.NoError(t, metav1.AddMetaToScheme(scheme))
|
||||||
@@ -640,6 +651,7 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo
|
|||||||
},
|
},
|
||||||
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
|
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
|
||||||
progress.On("TooManyErrors").Return(nil)
|
progress.On("TooManyErrors").Return(nil)
|
||||||
|
progress.On("HasDirPathFailedDeletion", "to-be-deleted/").Return(false)
|
||||||
|
|
||||||
scheme := runtime.NewScheme()
|
scheme := runtime.NewScheme()
|
||||||
require.NoError(t, metav1.AddMetaToScheme(scheme))
|
require.NoError(t, metav1.AddMetaToScheme(scheme))
|
||||||
@@ -695,6 +707,7 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo
|
|||||||
},
|
},
|
||||||
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
|
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
|
||||||
progress.On("TooManyErrors").Return(nil)
|
progress.On("TooManyErrors").Return(nil)
|
||||||
|
progress.On("HasDirPathFailedCreation", "dashboards/slow.json").Return(false)
|
||||||
|
|
||||||
repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/slow.json", "").
|
repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/slow.json", "").
|
||||||
Run(func(args mock.Arguments) {
|
Run(func(args mock.Arguments) {
|
||||||
@@ -708,19 +721,13 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo
|
|||||||
}).
|
}).
|
||||||
Return("", schema.GroupVersionKind{}, context.DeadlineExceeded)
|
Return("", schema.GroupVersionKind{}, context.DeadlineExceeded)
|
||||||
|
|
||||||
|
// applyChange records the error from WriteResourceFromFile
|
||||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
|
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
|
||||||
return result.Action == repository.FileActionCreated &&
|
return result.Action == repository.FileActionCreated &&
|
||||||
result.Path == "dashboards/slow.json" &&
|
result.Path == "dashboards/slow.json" &&
|
||||||
result.Error != nil &&
|
result.Error != nil &&
|
||||||
result.Error.Error() == "writing resource from file dashboards/slow.json: context deadline exceeded"
|
result.Error.Error() == "writing resource from file dashboards/slow.json: context deadline exceeded"
|
||||||
})).Return().Once()
|
})).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 {
|
if len(affectedFolders) > 0 {
|
||||||
cleanupStart := time.Now()
|
cleanupStart := time.Now()
|
||||||
span.AddEvent("checking if impacted folders should be deleted", trace.WithAttributes(attribute.Int("affected_folders", len(affectedFolders))))
|
span.AddEvent("checking if impacted folders should be deleted", trace.WithAttributes(attribute.Int("affected_folders", len(affectedFolders))))
|
||||||
err := cleanupOrphanedFolders(ctx, repo, affectedFolders, repositoryResources, tracer)
|
err := cleanupOrphanedFolders(ctx, repo, affectedFolders, repositoryResources, tracer, progress)
|
||||||
metrics.RecordIncrementalSyncPhase(jobs.IncrementalSyncPhaseCleanup, time.Since(cleanupStart))
|
metrics.RecordIncrementalSyncPhase(jobs.IncrementalSyncPhaseCleanup, time.Since(cleanupStart))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return tracing.Error(span, fmt.Errorf("cleanup orphaned folders: %w", err))
|
return tracing.Error(span, fmt.Errorf("cleanup orphaned folders: %w", err))
|
||||||
@@ -85,6 +85,20 @@ func applyIncrementalChanges(ctx context.Context, diff []repository.VersionedFil
|
|||||||
return nil, tracing.Error(span, err)
|
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 {
|
if err := resources.IsPathSupported(change.Path); err != nil {
|
||||||
ensureFolderCtx, ensureFolderSpan := tracer.Start(ctx, "provisioning.sync.incremental.ensure_folder_path_exist")
|
ensureFolderCtx, ensureFolderSpan := tracer.Start(ctx, "provisioning.sync.incremental.ensure_folder_path_exist")
|
||||||
// Maintain the safe segment for empty folders
|
// Maintain the safe segment for empty folders
|
||||||
@@ -98,7 +112,15 @@ func applyIncrementalChanges(ctx context.Context, diff []repository.VersionedFil
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
ensureFolderSpan.RecordError(err)
|
ensureFolderSpan.RecordError(err)
|
||||||
ensureFolderSpan.End()
|
ensureFolderSpan.End()
|
||||||
return nil, tracing.Error(span, fmt.Errorf("unable to create empty file folder: %w", err))
|
|
||||||
|
progress.Record(ensureFolderCtx, jobs.JobResourceResult{
|
||||||
|
Path: change.Path,
|
||||||
|
Action: repository.FileActionIgnored,
|
||||||
|
Group: resources.FolderKind.Group,
|
||||||
|
Kind: resources.FolderKind.Kind,
|
||||||
|
Error: err,
|
||||||
|
})
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
progress.Record(ensureFolderCtx, jobs.JobResourceResult{
|
progress.Record(ensureFolderCtx, jobs.JobResourceResult{
|
||||||
@@ -185,6 +207,7 @@ func cleanupOrphanedFolders(
|
|||||||
affectedFolders map[string]string,
|
affectedFolders map[string]string,
|
||||||
repositoryResources resources.RepositoryResources,
|
repositoryResources resources.RepositoryResources,
|
||||||
tracer tracing.Tracer,
|
tracer tracing.Tracer,
|
||||||
|
progress jobs.JobProgressRecorder,
|
||||||
) error {
|
) error {
|
||||||
ctx, span := tracer.Start(ctx, "provisioning.sync.incremental.cleanup_orphaned_folders")
|
ctx, span := tracer.Start(ctx, "provisioning.sync.incremental.cleanup_orphaned_folders")
|
||||||
defer span.End()
|
defer span.End()
|
||||||
@@ -198,6 +221,12 @@ func cleanupOrphanedFolders(
|
|||||||
for path, folderName := range affectedFolders {
|
for path, folderName := range affectedFolders {
|
||||||
span.SetAttributes(attribute.String("folder", folderName))
|
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
|
// if we can no longer find the folder in git, then we can delete it from grafana
|
||||||
_, err := readerRepo.Read(ctx, path, "")
|
_, err := readerRepo.Read(ctx, path, "")
|
||||||
if err != nil && (errors.Is(err, repository.ErrFileNotFound) || apierrors.IsNotFound(err)) {
|
if err != nil && (errors.Is(err, repository.ErrFileNotFound) || apierrors.IsNotFound(err)) {
|
||||||
|
|||||||
@@ -0,0 +1,623 @@
|
|||||||
|
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,6 +92,10 @@ func TestIncrementalSync(t *testing.T) {
|
|||||||
progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return()
|
progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return()
|
||||||
progress.On("SetMessage", mock.Anything, "versioned changes replicated").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
|
// Mock successful resource writes
|
||||||
repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", "new-ref").
|
repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", "new-ref").
|
||||||
Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil)
|
Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil)
|
||||||
@@ -127,6 +131,9 @@ func TestIncrementalSync(t *testing.T) {
|
|||||||
progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return()
|
progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return()
|
||||||
progress.On("SetMessage", mock.Anything, "versioned changes replicated").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
|
// Mock folder creation
|
||||||
repoResources.On("EnsureFolderPathExist", mock.Anything, "unsupported/path/").
|
repoResources.On("EnsureFolderPathExist", mock.Anything, "unsupported/path/").
|
||||||
Return("test-folder", nil)
|
Return("test-folder", nil)
|
||||||
@@ -161,6 +168,9 @@ func TestIncrementalSync(t *testing.T) {
|
|||||||
progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return()
|
progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return()
|
||||||
progress.On("SetMessage", mock.Anything, "versioned changes replicated").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{
|
progress.On("Record", mock.Anything, jobs.JobResourceResult{
|
||||||
Action: repository.FileActionIgnored,
|
Action: repository.FileActionIgnored,
|
||||||
Path: ".unsupported/path/file.txt",
|
Path: ".unsupported/path/file.txt",
|
||||||
@@ -222,6 +232,9 @@ func TestIncrementalSync(t *testing.T) {
|
|||||||
progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return()
|
progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return()
|
||||||
progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return()
|
progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return()
|
||||||
|
|
||||||
|
// Mock HasDirPathFailedCreation check
|
||||||
|
progress.On("HasDirPathFailedCreation", "dashboards/new.json").Return(false)
|
||||||
|
|
||||||
// Mock resource rename
|
// Mock resource rename
|
||||||
repoResources.On("RenameResourceFile", mock.Anything, "dashboards/old.json", "old-ref", "dashboards/new.json", "new-ref").
|
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)
|
Return("renamed-dashboard", "", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil)
|
||||||
@@ -254,6 +267,10 @@ func TestIncrementalSync(t *testing.T) {
|
|||||||
progress.On("SetTotal", mock.Anything, 1).Return()
|
progress.On("SetTotal", mock.Anything, 1).Return()
|
||||||
progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return()
|
progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return()
|
||||||
progress.On("SetMessage", mock.Anything, "versioned changes replicated").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{
|
progress.On("Record", mock.Anything, jobs.JobResourceResult{
|
||||||
Action: repository.FileActionIgnored,
|
Action: repository.FileActionIgnored,
|
||||||
Path: "dashboards/ignored.json",
|
Path: "dashboards/ignored.json",
|
||||||
@@ -277,16 +294,28 @@ func TestIncrementalSync(t *testing.T) {
|
|||||||
repo.On("CompareFiles", mock.Anything, "old-ref", "new-ref").Return(changes, nil)
|
repo.On("CompareFiles", mock.Anything, "old-ref", "new-ref").Return(changes, nil)
|
||||||
progress.On("SetTotal", mock.Anything, 1).Return()
|
progress.On("SetTotal", mock.Anything, 1).Return()
|
||||||
progress.On("SetMessage", mock.Anything, "replicating versioned changes").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
|
// Mock folder creation error
|
||||||
repoResources.On("EnsureFolderPathExist", mock.Anything, "unsupported/path/").
|
repoResources.On("EnsureFolderPathExist", mock.Anything, "unsupported/path/").
|
||||||
Return("", fmt.Errorf("failed to create folder"))
|
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)
|
progress.On("TooManyErrors").Return(nil)
|
||||||
},
|
},
|
||||||
previousRef: "old-ref",
|
previousRef: "old-ref",
|
||||||
currentRef: "new-ref",
|
currentRef: "new-ref",
|
||||||
expectedError: "unable to create empty file folder: failed to create folder",
|
expectedCalls: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "error writing resource",
|
name: "error writing resource",
|
||||||
@@ -303,6 +332,9 @@ func TestIncrementalSync(t *testing.T) {
|
|||||||
progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return()
|
progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return()
|
||||||
progress.On("SetMessage", mock.Anything, "versioned changes replicated").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
|
// Mock resource write error
|
||||||
repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", "new-ref").
|
repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", "new-ref").
|
||||||
Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, fmt.Errorf("write failed"))
|
Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, fmt.Errorf("write failed"))
|
||||||
@@ -372,7 +404,8 @@ func TestIncrementalSync(t *testing.T) {
|
|||||||
repo.On("CompareFiles", mock.Anything, "old-ref", "new-ref").Return(changes, nil)
|
repo.On("CompareFiles", mock.Anything, "old-ref", "new-ref").Return(changes, nil)
|
||||||
progress.On("SetTotal", mock.Anything, 1).Return()
|
progress.On("SetTotal", mock.Anything, 1).Return()
|
||||||
progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return()
|
progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return()
|
||||||
// Mock too many errors
|
|
||||||
|
// Mock too many errors - this is checked before processing files, so HasDirPathFailedCreation won't be called
|
||||||
progress.On("TooManyErrors").Return(fmt.Errorf("too many errors occurred"))
|
progress.On("TooManyErrors").Return(fmt.Errorf("too many errors occurred"))
|
||||||
},
|
},
|
||||||
previousRef: "old-ref",
|
previousRef: "old-ref",
|
||||||
@@ -428,6 +461,9 @@ func TestIncrementalSync_CleanupOrphanedFolders(t *testing.T) {
|
|||||||
repoResources.On("RemoveResourceFromFile", mock.Anything, "dashboards/old.json", "old-ref").
|
repoResources.On("RemoveResourceFromFile", mock.Anything, "dashboards/old.json", "old-ref").
|
||||||
Return("old-dashboard", "folder-uid", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil)
|
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
|
// 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/", "").
|
repo.MockReader.On("Read", mock.Anything, "dashboards/", "").
|
||||||
Return((*repository.FileInfo)(nil), repository.ErrFileNotFound)
|
Return((*repository.FileInfo)(nil), repository.ErrFileNotFound)
|
||||||
@@ -453,6 +489,10 @@ func TestIncrementalSync_CleanupOrphanedFolders(t *testing.T) {
|
|||||||
progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return()
|
progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return()
|
||||||
repoResources.On("RemoveResourceFromFile", mock.Anything, "dashboards/old.json", "old-ref").
|
repoResources.On("RemoveResourceFromFile", mock.Anything, "dashboards/old.json", "old-ref").
|
||||||
Return("old-dashboard", "folder-uid", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil)
|
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
|
// 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/", "").
|
repo.MockReader.On("Read", mock.Anything, "dashboards/", "").
|
||||||
Return(&repository.FileInfo{}, nil)
|
Return(&repository.FileInfo{}, nil)
|
||||||
@@ -485,6 +525,13 @@ func TestIncrementalSync_CleanupOrphanedFolders(t *testing.T) {
|
|||||||
repoResources.On("RemoveResourceFromFile", mock.Anything, "alerts/old-alert.yaml", "old-ref").
|
repoResources.On("RemoveResourceFromFile", mock.Anything, "alerts/old-alert.yaml", "old-ref").
|
||||||
Return("old-alert", "folder-uid-2", schema.GroupVersionKind{Kind: "Alert", Group: "alerts"}, nil)
|
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
|
// both not found in git, both should be deleted
|
||||||
repo.MockReader.On("Read", mock.Anything, "dashboards/", "").
|
repo.MockReader.On("Read", mock.Anything, "dashboards/", "").
|
||||||
Return((*repository.FileInfo)(nil), repository.ErrFileNotFound)
|
Return((*repository.FileInfo)(nil), repository.ErrFileNotFound)
|
||||||
@@ -492,9 +539,6 @@ func TestIncrementalSync_CleanupOrphanedFolders(t *testing.T) {
|
|||||||
Return((*repository.FileInfo)(nil), repository.ErrFileNotFound)
|
Return((*repository.FileInfo)(nil), repository.ErrFileNotFound)
|
||||||
repoResources.On("RemoveFolder", mock.Anything, "folder-uid-1").Return(nil)
|
repoResources.On("RemoveFolder", mock.Anything, "folder-uid-1").Return(nil)
|
||||||
repoResources.On("RemoveFolder", mock.Anything, "folder-uid-2").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,6 +559,22 @@ func (b *APIBuilder) InstallSchema(scheme *runtime.Scheme) error {
|
|||||||
return err
|
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)
|
metav1.AddToGroupVersion(scheme, provisioning.SchemeGroupVersion)
|
||||||
// Only 1 version (for now?)
|
// Only 1 version (for now?)
|
||||||
return scheme.SetVersionPriority(provisioning.SchemeGroupVersion)
|
return scheme.SetVersionPriority(provisioning.SchemeGroupVersion)
|
||||||
@@ -569,10 +585,19 @@ func (b *APIBuilder) AllowedV0Alpha1Resources() []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions) error {
|
func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions) error {
|
||||||
repositoryStorage, err := grafanaregistry.NewRegistryStore(opts.Scheme, provisioning.RepositoryResourceInfo, opts.OptsGetter)
|
// 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,
|
||||||
|
},
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create repository storage: %w", err)
|
return fmt.Errorf("failed to create repository storage: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
repositoryStatusStorage := grafanaregistry.NewRegistryStatusStore(opts.Scheme, repositoryStorage)
|
repositoryStatusStorage := grafanaregistry.NewRegistryStatusStore(opts.Scheme, repositoryStorage)
|
||||||
b.store = repositoryStorage
|
b.store = repositoryStorage
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
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,6 +20,21 @@ import (
|
|||||||
|
|
||||||
const MaxNumberOfFolders = 10000
|
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 {
|
type FolderManager struct {
|
||||||
repo repository.ReaderWriter
|
repo repository.ReaderWriter
|
||||||
tree FolderTree
|
tree FolderTree
|
||||||
@@ -73,7 +88,11 @@ func (fm *FolderManager) EnsureFolderPathExist(ctx context.Context, filePath str
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := fm.EnsureFolderExists(ctx, f, parent); err != nil {
|
if err := fm.EnsureFolderExists(ctx, f, parent); err != nil {
|
||||||
return fmt.Errorf("ensure folder exists: %w", err)
|
// Wrap in PathCreationError to indicate which path failed
|
||||||
|
return &PathCreationError{
|
||||||
|
Path: f.Path,
|
||||||
|
Err: fmt.Errorf("ensure folder exists: %w", err),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fm.tree.Add(f, parent)
|
fm.tree.Add(f, parent)
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1585,8 +1585,8 @@ var (
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "kubernetesAuthzApis",
|
Name: "kubernetesAuthzApis",
|
||||||
Description: "Registers AuthZ /apis endpoint",
|
Description: "Deprecated: Use kubernetesAuthzCoreRolesApi, kubernetesAuthzRolesApi, and kubernetesAuthzRoleBindingsApi instead",
|
||||||
Stage: FeatureStageExperimental,
|
Stage: FeatureStageDeprecated,
|
||||||
Owner: identityAccessTeam,
|
Owner: identityAccessTeam,
|
||||||
HideFromDocs: true,
|
HideFromDocs: true,
|
||||||
},
|
},
|
||||||
@@ -1611,6 +1611,27 @@ var (
|
|||||||
Owner: identityAccessTeam,
|
Owner: identityAccessTeam,
|
||||||
HideFromDocs: true,
|
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",
|
Name: "kubernetesAuthnMutation",
|
||||||
Description: "Enables create, delete, and update mutations for resources owned by IAM identity",
|
Description: "Enables create, delete, and update mutations for resources owned by IAM identity",
|
||||||
@@ -1859,14 +1880,6 @@ var (
|
|||||||
Expression: "false",
|
Expression: "false",
|
||||||
RequiresRestart: true,
|
RequiresRestart: true,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
Name: "tempoSearchBackendMigration",
|
|
||||||
Description: "Run search queries through the tempo backend",
|
|
||||||
Stage: FeatureStageGeneralAvailability,
|
|
||||||
Owner: grafanaOSSBigTent,
|
|
||||||
Expression: "false",
|
|
||||||
RequiresRestart: true,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
Name: "cdnPluginsLoadFirst",
|
Name: "cdnPluginsLoadFirst",
|
||||||
Description: "Prioritize loading plugins from the CDN before other sources",
|
Description: "Prioritize loading plugins from the CDN before other sources",
|
||||||
|
|||||||
Generated
+4
-2
@@ -217,10 +217,13 @@ pluginsAutoUpdate,experimental,@grafana/plugins-platform-backend,false,false,fal
|
|||||||
alertingListViewV2PreviewToggle,privatePreview,@grafana/alerting-squad,false,false,true
|
alertingListViewV2PreviewToggle,privatePreview,@grafana/alerting-squad,false,false,true
|
||||||
alertRuleUseFiredAtForStartsAt,experimental,@grafana/alerting-squad,false,false,false
|
alertRuleUseFiredAtForStartsAt,experimental,@grafana/alerting-squad,false,false,false
|
||||||
alertingBulkActionsInUI,GA,@grafana/alerting-squad,false,false,true
|
alertingBulkActionsInUI,GA,@grafana/alerting-squad,false,false,true
|
||||||
kubernetesAuthzApis,experimental,@grafana/identity-access-team,false,false,false
|
kubernetesAuthzApis,deprecated,@grafana/identity-access-team,false,false,false
|
||||||
kubernetesAuthZHandlerRedirect,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
|
kubernetesAuthzResourcePermissionApis,experimental,@grafana/identity-access-team,false,false,false
|
||||||
kubernetesAuthzZanzanaSync,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
|
kubernetesAuthnMutation,experimental,@grafana/identity-access-team,false,false,false
|
||||||
kubernetesExternalGroupMapping,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
|
restoreDashboards,experimental,@grafana/grafana-search-navigate-organise,false,false,false
|
||||||
@@ -253,7 +256,6 @@ graphiteBackendMode,privatePreview,@grafana/partner-datasources,false,false,fals
|
|||||||
azureResourcePickerUpdates,GA,@grafana/partner-datasources,false,false,true
|
azureResourcePickerUpdates,GA,@grafana/partner-datasources,false,false,true
|
||||||
prometheusTypeMigration,experimental,@grafana/partner-datasources,false,true,false
|
prometheusTypeMigration,experimental,@grafana/partner-datasources,false,true,false
|
||||||
pluginContainers,privatePreview,@grafana/plugins-platform-backend,false,true,false
|
pluginContainers,privatePreview,@grafana/plugins-platform-backend,false,true,false
|
||||||
tempoSearchBackendMigration,GA,@grafana/oss-big-tent,false,true,false
|
|
||||||
cdnPluginsLoadFirst,experimental,@grafana/plugins-platform-backend,false,false,false
|
cdnPluginsLoadFirst,experimental,@grafana/plugins-platform-backend,false,false,false
|
||||||
cdnPluginsUrls,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
|
pluginInstallAPISync,experimental,@grafana/plugins-platform-backend,false,false,false
|
||||||
|
|||||||
|
Generated
+13
-5
@@ -631,7 +631,7 @@ const (
|
|||||||
FlagAlertRuleUseFiredAtForStartsAt = "alertRuleUseFiredAtForStartsAt"
|
FlagAlertRuleUseFiredAtForStartsAt = "alertRuleUseFiredAtForStartsAt"
|
||||||
|
|
||||||
// FlagKubernetesAuthzApis
|
// FlagKubernetesAuthzApis
|
||||||
// Registers AuthZ /apis endpoint
|
// Deprecated: Use kubernetesAuthzCoreRolesApi, kubernetesAuthzRolesApi, and kubernetesAuthzRoleBindingsApi instead
|
||||||
FlagKubernetesAuthzApis = "kubernetesAuthzApis"
|
FlagKubernetesAuthzApis = "kubernetesAuthzApis"
|
||||||
|
|
||||||
// FlagKubernetesAuthZHandlerRedirect
|
// FlagKubernetesAuthZHandlerRedirect
|
||||||
@@ -646,6 +646,18 @@ const (
|
|||||||
// Enable sync of Zanzana authorization store on AuthZ CRD mutations
|
// Enable sync of Zanzana authorization store on AuthZ CRD mutations
|
||||||
FlagKubernetesAuthzZanzanaSync = "kubernetesAuthzZanzanaSync"
|
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
|
// FlagKubernetesAuthnMutation
|
||||||
// Enables create, delete, and update mutations for resources owned by IAM identity
|
// Enables create, delete, and update mutations for resources owned by IAM identity
|
||||||
FlagKubernetesAuthnMutation = "kubernetesAuthnMutation"
|
FlagKubernetesAuthnMutation = "kubernetesAuthnMutation"
|
||||||
@@ -730,10 +742,6 @@ const (
|
|||||||
// Enables running plugins in containers
|
// Enables running plugins in containers
|
||||||
FlagPluginContainers = "pluginContainers"
|
FlagPluginContainers = "pluginContainers"
|
||||||
|
|
||||||
// FlagTempoSearchBackendMigration
|
|
||||||
// Run search queries through the tempo backend
|
|
||||||
FlagTempoSearchBackendMigration = "tempoSearchBackendMigration"
|
|
||||||
|
|
||||||
// FlagCdnPluginsLoadFirst
|
// FlagCdnPluginsLoadFirst
|
||||||
// Prioritize loading plugins from the CDN before other sources
|
// Prioritize loading plugins from the CDN before other sources
|
||||||
FlagCdnPluginsLoadFirst = "cdnPluginsLoadFirst"
|
FlagCdnPluginsLoadFirst = "cdnPluginsLoadFirst"
|
||||||
|
|||||||
+45
-3
@@ -1951,11 +1951,27 @@
|
|||||||
{
|
{
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"name": "kubernetesAuthzApis",
|
"name": "kubernetesAuthzApis",
|
||||||
"resourceVersion": "1764664939750",
|
"resourceVersion": "1767954559317",
|
||||||
"creationTimestamp": "2025-06-18T07:43:01Z"
|
"creationTimestamp": "2025-06-18T07:43:01Z",
|
||||||
|
"annotations": {
|
||||||
|
"grafana.app/updatedTimestamp": "2026-01-09 10:29:19.317164 +0000 UTC"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"spec": {
|
"spec": {
|
||||||
"description": "Registers AuthZ /apis endpoint",
|
"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",
|
||||||
"stage": "experimental",
|
"stage": "experimental",
|
||||||
"codeowner": "@grafana/identity-access-team",
|
"codeowner": "@grafana/identity-access-team",
|
||||||
"hideFromDocs": true
|
"hideFromDocs": true
|
||||||
@@ -1975,6 +1991,32 @@
|
|||||||
"hideFromDocs": true
|
"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": {
|
"metadata": {
|
||||||
"name": "kubernetesAuthzZanzanaSync",
|
"name": "kubernetesAuthzZanzanaSync",
|
||||||
|
|||||||
@@ -559,3 +559,175 @@ func TestIntegrationConnectionController_HealthCheckUpdates(t *testing.T) {
|
|||||||
assert.True(t, final.Status.Health.Healthy, "connection should remain healthy")
|
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 { generatedAPI } from '@grafana/api-clients/rtkq/collections/v1alpha1';
|
||||||
import { t } from '@grafana/i18n';
|
import { t } from '@grafana/i18n';
|
||||||
import { notifyApp } from 'app/core/actions';
|
|
||||||
import { createSuccessNotification, createErrorNotification } from 'app/core/copy/appNotification';
|
import { createSuccessNotification, createErrorNotification } from 'app/core/copy/appNotification';
|
||||||
|
import { notifyApp } from 'app/core/reducers/appNotification';
|
||||||
|
|
||||||
export const collectionsAPIv1alpha1 = generatedAPI.enhanceEndpoints({
|
export const collectionsAPIv1alpha1 = generatedAPI.enhanceEndpoints({
|
||||||
endpoints: {
|
endpoints: {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { generatedAPI, type Playlist, type PlaylistSpec } from '@grafana/api-clients/rtkq/playlist/v0alpha1';
|
import { generatedAPI, type Playlist, type PlaylistSpec } from '@grafana/api-clients/rtkq/playlist/v0alpha1';
|
||||||
import { getBackendSrv } from '@grafana/runtime';
|
import { getBackendSrv } from '@grafana/runtime';
|
||||||
|
|
||||||
import { notifyApp } from '../../../../core/actions';
|
|
||||||
import { createSuccessNotification } from '../../../../core/copy/appNotification';
|
import { createSuccessNotification } from '../../../../core/copy/appNotification';
|
||||||
|
import { notifyApp } from '../../../../core/reducers/appNotification';
|
||||||
import { contextSrv } from '../../../../core/services/context_srv';
|
import { contextSrv } from '../../../../core/services/context_srv';
|
||||||
import { handleError } from '../../../utils';
|
import { handleError } from '../../../utils';
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import { isFetchError } from '@grafana/runtime';
|
|||||||
import { clearFolders } from 'app/features/browse-dashboards/state/slice';
|
import { clearFolders } from 'app/features/browse-dashboards/state/slice';
|
||||||
import { getState } from 'app/store/store';
|
import { getState } from 'app/store/store';
|
||||||
|
|
||||||
import { notifyApp } from '../../../../core/actions';
|
|
||||||
import { createSuccessNotification, createErrorNotification } from '../../../../core/copy/appNotification';
|
import { createSuccessNotification, createErrorNotification } from '../../../../core/copy/appNotification';
|
||||||
|
import { notifyApp } from '../../../../core/reducers/appNotification';
|
||||||
import { PAGE_SIZE } from '../../../../features/browse-dashboards/api/services';
|
import { PAGE_SIZE } from '../../../../features/browse-dashboards/api/services';
|
||||||
import { refetchChildren } from '../../../../features/browse-dashboards/state/actions';
|
import { refetchChildren } from '../../../../features/browse-dashboards/state/actions';
|
||||||
import { handleError } from '../../../utils';
|
import { handleError } from '../../../utils';
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { normalizeError } from '@grafana/api-clients';
|
import { normalizeError } from '@grafana/api-clients';
|
||||||
import { ThunkDispatch } from 'app/types/store';
|
import { ThunkDispatch } from 'app/types/store';
|
||||||
|
|
||||||
import { notifyApp } from '../core/actions';
|
|
||||||
import { createErrorNotification } from '../core/copy/appNotification';
|
import { createErrorNotification } from '../core/copy/appNotification';
|
||||||
|
import { notifyApp } from '../core/reducers/appNotification';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle an error from a k8s API call
|
* Handle an error from a k8s API call
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
import { hideAppNotification, notifyApp } from '../reducers/appNotification';
|
|
||||||
import { updateNavIndex, updateConfigurationSubtitle } from '../reducers/navModel';
|
|
||||||
|
|
||||||
export { updateNavIndex, updateConfigurationSubtitle, notifyApp, hideAppNotification };
|
|
||||||
@@ -4,10 +4,9 @@ import { useLocation } from 'react-router-dom';
|
|||||||
|
|
||||||
import { AlertErrorPayload, AlertPayload, AppEvents, GrafanaTheme2 } from '@grafana/data';
|
import { AlertErrorPayload, AlertPayload, AppEvents, GrafanaTheme2 } from '@grafana/data';
|
||||||
import { useStyles2, Stack } from '@grafana/ui';
|
import { useStyles2, Stack } from '@grafana/ui';
|
||||||
import { notifyApp, hideAppNotification } from 'app/core/actions';
|
|
||||||
import { appEvents } from 'app/core/app_events';
|
import { appEvents } from 'app/core/app_events';
|
||||||
import { useGrafana } from 'app/core/context/GrafanaContext';
|
import { useGrafana } from 'app/core/context/GrafanaContext';
|
||||||
import { selectVisible } from 'app/core/reducers/appNotification';
|
import { hideAppNotification, notifyApp, selectVisible } from 'app/core/reducers/appNotification';
|
||||||
import { useSelector, useDispatch } from 'app/types/store';
|
import { useSelector, useDispatch } from 'app/types/store';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { dispatch as storeDispatch } from 'app/store/store';
|
|||||||
import { AppNotificationSeverity, AppNotification } from 'app/types/appNotifications';
|
import { AppNotificationSeverity, AppNotification } from 'app/types/appNotifications';
|
||||||
import { useDispatch } from 'app/types/store';
|
import { useDispatch } from 'app/types/store';
|
||||||
|
|
||||||
import { notifyApp } from '../actions';
|
import { notifyApp } from '../reducers/appNotification';
|
||||||
|
|
||||||
const defaultSuccessNotification = {
|
const defaultSuccessNotification = {
|
||||||
title: '',
|
title: '',
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { t } from '@grafana/i18n';
|
import { t } from '@grafana/i18n';
|
||||||
import { getDataSourceSrv } from '@grafana/runtime';
|
import { getDataSourceSrv } from '@grafana/runtime';
|
||||||
import { notifyApp } from 'app/core/actions';
|
|
||||||
import { createErrorNotification, createWarningNotification } from 'app/core/copy/appNotification';
|
import { createErrorNotification, createWarningNotification } from 'app/core/copy/appNotification';
|
||||||
import { dispatch } from 'app/store/store';
|
import { dispatch } from 'app/store/store';
|
||||||
import { RichHistoryQuery } from 'app/types/explore';
|
import { RichHistoryQuery } from 'app/types/explore';
|
||||||
@@ -23,6 +22,7 @@ import {
|
|||||||
} from '../history/RichHistoryStorage';
|
} from '../history/RichHistoryStorage';
|
||||||
import { createRetentionPeriodBoundary } from '../history/richHistoryLocalStorageUtils';
|
import { createRetentionPeriodBoundary } from '../history/richHistoryLocalStorageUtils';
|
||||||
import { getLocalRichHistoryStorage, getRichHistoryStorage } from '../history/richHistoryStorageProvider';
|
import { getLocalRichHistoryStorage, getRichHistoryStorage } from '../history/richHistoryStorageProvider';
|
||||||
|
import { notifyApp } from '../reducers/appNotification';
|
||||||
import { contextSrv } from '../services/context_srv';
|
import { contextSrv } from '../services/context_srv';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { t } from '@grafana/i18n';
|
|||||||
import { getBackendSrv, config, locationService } from '@grafana/runtime';
|
import { getBackendSrv, config, locationService } from '@grafana/runtime';
|
||||||
import { sceneGraph, SceneTimeRangeLike, VizPanel } from '@grafana/scenes';
|
import { sceneGraph, SceneTimeRangeLike, VizPanel } from '@grafana/scenes';
|
||||||
import { shortURLAPIv1beta1 } from 'app/api/clients/shorturl/v1beta1';
|
import { shortURLAPIv1beta1 } from 'app/api/clients/shorturl/v1beta1';
|
||||||
import { notifyApp } from 'app/core/actions';
|
|
||||||
import { createErrorNotification, createSuccessNotification } from 'app/core/copy/appNotification';
|
import { createErrorNotification, createSuccessNotification } from 'app/core/copy/appNotification';
|
||||||
import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene';
|
import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene';
|
||||||
import { getDashboardUrl } from 'app/features/dashboard-scene/utils/getDashboardUrl';
|
import { getDashboardUrl } from 'app/features/dashboard-scene/utils/getDashboardUrl';
|
||||||
@@ -14,6 +13,7 @@ import { dispatch } from 'app/store/store';
|
|||||||
import { ShortURL } from '../../../../apps/shorturl/plugin/src/generated/shorturl/v1beta1/shorturl_object_gen';
|
import { ShortURL } from '../../../../apps/shorturl/plugin/src/generated/shorturl/v1beta1/shorturl_object_gen';
|
||||||
import { extractErrorMessage } from '../../api/utils';
|
import { extractErrorMessage } from '../../api/utils';
|
||||||
import { ShareLinkConfiguration } from '../../features/dashboard-scene/sharing/ShareButton/utils';
|
import { ShareLinkConfiguration } from '../../features/dashboard-scene/sharing/ShareButton/utils';
|
||||||
|
import { notifyApp } from '../reducers/appNotification';
|
||||||
|
|
||||||
import { copyStringToClipboard } from './explore';
|
import { copyStringToClipboard } from './explore';
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { UrlQueryMap, getTimeZone, getDefaultTimeRange, dateMath } from '@grafana/data';
|
import { UrlQueryMap, getTimeZone, getDefaultTimeRange, dateMath } from '@grafana/data';
|
||||||
import { locationService } from '@grafana/runtime';
|
import { locationService } from '@grafana/runtime';
|
||||||
import { getFolderByUidFacade } from 'app/api/clients/folder/v1beta1/hooks';
|
import { getFolderByUidFacade } from 'app/api/clients/folder/v1beta1/hooks';
|
||||||
import { updateNavIndex } from 'app/core/actions';
|
import { updateNavIndex } from 'app/core/reducers/navModel';
|
||||||
import { buildNavModel } from 'app/features/folders/state/navModel';
|
import { buildNavModel } from 'app/features/folders/state/navModel';
|
||||||
import { store } from 'app/store/store';
|
import { store } from 'app/store/store';
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import {
|
|||||||
sceneGraph,
|
sceneGraph,
|
||||||
SceneTimeRangeLike,
|
SceneTimeRangeLike,
|
||||||
} from '@grafana/scenes';
|
} from '@grafana/scenes';
|
||||||
import { notifyApp } from 'app/core/actions';
|
|
||||||
import { createErrorNotification } from 'app/core/copy/appNotification';
|
import { createErrorNotification } from 'app/core/copy/appNotification';
|
||||||
|
import { notifyApp } from 'app/core/reducers/appNotification';
|
||||||
import { contextSrv } from 'app/core/services/context_srv';
|
import { contextSrv } from 'app/core/services/context_srv';
|
||||||
import { getMessageFromError } from 'app/core/utils/errors';
|
import { getMessageFromError } from 'app/core/utils/errors';
|
||||||
import { alertRuleApi } from 'app/features/alerting/unified/api/alertRuleApi';
|
import { alertRuleApi } from 'app/features/alerting/unified/api/alertRuleApi';
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ import {
|
|||||||
LibraryPanelRef,
|
LibraryPanelRef,
|
||||||
LibraryPanelKind,
|
LibraryPanelKind,
|
||||||
} from '@grafana/schema/dist/esm/schema/dashboard/v2';
|
} from '@grafana/schema/dist/esm/schema/dashboard/v2';
|
||||||
import { notifyApp } from 'app/core/actions';
|
|
||||||
import config from 'app/core/config';
|
import config from 'app/core/config';
|
||||||
import { createErrorNotification } from 'app/core/copy/appNotification';
|
import { createErrorNotification } from 'app/core/copy/appNotification';
|
||||||
|
import { notifyApp } from 'app/core/reducers/appNotification';
|
||||||
import { buildPanelKind } from 'app/features/dashboard/api/ResponseTransformers';
|
import { buildPanelKind } from 'app/features/dashboard/api/ResponseTransformers';
|
||||||
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
|
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
|
||||||
import { PanelModel, GridPos } from 'app/features/dashboard/state/PanelModel';
|
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 { config } from '@grafana/runtime';
|
||||||
import { SceneComponentProps } from '@grafana/scenes';
|
import { SceneComponentProps } from '@grafana/scenes';
|
||||||
import { Button, ClipboardButton, CodeEditor, Label, Spinner, Stack, Switch, useStyles2 } from '@grafana/ui';
|
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 { createSuccessNotification } from 'app/core/copy/appNotification';
|
||||||
|
import { notifyApp } from 'app/core/reducers/appNotification';
|
||||||
import { dispatch } from 'app/store/store';
|
import { dispatch } from 'app/store/store';
|
||||||
|
|
||||||
import { ShareExportTab } from '../ShareExportTab';
|
import { ShareExportTab } from '../ShareExportTab';
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { Trans, t } from '@grafana/i18n';
|
|||||||
import { SceneComponentProps, sceneGraph, SceneObjectBase, SceneObjectRef, VizPanel } from '@grafana/scenes';
|
import { SceneComponentProps, sceneGraph, SceneObjectBase, SceneObjectRef, VizPanel } from '@grafana/scenes';
|
||||||
import { Dashboard } from '@grafana/schema';
|
import { Dashboard } from '@grafana/schema';
|
||||||
import { Button, ClipboardButton, Field, Input, Modal, RadioButtonGroup, Stack } from '@grafana/ui';
|
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 { createSuccessNotification } from 'app/core/copy/appNotification';
|
||||||
|
import { notifyApp } from 'app/core/reducers/appNotification';
|
||||||
import { getTrackingSource, shareDashboardType } from 'app/features/dashboard/components/ShareModal/utils';
|
import { getTrackingSource, shareDashboardType } from 'app/features/dashboard/components/ShareModal/utils';
|
||||||
import { getDashboardSnapshotSrv, SnapshotSharingOptions } from 'app/features/dashboard/services/SnapshotSrv';
|
import { getDashboardSnapshotSrv, SnapshotSharingOptions } from 'app/features/dashboard/services/SnapshotSrv';
|
||||||
import { dispatch } from 'app/store/store';
|
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 { createBaseQuery } from '@grafana/api-clients/rtkq';
|
||||||
import { t } from '@grafana/i18n';
|
import { t } from '@grafana/i18n';
|
||||||
import { config, FetchError, isFetchError } from '@grafana/runtime';
|
import { config, FetchError, isFetchError } from '@grafana/runtime';
|
||||||
import { notifyApp } from 'app/core/actions';
|
|
||||||
import { createErrorNotification, createSuccessNotification } from 'app/core/copy/appNotification';
|
import { createErrorNotification, createSuccessNotification } from 'app/core/copy/appNotification';
|
||||||
|
import { notifyApp } from 'app/core/reducers/appNotification';
|
||||||
import {
|
import {
|
||||||
PublicDashboard,
|
PublicDashboard,
|
||||||
PublicDashboardSettings,
|
PublicDashboardSettings,
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
ToolbarButtonRow,
|
ToolbarButtonRow,
|
||||||
ConfirmModal,
|
ConfirmModal,
|
||||||
} from '@grafana/ui';
|
} from '@grafana/ui';
|
||||||
import { updateNavIndex } from 'app/core/actions';
|
|
||||||
import { appEvents } from 'app/core/app_events';
|
import { appEvents } from 'app/core/app_events';
|
||||||
import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate';
|
import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate';
|
||||||
import { NavToolbarSeparator } from 'app/core/components/AppChrome/NavToolbar/NavToolbarSeparator';
|
import { NavToolbarSeparator } from 'app/core/components/AppChrome/NavToolbar/NavToolbarSeparator';
|
||||||
@@ -22,7 +21,7 @@ import config from 'app/core/config';
|
|||||||
import { useAppNotification } from 'app/core/copy/appNotification';
|
import { useAppNotification } from 'app/core/copy/appNotification';
|
||||||
import { useBusEvent } from 'app/core/hooks/useBusEvent';
|
import { useBusEvent } from 'app/core/hooks/useBusEvent';
|
||||||
import { ID_PREFIX, setStarred } from 'app/core/reducers/navBarTree';
|
import { ID_PREFIX, setStarred } from 'app/core/reducers/navBarTree';
|
||||||
import { removeNavIndex } from 'app/core/reducers/navModel';
|
import { removeNavIndex, updateNavIndex } from 'app/core/reducers/navModel';
|
||||||
import AddPanelButton from 'app/features/dashboard/components/AddPanelButton/AddPanelButton';
|
import AddPanelButton from 'app/features/dashboard/components/AddPanelButton/AddPanelButton';
|
||||||
import { SaveDashboardDrawer } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardDrawer';
|
import { SaveDashboardDrawer } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardDrawer';
|
||||||
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
|
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { appEvents } from 'app/core/app_events';
|
|||||||
import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate';
|
import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate';
|
||||||
import { Page } from 'app/core/components/Page/Page';
|
import { Page } from 'app/core/components/Page/Page';
|
||||||
import { SplitPaneWrapper } from 'app/core/components/SplitPaneWrapper/SplitPaneWrapper';
|
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 { SubMenuItems } from 'app/features/dashboard/components/SubMenu/SubMenuItems';
|
||||||
import { SaveLibraryPanelModal } from 'app/features/library-panels/components/SaveLibraryPanelModal/SaveLibraryPanelModal';
|
import { SaveLibraryPanelModal } from 'app/features/library-panels/components/SaveLibraryPanelModal/SaveLibraryPanelModal';
|
||||||
import { PanelModelWithLibraryPanel } from 'app/features/library-panels/types';
|
import { PanelModelWithLibraryPanel } from 'app/features/library-panels/types';
|
||||||
@@ -32,7 +33,6 @@ import { updateTimeZoneForSession } from 'app/features/profile/state/reducers';
|
|||||||
import { PanelOptionsChangedEvent, ShowModalReactEvent } from 'app/types/events';
|
import { PanelOptionsChangedEvent, ShowModalReactEvent } from 'app/types/events';
|
||||||
import { StoreState } from 'app/types/store';
|
import { StoreState } from 'app/types/store';
|
||||||
|
|
||||||
import { notifyApp } from '../../../../core/actions';
|
|
||||||
import { UnlinkModal } from '../../../dashboard-scene/scene/UnlinkModal';
|
import { UnlinkModal } from '../../../dashboard-scene/scene/UnlinkModal';
|
||||||
import { isPanelModelLibraryPanel } from '../../../library-panels/guard';
|
import { isPanelModelLibraryPanel } from '../../../library-panels/guard';
|
||||||
import { getVariablesByKey } from '../../../variables/state/selectors';
|
import { getVariablesByKey } from '../../../variables/state/selectors';
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import { createTheme } from '@grafana/data';
|
|||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
import { config, setDataSourceSrv } from '@grafana/runtime';
|
import { config, setDataSourceSrv } from '@grafana/runtime';
|
||||||
import { Dashboard } from '@grafana/schema';
|
import { Dashboard } from '@grafana/schema';
|
||||||
import { notifyApp } from 'app/core/actions';
|
|
||||||
import { AppChrome } from 'app/core/components/AppChrome/AppChrome';
|
import { AppChrome } from 'app/core/components/AppChrome/AppChrome';
|
||||||
import { getRouteComponentProps } from 'app/core/navigation/mocks/routeProps';
|
import { getRouteComponentProps } from 'app/core/navigation/mocks/routeProps';
|
||||||
import { RouteDescriptor } from 'app/core/navigation/types';
|
import { RouteDescriptor } from 'app/core/navigation/types';
|
||||||
|
import { notifyApp } from 'app/core/reducers/appNotification';
|
||||||
import { HOME_NAV_ID } from 'app/core/reducers/navModel';
|
import { HOME_NAV_ID } from 'app/core/reducers/navModel';
|
||||||
import { DashboardInitPhase, DashboardMeta, DashboardRoutes } from 'app/types/dashboard';
|
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 { selectors } from '@grafana/e2e-selectors';
|
||||||
import { locationService } from '@grafana/runtime';
|
import { locationService } from '@grafana/runtime';
|
||||||
import { Themeable2, withTheme2 } from '@grafana/ui';
|
import { Themeable2, withTheme2 } from '@grafana/ui';
|
||||||
import { notifyApp } from 'app/core/actions';
|
|
||||||
import { ScrollRefElement } from 'app/core/components/NativeScrollbar';
|
import { ScrollRefElement } from 'app/core/components/NativeScrollbar';
|
||||||
import { Page } from 'app/core/components/Page/Page';
|
import { Page } from 'app/core/components/Page/Page';
|
||||||
import { GrafanaContext, GrafanaContextType } from 'app/core/context/GrafanaContext';
|
import { GrafanaContext, GrafanaContextType } from 'app/core/context/GrafanaContext';
|
||||||
import { createErrorNotification } from 'app/core/copy/appNotification';
|
import { createErrorNotification } from 'app/core/copy/appNotification';
|
||||||
import { getKioskMode } from 'app/core/navigation/kiosk';
|
import { getKioskMode } from 'app/core/navigation/kiosk';
|
||||||
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
|
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
|
||||||
|
import { notifyApp } from 'app/core/reducers/appNotification';
|
||||||
import { ID_PREFIX } from 'app/core/reducers/navBarTree';
|
import { ID_PREFIX } from 'app/core/reducers/navBarTree';
|
||||||
import { getNavModel } from 'app/core/selectors/navModel';
|
import { getNavModel } from 'app/core/selectors/navModel';
|
||||||
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
|
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
|
||||||
|
|||||||
+1
-1
@@ -1,8 +1,8 @@
|
|||||||
import { PanelModel } from '@grafana/data';
|
import { PanelModel } from '@grafana/data';
|
||||||
import { t } from '@grafana/i18n';
|
import { t } from '@grafana/i18n';
|
||||||
import { locationService } from '@grafana/runtime';
|
import { locationService } from '@grafana/runtime';
|
||||||
import { notifyApp } from 'app/core/actions';
|
|
||||||
import { createErrorNotification } from 'app/core/copy/appNotification';
|
import { createErrorNotification } from 'app/core/copy/appNotification';
|
||||||
|
import { notifyApp } from 'app/core/reducers/appNotification';
|
||||||
import { DataSourceInput } from 'app/features/manage-dashboards/state/reducers';
|
import { DataSourceInput } from 'app/features/manage-dashboards/state/reducers';
|
||||||
import { DashboardJson } from 'app/features/manage-dashboards/types';
|
import { DashboardJson } from 'app/features/manage-dashboards/types';
|
||||||
import { dispatch } from 'app/types/store';
|
import { dispatch } from 'app/types/store';
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { TimeZone } from '@grafana/data';
|
import { TimeZone } from '@grafana/data';
|
||||||
import { getBackendSrv } from '@grafana/runtime';
|
import { getBackendSrv } from '@grafana/runtime';
|
||||||
import { WeekStart } from '@grafana/ui';
|
import { WeekStart } from '@grafana/ui';
|
||||||
import { notifyApp } from 'app/core/actions';
|
|
||||||
import { createSuccessNotification } from 'app/core/copy/appNotification';
|
import { createSuccessNotification } from 'app/core/copy/appNotification';
|
||||||
|
import { notifyApp } from 'app/core/reducers/appNotification';
|
||||||
import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api';
|
import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api';
|
||||||
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
|
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
|
||||||
import { removeAllPanels } from 'app/features/panel/state/reducers';
|
import { removeAllPanels } from 'app/features/panel/state/reducers';
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { DataQuery, locationUtil, setWeekStart, DashboardLoadedEvent } from '@grafana/data';
|
import { DataQuery, locationUtil, setWeekStart, DashboardLoadedEvent } from '@grafana/data';
|
||||||
import { t } from '@grafana/i18n';
|
import { t } from '@grafana/i18n';
|
||||||
import { config, isFetchError, locationService } from '@grafana/runtime';
|
import { config, isFetchError, locationService } from '@grafana/runtime';
|
||||||
import { notifyApp } from 'app/core/actions';
|
|
||||||
import { appEvents } from 'app/core/app_events';
|
import { appEvents } from 'app/core/app_events';
|
||||||
import { createErrorNotification } from 'app/core/copy/appNotification';
|
import { createErrorNotification } from 'app/core/copy/appNotification';
|
||||||
|
import { notifyApp } from 'app/core/reducers/appNotification';
|
||||||
import { backendSrv } from 'app/core/services/backend_srv';
|
import { backendSrv } from 'app/core/services/backend_srv';
|
||||||
import { KeybindingSrv } from 'app/core/services/keybindingSrv';
|
import { KeybindingSrv } from 'app/core/services/keybindingSrv';
|
||||||
import store from 'app/core/store';
|
import store from 'app/core/store';
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ import {
|
|||||||
isFetchError,
|
isFetchError,
|
||||||
locationService,
|
locationService,
|
||||||
} from '@grafana/runtime';
|
} from '@grafana/runtime';
|
||||||
import { updateNavIndex } from 'app/core/actions';
|
|
||||||
import { appEvents } from 'app/core/app_events';
|
import { appEvents } from 'app/core/app_events';
|
||||||
|
import { updateNavIndex } from 'app/core/reducers/navModel';
|
||||||
import { getBackendSrv } from 'app/core/services/backend_srv';
|
import { getBackendSrv } from 'app/core/services/backend_srv';
|
||||||
import { contextSrv } from 'app/core/services/context_srv';
|
import { contextSrv } from 'app/core/services/context_srv';
|
||||||
import { DatasourceAPIVersions } from 'app/features/apiserver/client';
|
import { DatasourceAPIVersions } from 'app/features/apiserver/client';
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import { Trans, t } from '@grafana/i18n';
|
|||||||
import { config, reportInteraction, getAppEvents } from '@grafana/runtime';
|
import { config, reportInteraction, getAppEvents } from '@grafana/runtime';
|
||||||
import { DataQuery } from '@grafana/schema';
|
import { DataQuery } from '@grafana/schema';
|
||||||
import { TextArea, Button, IconButton, useStyles2 } from '@grafana/ui';
|
import { TextArea, Button, IconButton, useStyles2 } from '@grafana/ui';
|
||||||
import { notifyApp } from 'app/core/actions';
|
|
||||||
import { createSuccessNotification } from 'app/core/copy/appNotification';
|
import { createSuccessNotification } from 'app/core/copy/appNotification';
|
||||||
|
import { notifyApp } from 'app/core/reducers/appNotification';
|
||||||
import { copyStringToClipboard } from 'app/core/utils/explore';
|
import { copyStringToClipboard } from 'app/core/utils/explore';
|
||||||
import { createUrlFromRichHistory, createQueryText } from 'app/core/utils/richHistory';
|
import { createUrlFromRichHistory, createQueryText } from 'app/core/utils/richHistory';
|
||||||
import { createAndCopyShortLink } from 'app/core/utils/shortLinks';
|
import { createAndCopyShortLink } from 'app/core/utils/shortLinks';
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
|||||||
import { Trans, t } from '@grafana/i18n';
|
import { Trans, t } from '@grafana/i18n';
|
||||||
import { getAppEvents } from '@grafana/runtime';
|
import { getAppEvents } from '@grafana/runtime';
|
||||||
import { useStyles2, Select, Button, Field, InlineField, InlineSwitch, Alert } from '@grafana/ui';
|
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 { createSuccessNotification } from 'app/core/copy/appNotification';
|
||||||
import { MAX_HISTORY_ITEMS } from 'app/core/history/RichHistoryLocalStorage';
|
import { MAX_HISTORY_ITEMS } from 'app/core/history/RichHistoryLocalStorage';
|
||||||
|
import { notifyApp } from 'app/core/reducers/appNotification';
|
||||||
import { dispatch } from 'app/store/store';
|
import { dispatch } from 'app/store/store';
|
||||||
|
|
||||||
import { supportedFeatures } from '../../../core/history/richHistoryStorageProvider';
|
import { supportedFeatures } from '../../../core/history/richHistoryStorageProvider';
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { Observable } from 'rxjs';
|
|||||||
|
|
||||||
import { DataLinkTransformationConfig } from '@grafana/data';
|
import { DataLinkTransformationConfig } from '@grafana/data';
|
||||||
import { CorrelationData, getDataSourceSrv, reportInteraction } from '@grafana/runtime';
|
import { CorrelationData, getDataSourceSrv, reportInteraction } from '@grafana/runtime';
|
||||||
import { notifyApp } from 'app/core/actions';
|
|
||||||
import { createErrorNotification } from 'app/core/copy/appNotification';
|
import { createErrorNotification } from 'app/core/copy/appNotification';
|
||||||
|
import { notifyApp } from 'app/core/reducers/appNotification';
|
||||||
import { CreateCorrelationParams } from 'app/features/correlations/types';
|
import { CreateCorrelationParams } from 'app/features/correlations/types';
|
||||||
import { getCorrelationsBySourceUIDs, createCorrelation, generateDefaultLabel } from 'app/features/correlations/utils';
|
import { getCorrelationsBySourceUIDs, createCorrelation, generateDefaultLabel } from 'app/features/correlations/utils';
|
||||||
import { store } from 'app/store/store';
|
import { store } from 'app/store/store';
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
import { combinePanelData } from '@grafana/o11y-ds-frontend';
|
import { combinePanelData } from '@grafana/o11y-ds-frontend';
|
||||||
import { config, getDataSourceSrv } from '@grafana/runtime';
|
import { config, getDataSourceSrv } from '@grafana/runtime';
|
||||||
import { DataQuery } from '@grafana/schema';
|
import { DataQuery } from '@grafana/schema';
|
||||||
|
import { notifyApp } from 'app/core/reducers/appNotification';
|
||||||
import {
|
import {
|
||||||
buildQueryTransaction,
|
buildQueryTransaction,
|
||||||
ensureQueries,
|
ensureQueries,
|
||||||
@@ -48,7 +49,6 @@ import {
|
|||||||
} from 'app/types/explore';
|
} from 'app/types/explore';
|
||||||
import { createAsyncThunk, StoreState, ThunkDispatch, ThunkResult } from 'app/types/store';
|
import { createAsyncThunk, StoreState, ThunkDispatch, ThunkResult } from 'app/types/store';
|
||||||
|
|
||||||
import { notifyApp } from '../../../core/actions';
|
|
||||||
import { createErrorNotification } from '../../../core/copy/appNotification';
|
import { createErrorNotification } from '../../../core/copy/appNotification';
|
||||||
import { runRequest } from '../../query/state/runRequest';
|
import { runRequest } from '../../query/state/runRequest';
|
||||||
import { decorateData, decorateWithLogsResult } from '../utils/decorators';
|
import { decorateData, decorateWithLogsResult } from '../utils/decorators';
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import {
|
|||||||
PanelQueryKind,
|
PanelQueryKind,
|
||||||
AnnotationQueryKind,
|
AnnotationQueryKind,
|
||||||
} from '@grafana/schema/dist/esm/schema/dashboard/v2';
|
} from '@grafana/schema/dist/esm/schema/dashboard/v2';
|
||||||
import { notifyApp } from 'app/core/actions';
|
|
||||||
import { createErrorNotification } from 'app/core/copy/appNotification';
|
import { createErrorNotification } from 'app/core/copy/appNotification';
|
||||||
|
import { notifyApp } from 'app/core/reducers/appNotification';
|
||||||
import { browseDashboardsAPI, ImportInputs } from 'app/features/browse-dashboards/api/browseDashboardsAPI';
|
import { browseDashboardsAPI, ImportInputs } from 'app/features/browse-dashboards/api/browseDashboardsAPI';
|
||||||
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
|
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
|
||||||
import { ThunkResult } from 'app/types/store';
|
import { ThunkResult } from 'app/types/store';
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { thunkTester } from 'test/core/thunk/thunkTester';
|
|||||||
|
|
||||||
import { OrgRole } from '@grafana/data';
|
import { OrgRole } from '@grafana/data';
|
||||||
import { BackendSrv } from '@grafana/runtime';
|
import { BackendSrv } from '@grafana/runtime';
|
||||||
import { updateConfigurationSubtitle } from 'app/core/actions';
|
import { updateConfigurationSubtitle } from 'app/core/reducers/navModel';
|
||||||
|
|
||||||
import { updateOrganization, setUserOrganization, getUserOrganizations } from './actions';
|
import { updateOrganization, setUserOrganization, getUserOrganizations } from './actions';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getBackendSrv } from '@grafana/runtime';
|
import { getBackendSrv } from '@grafana/runtime';
|
||||||
import { updateConfigurationSubtitle } from 'app/core/actions';
|
import { updateConfigurationSubtitle } from 'app/core/reducers/navModel';
|
||||||
import { ThunkResult } from 'app/types/store';
|
import { ThunkResult } from 'app/types/store';
|
||||||
import { UserOrg } from 'app/types/user';
|
import { UserOrg } from 'app/types/user';
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import {
|
|||||||
useUpdateTeamMutation,
|
useUpdateTeamMutation,
|
||||||
UpdateTeamCommand,
|
UpdateTeamCommand,
|
||||||
} from 'app/api/clients/legacy';
|
} from 'app/api/clients/legacy';
|
||||||
import { updateNavIndex } from 'app/core/actions';
|
|
||||||
import { addFilteredDisplayName } from 'app/core/components/RolePicker/utils';
|
import { addFilteredDisplayName } from 'app/core/components/RolePicker/utils';
|
||||||
|
import { updateNavIndex } from 'app/core/reducers/navModel';
|
||||||
import { contextSrv } from 'app/core/services/context_srv';
|
import { contextSrv } from 'app/core/services/context_srv';
|
||||||
import { AccessControlAction, Role } from 'app/types/accessControl';
|
import { AccessControlAction, Role } from 'app/types/accessControl';
|
||||||
import { useDispatch } from 'app/types/store';
|
import { useDispatch } from 'app/types/store';
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import { CodeEditor, Combobox, Field, Stack, useStyles2 } from '@grafana/ui';
|
|||||||
import { ThemeDemo } from '@grafana/ui/internal';
|
import { ThemeDemo } from '@grafana/ui/internal';
|
||||||
import { Page } from 'app/core/components/Page/Page';
|
import { Page } from 'app/core/components/Page/Page';
|
||||||
|
|
||||||
import { notifyApp } from '../../core/actions';
|
|
||||||
import { createErrorNotification } from '../../core/copy/appNotification';
|
import { createErrorNotification } from '../../core/copy/appNotification';
|
||||||
|
import { notifyApp } from '../../core/reducers/appNotification';
|
||||||
import { HOME_NAV_ID } from '../../core/reducers/navModel';
|
import { HOME_NAV_ID } from '../../core/reducers/navModel';
|
||||||
import { getNavModel } from '../../core/selectors/navModel';
|
import { getNavModel } from '../../core/selectors/navModel';
|
||||||
import { ThemeProvider } from '../../core/utils/ConfigProvider';
|
import { ThemeProvider } from '../../core/utils/ConfigProvider';
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { dateTime } from '@grafana/data';
|
import { dateTime } from '@grafana/data';
|
||||||
|
import { notifyApp } from 'app/core/reducers/appNotification';
|
||||||
|
|
||||||
import { reduxTester } from '../../../../test/core/redux/reduxTester';
|
import { reduxTester } from '../../../../test/core/redux/reduxTester';
|
||||||
import { silenceConsoleOutput } from '../../../../test/core/utils/silenceConsoleOutput';
|
import { silenceConsoleOutput } from '../../../../test/core/utils/silenceConsoleOutput';
|
||||||
import { notifyApp } from '../../../core/actions';
|
|
||||||
import { getTimeSrv, setTimeSrv, TimeSrv } from '../../dashboard/services/TimeSrv';
|
import { getTimeSrv, setTimeSrv, TimeSrv } from '../../dashboard/services/TimeSrv';
|
||||||
import { TemplateSrv } from '../../templating/template_srv';
|
import { TemplateSrv } from '../../templating/template_srv';
|
||||||
import { variableAdapters } from '../adapters';
|
import { variableAdapters } from '../adapters';
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
VariableWithOptions,
|
VariableWithOptions,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { config, locationService, logWarning } from '@grafana/runtime';
|
import { config, locationService, logWarning } from '@grafana/runtime';
|
||||||
import { notifyApp } from 'app/core/actions';
|
import { notifyApp } from 'app/core/reducers/appNotification';
|
||||||
import { contextSrv } from 'app/core/services/context_srv';
|
import { contextSrv } from 'app/core/services/context_srv';
|
||||||
import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv';
|
import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv';
|
||||||
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
|
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import config from '@grafana/plugin-configs/webpack.config.ts';
|
import config from '@grafana/plugin-configs/webpack.config.ts';
|
||||||
|
|
||||||
// eslint-disable-next-line no-barrel-files/no-barrel-files
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import config from '@grafana/plugin-configs/webpack.config.ts';
|
import config from '@grafana/plugin-configs/webpack.config.ts';
|
||||||
|
|
||||||
// eslint-disable-next-line no-barrel-files/no-barrel-files
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import config from '@grafana/plugin-configs/webpack.config.ts';
|
import config from '@grafana/plugin-configs/webpack.config.ts';
|
||||||
|
|
||||||
// eslint-disable-next-line no-barrel-files/no-barrel-files
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import config from '@grafana/plugin-configs/webpack.config.ts';
|
import config from '@grafana/plugin-configs/webpack.config.ts';
|
||||||
|
|
||||||
// eslint-disable-next-line no-barrel-files/no-barrel-files
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import config from '@grafana/plugin-configs/webpack.config.ts';
|
import config from '@grafana/plugin-configs/webpack.config.ts';
|
||||||
|
|
||||||
// eslint-disable-next-line no-barrel-files/no-barrel-files
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import config from '@grafana/plugin-configs/webpack.config.ts';
|
import config from '@grafana/plugin-configs/webpack.config.ts';
|
||||||
|
|
||||||
// eslint-disable-next-line no-barrel-files/no-barrel-files
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import config from '@grafana/plugin-configs/webpack.config.ts';
|
import config from '@grafana/plugin-configs/webpack.config.ts';
|
||||||
|
|
||||||
// eslint-disable-next-line no-barrel-files/no-barrel-files
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ describe('Tempo data source', () => {
|
|||||||
|
|
||||||
describe('runs correctly', () => {
|
describe('runs correctly', () => {
|
||||||
const handleStreamingQuery = jest.spyOn(TempoDatasource.prototype, 'handleStreamingQuery');
|
const handleStreamingQuery = jest.spyOn(TempoDatasource.prototype, 'handleStreamingQuery');
|
||||||
const request = jest.spyOn(TempoDatasource.prototype, '_request');
|
|
||||||
const templateSrv: TemplateSrv = { replace: (s: string) => s } as unknown as TemplateSrv;
|
const templateSrv: TemplateSrv = { replace: (s: string) => s } as unknown as TemplateSrv;
|
||||||
|
|
||||||
const range = {
|
const range = {
|
||||||
@@ -97,7 +96,6 @@ describe('Tempo data source', () => {
|
|||||||
const ds = new TempoDatasource(defaultSettings, templateSrv);
|
const ds = new TempoDatasource(defaultSettings, templateSrv);
|
||||||
await lastValueFrom(ds.query(traceqlQuery as DataQueryRequest<TempoQuery>));
|
await lastValueFrom(ds.query(traceqlQuery as DataQueryRequest<TempoQuery>));
|
||||||
expect(handleStreamingQuery).toHaveBeenCalledTimes(1);
|
expect(handleStreamingQuery).toHaveBeenCalledTimes(1);
|
||||||
expect(request).toHaveBeenCalledTimes(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('for traceqlSearch queries when live is enabled', async () => {
|
it('for traceqlSearch queries when live is enabled', async () => {
|
||||||
@@ -105,7 +103,6 @@ describe('Tempo data source', () => {
|
|||||||
const ds = new TempoDatasource(defaultSettings, templateSrv);
|
const ds = new TempoDatasource(defaultSettings, templateSrv);
|
||||||
await lastValueFrom(ds.query(traceqlSearchQuery as DataQueryRequest<TempoQuery>));
|
await lastValueFrom(ds.query(traceqlSearchQuery as DataQueryRequest<TempoQuery>));
|
||||||
expect(handleStreamingQuery).toHaveBeenCalledTimes(1);
|
expect(handleStreamingQuery).toHaveBeenCalledTimes(1);
|
||||||
expect(request).toHaveBeenCalledTimes(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('for traceql queries when live is not enabled', async () => {
|
it('for traceql queries when live is not enabled', async () => {
|
||||||
@@ -113,7 +110,6 @@ describe('Tempo data source', () => {
|
|||||||
const ds = new TempoDatasource(defaultSettings, templateSrv);
|
const ds = new TempoDatasource(defaultSettings, templateSrv);
|
||||||
await lastValueFrom(ds.query(traceqlQuery as DataQueryRequest<TempoQuery>));
|
await lastValueFrom(ds.query(traceqlQuery as DataQueryRequest<TempoQuery>));
|
||||||
expect(handleStreamingQuery).toHaveBeenCalledTimes(1);
|
expect(handleStreamingQuery).toHaveBeenCalledTimes(1);
|
||||||
expect(request).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('for traceqlSearch queries when live is not enabled', async () => {
|
it('for traceqlSearch queries when live is not enabled', async () => {
|
||||||
@@ -121,7 +117,6 @@ describe('Tempo data source', () => {
|
|||||||
const ds = new TempoDatasource(defaultSettings, templateSrv);
|
const ds = new TempoDatasource(defaultSettings, templateSrv);
|
||||||
await lastValueFrom(ds.query(traceqlSearchQuery as DataQueryRequest<TempoQuery>));
|
await lastValueFrom(ds.query(traceqlSearchQuery as DataQueryRequest<TempoQuery>));
|
||||||
expect(handleStreamingQuery).toHaveBeenCalledTimes(1);
|
expect(handleStreamingQuery).toHaveBeenCalledTimes(1);
|
||||||
expect(request).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -23,14 +23,11 @@ import {
|
|||||||
SelectableValue,
|
SelectableValue,
|
||||||
TestDataSourceResponse,
|
TestDataSourceResponse,
|
||||||
TimeRange,
|
TimeRange,
|
||||||
urlUtil,
|
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { NodeGraphOptions, SpanBarOptions, TraceToLogsOptions } from '@grafana/o11y-ds-frontend';
|
import { NodeGraphOptions, SpanBarOptions, TraceToLogsOptions } from '@grafana/o11y-ds-frontend';
|
||||||
import {
|
import {
|
||||||
BackendSrvRequest,
|
|
||||||
config,
|
config,
|
||||||
DataSourceWithBackend,
|
DataSourceWithBackend,
|
||||||
getBackendSrv,
|
|
||||||
getDataSourceSrv,
|
getDataSourceSrv,
|
||||||
getTemplateSrv,
|
getTemplateSrv,
|
||||||
reportInteraction,
|
reportInteraction,
|
||||||
@@ -59,7 +56,6 @@ import {
|
|||||||
import TempoLanguageProvider from './language_provider';
|
import TempoLanguageProvider from './language_provider';
|
||||||
import {
|
import {
|
||||||
enhanceTraceQlMetricsResponse,
|
enhanceTraceQlMetricsResponse,
|
||||||
formatTraceQLResponse,
|
|
||||||
transformFromOTLP as transformFromOTEL,
|
transformFromOTLP as transformFromOTEL,
|
||||||
transformTrace,
|
transformTrace,
|
||||||
} from './resultTransformer';
|
} from './resultTransformer';
|
||||||
@@ -419,12 +415,7 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
|
|||||||
if (useStreaming) {
|
if (useStreaming) {
|
||||||
return this.handleStreamingQuery(options, targets.traceql, queryValue);
|
return this.handleStreamingQuery(options, targets.traceql, queryValue);
|
||||||
}
|
}
|
||||||
|
subQueries.push(this.handleTraceQlQuery(options, targets));
|
||||||
if (config.featureToggles.tempoSearchBackendMigration) {
|
|
||||||
subQueries.push(this.handleTraceQlQuery(options, targets));
|
|
||||||
} else {
|
|
||||||
subQueries.push(this.oldSearchQueryLogic(options, targets, queryValue));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -464,11 +455,7 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
|
|||||||
if (this.isStreamingSearchEnabled()) {
|
if (this.isStreamingSearchEnabled()) {
|
||||||
subQueries.push(this.handleStreamingQuery(options, traceqlSearchTargets, queryFromFilters));
|
subQueries.push(this.handleStreamingQuery(options, traceqlSearchTargets, queryFromFilters));
|
||||||
} else {
|
} else {
|
||||||
if (config.featureToggles.tempoSearchBackendMigration) {
|
subQueries.push(this.handleTraceQlQuery(options, targets));
|
||||||
subQueries.push(this.handleTraceQlQuery(options, targets));
|
|
||||||
} else {
|
|
||||||
subQueries.push(this.oldSearchQueryLogic(options, targets, queryFromFilters));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -712,49 +699,6 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// this is just a short term function, we will remove once we have rolled
|
|
||||||
// out the backend migration and are happy with the stability of the feature
|
|
||||||
oldSearchQueryLogic = (
|
|
||||||
options: DataQueryRequest<TempoQuery>,
|
|
||||||
targets: { [type: string]: TempoQuery[] },
|
|
||||||
queryValue: string
|
|
||||||
) => {
|
|
||||||
const startTime = performance.now();
|
|
||||||
const tableType = targets.traceqlSearch?.[0]?.tableType ?? targets.traceql?.[0]?.tableType;
|
|
||||||
|
|
||||||
return this._request('/api/search', {
|
|
||||||
q: queryValue,
|
|
||||||
limit: options.targets[0].limit ?? DEFAULT_LIMIT,
|
|
||||||
spss: options.targets[0].spss ?? DEFAULT_SPSS,
|
|
||||||
start: options.range.from.unix(),
|
|
||||||
end: options.range.to.unix(),
|
|
||||||
}).pipe(
|
|
||||||
map((response) => {
|
|
||||||
reportTempoQueryMetrics('grafana_traces_traceql_response', options, {
|
|
||||||
success: true,
|
|
||||||
streaming: false,
|
|
||||||
latencyMs: Math.round(performance.now() - startTime), // rounded to nearest millisecond
|
|
||||||
query: queryValue ?? '',
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
data: formatTraceQLResponse(response.data.traces, this.instanceSettings, tableType),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
catchError((err) => {
|
|
||||||
reportTempoQueryMetrics('grafana_traces_traceql_response', options, {
|
|
||||||
success: false,
|
|
||||||
streaming: false,
|
|
||||||
latencyMs: Math.round(performance.now() - startTime), // rounded to nearest millisecond
|
|
||||||
query: queryValue ?? '',
|
|
||||||
error: getErrorMessage(err.message),
|
|
||||||
statusCode: err.status,
|
|
||||||
statusText: err.statusText,
|
|
||||||
});
|
|
||||||
return of({ error: { message: getErrorMessage(err?.data?.message) }, data: [] });
|
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
handleTraceQlMetricsQuery(
|
handleTraceQlMetricsQuery(
|
||||||
options: DataQueryRequest<TempoQuery>,
|
options: DataQueryRequest<TempoQuery>,
|
||||||
targets: TempoQuery[],
|
targets: TempoQuery[],
|
||||||
@@ -926,13 +870,6 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
|
|||||||
return res?.data ?? res;
|
return res?.data ?? res;
|
||||||
}
|
}
|
||||||
|
|
||||||
_request(apiUrl: string, data?: unknown, options?: Partial<BackendSrvRequest>): Observable<Record<string, any>> {
|
|
||||||
const params = data ? urlUtil.serializeParams(data) : '';
|
|
||||||
const url = `${this.instanceSettings.url}${apiUrl}${params.length ? `?${params}` : ''}`;
|
|
||||||
const req = { ...options, url };
|
|
||||||
return getBackendSrv().fetch(req);
|
|
||||||
}
|
|
||||||
|
|
||||||
async testDatasource(): Promise<TestDataSourceResponse> {
|
async testDatasource(): Promise<TestDataSourceResponse> {
|
||||||
return await super.testDatasource();
|
return await super.testDatasource();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import config from '@grafana/plugin-configs/webpack.config.ts';
|
import config from '@grafana/plugin-configs/webpack.config.ts';
|
||||||
|
|
||||||
// eslint-disable-next-line no-barrel-files/no-barrel-files
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import config from '@grafana/plugin-configs/webpack.config.ts';
|
import config from '@grafana/plugin-configs/webpack.config.ts';
|
||||||
|
|
||||||
// eslint-disable-next-line no-barrel-files/no-barrel-files
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
Reference in New Issue
Block a user