Compare commits

...

9 Commits

Author SHA1 Message Date
Roberto Jimenez Sanchez 6710c563c7 Provisioning: Improve connection deletion error handling with undelete
When finalizer removal fails, the connection is now "undeleted" by removing
the DeletionTimestamp. This prevents connections from being stuck in deletion
state and allows users to retry deletion later.

Additionally:
- Expand retry logic to handle more transient errors (not just ServiceUnavailable)
- Add isTransientError helper to detect retriable errors
- Add comprehensive tests for undelete behavior and transient error detection

This ensures that if the controller cannot remove the finalizer due to
transient errors (network issues, API timeouts, etc.), the connection returns
to normal state rather than remaining stuck in deletion.
2026-01-12 08:38:14 +01:00
Roberto Jimenez Sanchez c3bbd588e0 Provisioning: Add finalizer-based deletion handling for connections
This change adds a finalizer to connections to prevent race conditions
when deleting connections while repositories reference them. The finalizer
ensures that even if a repository is created during a connection deletion,
the connection will not be deleted until all repositories are removed.

Implementation:
- Add BlockDeletionFinalizer constant for connections
- Add finalizer to connections on creation in Mutate function
- Update ConnectionController to handle deletion and check for repositories
- Controller blocks deletion by keeping finalizer when repositories exist
- Controller removes finalizer only when no repositories reference connection
- Add comprehensive unit tests for finalizer handling

This complements the admission webhook validation by providing controller-level
protection against race conditions.
2026-01-09 17:51:02 +01:00
Roberto Jimenez Sanchez ba12ac68cc Provisioning: Block connection deletion when repositories reference it
This change prevents deletion of Connections when any Repository
references them via spec.connection.name. This uses the field selector
feature added in the previous commit.

Implementation:
- Add GetRepositoriesByConnection function to query repositories by
  connection name using the spec.connection.name field selector
- Add validateDelete method to handle Connection deletion validation
- Return Forbidden error with list of connected repository names

Closes: https://github.com/grafana/git-ui-sync-project/issues/730
2026-01-09 16:34:41 +01:00
Roberto Jimenez Sanchez f625902e4b Provisioning: Add fieldSelector for Repository by spec.connection.name
This change adds the ability to filter repositories by their connection
name using Kubernetes field selectors, enabling queries like:

  kubectl get repositories --field-selector spec.connection.name=my-connection

Implementation:
- Add RepositoryGetAttrs and RepositoryToSelectableFields functions
- Register field label conversion for spec.connection.name in InstallSchema
- Extend generic storage to support custom selectable fields via
  NewRegistryStoreWithSelectableFields
- Add unit tests for repository field functions
- Add integration tests for field selector functionality
2026-01-09 13:18:59 +01:00
Ashley Harrison 71a65e1f80 Custom branding: Correctly override bouncing loader (#115871)
use the custom branding logo for the bouncing loader
2026-01-09 11:56:55 +00:00
Ashley Harrison ec12176220 Chore: Bump storybook to fix CVE (#115927)
* bump storybook to fix CVE

* reapply patch
2026-01-09 11:56:29 +00:00
Stephanie Hingtgen 0cf4f7c4de Library Elements: Deprecate folderFilter query param; update docs for folderFilterUIDs (#116048) 2026-01-09 04:24:18 -07:00
Stephanie Hingtgen b0785e506f Dashboard Tags: Validate max length (#116047) 2026-01-09 03:57:39 -07:00
Stephanie Hingtgen 5f8668b3aa Preferences: Add API validation and update documentation (#116045) 2026-01-09 03:57:15 -07:00
39 changed files with 2027 additions and 273 deletions
@@ -1,8 +1,8 @@
diff --git a/dist/builder-manager/index.js b/dist/builder-manager/index.js
index 3d7f9b213dae1801bda62b31db31b9113e382ccd..212501c63d20146c29db63fb0f6300c6779eecb5 100644
index ac8ac6a5f6a3b7852c4064e93dc9acd3201289e6..34a0a5a5c38dd7fe525c9ebd382a10a451d4d4f3 100644
--- a/dist/builder-manager/index.js
+++ b/dist/builder-manager/index.js
@@ -1970,7 +1970,7 @@ var pa = /^\/($|\?)/, G, C, xt = /* @__PURE__ */ o(async (e) => {
@@ -1974,7 +1974,7 @@ var pa = /^\/($|\?)/, G, C, xt = /* @__PURE__ */ o(async (e) => {
bundle: !0,
minify: !0,
sourcemap: !1,
@@ -0,0 +1,4 @@
package connection
// BlockDeletionFinalizer prevents deletion of connections while repositories reference them
const BlockDeletionFinalizer = "block-deletion-while-repositories-exist"
@@ -186,7 +186,7 @@ For the JSON and field usage notes, refer to the [links schema documentation](ht
### `tags`
The tags associated with the dashboard:
Tags associated with the dashboard. Each tag can be up to 50 characters long.
` [...string]`
@@ -41,7 +41,8 @@ Query parameters:
- `sortDirection`: Sort order of elements. Use `alpha-asc` for ascending and `alpha-desc` for descending sort order.
- `typeFilter`: A comma separated list of types to filter the elements by.
- `excludeUid`: Element UID to exclude from search results.
- `folderFilter`: A comma separated list of folder IDs to filter the elements by.
- `folderFilter`: **Deprecated.** A comma separated list of folder IDs to filter the elements by. Use `folderFilterUIDs` instead.
- `folderFilterUIDs`: A comma separated list of folder UIDs to filter the elements by.
- `perPage`: The number of results per page; default is 100.
- `page`: The page for a set of records, given that only `perPage` records are returned at a time. Numbering starts at `1`.
@@ -25,7 +25,7 @@ Keys:
- **theme** - One of: `light`, `dark`, or an empty string for the default theme
- **homeDashboardId** - Deprecated. Use `homeDashboardUID` instead.
- **homeDashboardUID**: The `:uid` of a dashboard
- **timezone** - One of: `utc`, `browser`, or an empty string for the default
- **timezone** - Any valid IANA timezone string (e.g., `America/New_York`, `Europe/London`), `utc`, `browser`, or an empty string for the default.
Omitting a key will cause the current value to be replaced with the
system default value.
+1 -1
View File
@@ -462,7 +462,7 @@
"js-yaml@npm:4.1.0": "^4.1.0",
"js-yaml@npm:=4.1.0": "^4.1.0",
"nodemailer": "7.0.11",
"@storybook/core@npm:8.6.2": "patch:@storybook/core@npm%3A8.6.2#~/.yarn/patches/@storybook-core-npm-8.6.2-8c752112c0.patch"
"@storybook/core@npm:8.6.15": "patch:@storybook/core@npm%3A8.6.15#~/.yarn/patches/@storybook-core-npm-8.6.15-a468a35170.patch"
},
"workspaces": {
"packages": [
@@ -1021,6 +1021,7 @@ const injectedRtkApi = api
typeFilter: queryArg.typeFilter,
excludeUid: queryArg.excludeUid,
folderFilter: queryArg.folderFilter,
folderFilterUIDs: queryArg.folderFilterUiDs,
perPage: queryArg.perPage,
page: queryArg.page,
},
@@ -2915,8 +2916,11 @@ export type GetLibraryElementsApiArg = {
typeFilter?: string;
/** Element UID to exclude from search results. */
excludeUid?: string;
/** A comma separated list of folder ID(s) to filter the elements by. */
/** A comma separated list of folder ID(s) to filter the elements by.
Deprecated: Use FolderFilterUIDs instead. */
folderFilter?: string;
/** A comma separated list of folder UID(s) to filter the elements by. */
folderFilterUiDs?: string;
/** The number of results per page. */
perPage?: number;
/** The page for a set of records, given that only perPage records are returned at a time. Numbering starts at 1. */
@@ -5312,7 +5316,8 @@ export type PatchPrefsCmd = {
queryHistory?: QueryHistoryPreference;
regionalFormat?: string;
theme?: 'light' | 'dark';
timezone?: 'utc' | 'browser';
/** Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string */
timezone?: string;
weekStart?: string;
};
export type UpdatePrefsCmd = {
@@ -5325,7 +5330,8 @@ export type UpdatePrefsCmd = {
queryHistory?: QueryHistoryPreference;
regionalFormat?: string;
theme?: 'light' | 'dark' | 'system';
timezone?: 'utc' | 'browser';
/** Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string */
timezone?: string;
weekStart?: string;
};
export type OrgUserDto = {
@@ -86,7 +86,8 @@ export type PatchPrefsCmd = {
queryHistory?: QueryHistoryPreference;
regionalFormat?: string;
theme?: 'light' | 'dark';
timezone?: 'utc' | 'browser';
/** Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string */
timezone?: string;
weekStart?: string;
};
export type UpdatePrefsCmd = {
@@ -99,7 +100,8 @@ export type UpdatePrefsCmd = {
queryHistory?: QueryHistoryPreference;
regionalFormat?: string;
theme?: 'light' | 'dark' | 'system';
timezone?: 'utc' | 'browser';
/** Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string */
timezone?: string;
weekStart?: string;
};
export const {
+14 -14
View File
@@ -137,23 +137,23 @@
"@babel/core": "7.28.0",
"@faker-js/faker": "^9.0.0",
"@rollup/plugin-node-resolve": "16.0.1",
"@storybook/addon-a11y": "^8.6.2",
"@storybook/addon-actions": "^8.6.2",
"@storybook/addon-docs": "^8.6.2",
"@storybook/addon-essentials": "^8.6.2",
"@storybook/addon-storysource": "^8.6.2",
"@storybook/addon-a11y": "^8.6.15",
"@storybook/addon-actions": "^8.6.15",
"@storybook/addon-docs": "^8.6.15",
"@storybook/addon-essentials": "^8.6.15",
"@storybook/addon-storysource": "^8.6.15",
"@storybook/addon-webpack5-compiler-swc": "^2.1.0",
"@storybook/blocks": "^8.6.2",
"@storybook/components": "^8.6.2",
"@storybook/core-events": "^8.6.2",
"@storybook/manager-api": "^8.6.2",
"@storybook/blocks": "^8.6.15",
"@storybook/components": "^8.6.15",
"@storybook/core-events": "^8.6.15",
"@storybook/manager-api": "^8.6.15",
"@storybook/mdx2-csf": "1.1.0",
"@storybook/preset-scss": "1.0.3",
"@storybook/preview-api": "^8.6.2",
"@storybook/react": "^8.6.2",
"@storybook/react-webpack5": "^8.6.2",
"@storybook/preview-api": "^8.6.15",
"@storybook/react": "^8.6.15",
"@storybook/react-webpack5": "^8.6.15",
"@storybook/test-runner": "^0.23.0",
"@storybook/theming": "^8.6.2",
"@storybook/theming": "^8.6.15",
"@testing-library/dom": "10.4.1",
"@testing-library/jest-dom": "6.6.4",
"@testing-library/react": "16.3.0",
@@ -200,7 +200,7 @@
"rollup-plugin-node-externals": "^8.0.0",
"rollup-plugin-svg-import": "3.0.0",
"sass-loader": "16.0.5",
"storybook": "^8.6.2",
"storybook": "^8.6.15",
"style-loader": "4.0.0",
"typescript": "5.9.2",
"webpack": "5.101.0"
@@ -54,6 +54,7 @@ export const TagsInput = forwardRef<HTMLInputElement, Props>(
const [newTagName, setNewTagName] = useState('');
const styles = useStyles2(getStyles);
const theme = useTheme2();
const isTagTooLong = newTagName.length > 50;
const onNameChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setNewTagName(event.target.value);
@@ -65,6 +66,9 @@ export const TagsInput = forwardRef<HTMLInputElement, Props>(
const onAdd = (event?: React.MouseEvent | React.KeyboardEvent) => {
event?.preventDefault();
if (newTagName.length > 50) {
return;
}
if (!tags.includes(newTagName)) {
onChange(tags.concat(newTagName));
}
@@ -94,14 +98,17 @@ export const TagsInput = forwardRef<HTMLInputElement, Props>(
value={newTagName}
onKeyDown={onKeyboardAdd}
onBlur={onBlur}
invalid={invalid}
invalid={invalid || isTagTooLong}
suffix={
<Button
fill="text"
className={styles.addButtonStyle}
onClick={onAdd}
size="md"
disabled={newTagName.length <= 0}
disabled={newTagName.length <= 0 || isTagTooLong}
title={
isTagTooLong ? t('grafana-ui.tags-input.tag-too-long', 'Tag too long, max 50 characters') : undefined
}
>
<Trans i18nKey="grafana-ui.tags-input.add">Add</Trans>
</Button>
+2 -2
View File
@@ -13,7 +13,7 @@ type UpdatePrefsCmd struct {
// Deprecated: Use HomeDashboardUID instead
HomeDashboardID int64 `json:"homeDashboardId"`
HomeDashboardUID *string `json:"homeDashboardUID,omitempty"`
// Enum: utc,browser
// Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string
Timezone string `json:"timezone"`
WeekStart string `json:"weekStart"`
QueryHistory *pref.QueryHistoryPreference `json:"queryHistory,omitempty"`
@@ -31,7 +31,7 @@ type PatchPrefsCmd struct {
// Default:0
// Deprecated: Use HomeDashboardUID instead
HomeDashboardID *int64 `json:"homeDashboardId,omitempty"`
// Enum: utc,browser
// Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string
Timezone *string `json:"timezone,omitempty"`
WeekStart *string `json:"weekStart,omitempty"`
Language *string `json:"language,omitempty"`
+4
View File
@@ -134,6 +134,10 @@ func (hs *HTTPServer) patchPreferencesFor(ctx context.Context, orgID, userID, te
return response.Error(http.StatusBadRequest, "Invalid theme", nil)
}
if dtoCmd.Timezone != nil && !pref.IsValidTimezone(*dtoCmd.Timezone) {
return response.Error(http.StatusBadRequest, "Invalid timezone. Must be a valid IANA timezone (e.g., America/New_York), 'utc', 'browser', or empty string", nil)
}
// convert dashboard UID to ID in order to store internally if it exists in the query, otherwise take the id from query
// nolint:staticcheck
dashboardID := dtoCmd.HomeDashboardID
+34 -2
View File
@@ -1,26 +1,58 @@
package generic
import (
"k8s.io/apimachinery/pkg/fields"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/registry/generic"
"k8s.io/apiserver/pkg/registry/generic/registry"
"k8s.io/apiserver/pkg/storage"
"github.com/grafana/grafana/pkg/apimachinery/utils"
)
// SelectableFieldsOptions allows customizing field selector behavior for a resource.
type SelectableFieldsOptions struct {
// GetAttrs returns labels and fields for the object.
// If nil, the default GetAttrs is used which only exposes metadata.name.
GetAttrs func(obj runtime.Object) (labels.Set, fields.Set, error)
}
func NewRegistryStore(scheme *runtime.Scheme, resourceInfo utils.ResourceInfo, optsGetter generic.RESTOptionsGetter) (*registry.Store, error) {
return NewRegistryStoreWithSelectableFields(scheme, resourceInfo, optsGetter, SelectableFieldsOptions{})
}
// NewRegistryStoreWithSelectableFields creates a registry store with custom selectable fields support.
// Use this when you need to filter resources by custom fields like spec.connection.name.
func NewRegistryStoreWithSelectableFields(scheme *runtime.Scheme, resourceInfo utils.ResourceInfo, optsGetter generic.RESTOptionsGetter, fieldOpts SelectableFieldsOptions) (*registry.Store, error) {
gv := resourceInfo.GroupVersion()
gv.Version = runtime.APIVersionInternal
strategy := NewStrategy(scheme, gv)
if resourceInfo.IsClusterScoped() {
strategy = strategy.WithClusterScope()
}
// Use custom GetAttrs if provided, otherwise use default
attrFunc := GetAttrs
predicateFunc := Matcher
if fieldOpts.GetAttrs != nil {
attrFunc = fieldOpts.GetAttrs
// Create a matcher that uses the custom GetAttrs
predicateFunc = func(label labels.Selector, field fields.Selector) storage.SelectionPredicate {
return storage.SelectionPredicate{
Label: label,
Field: field,
GetAttrs: attrFunc,
}
}
}
store := &registry.Store{
NewFunc: resourceInfo.NewFunc,
NewListFunc: resourceInfo.NewListFunc,
KeyRootFunc: KeyRootFunc(resourceInfo.GroupResource()),
KeyFunc: NamespaceKeyFunc(resourceInfo.GroupResource()),
PredicateFunc: Matcher,
PredicateFunc: predicateFunc,
DefaultQualifiedResource: resourceInfo.GroupResource(),
SingularQualifiedResource: resourceInfo.SingularGroupResource(),
TableConvertor: resourceInfo.TableConverter(),
@@ -28,7 +60,7 @@ func NewRegistryStore(scheme *runtime.Scheme, resourceInfo utils.ResourceInfo, o
UpdateStrategy: strategy,
DeleteStrategy: strategy,
}
options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: GetAttrs}
options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: attrFunc}
if err := store.CompleteWithOptions(options); err != nil {
return nil, err
}
+36
View File
@@ -389,6 +389,11 @@ func (b *DashboardsAPIBuilder) validateCreate(ctx context.Context, a admission.A
return apierrors.NewBadRequest(err.Error())
}
// Validate tags
if err := validateDashboardTags(dashObj); err != nil {
return apierrors.NewBadRequest(err.Error())
}
id, err := identity.GetRequester(ctx)
if err != nil {
return fmt.Errorf("error getting requester: %w", err)
@@ -459,6 +464,11 @@ func (b *DashboardsAPIBuilder) validateUpdate(ctx context.Context, a admission.A
return apierrors.NewBadRequest(err.Error())
}
// Validate tags
if err := validateDashboardTags(newDashObj); err != nil {
return apierrors.NewBadRequest(err.Error())
}
// Validate folder existence if specified and changed
if !a.IsDryRun() && newAccessor.GetFolder() != oldAccessor.GetFolder() && newAccessor.GetFolder() != "" {
id, err := identity.GetRequester(ctx)
@@ -556,6 +566,32 @@ func getDashboardProperties(obj runtime.Object) (string, string, error) {
return title, refresh, nil
}
// validateDashboardTags validates that all dashboard tags are within the maximum length
func validateDashboardTags(obj runtime.Object) error {
var tags []string
switch d := obj.(type) {
case *dashv0.Dashboard:
tags = d.Spec.GetNestedStringSlice("tags")
case *dashv1.Dashboard:
tags = d.Spec.GetNestedStringSlice("tags")
case *dashv2alpha1.Dashboard:
tags = d.Spec.Tags
case *dashv2beta1.Dashboard:
tags = d.Spec.Tags
default:
return fmt.Errorf("unsupported dashboard version: %T", obj)
}
for _, tag := range tags {
if len(tag) > 50 {
return dashboards.ErrDashboardTagTooLong
}
}
return nil
}
func (b *DashboardsAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions) error {
storageOpts := apistore.StorageOptions{
EnableFolderSupport: true,
@@ -208,6 +208,11 @@ func (s *preferenceStorage) save(ctx context.Context, obj runtime.Object) (runti
// Create implements rest.Creater.
func (s *preferenceStorage) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) {
if createValidation != nil {
if err := createValidation(ctx, obj); err != nil {
return nil, err
}
}
return s.save(ctx, obj)
}
@@ -223,6 +228,12 @@ func (s *preferenceStorage) Update(ctx context.Context, name string, objInfo res
return nil, false, err
}
if updateValidation != nil {
if err := updateValidation(ctx, obj, old); err != nil {
return nil, false, err
}
}
obj, err = s.save(ctx, obj)
return obj, false, err
}
+35 -1
View File
@@ -1,9 +1,14 @@
package preferences
import (
"context"
"fmt"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/admission"
"k8s.io/apiserver/pkg/authorization/authorizer"
"k8s.io/apiserver/pkg/registry/rest"
genericapiserver "k8s.io/apiserver/pkg/server"
@@ -24,7 +29,8 @@ import (
)
var (
_ builder.APIGroupBuilder = (*APIBuilder)(nil)
_ builder.APIGroupBuilder = (*APIBuilder)(nil)
_ builder.APIGroupValidation = (*APIBuilder)(nil)
)
type APIBuilder struct {
@@ -108,3 +114,31 @@ func (b *APIBuilder) GetAPIRoutes(gv schema.GroupVersion) *builder.APIRoutes {
defs := b.GetOpenAPIDefinitions()(func(path string) spec.Ref { return spec.Ref{} })
return b.merger.GetAPIRoutes(defs)
}
// Validate validates that the preference object has valid theme and timezone (if specified)
func (b *APIBuilder) Validate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) error {
if a.GetResource().Resource != "preferences" {
return nil
}
op := a.GetOperation()
if op != admission.Create && op != admission.Update {
return nil
}
obj := a.GetObject()
p, ok := obj.(*preferences.Preferences)
if !ok {
return apierrors.NewBadRequest(fmt.Sprintf("expected Preferences object, got %T", obj))
}
if p.Spec.Timezone != nil && !pref.IsValidTimezone(*p.Spec.Timezone) {
return apierrors.NewBadRequest("invalid timezone: must be a valid IANA timezone (e.g., America/New_York), 'utc', 'browser', or empty string")
}
if p.Spec.Theme != nil && *p.Spec.Theme != "" && !pref.IsValidThemeID(*p.Spec.Theme) {
return apierrors.NewBadRequest("invalid theme")
}
return nil
}
@@ -4,9 +4,15 @@ import (
"context"
"errors"
"fmt"
"net"
"strings"
"time"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
"k8s.io/apimachinery/pkg/util/wait"
"k8s.io/client-go/tools/cache"
@@ -14,9 +20,11 @@ import (
"github.com/grafana/grafana-app-sdk/logging"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
connectionvalidation "github.com/grafana/grafana/apps/provisioning/pkg/connection"
client "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1"
informer "github.com/grafana/grafana/apps/provisioning/pkg/generated/informers/externalversions/provisioning/v0alpha1"
listers "github.com/grafana/grafana/apps/provisioning/pkg/generated/listers/provisioning/v0alpha1"
"k8s.io/apimachinery/pkg/fields"
)
const connectionLoggerName = "provisioning-connection-controller"
@@ -41,6 +49,11 @@ type ConnectionStatusPatcher interface {
Patch(ctx context.Context, conn *provisioning.Connection, patchOperations ...map[string]interface{}) error
}
// RepositoryLister interface for listing repositories
type RepositoryLister interface {
List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error)
}
// ConnectionController controls Connection resources.
type ConnectionController struct {
client client.ProvisioningV0alpha1Interface
@@ -49,6 +62,7 @@ type ConnectionController struct {
logger logging.Logger
statusPatcher ConnectionStatusPatcher
repoLister RepositoryLister
queue workqueue.TypedRateLimitingInterface[*connectionQueueItem]
}
@@ -58,6 +72,7 @@ func NewConnectionController(
provisioningClient client.ProvisioningV0alpha1Interface,
connInformer informer.ConnectionInformer,
statusPatcher ConnectionStatusPatcher,
repoLister RepositoryLister,
) (*ConnectionController, error) {
cc := &ConnectionController{
client: provisioningClient,
@@ -70,6 +85,7 @@ func NewConnectionController(
},
),
statusPatcher: statusPatcher,
repoLister: repoLister,
logger: logging.DefaultLogger.With("logger", connectionLoggerName),
}
@@ -141,13 +157,14 @@ func (cc *ConnectionController) processNextWorkItem(ctx context.Context) bool {
return true
}
if !apierrors.IsServiceUnavailable(err) {
logger.Info("ConnectionController will not retry")
// Check if error is transient and should be retried
if !isTransientError(err) {
logger.Info("ConnectionController will not retry (non-transient error)")
cc.queue.Forget(item)
return true
}
logger.Info("ConnectionController will retry as service is unavailable")
logger.Info("ConnectionController will retry (transient error)")
utilruntime.HandleError(fmt.Errorf("%v failed with: %v", item, err))
cc.queue.AddRateLimited(item)
@@ -171,10 +188,9 @@ func (cc *ConnectionController) process(ctx context.Context, item *connectionQue
return err
}
// Skip if being deleted
// Handle deletion if being deleted
if conn.DeletionTimestamp != nil {
logger.Info("connection is being deleted, skipping")
return nil
return cc.handleDelete(ctx, conn)
}
hasSpecChanged := conn.Generation != conn.Status.ObservedGeneration
@@ -229,6 +245,147 @@ func (cc *ConnectionController) process(ctx context.Context, item *connectionQue
return nil
}
func (cc *ConnectionController) handleDelete(ctx context.Context, conn *provisioning.Connection) error {
logger := logging.FromContext(ctx)
logger.Info("handle connection delete")
// Check if finalizer is present
hasFinalizer := false
for _, f := range conn.Finalizers {
if f == connectionvalidation.BlockDeletionFinalizer {
hasFinalizer = true
break
}
}
if !hasFinalizer {
logger.Info("no finalizer to process")
return nil
}
// Check if any repositories reference this connection using field selector
fieldSelector := fields.OneTermEqualSelector("spec.connection.name", conn.Name)
var allRepos []provisioning.Repository
continueToken := ""
var err error
for {
var obj runtime.Object
obj, err = cc.repoLister.List(ctx, &internalversion.ListOptions{
Limit: 100,
Continue: continueToken,
FieldSelector: fieldSelector,
})
if err != nil {
logger.Error("failed to check for connected repositories", "error", err)
return fmt.Errorf("check for connected repositories: %w", err)
}
repositoryList, ok := obj.(*provisioning.RepositoryList)
if !ok {
logger.Error("expected repository list", "type", fmt.Sprintf("%T", obj))
return fmt.Errorf("expected repository list, got %T", obj)
}
allRepos = append(allRepos, repositoryList.Items...)
continueToken = repositoryList.GetContinue()
if continueToken == "" {
break
}
}
if len(allRepos) > 0 {
repoNames := make([]string, 0, len(allRepos))
for _, repo := range allRepos {
repoNames = append(repoNames, repo.Name)
}
logger.Info("cannot delete connection while repositories reference it", "repositories", repoNames)
// Don't remove finalizer - this will prevent deletion
// The connection will remain in deletion state until repositories are removed
return fmt.Errorf("cannot delete connection while repositories are using it: %s", strings.Join(repoNames, ", "))
}
// No repositories reference this connection, remove finalizer to allow deletion
logger.Info("no repositories reference connection, removing finalizer")
_, err = cc.client.Connections(conn.GetNamespace()).
Patch(ctx, conn.Name, types.JSONPatchType, []byte(`[
{ "op": "remove", "path": "/metadata/finalizers" }
]`), metav1.PatchOptions{
FieldManager: "provisioning-connection-controller",
})
if err != nil {
// If we can't remove the finalizer, undelete the connection so it can be retried later
// This prevents the connection from being stuck in deletion state
logger.Error("failed to remove finalizer, undeleting connection", "error", err)
undeleteErr := cc.undeleteConnection(ctx, conn, err)
if undeleteErr != nil {
return fmt.Errorf("remove finalizer: %w; failed to undelete: %w", err, undeleteErr)
}
return fmt.Errorf("remove finalizer: %w (connection has been undeleted, deletion can be retried)", err)
}
return nil
}
// undeleteConnection removes the DeletionTimestamp to "undelete" the connection
// This is used when finalizer removal fails, allowing the deletion to be retried later
func (cc *ConnectionController) undeleteConnection(ctx context.Context, conn *provisioning.Connection, originalErr error) error {
logger := logging.FromContext(ctx)
logger.Info("undeleting connection due to finalizer removal failure", "error", originalErr.Error())
// Remove DeletionTimestamp by patching it to null
_, err := cc.client.Connections(conn.GetNamespace()).
Patch(ctx, conn.Name, types.JSONPatchType, []byte(`[
{ "op": "remove", "path": "/metadata/deletionTimestamp" }
]`), metav1.PatchOptions{
FieldManager: "provisioning-connection-controller",
})
if err != nil {
logger.Error("failed to undelete connection", "error", err)
return fmt.Errorf("undelete connection: %w", err)
}
logger.Info("connection undeleted successfully, deletion can be retried")
return nil
}
// isTransientError determines if an error is transient and should be retried
func isTransientError(err error) bool {
if err == nil {
return false
}
// Check for Kubernetes API transient errors
if apierrors.IsServiceUnavailable(err) {
return true
}
if apierrors.IsServerTimeout(err) {
return true
}
if apierrors.IsTooManyRequests(err) {
return true
}
if apierrors.IsInternalError(err) {
return true
}
if apierrors.IsTimeout(err) {
return true
}
// Check for network errors
var netErr net.Error
if errors.As(err, &netErr) {
if netErr.Timeout() {
return true
}
}
// Check for connection errors
var opErr *net.OpError
return errors.As(err, &opErr)
}
// shouldCheckHealth determines if a connection health check should be performed.
func (cc *ConnectionController) shouldCheckHealth(conn *provisioning.Connection) bool {
// If the connection has been updated, always check health
@@ -1,13 +1,28 @@
package controller
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/watch"
"k8s.io/client-go/rest"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
connectionvalidation "github.com/grafana/grafana/apps/provisioning/pkg/connection"
applyconfiguration "github.com/grafana/grafana/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1"
client "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1"
)
func TestConnectionController_shouldCheckHealth(t *testing.T) {
@@ -285,3 +300,398 @@ func TestConnectionController_processNextWorkItem(t *testing.T) {
assert.NotNil(t, cc)
})
}
// mockRepositoryLister is a mock implementation of RepositoryLister for testing
type mockRepositoryLister struct {
mock.Mock
}
func (m *mockRepositoryLister) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
args := m.Called(ctx, options)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(runtime.Object), args.Error(1)
}
// mockConnectionInterface is a mock implementation of client.ConnectionInterface for testing
type mockConnectionInterface struct {
mock.Mock
}
func (m *mockConnectionInterface) Create(ctx context.Context, connection *provisioning.Connection, opts metav1.CreateOptions) (*provisioning.Connection, error) {
args := m.Called(ctx, connection, opts)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*provisioning.Connection), args.Error(1)
}
func (m *mockConnectionInterface) Update(ctx context.Context, connection *provisioning.Connection, opts metav1.UpdateOptions) (*provisioning.Connection, error) {
args := m.Called(ctx, connection, opts)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*provisioning.Connection), args.Error(1)
}
func (m *mockConnectionInterface) UpdateStatus(ctx context.Context, connection *provisioning.Connection, opts metav1.UpdateOptions) (*provisioning.Connection, error) {
args := m.Called(ctx, connection, opts)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*provisioning.Connection), args.Error(1)
}
func (m *mockConnectionInterface) Delete(ctx context.Context, name string, opts metav1.DeleteOptions) error {
args := m.Called(ctx, name, opts)
return args.Error(0)
}
func (m *mockConnectionInterface) DeleteCollection(ctx context.Context, opts metav1.DeleteOptions, listOpts metav1.ListOptions) error {
args := m.Called(ctx, opts, listOpts)
return args.Error(0)
}
func (m *mockConnectionInterface) Get(ctx context.Context, name string, opts metav1.GetOptions) (*provisioning.Connection, error) {
args := m.Called(ctx, name, opts)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*provisioning.Connection), args.Error(1)
}
func (m *mockConnectionInterface) List(ctx context.Context, opts metav1.ListOptions) (*provisioning.ConnectionList, error) {
args := m.Called(ctx, opts)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*provisioning.ConnectionList), args.Error(1)
}
func (m *mockConnectionInterface) Watch(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) {
args := m.Called(ctx, opts)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(watch.Interface), args.Error(1)
}
func (m *mockConnectionInterface) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts metav1.PatchOptions, subresources ...string) (*provisioning.Connection, error) {
args := m.Called(ctx, name, pt, data, opts, subresources)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*provisioning.Connection), args.Error(1)
}
func (m *mockConnectionInterface) Apply(ctx context.Context, connection *applyconfiguration.ConnectionApplyConfiguration, opts metav1.ApplyOptions) (*provisioning.Connection, error) {
args := m.Called(ctx, connection, opts)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*provisioning.Connection), args.Error(1)
}
func (m *mockConnectionInterface) ApplyStatus(ctx context.Context, connection *applyconfiguration.ConnectionApplyConfiguration, opts metav1.ApplyOptions) (*provisioning.Connection, error) {
args := m.Called(ctx, connection, opts)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*provisioning.Connection), args.Error(1)
}
// mockProvisioningV0alpha1InterfaceForConnections is a mock implementation of client.ProvisioningV0alpha1Interface for connection tests
type mockProvisioningV0alpha1InterfaceForConnections struct {
mock.Mock
connections *mockConnectionInterface
}
func (m *mockProvisioningV0alpha1InterfaceForConnections) RESTClient() rest.Interface {
panic("not needed for testing")
}
func (m *mockProvisioningV0alpha1InterfaceForConnections) HistoricJobs(namespace string) client.HistoricJobInterface {
panic("not needed for testing")
}
func (m *mockProvisioningV0alpha1InterfaceForConnections) Jobs(namespace string) client.JobInterface {
panic("not needed for testing")
}
func (m *mockProvisioningV0alpha1InterfaceForConnections) Connections(namespace string) client.ConnectionInterface {
return m.connections
}
func (m *mockProvisioningV0alpha1InterfaceForConnections) Repositories(namespace string) client.RepositoryInterface {
panic("not needed for testing")
}
func TestConnectionController_handleDelete(t *testing.T) {
ctx := context.Background()
tests := []struct {
name string
connection *provisioning.Connection
repoListerSetup func(*mockRepositoryLister)
connectionSetup func(*mockConnectionInterface)
expectedError string
expectFinalizerRemoved bool
}{
{
name: "no finalizer present, should return nil",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{
Name: "test-conn",
Namespace: "default",
DeletionTimestamp: &metav1.Time{Time: time.Now()},
Finalizers: []string{},
},
},
repoListerSetup: func(m *mockRepositoryLister) {},
connectionSetup: func(m *mockConnectionInterface) {},
expectedError: "",
expectFinalizerRemoved: false,
},
{
name: "finalizer present but repositories exist, should block deletion",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{
Name: "test-conn",
Namespace: "default",
DeletionTimestamp: &metav1.Time{Time: time.Now()},
Finalizers: []string{connectionvalidation.BlockDeletionFinalizer},
},
},
repoListerSetup: func(m *mockRepositoryLister) {
m.On("List", ctx, mock.MatchedBy(func(opts *internalversion.ListOptions) bool {
return opts.FieldSelector != nil && opts.FieldSelector.String() == "spec.connection.name=test-conn"
})).Return(&provisioning.RepositoryList{
Items: []provisioning.Repository{
{
ObjectMeta: metav1.ObjectMeta{Name: "repo-1"},
Spec: provisioning.RepositorySpec{
Connection: &provisioning.ConnectionInfo{Name: "test-conn"},
},
},
{
ObjectMeta: metav1.ObjectMeta{Name: "repo-2"},
Spec: provisioning.RepositorySpec{
Connection: &provisioning.ConnectionInfo{Name: "test-conn"},
},
},
},
}, nil)
},
connectionSetup: func(m *mockConnectionInterface) {},
expectedError: "cannot delete connection while repositories are using it: repo-1, repo-2",
expectFinalizerRemoved: false,
},
{
name: "finalizer present and no repositories, should remove finalizer",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{
Name: "test-conn",
Namespace: "default",
DeletionTimestamp: &metav1.Time{Time: time.Now()},
Finalizers: []string{connectionvalidation.BlockDeletionFinalizer},
},
},
repoListerSetup: func(m *mockRepositoryLister) {
m.On("List", ctx, mock.MatchedBy(func(opts *internalversion.ListOptions) bool {
return opts.FieldSelector != nil && opts.FieldSelector.String() == "spec.connection.name=test-conn"
})).Return(&provisioning.RepositoryList{
Items: []provisioning.Repository{},
}, nil)
},
connectionSetup: func(m *mockConnectionInterface) {
m.On("Patch", ctx, "test-conn", types.JSONPatchType, mock.Anything, metav1.PatchOptions{
FieldManager: "provisioning-connection-controller",
}, mock.Anything).Return(&provisioning.Connection{}, nil)
},
expectedError: "",
expectFinalizerRemoved: true,
},
{
name: "error checking repositories, should return error",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{
Name: "test-conn",
Namespace: "default",
DeletionTimestamp: &metav1.Time{Time: time.Now()},
Finalizers: []string{connectionvalidation.BlockDeletionFinalizer},
},
},
repoListerSetup: func(m *mockRepositoryLister) {
m.On("List", ctx, mock.Anything).Return(nil, errors.New("list error"))
},
connectionSetup: func(m *mockConnectionInterface) {},
expectedError: "check for connected repositories: list error",
expectFinalizerRemoved: false,
},
{
name: "error removing finalizer, should undelete connection",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{
Name: "test-conn",
Namespace: "default",
DeletionTimestamp: &metav1.Time{Time: time.Now()},
Finalizers: []string{connectionvalidation.BlockDeletionFinalizer},
},
},
repoListerSetup: func(m *mockRepositoryLister) {
m.On("List", ctx, mock.Anything).Return(&provisioning.RepositoryList{
Items: []provisioning.Repository{},
}, nil)
},
connectionSetup: func(m *mockConnectionInterface) {
// First patch fails (remove finalizer)
m.On("Patch", ctx, "test-conn", types.JSONPatchType, mock.MatchedBy(func(data []byte) bool {
return string(data) == `[
{ "op": "remove", "path": "/metadata/finalizers" }
]`
}), metav1.PatchOptions{
FieldManager: "provisioning-connection-controller",
}, mock.Anything).Return(nil, errors.New("patch error")).Once()
// Second patch succeeds (undelete - remove DeletionTimestamp)
m.On("Patch", ctx, "test-conn", types.JSONPatchType, mock.MatchedBy(func(data []byte) bool {
return string(data) == `[
{ "op": "remove", "path": "/metadata/deletionTimestamp" }
]`
}), metav1.PatchOptions{
FieldManager: "provisioning-connection-controller",
}, mock.Anything).Return(&provisioning.Connection{}, nil).Once()
},
expectedError: "remove finalizer: patch error (connection has been undeleted, deletion can be retried)",
expectFinalizerRemoved: false,
},
{
name: "pagination handled correctly",
connection: &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{
Name: "test-conn",
Namespace: "default",
DeletionTimestamp: &metav1.Time{Time: time.Now()},
Finalizers: []string{connectionvalidation.BlockDeletionFinalizer},
},
},
repoListerSetup: func(m *mockRepositoryLister) {
// First call returns empty with continue token (testing pagination even when empty)
m.On("List", ctx, mock.MatchedBy(func(opts *internalversion.ListOptions) bool {
return opts.Continue == ""
})).Return(&provisioning.RepositoryList{
Items: []provisioning.Repository{},
ListMeta: metav1.ListMeta{Continue: "continue-token"},
}, nil)
// Second call returns empty with no continue token
m.On("List", ctx, mock.MatchedBy(func(opts *internalversion.ListOptions) bool {
return opts.Continue == "continue-token"
})).Return(&provisioning.RepositoryList{
Items: []provisioning.Repository{},
}, nil)
},
connectionSetup: func(m *mockConnectionInterface) {
m.On("Patch", ctx, "test-conn", types.JSONPatchType, mock.Anything, metav1.PatchOptions{
FieldManager: "provisioning-connection-controller",
}, mock.Anything).Return(&provisioning.Connection{}, nil)
},
expectedError: "",
expectFinalizerRemoved: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repoLister := new(mockRepositoryLister)
connInterface := new(mockConnectionInterface)
client := &mockProvisioningV0alpha1InterfaceForConnections{connections: connInterface}
tt.repoListerSetup(repoLister)
tt.connectionSetup(connInterface)
cc := &ConnectionController{
client: client,
repoLister: repoLister,
logger: nil, // logger is optional for testing
}
err := cc.handleDelete(ctx, tt.connection)
if tt.expectedError != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.expectedError)
} else {
require.NoError(t, err)
}
if tt.expectFinalizerRemoved {
connInterface.AssertCalled(t, "Patch", ctx, "test-conn", types.JSONPatchType, mock.Anything, metav1.PatchOptions{
FieldManager: "provisioning-connection-controller",
}, mock.Anything)
} else if tt.expectedError != "" && strings.Contains(tt.expectedError, "undeleted") {
// For undelete case, we expect both patches to be called (remove finalizer fails, then undelete succeeds)
connInterface.AssertNumberOfCalls(t, "Patch", 2)
}
// For other error cases (repositories exist), no successful patch should occur
repoLister.AssertExpectations(t)
connInterface.AssertExpectations(t)
})
}
}
func TestIsTransientError(t *testing.T) {
tests := []struct {
name string
err error
expected bool
}{
{
name: "nil error",
err: nil,
expected: false,
},
{
name: "service unavailable",
err: apierrors.NewServiceUnavailable("service unavailable"),
expected: true,
},
{
name: "server timeout",
err: apierrors.NewServerTimeout(schema.GroupResource{}, "operation", 0),
expected: true,
},
{
name: "too many requests",
err: apierrors.NewTooManyRequests("too many requests", 0),
expected: true,
},
{
name: "internal error",
err: apierrors.NewInternalError(errors.New("internal error")),
expected: true,
},
{
name: "not found error",
err: apierrors.NewNotFound(schema.GroupResource{}, "resource"),
expected: false,
},
{
name: "forbidden error",
err: apierrors.NewForbidden(schema.GroupResource{}, "resource", errors.New("forbidden")),
expected: false,
},
{
name: "generic error",
err: errors.New("generic error"),
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isTransientError(tt.err)
assert.Equal(t, tt.expected, result)
})
}
}
+76 -2
View File
@@ -559,6 +559,22 @@ func (b *APIBuilder) InstallSchema(scheme *runtime.Scheme) error {
return err
}
// Register custom field label conversion for Repository to enable field selectors like spec.connection.name
err = scheme.AddFieldLabelConversionFunc(
provisioning.SchemeGroupVersion.WithKind("Repository"),
func(label, value string) (string, string, error) {
switch label {
case "metadata.name", "metadata.namespace", "spec.connection.name":
return label, value, nil
default:
return "", "", fmt.Errorf("field label not supported for Repository: %s", label)
}
},
)
if err != nil {
return err
}
metav1.AddToGroupVersion(scheme, provisioning.SchemeGroupVersion)
// Only 1 version (for now?)
return scheme.SetVersionPriority(provisioning.SchemeGroupVersion)
@@ -569,10 +585,19 @@ func (b *APIBuilder) AllowedV0Alpha1Resources() []string {
}
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 {
return fmt.Errorf("failed to create repository storage: %w", err)
}
repositoryStatusStorage := grafanaregistry.NewRegistryStatusStore(opts.Scheme, repositoryStorage)
b.store = repositoryStorage
@@ -660,6 +685,12 @@ func (b *APIBuilder) Mutate(ctx context.Context, a admission.Attributes, o admis
// TODO: complete this as part of https://github.com/grafana/git-ui-sync-project/issues/700
c, ok := obj.(*provisioning.Connection)
if ok {
// Add finalizer on create to prevent deletion while repositories reference it
if len(c.Finalizers) == 0 && a.GetOperation() == admission.Create {
c.Finalizers = []string{
connectionvalidation.BlockDeletionFinalizer,
}
}
return connectionvalidation.MutateConnection(c)
}
@@ -695,7 +726,13 @@ func (b *APIBuilder) Mutate(ctx context.Context, a admission.Attributes, o admis
// TODO: move logic to a more appropriate place. Probably controller/validation.go
func (b *APIBuilder) Validate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) (err error) {
obj := a.GetObject()
if obj == nil || a.GetOperation() == admission.Connect || a.GetOperation() == admission.Delete {
// Handle Connection deletion - check for connected repositories
if a.GetOperation() == admission.Delete {
return b.validateDelete(ctx, a)
}
if obj == nil || a.GetOperation() == admission.Connect {
return nil // This is normal for sub-resource
}
@@ -775,6 +812,42 @@ func invalidRepositoryError(name string, list field.ErrorList) error {
name, list)
}
// validateDelete handles validation for delete operations
func (b *APIBuilder) validateDelete(ctx context.Context, a admission.Attributes) error {
// Only validate Connection deletions
if a.GetResource().Resource != "connections" {
return nil
}
connectionName := a.GetName()
namespace := a.GetNamespace()
// Set namespace in context for the repository store query
ctx, _, err := identity.WithProvisioningIdentity(ctx, namespace)
if err != nil {
return apierrors.NewInternalError(fmt.Errorf("failed to set provisioning identity: %w", err))
}
repos, err := GetRepositoriesByConnection(ctx, b.store, connectionName)
if err != nil {
return apierrors.NewInternalError(fmt.Errorf("failed to check for connected repositories: %w", err))
}
if len(repos) > 0 {
repoNames := make([]string, 0, len(repos))
for _, repo := range repos {
repoNames = append(repoNames, repo.Name)
}
return apierrors.NewForbidden(
provisioning.ConnectionResourceInfo.GroupResource(),
connectionName,
fmt.Errorf("cannot delete connection while repositories are using it: %s", strings.Join(repoNames, ", ")),
)
}
return nil
}
func (b *APIBuilder) VerifyAgainstExistingRepositories(ctx context.Context, cfg *provisioning.Repository) *field.Error {
return VerifyAgainstExistingRepositories(ctx, b.store, cfg)
}
@@ -947,6 +1020,7 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH
b.GetClient(),
connInformer,
connStatusPatcher,
b.store,
)
if err != nil {
return err
@@ -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"])
})
}
@@ -7,6 +7,7 @@ import (
"strings"
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
"k8s.io/apimachinery/pkg/fields"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/util/validation/field"
"k8s.io/apiserver/pkg/endpoints/request"
@@ -50,6 +51,39 @@ func GetRepositoriesInNamespace(ctx context.Context, store RepositoryLister) ([]
return allRepositories, nil
}
// GetRepositoriesByConnection retrieves all repositories that reference a specific connection
func GetRepositoriesByConnection(ctx context.Context, store RepositoryLister, connectionName string) ([]provisioning.Repository, error) {
var allRepositories []provisioning.Repository
continueToken := ""
fieldSelector := fields.OneTermEqualSelector("spec.connection.name", connectionName)
for {
obj, err := store.List(ctx, &internalversion.ListOptions{
Limit: 100,
Continue: continueToken,
FieldSelector: fieldSelector,
})
if err != nil {
return nil, err
}
repositoryList, ok := obj.(*provisioning.RepositoryList)
if !ok {
return nil, fmt.Errorf("expected repository list")
}
allRepositories = append(allRepositories, repositoryList.Items...)
continueToken = repositoryList.GetContinue()
if continueToken == "" {
break
}
}
return allRepositories, nil
}
// VerifyAgainstExistingRepositories validates a repository configuration against existing repositories
func VerifyAgainstExistingRepositories(ctx context.Context, store RepositoryLister, cfg *provisioning.Repository) *field.Error {
ctx, _, err := identity.WithProvisioningIdentity(ctx, cfg.Namespace)
@@ -0,0 +1,200 @@
package provisioning
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/fields"
"k8s.io/apimachinery/pkg/runtime"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
)
// mockRepositoryLister is a mock implementation of RepositoryLister for testing
type mockRepositoryLister struct {
repositories []provisioning.Repository
listErr error
// Track the field selector used in List calls
lastFieldSelector fields.Selector
}
func (m *mockRepositoryLister) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
if m.listErr != nil {
return nil, m.listErr
}
// Store the field selector for verification
m.lastFieldSelector = options.FieldSelector
// Filter repositories based on field selector if present
filteredRepos := m.repositories
if options.FieldSelector != nil && !options.FieldSelector.Empty() {
filteredRepos = make([]provisioning.Repository, 0)
for _, repo := range m.repositories {
// Simulate field selector matching for spec.connection.name
repoFields := fields.Set{
"spec.connection.name": getRepoConnectionName(&repo),
}
if options.FieldSelector.Matches(repoFields) {
filteredRepos = append(filteredRepos, repo)
}
}
}
return &provisioning.RepositoryList{
Items: filteredRepos,
}, nil
}
func getRepoConnectionName(repo *provisioning.Repository) string {
if repo.Spec.Connection == nil {
return ""
}
return repo.Spec.Connection.Name
}
func TestGetRepositoriesByConnection(t *testing.T) {
tests := []struct {
name string
repositories []provisioning.Repository
connectionName string
expectedCount int
expectedNames []string
expectedErr bool
}{
{
name: "empty repository list returns empty",
repositories: []provisioning.Repository{},
connectionName: "test-conn",
expectedCount: 0,
expectedNames: []string{},
},
{
name: "finds single matching repository",
repositories: []provisioning.Repository{
{
ObjectMeta: metav1.ObjectMeta{Name: "repo-1"},
Spec: provisioning.RepositorySpec{
Connection: &provisioning.ConnectionInfo{Name: "conn-a"},
},
},
{
ObjectMeta: metav1.ObjectMeta{Name: "repo-2"},
Spec: provisioning.RepositorySpec{
Connection: &provisioning.ConnectionInfo{Name: "conn-b"},
},
},
},
connectionName: "conn-a",
expectedCount: 1,
expectedNames: []string{"repo-1"},
},
{
name: "finds multiple matching repositories",
repositories: []provisioning.Repository{
{
ObjectMeta: metav1.ObjectMeta{Name: "repo-1"},
Spec: provisioning.RepositorySpec{
Connection: &provisioning.ConnectionInfo{Name: "shared-conn"},
},
},
{
ObjectMeta: metav1.ObjectMeta{Name: "repo-2"},
Spec: provisioning.RepositorySpec{
Connection: &provisioning.ConnectionInfo{Name: "shared-conn"},
},
},
{
ObjectMeta: metav1.ObjectMeta{Name: "repo-3"},
Spec: provisioning.RepositorySpec{
Connection: &provisioning.ConnectionInfo{Name: "different-conn"},
},
},
},
connectionName: "shared-conn",
expectedCount: 2,
expectedNames: []string{"repo-1", "repo-2"},
},
{
name: "no matches returns empty list",
repositories: []provisioning.Repository{
{
ObjectMeta: metav1.ObjectMeta{Name: "repo-1"},
Spec: provisioning.RepositorySpec{
Connection: &provisioning.ConnectionInfo{Name: "conn-a"},
},
},
},
connectionName: "non-existent",
expectedCount: 0,
expectedNames: []string{},
},
{
name: "empty connection name matches repos without connection",
repositories: []provisioning.Repository{
{
ObjectMeta: metav1.ObjectMeta{Name: "repo-with-conn"},
Spec: provisioning.RepositorySpec{
Connection: &provisioning.ConnectionInfo{Name: "some-conn"},
},
},
{
ObjectMeta: metav1.ObjectMeta{Name: "repo-without-conn"},
Spec: provisioning.RepositorySpec{
Connection: nil,
},
},
},
connectionName: "",
expectedCount: 1,
expectedNames: []string{"repo-without-conn"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mock := &mockRepositoryLister{repositories: tt.repositories}
ctx := context.Background()
repos, err := GetRepositoriesByConnection(ctx, mock, tt.connectionName)
if tt.expectedErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Len(t, repos, tt.expectedCount)
// Verify the field selector was used
require.NotNil(t, mock.lastFieldSelector, "field selector should have been set")
expectedSelector := fields.OneTermEqualSelector("spec.connection.name", tt.connectionName)
assert.Equal(t, expectedSelector.String(), mock.lastFieldSelector.String())
// Verify the correct repositories were returned
actualNames := make([]string, len(repos))
for i, repo := range repos {
actualNames[i] = repo.Name
}
for _, expectedName := range tt.expectedNames {
assert.Contains(t, actualNames, expectedName)
}
})
}
}
func TestGetRepositoriesByConnection_ListError(t *testing.T) {
mock := &mockRepositoryLister{
listErr: assert.AnError,
}
ctx := context.Background()
repos, err := GetRepositoriesByConnection(ctx, mock, "any-conn")
require.Error(t, err)
assert.Nil(t, repos)
}
@@ -542,6 +542,9 @@ func (d *dashboardStore) saveDashboard(ctx context.Context, sess *db.Session, cm
tags := dash.GetTags()
if len(tags) > 0 {
for _, tag := range tags {
if len(tag) > 50 {
return nil, dashboards.ErrDashboardTagTooLong
}
if _, err := sess.Insert(dashboardTag{DashboardId: dash.ID, Term: tag, OrgID: dash.OrgID, DashboardUID: dash.UID}); err != nil {
return nil, err
}
+5
View File
@@ -79,6 +79,11 @@ var (
Reason: "message too long, max 500 characters",
StatusCode: 400,
}
ErrDashboardTagTooLong = dashboardaccess.DashboardErr{
Reason: "dashboard tag too long, max 50 characters",
StatusCode: 400,
Status: "tag-too-long",
}
ErrDashboardCannotSaveProvisionedDashboard = dashboardaccess.DashboardErr{
Reason: "Cannot save provisioned dashboard",
StatusCode: 400,
+6
View File
@@ -501,9 +501,15 @@ type GetLibraryElementsParams struct {
// required:false
ExcludeUID string `json:"excludeUid"`
// A comma separated list of folder ID(s) to filter the elements by.
// Deprecated: Use FolderFilterUIDs instead.
// in:query
// required:false
// deprecated:true
FolderFilter string `json:"folderFilter"`
// A comma separated list of folder UID(s) to filter the elements by.
// in:query
// required:false
FolderFilterUIDs string `json:"folderFilterUIDs"`
// The number of results per page.
// in:query
// required:false
+4
View File
@@ -20,6 +20,10 @@ func UpdatePreferencesFor(ctx context.Context,
return response.Error(http.StatusBadRequest, "Invalid theme", nil)
}
if !pref.IsValidTimezone(dtoCmd.Timezone) {
return response.Error(http.StatusBadRequest, "Invalid timezone. Must be a valid IANA timezone (e.g., America/New_York), 'utc', 'browser', or empty string", nil)
}
// convert dashboard UID to ID in order to store internally if it exists in the query, otherwise take the id from query
// nolint:staticcheck
dashboardID := dtoCmd.HomeDashboardID
+21
View File
@@ -0,0 +1,21 @@
package pref
import (
"time"
)
// IsValidTimezone checks if the timezone string is valid.
// It accepts:
// - "" - uses default
// - "utc"
// - "browser"
// - Any valid IANA timezone (e.g., "America/New_York", "Europe/London")
func IsValidTimezone(timezone string) bool {
if timezone == "" || timezone == "utc" || timezone == "browser" {
return true
}
// try to load as IANA timezone
_, err := time.LoadLocation(timezone)
return err == nil
}
+38
View File
@@ -0,0 +1,38 @@
package pref
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestIsValidTimezone(t *testing.T) {
tests := []struct {
timezone string
valid bool
}{
{
timezone: "utc",
valid: true,
},
{
timezone: "browser",
valid: true,
},
{
timezone: "Europe/London",
valid: true,
},
{
timezone: "invalid",
valid: false,
},
{
timezone: "",
valid: true,
},
}
for _, test := range tests {
assert.Equal(t, test.valid, IsValidTimezone(test.timezone))
}
}
@@ -393,6 +393,66 @@ func runDashboardValidationTests(t *testing.T, ctx TestContext) {
})
})
t.Run("Dashboard tag validations", func(t *testing.T) {
t.Run("reject dashboard with tag over 50 characters on creation", func(t *testing.T) {
dashObj := createDashboardObject(t, "Dashboard with Long Tag", "", 0)
meta, _ := utils.MetaAccessor(dashObj)
spec, _ := meta.GetSpec()
specMap := spec.(map[string]interface{})
specMap["tags"] = []string{"this-is-a-very-long-tag-that-exceeds-fifty-characters-limit"}
_ = meta.SetSpec(specMap)
_, err := adminClient.Resource.Create(context.Background(), dashObj, v1.CreateOptions{})
require.Error(t, err)
require.Contains(t, err.Error(), "tag too long")
})
t.Run("reject dashboard update with tag over 50 characters", func(t *testing.T) {
dash, err := createDashboard(t, adminClient, "Valid Dashboard", nil, nil, ctx.Helper)
require.NoError(t, err)
require.NotNil(t, dash)
meta, _ := utils.MetaAccessor(dash)
spec, _ := meta.GetSpec()
specMap := spec.(map[string]interface{})
specMap["tags"] = []string{"this-is-a-very-long-tag-that-exceeds-fifty-characters-limit"}
_ = meta.SetSpec(specMap)
_, err = adminClient.Resource.Update(context.Background(), dash, v1.UpdateOptions{})
require.Error(t, err)
require.Contains(t, err.Error(), "tag too long")
err = adminClient.Resource.Delete(context.Background(), dash.GetName(), v1.DeleteOptions{})
require.NoError(t, err)
})
t.Run("accept dashboard with tag at 50 characters", func(t *testing.T) {
dashObj := createDashboardObject(t, "Dashboard with Valid Tag", "", 0)
meta, _ := utils.MetaAccessor(dashObj)
spec, _ := meta.GetSpec()
specMap := spec.(map[string]interface{})
specMap["tags"] = []string{"this-tag-is-exactly-fifty-characters-long-12345"}
_ = meta.SetSpec(specMap)
createdDash, err := adminClient.Resource.Create(context.Background(), dashObj, v1.CreateOptions{})
require.NoError(t, err)
require.NotNil(t, createdDash)
err = adminClient.Resource.Delete(context.Background(), createdDash.GetName(), v1.DeleteOptions{})
require.NoError(t, err)
})
t.Run("reject dashboard with multiple tags where one exceeds limit", func(t *testing.T) {
dashObj := createDashboardObject(t, "Dashboard with Mixed Tags", "", 0)
meta, _ := utils.MetaAccessor(dashObj)
spec, _ := meta.GetSpec()
specMap := spec.(map[string]interface{})
specMap["tags"] = []string{
"valid-tag",
"another-valid-tag",
"this-is-a-very-long-tag-that-exceeds-fifty-characters-limit",
}
_ = meta.SetSpec(specMap)
_, err := adminClient.Resource.Create(context.Background(), dashObj, v1.CreateOptions{})
require.Error(t, err)
require.Contains(t, err.Error(), "tag too long")
})
})
t.Run("Dashboard folder validations", func(t *testing.T) {
// Test non-existent folder UID
t.Run("reject dashboard with non-existent folder UID", func(t *testing.T) {
@@ -67,7 +67,7 @@ func TestIntegrationPreferences(t *testing.T) {
Path: fmt.Sprintf("/api/teams/%d/preferences", helper.Org1.Staff.ID),
Body: []byte(`{
"weekStart": "sunday",
"timezone": "africa"
"timezone": "Africa/Johannesburg"
}`),
}, &raw)
require.Equal(t, http.StatusOK, legacyResponse.Response.StatusCode, "create preference for user")
@@ -79,7 +79,7 @@ func TestIntegrationPreferences(t *testing.T) {
Path: "/api/org/preferences",
Body: []byte(`{
"weekStart": "sunday",
"timezone": "africa",
"timezone": "Africa/Accra",
"theme": "dark"
}`),
}, &raw)
@@ -144,7 +144,7 @@ func TestIntegrationPreferences(t *testing.T) {
jj, _ = json.Marshal(bootdata.Result.User)
require.JSONEq(t, `{
"timezone":"africa",
"timezone":"Africa/Johannesburg",
"weekStart":"saturday",
"theme":"dark",
"language":"en-US", `+ // FROM global default!
@@ -157,10 +157,10 @@ func TestIntegrationPreferences(t *testing.T) {
Path: "/apis/preferences.grafana.app/v1alpha1/namespaces/default/preferences/merged",
}, &preferences.Preferences{})
require.Equal(t, http.StatusOK, merged.Response.StatusCode, "get merged preferences")
require.Equal(t, "saturday", *merged.Result.Spec.WeekStart) // from user
require.Equal(t, "africa", *merged.Result.Spec.Timezone) // from team
require.Equal(t, "dark", *merged.Result.Spec.Theme) // from org
require.Equal(t, "en-US", *merged.Result.Spec.Language) // settings.ini
require.Equal(t, "dd/mm/yyyy", *merged.Result.Spec.RegionalFormat) // from user update
require.Equal(t, "saturday", *merged.Result.Spec.WeekStart) // from user
require.Equal(t, "Africa/Johannesburg", *merged.Result.Spec.Timezone) // from team
require.Equal(t, "dark", *merged.Result.Spec.Theme) // from org
require.Equal(t, "en-US", *merged.Result.Spec.Language) // settings.ini
require.Equal(t, "dd/mm/yyyy", *merged.Result.Spec.RegionalFormat) // from user update
})
}
@@ -559,3 +559,376 @@ func TestIntegrationConnectionController_HealthCheckUpdates(t *testing.T) {
assert.True(t, final.Status.Health.Healthy, "connection should remain healthy")
})
}
func TestIntegrationProvisioning_RepositoryFieldSelectorByConnection(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
helper := runGrafana(t)
ctx := context.Background()
createOptions := metav1.CreateOptions{FieldValidation: "Strict"}
// Create a connection first
connection := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Connection",
"metadata": map[string]any{
"name": "test-conn-for-field-selector",
"namespace": "default",
},
"spec": map[string]any{
"type": "github",
"github": map[string]any{
"appID": "123456",
"installationID": "789012",
},
},
"secure": map[string]any{
"privateKey": map[string]any{
"create": "test-private-key",
},
},
}}
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
require.NoError(t, err, "failed to create connection")
t.Cleanup(func() {
// Clean up repositories first
_ = helper.Repositories.Resource.Delete(ctx, "repo-with-connection", metav1.DeleteOptions{})
_ = helper.Repositories.Resource.Delete(ctx, "repo-without-connection", metav1.DeleteOptions{})
_ = helper.Repositories.Resource.Delete(ctx, "repo-with-different-connection", metav1.DeleteOptions{})
// Then clean up the connection
_ = helper.Connections.Resource.Delete(ctx, "test-conn-for-field-selector", metav1.DeleteOptions{})
})
// Create a repository WITH the connection
repoWithConnection := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Repository",
"metadata": map[string]any{
"name": "repo-with-connection",
"namespace": "default",
},
"spec": map[string]any{
"title": "Repo With Connection",
"type": "local",
"sync": map[string]any{
"enabled": false,
"target": "folder",
},
"local": map[string]any{
"path": helper.ProvisioningPath,
},
"connection": map[string]any{
"name": "test-conn-for-field-selector",
},
},
}}
_, err = helper.Repositories.Resource.Create(ctx, repoWithConnection, createOptions)
require.NoError(t, err, "failed to create repository with connection")
// Create a repository WITHOUT the connection
repoWithoutConnection := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Repository",
"metadata": map[string]any{
"name": "repo-without-connection",
"namespace": "default",
},
"spec": map[string]any{
"title": "Repo Without Connection",
"type": "local",
"sync": map[string]any{
"enabled": false,
"target": "folder",
},
"local": map[string]any{
"path": helper.ProvisioningPath,
},
},
}}
_, err = helper.Repositories.Resource.Create(ctx, repoWithoutConnection, createOptions)
require.NoError(t, err, "failed to create repository without connection")
// Create a repository with a DIFFERENT connection name (non-existent)
repoWithDifferentConnection := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Repository",
"metadata": map[string]any{
"name": "repo-with-different-connection",
"namespace": "default",
},
"spec": map[string]any{
"title": "Repo With Different Connection",
"type": "local",
"sync": map[string]any{
"enabled": false,
"target": "folder",
},
"local": map[string]any{
"path": helper.ProvisioningPath,
},
"connection": map[string]any{
"name": "some-other-connection",
},
},
}}
_, err = helper.Repositories.Resource.Create(ctx, repoWithDifferentConnection, createOptions)
require.NoError(t, err, "failed to create repository with different connection")
t.Run("filter repositories by spec.connection.name", func(t *testing.T) {
// List repositories with field selector for the specific connection
list, err := helper.Repositories.Resource.List(ctx, metav1.ListOptions{
FieldSelector: "spec.connection.name=test-conn-for-field-selector",
})
require.NoError(t, err, "failed to list repositories with field selector")
// Should only return the repository with the matching connection
assert.Len(t, list.Items, 1, "should return exactly one repository")
assert.Equal(t, "repo-with-connection", list.Items[0].GetName(), "should return the correct repository")
})
t.Run("filter repositories by non-existent connection returns empty", func(t *testing.T) {
// List repositories with field selector for a non-existent connection
list, err := helper.Repositories.Resource.List(ctx, metav1.ListOptions{
FieldSelector: "spec.connection.name=non-existent-connection",
})
require.NoError(t, err, "failed to list repositories with field selector")
// Should return empty list
assert.Len(t, list.Items, 0, "should return no repositories for non-existent connection")
})
t.Run("filter repositories by empty connection name", func(t *testing.T) {
// List repositories with field selector for empty connection (repos without connection)
list, err := helper.Repositories.Resource.List(ctx, metav1.ListOptions{
FieldSelector: "spec.connection.name=",
})
require.NoError(t, err, "failed to list repositories with empty connection field selector")
// Should return the repository without a connection
assert.Len(t, list.Items, 1, "should return exactly one repository without connection")
assert.Equal(t, "repo-without-connection", list.Items[0].GetName(), "should return the repository without connection")
})
t.Run("list all repositories without field selector", func(t *testing.T) {
// List all repositories without field selector
list, err := helper.Repositories.Resource.List(ctx, metav1.ListOptions{})
require.NoError(t, err, "failed to list all repositories")
// Should return all three repositories
assert.Len(t, list.Items, 3, "should return all three repositories")
names := make([]string, len(list.Items))
for i, item := range list.Items {
names[i] = item.GetName()
}
assert.Contains(t, names, "repo-with-connection")
assert.Contains(t, names, "repo-without-connection")
assert.Contains(t, names, "repo-with-different-connection")
})
}
func TestIntegrationProvisioning_ConnectionDeletionBlocking(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
helper := runGrafana(t)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
t.Cleanup(cancel)
createOptions := metav1.CreateOptions{}
// Create a connection for testing deletion blocking
connName := "test-conn-delete-blocking"
_, err := helper.Connections.Resource.Create(ctx, &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Connection",
"metadata": map[string]any{
"name": connName,
"namespace": "default",
},
"spec": map[string]any{
"type": "github",
"github": map[string]any{
"appID": "123456",
"installationID": "454545",
},
},
"secure": map[string]any{
"privateKey": map[string]any{
"create": "someSecret",
},
},
},
}, createOptions)
require.NoError(t, err, "failed to create test connection")
t.Run("delete connection without connected repositories succeeds", func(t *testing.T) {
// Create a connection that has no repositories
emptyConnName := "test-conn-no-repos"
_, err := helper.Connections.Resource.Create(ctx, &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Connection",
"metadata": map[string]any{
"name": emptyConnName,
"namespace": "default",
},
"spec": map[string]any{
"type": "github",
"github": map[string]any{
"appID": "123457",
"installationID": "454546",
},
},
"secure": map[string]any{
"privateKey": map[string]any{
"create": "someSecret",
},
},
},
}, createOptions)
require.NoError(t, err, "failed to create test connection without repos")
// Delete should succeed since no repositories reference it
err = helper.Connections.Resource.Delete(ctx, emptyConnName, metav1.DeleteOptions{})
require.NoError(t, err, "deleting connection without connected repositories should succeed")
// Verify the connection is deleted
_, err = helper.Connections.Resource.Get(ctx, emptyConnName, metav1.GetOptions{})
require.True(t, k8serrors.IsNotFound(err), "connection should be deleted")
})
t.Run("delete connection with connected repository fails", func(t *testing.T) {
// Create a repository that uses the connection
repoName := "repo-using-connection"
_, err := helper.Repositories.Resource.Create(ctx, &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Repository",
"metadata": map[string]any{
"name": repoName,
"namespace": "default",
},
"spec": map[string]any{
"title": "Test Repository",
"type": "local",
"sync": map[string]any{
"enabled": false,
"target": "folder",
},
"local": map[string]any{
"path": helper.ProvisioningPath,
},
"connection": map[string]any{
"name": connName,
},
},
},
}, createOptions)
require.NoError(t, err, "failed to create repository using connection")
// Attempt to delete the connection - should fail
err = helper.Connections.Resource.Delete(ctx, connName, metav1.DeleteOptions{})
require.Error(t, err, "deleting connection with connected repository should fail")
require.True(t, k8serrors.IsForbidden(err), "error should be Forbidden, got: %v", err)
assert.Contains(t, err.Error(), repoName, "error should mention the connected repository name")
assert.Contains(t, err.Error(), "cannot delete connection while repositories are using it", "error should explain why deletion is blocked")
// Clean up: delete the repository first
err = helper.Repositories.Resource.Delete(ctx, repoName, metav1.DeleteOptions{})
require.NoError(t, err, "failed to delete test repository")
// Wait for the repository to be deleted
require.Eventually(t, func() bool {
_, err := helper.Repositories.Resource.Get(ctx, repoName, metav1.GetOptions{})
return k8serrors.IsNotFound(err)
}, 10*time.Second, 100*time.Millisecond, "repository should be deleted")
})
t.Run("delete connection after disconnecting repository succeeds", func(t *testing.T) {
// Now that the repository is deleted, the connection should be deletable
err = helper.Connections.Resource.Delete(ctx, connName, metav1.DeleteOptions{})
require.NoError(t, err, "deleting connection after removing connected repositories should succeed")
// Verify the connection is deleted
_, err = helper.Connections.Resource.Get(ctx, connName, metav1.GetOptions{})
require.True(t, k8serrors.IsNotFound(err), "connection should be deleted")
})
t.Run("delete connection with multiple connected repositories lists all", func(t *testing.T) {
// Create a new connection
multiConnName := "test-conn-multi-repos"
_, err := helper.Connections.Resource.Create(ctx, &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Connection",
"metadata": map[string]any{
"name": multiConnName,
"namespace": "default",
},
"spec": map[string]any{
"type": "github",
"github": map[string]any{
"appID": "123458",
"installationID": "454547",
},
},
"secure": map[string]any{
"privateKey": map[string]any{
"create": "someSecret",
},
},
},
}, createOptions)
require.NoError(t, err, "failed to create multi-repo test connection")
// Create multiple repositories using this connection
repoNames := []string{"multi-repo-1", "multi-repo-2"}
for _, repoName := range repoNames {
_, err := helper.Repositories.Resource.Create(ctx, &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Repository",
"metadata": map[string]any{
"name": repoName,
"namespace": "default",
},
"spec": map[string]any{
"title": "Test Repository " + repoName,
"type": "local",
"sync": map[string]any{
"enabled": false,
"target": "folder",
},
"local": map[string]any{
"path": helper.ProvisioningPath,
},
"connection": map[string]any{
"name": multiConnName,
},
},
},
}, createOptions)
require.NoError(t, err, "failed to create repository %s", repoName)
}
// Attempt to delete - should fail and list all repos
err = helper.Connections.Resource.Delete(ctx, multiConnName, metav1.DeleteOptions{})
require.Error(t, err, "deleting connection with multiple repos should fail")
require.True(t, k8serrors.IsForbidden(err), "error should be Forbidden")
for _, repoName := range repoNames {
assert.Contains(t, err.Error(), repoName, "error should mention repository %s", repoName)
}
// Clean up
for _, repoName := range repoNames {
_ = helper.Repositories.Resource.Delete(ctx, repoName, metav1.DeleteOptions{})
}
_ = helper.Connections.Resource.Delete(ctx, multiConnName, metav1.DeleteOptions{})
})
}
+4 -10
View File
@@ -6152,11 +6152,8 @@
]
},
"timezone": {
"type": "string",
"enum": [
"utc",
"browser"
]
"description": "Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string",
"type": "string"
},
"weekStart": {
"type": "string"
@@ -8657,11 +8654,8 @@
]
},
"timezone": {
"type": "string",
"enum": [
"utc",
"browser"
]
"description": "Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string",
"type": "string"
},
"weekStart": {
"type": "string"
+11 -11
View File
@@ -6167,10 +6167,16 @@
},
{
"type": "string",
"description": "A comma separated list of folder ID(s) to filter the elements by.",
"description": "A comma separated list of folder ID(s) to filter the elements by.\nDeprecated: Use FolderFilterUIDs instead.",
"name": "folderFilter",
"in": "query"
},
{
"type": "string",
"description": "A comma separated list of folder UID(s) to filter the elements by.",
"name": "folderFilterUIDs",
"in": "query"
},
{
"type": "integer",
"format": "int64",
@@ -18729,11 +18735,8 @@
]
},
"timezone": {
"type": "string",
"enum": [
"utc",
"browser"
]
"description": "Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string",
"type": "string"
},
"weekStart": {
"type": "string"
@@ -23120,11 +23123,8 @@
]
},
"timezone": {
"type": "string",
"enum": [
"utc",
"browser"
]
"description": "Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string",
"type": "string"
},
"weekStart": {
"type": "string"
@@ -3,7 +3,8 @@ import { css, keyframes } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { useStyles2 } from '@grafana/ui';
import grafanaIconSvg from 'img/grafana_icon.svg';
import { Branding } from '../Branding/Branding';
export function BouncingLoader() {
const styles = useStyles2(getStyles);
@@ -16,7 +17,7 @@ export function BouncingLoader() {
aria-label={t('bouncing-loader.label', 'Loading')}
>
<div className={styles.bounce}>
<img alt="" src={grafanaIconSvg} className={styles.logo} />
<Branding.LoginLogo className={styles.logo} />
</div>
</div>
);
@@ -18,6 +18,10 @@ export const validateDashboardJson = (json: string) => {
if (hasInvalidTag) {
return t('dashboard.validation.tags-expected-strings', 'tags expected array of strings');
}
const hasTooLongTag = dashboard.tags.some((tag: string) => tag.length > 50);
if (hasTooLongTag) {
return t('dashboard.validation.tag-too-long', 'Dashboard tag too long, max 50 characters');
}
} else {
return t('dashboard.validation.tags-expected-array', 'tags expected array');
}
+3 -1
View File
@@ -5696,6 +5696,7 @@
"validation": {
"invalid-dashboard-id": "Could not find a valid Grafana.com ID",
"invalid-json": "Not valid JSON",
"tag-too-long": "Dashboard tag too long, max 50 characters",
"tags-expected-array": "tags expected array",
"tags-expected-strings": "tags expected array of strings"
},
@@ -9254,7 +9255,8 @@
"tags-input": {
"add": "Add",
"placeholder-new-tag": "New tag (enter key to add)",
"remove": "Remove tag: {{name}}"
"remove": "Remove tag: {{name}}",
"tag-too-long": "Tag too long, max 50 characters"
},
"time-sync-button": {
"aria-label-sync": "Sync times",
+11 -9
View File
@@ -8264,10 +8264,7 @@
"type": "string"
},
"timezone": {
"enum": [
"utc",
"browser"
],
"description": "Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string",
"type": "string"
},
"weekStart": {
@@ -12654,10 +12651,7 @@
"type": "string"
},
"timezone": {
"enum": [
"utc",
"browser"
],
"description": "Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string",
"type": "string"
},
"weekStart": {
@@ -20727,13 +20721,21 @@
}
},
{
"description": "A comma separated list of folder ID(s) to filter the elements by.",
"description": "A comma separated list of folder ID(s) to filter the elements by.\nDeprecated: Use FolderFilterUIDs instead.",
"in": "query",
"name": "folderFilter",
"schema": {
"type": "string"
}
},
{
"description": "A comma separated list of folder UID(s) to filter the elements by.",
"in": "query",
"name": "folderFilterUIDs",
"schema": {
"type": "string"
}
},
{
"description": "The number of results per page.",
"in": "query",
+193 -192
View File
@@ -3792,23 +3792,23 @@ __metadata:
"@react-aria/overlays": "npm:3.30.0"
"@react-aria/utils": "npm:3.31.0"
"@rollup/plugin-node-resolve": "npm:16.0.1"
"@storybook/addon-a11y": "npm:^8.6.2"
"@storybook/addon-actions": "npm:^8.6.2"
"@storybook/addon-docs": "npm:^8.6.2"
"@storybook/addon-essentials": "npm:^8.6.2"
"@storybook/addon-storysource": "npm:^8.6.2"
"@storybook/addon-a11y": "npm:^8.6.15"
"@storybook/addon-actions": "npm:^8.6.15"
"@storybook/addon-docs": "npm:^8.6.15"
"@storybook/addon-essentials": "npm:^8.6.15"
"@storybook/addon-storysource": "npm:^8.6.15"
"@storybook/addon-webpack5-compiler-swc": "npm:^2.1.0"
"@storybook/blocks": "npm:^8.6.2"
"@storybook/components": "npm:^8.6.2"
"@storybook/core-events": "npm:^8.6.2"
"@storybook/manager-api": "npm:^8.6.2"
"@storybook/blocks": "npm:^8.6.15"
"@storybook/components": "npm:^8.6.15"
"@storybook/core-events": "npm:^8.6.15"
"@storybook/manager-api": "npm:^8.6.15"
"@storybook/mdx2-csf": "npm:1.1.0"
"@storybook/preset-scss": "npm:1.0.3"
"@storybook/preview-api": "npm:^8.6.2"
"@storybook/react": "npm:^8.6.2"
"@storybook/react-webpack5": "npm:^8.6.2"
"@storybook/preview-api": "npm:^8.6.15"
"@storybook/react": "npm:^8.6.15"
"@storybook/react-webpack5": "npm:^8.6.15"
"@storybook/test-runner": "npm:^0.23.0"
"@storybook/theming": "npm:^8.6.2"
"@storybook/theming": "npm:^8.6.15"
"@tanstack/react-virtual": "npm:^3.5.1"
"@testing-library/dom": "npm:10.4.1"
"@testing-library/jest-dom": "npm:6.6.4"
@@ -3899,7 +3899,7 @@ __metadata:
slate: "npm:0.47.9"
slate-plain-serializer: "npm:0.7.13"
slate-react: "npm:0.22.10"
storybook: "npm:^8.6.2"
storybook: "npm:^8.6.15"
style-loader: "npm:4.0.0"
tinycolor2: "npm:1.6.0"
tslib: "npm:2.8.1"
@@ -7961,22 +7961,23 @@ __metadata:
languageName: node
linkType: hard
"@storybook/addon-a11y@npm:^8.6.2":
version: 8.6.2
resolution: "@storybook/addon-a11y@npm:8.6.2"
"@storybook/addon-a11y@npm:^8.6.15":
version: 8.6.15
resolution: "@storybook/addon-a11y@npm:8.6.15"
dependencies:
"@storybook/addon-highlight": "npm:8.6.2"
"@storybook/test": "npm:8.6.2"
"@storybook/addon-highlight": "npm:8.6.15"
"@storybook/global": "npm:^5.0.0"
"@storybook/test": "npm:8.6.15"
axe-core: "npm:^4.2.0"
peerDependencies:
storybook: ^8.6.2
checksum: 10/c7a161734c4d587bbc2b926fcf01103203b4f50112f5ea19bdbd4dad4c1c62bb358613e3c6e608335f064ef7b7627f5a1ffeb1f524ec008bb19e86e50b1dfb27
storybook: ^8.6.15
checksum: 10/558fcb105486112118bfc5f0068efcb5d4b66b508820f8e2a6d4041e7b06029b64d2d3a8b51a7a6049c836b900ead67c33fa9f89e0a1b550f0e5eedbab18e33c
languageName: node
linkType: hard
"@storybook/addon-actions@npm:8.6.2, @storybook/addon-actions@npm:^8.6.2":
version: 8.6.2
resolution: "@storybook/addon-actions@npm:8.6.2"
"@storybook/addon-actions@npm:8.6.15, @storybook/addon-actions@npm:^8.6.15":
version: 8.6.15
resolution: "@storybook/addon-actions@npm:8.6.15"
dependencies:
"@storybook/global": "npm:^5.0.0"
"@types/uuid": "npm:^9.0.1"
@@ -7984,139 +7985,139 @@ __metadata:
polished: "npm:^4.2.2"
uuid: "npm:^9.0.0"
peerDependencies:
storybook: ^8.6.2
checksum: 10/16127ee35f08fe98df98a688a8724c803274cbe1a81d3ae46727ad6b34bdae913dd905a3a124954805e4f2650e56e18f80de2f4cf7cb0fc0480c185cf342a8e5
storybook: ^8.6.15
checksum: 10/4d47e3ce9319d282e5abb44e7694748792c29f37c883afbcfc8939353be47bcc004ab41ce1f421c3d5bafbaa0366935f0b90272e5e6542a2229db55f63bef82c
languageName: node
linkType: hard
"@storybook/addon-backgrounds@npm:8.6.2":
version: 8.6.2
resolution: "@storybook/addon-backgrounds@npm:8.6.2"
"@storybook/addon-backgrounds@npm:8.6.15":
version: 8.6.15
resolution: "@storybook/addon-backgrounds@npm:8.6.15"
dependencies:
"@storybook/global": "npm:^5.0.0"
memoizerific: "npm:^1.11.3"
ts-dedent: "npm:^2.0.0"
peerDependencies:
storybook: ^8.6.2
checksum: 10/b303afb745fb34cb77565f595fdd5f1749927f3be6bc58702f3ed6b8931d89abc099b46940436a1f0509b4a6580f7c58100bf091ba6e5fe7f040704ac723118f
storybook: ^8.6.15
checksum: 10/c96107e892d39d5841e7f64f53a619527268636c61184770ca07684432fb3cee30c224a35b716ff30ba9bcdb4326649cd4e7873359d65a7fa8889b0fe7a15ad4
languageName: node
linkType: hard
"@storybook/addon-controls@npm:8.6.2":
version: 8.6.2
resolution: "@storybook/addon-controls@npm:8.6.2"
"@storybook/addon-controls@npm:8.6.15":
version: 8.6.15
resolution: "@storybook/addon-controls@npm:8.6.15"
dependencies:
"@storybook/global": "npm:^5.0.0"
dequal: "npm:^2.0.2"
ts-dedent: "npm:^2.0.0"
peerDependencies:
storybook: ^8.6.2
checksum: 10/4d54617c514b6e88d5708e2d3b067a01f3863e83dcf327bdec0b55df36bf8e6269ab1d9e5c5cb0edb27e9554c2d212ce087356b9d68779744854207febd26808
storybook: ^8.6.15
checksum: 10/ee7ea1e4d6cdb47c233ff3dd48196e649bea62bb88816261f65eb0ecd4f83e1593f09e657fc91ed8595b6279a04a1267c01d82020ef749d90c28d4f71b879224
languageName: node
linkType: hard
"@storybook/addon-docs@npm:8.6.2, @storybook/addon-docs@npm:^8.6.2":
version: 8.6.2
resolution: "@storybook/addon-docs@npm:8.6.2"
"@storybook/addon-docs@npm:8.6.15, @storybook/addon-docs@npm:^8.6.15":
version: 8.6.15
resolution: "@storybook/addon-docs@npm:8.6.15"
dependencies:
"@mdx-js/react": "npm:^3.0.0"
"@storybook/blocks": "npm:8.6.2"
"@storybook/csf-plugin": "npm:8.6.2"
"@storybook/react-dom-shim": "npm:8.6.2"
"@storybook/blocks": "npm:8.6.15"
"@storybook/csf-plugin": "npm:8.6.15"
"@storybook/react-dom-shim": "npm:8.6.15"
react: "npm:^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
react-dom: "npm:^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
ts-dedent: "npm:^2.0.0"
peerDependencies:
storybook: ^8.6.2
checksum: 10/d13752c4f31f01426724dbfdc70313948475dbbf6c43ed1cceca8d37d86424f79369c1b06c5e4daebe581e8702b02c9522ea77aff1864a7228662f4c8517fedf
storybook: ^8.6.15
checksum: 10/ec18d166ebab276258098ef86e9ad9bcaa163b357766bcef957a8f8c2260d4c128834497304cd0cde8fed1b0dfa02b9e79d82b88d61e21eab35437b2d17fa163
languageName: node
linkType: hard
"@storybook/addon-essentials@npm:^8.6.2":
version: 8.6.2
resolution: "@storybook/addon-essentials@npm:8.6.2"
"@storybook/addon-essentials@npm:^8.6.15":
version: 8.6.15
resolution: "@storybook/addon-essentials@npm:8.6.15"
dependencies:
"@storybook/addon-actions": "npm:8.6.2"
"@storybook/addon-backgrounds": "npm:8.6.2"
"@storybook/addon-controls": "npm:8.6.2"
"@storybook/addon-docs": "npm:8.6.2"
"@storybook/addon-highlight": "npm:8.6.2"
"@storybook/addon-measure": "npm:8.6.2"
"@storybook/addon-outline": "npm:8.6.2"
"@storybook/addon-toolbars": "npm:8.6.2"
"@storybook/addon-viewport": "npm:8.6.2"
"@storybook/addon-actions": "npm:8.6.15"
"@storybook/addon-backgrounds": "npm:8.6.15"
"@storybook/addon-controls": "npm:8.6.15"
"@storybook/addon-docs": "npm:8.6.15"
"@storybook/addon-highlight": "npm:8.6.15"
"@storybook/addon-measure": "npm:8.6.15"
"@storybook/addon-outline": "npm:8.6.15"
"@storybook/addon-toolbars": "npm:8.6.15"
"@storybook/addon-viewport": "npm:8.6.15"
ts-dedent: "npm:^2.0.0"
peerDependencies:
storybook: ^8.6.2
checksum: 10/2e8a9cb6fed038122230929f72dfc94116c7884d838f633b81f1b485bf169129cf8e91bc31cc9949cbfc39d31ada1d5ae4a8bc61c533b47c9b63ea7d5045c468
storybook: ^8.6.15
checksum: 10/0416693b5f0b7f727deaa2c575e1ad826b93852f41fbd0d905c7bad54c31cf312278575cacc28498db81b35b027373e98c5980b307876297e075e74f9267e7f8
languageName: node
linkType: hard
"@storybook/addon-highlight@npm:8.6.2":
version: 8.6.2
resolution: "@storybook/addon-highlight@npm:8.6.2"
"@storybook/addon-highlight@npm:8.6.15":
version: 8.6.15
resolution: "@storybook/addon-highlight@npm:8.6.15"
dependencies:
"@storybook/global": "npm:^5.0.0"
peerDependencies:
storybook: ^8.6.2
checksum: 10/0bd8298612390daa6d455876c3485e8e60751f51d6a2e67c79d2bb37b27a5059b1fab3ed4117371f6b375a6294a5e038abe6bd65db9259723578e4c55b0abcd2
storybook: ^8.6.15
checksum: 10/51f0a7fbf6c81e78e74f71943f8cf2b5f7e9ec08712e9f186381ecaa15f011c4c7df9d8d99e1e3b44e37bd646ff4d162cf91024d6b6a8b881fe988ccab47f786
languageName: node
linkType: hard
"@storybook/addon-measure@npm:8.6.2":
version: 8.6.2
resolution: "@storybook/addon-measure@npm:8.6.2"
"@storybook/addon-measure@npm:8.6.15":
version: 8.6.15
resolution: "@storybook/addon-measure@npm:8.6.15"
dependencies:
"@storybook/global": "npm:^5.0.0"
tiny-invariant: "npm:^1.3.1"
peerDependencies:
storybook: ^8.6.2
checksum: 10/c81946d8459aa953f633503872f30ae13336cfb9c7e539c49dccbeae6f33e57e774ff35a197d3aea46d28a33c0a45a497a14eacd403817fa87572d377d5ad4d9
storybook: ^8.6.15
checksum: 10/62b899f873e0024ed21e081759d731ff978fe917e8739dab0de3ebc857bb43641c9c9074f4a562864b353550fafd17950ab8dbd17b20a97371501ebe5f188a05
languageName: node
linkType: hard
"@storybook/addon-outline@npm:8.6.2":
version: 8.6.2
resolution: "@storybook/addon-outline@npm:8.6.2"
"@storybook/addon-outline@npm:8.6.15":
version: 8.6.15
resolution: "@storybook/addon-outline@npm:8.6.15"
dependencies:
"@storybook/global": "npm:^5.0.0"
ts-dedent: "npm:^2.0.0"
peerDependencies:
storybook: ^8.6.2
checksum: 10/357a72cb76cd8d1d2e7ff5ab1fc152f17b1f8c4ab087d8b94e1f157554a641aa47d96ede8087d5045eda8b99dfc2d94c29c2bdc1dce8f8d7d0c0a0b660aae966
storybook: ^8.6.15
checksum: 10/9439e6ab319475df7fed652ea4265916699df45d4ba4a04ec6b576a604f4d0917b2b5c8f1a48cabfd174f7baf3b943d3789ba80efb9e299ecefb388f8bf22f79
languageName: node
linkType: hard
"@storybook/addon-storysource@npm:^8.6.2":
version: 8.6.2
resolution: "@storybook/addon-storysource@npm:8.6.2"
"@storybook/addon-storysource@npm:^8.6.15":
version: 8.6.15
resolution: "@storybook/addon-storysource@npm:8.6.15"
dependencies:
"@storybook/source-loader": "npm:8.6.2"
"@storybook/source-loader": "npm:8.6.15"
estraverse: "npm:^5.2.0"
tiny-invariant: "npm:^1.3.1"
peerDependencies:
storybook: ^8.6.2
checksum: 10/4c06dab42fd2ff88df632960bfc70144e7ab423bec373be7f1b318519a383f33515a296440e77c085345e220af5de77dd9c500a49fcc66e23db9612164d94d9f
storybook: ^8.6.15
checksum: 10/499555d1178795c8c046a0269a980006e586d3445a734e65d9789ab84b2b62d4c4193b3d0e241bfc3954aec9977b9777b5ec357d22a6929c9325b3f4427d399c
languageName: node
linkType: hard
"@storybook/addon-toolbars@npm:8.6.2":
version: 8.6.2
resolution: "@storybook/addon-toolbars@npm:8.6.2"
"@storybook/addon-toolbars@npm:8.6.15":
version: 8.6.15
resolution: "@storybook/addon-toolbars@npm:8.6.15"
peerDependencies:
storybook: ^8.6.2
checksum: 10/7c7863a1e9698128557cf38bdce81aab127958c4892aeaa2f9035042d0fd36edcabb296575cf02a69d8f19abe4b1b114223a6312f25c84dff827314f004303ee
storybook: ^8.6.15
checksum: 10/27cfba470fd0f85d8bd236a7929b77cc50c89948926a4463f3473bcd04f823cebbf08f8e240a4055d77aac9636d32cfaaee3ca912ff32ee5806e3250df53dff7
languageName: node
linkType: hard
"@storybook/addon-viewport@npm:8.6.2":
version: 8.6.2
resolution: "@storybook/addon-viewport@npm:8.6.2"
"@storybook/addon-viewport@npm:8.6.15":
version: 8.6.15
resolution: "@storybook/addon-viewport@npm:8.6.15"
dependencies:
memoizerific: "npm:^1.11.3"
peerDependencies:
storybook: ^8.6.2
checksum: 10/60e67fb0b2f21c889f416bbb7d1729bf3310f56efa23d016d9984f6a9fce39cccccc02422b6ece736a558fef0053c5ac39d46df1735aa2e560e580a3fae3337f
storybook: ^8.6.15
checksum: 10/2b3359ac92e9a131c7dc7c4b6bc9d131786c86bead9490d464e4b275278e309a579cd67756d4d9ea5764aae502e3e65104e98486933f7f57b583ab8a3757db8b
languageName: node
linkType: hard
@@ -8142,22 +8143,22 @@ __metadata:
languageName: node
linkType: hard
"@storybook/blocks@npm:8.6.2, @storybook/blocks@npm:^8.6.2":
version: 8.6.2
resolution: "@storybook/blocks@npm:8.6.2"
"@storybook/blocks@npm:8.6.15, @storybook/blocks@npm:^8.6.15":
version: 8.6.15
resolution: "@storybook/blocks@npm:8.6.15"
dependencies:
"@storybook/icons": "npm:^1.2.12"
ts-dedent: "npm:^2.0.0"
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
storybook: ^8.6.2
storybook: ^8.6.15
peerDependenciesMeta:
react:
optional: true
react-dom:
optional: true
checksum: 10/8137b042e99572b7bdd6df3484c75d3b1cf78b15bb7d3a7ad09738e94ec21481d295acfe2b59fa547be9ee5a0b075bb485f88a1972948e4703aef6b174b60ead
checksum: 10/7598b9fe3c5dcabc02b22eee3780055ae7ca259bc51520c5bf12c34395d11dbac4b0b3f817c46492b407a16f67ff5ba36879fd30c91299ddb053e9f274664e21
languageName: node
linkType: hard
@@ -8189,11 +8190,11 @@ __metadata:
languageName: node
linkType: hard
"@storybook/builder-webpack5@npm:8.6.2":
version: 8.6.2
resolution: "@storybook/builder-webpack5@npm:8.6.2"
"@storybook/builder-webpack5@npm:8.6.15":
version: 8.6.15
resolution: "@storybook/builder-webpack5@npm:8.6.15"
dependencies:
"@storybook/core-webpack": "npm:8.6.2"
"@storybook/core-webpack": "npm:8.6.15"
"@types/semver": "npm:^7.3.4"
browser-assert: "npm:^1.2.1"
case-sensitive-paths-webpack-plugin: "npm:^2.4.0"
@@ -8218,29 +8219,29 @@ __metadata:
webpack-hot-middleware: "npm:^2.25.1"
webpack-virtual-modules: "npm:^0.6.0"
peerDependencies:
storybook: ^8.6.2
storybook: ^8.6.15
peerDependenciesMeta:
typescript:
optional: true
checksum: 10/909d74c281a41a43d17ff9f313231283ac21c9c97ef47d8f8f96753f0b639dbdaeb2d12d46a7cd4fd8001bffcc2d707e83d47ab62fe3757ab30093159a1eded1
checksum: 10/8e8e816dbfa83e2fd56401a65a1551ecbaa658db54b2270e90560710c024878a09df48e1a4e114eacc75ff5c924cb7a277f4e3501ae4744c6a2742ab55135afe
languageName: node
linkType: hard
"@storybook/components@npm:8.6.2, @storybook/components@npm:^8.6.2":
version: 8.6.2
resolution: "@storybook/components@npm:8.6.2"
"@storybook/components@npm:8.6.15, @storybook/components@npm:^8.6.15":
version: 8.6.15
resolution: "@storybook/components@npm:8.6.15"
peerDependencies:
storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0
checksum: 10/d85bb39aedd03a05043194debf3d35965dbe84d386029f63f0aa20ac943f4cc635558f6b08cc8ec013a2cc7f2407ef3c8735a76d4805e41569aaabe8efe59fd4
checksum: 10/350075ffe67cfc307c0f8f9b6568c5f25e97a2ffb55d723b88c16a3f3ad6d687c312580c4b4d3cbf8ef245a30723797d89be20a44f6c500f79e7b173b0cb79d0
languageName: node
linkType: hard
"@storybook/core-events@npm:^8.6.2":
version: 8.6.2
resolution: "@storybook/core-events@npm:8.6.2"
"@storybook/core-events@npm:^8.6.15":
version: 8.6.15
resolution: "@storybook/core-events@npm:8.6.15"
peerDependencies:
storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0
checksum: 10/d2f574be4bc4fd5be82be376954b795dca291dd834b62ef4545eb912bf0c879bd6b4e544613cc399bbe5161c05788d32ad9119811d05921f690417fd3f1448f4
checksum: 10/95dd9f683d502c8b83600d455c960afe9b18278177324bfbb3904a9ef7a6022939017924659088118bb7b272b6ad7e4912b6e6a222e3274a480ab61183b89616
languageName: node
linkType: hard
@@ -8255,22 +8256,22 @@ __metadata:
languageName: node
linkType: hard
"@storybook/core-webpack@npm:8.6.2":
version: 8.6.2
resolution: "@storybook/core-webpack@npm:8.6.2"
"@storybook/core-webpack@npm:8.6.15":
version: 8.6.15
resolution: "@storybook/core-webpack@npm:8.6.15"
dependencies:
ts-dedent: "npm:^2.0.0"
peerDependencies:
storybook: ^8.6.2
checksum: 10/666edcb895b034b74fa86bb6d6dc16d26078ffc3524d20917ca27c40b4f722706622f83c4f97a323f8fc441d655a5ab124c9fbea12a2fa2e25000cf1231a5e78
storybook: ^8.6.15
checksum: 10/494110266d622be00a61cd160a4e799ed4c4c447ab04699d497de7c3d95ee800f212680c31722b8938819577853e095af545f7853e7ce4cf751d2a9e9b4c7f12
languageName: node
linkType: hard
"@storybook/core@npm:8.6.2":
version: 8.6.2
resolution: "@storybook/core@npm:8.6.2"
"@storybook/core@npm:8.6.15":
version: 8.6.15
resolution: "@storybook/core@npm:8.6.15"
dependencies:
"@storybook/theming": "npm:8.6.2"
"@storybook/theming": "npm:8.6.15"
better-opn: "npm:^3.0.2"
browser-assert: "npm:^1.2.1"
esbuild: "npm:^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0"
@@ -8286,15 +8287,15 @@ __metadata:
peerDependenciesMeta:
prettier:
optional: true
checksum: 10/57d8af6d822c4cbeab201aec813830d632165c2411bf8708481ad0daa2fa942ce6a912e0ef764e6acb9bd2053baf0614b7726e5827b071f3f845d459ac1f3d3a
checksum: 10/d268d6fa00c38b35e5c363ee33779c2e087ab8e4681e0e205baa2fdb2780ea9feda3c9f6db35d60092778878d2782b2093c744bdf1af173c5688c3e1e0e960ac
languageName: node
linkType: hard
"@storybook/core@patch:@storybook/core@npm%3A8.6.2#~/.yarn/patches/@storybook-core-npm-8.6.2-8c752112c0.patch":
version: 8.6.2
resolution: "@storybook/core@patch:@storybook/core@npm%3A8.6.2#~/.yarn/patches/@storybook-core-npm-8.6.2-8c752112c0.patch::version=8.6.2&hash=f4cc1f"
"@storybook/core@patch:@storybook/core@npm%3A8.6.15#~/.yarn/patches/@storybook-core-npm-8.6.15-a468a35170.patch":
version: 8.6.15
resolution: "@storybook/core@patch:@storybook/core@npm%3A8.6.15#~/.yarn/patches/@storybook-core-npm-8.6.15-a468a35170.patch::version=8.6.15&hash=c479fb"
dependencies:
"@storybook/theming": "npm:8.6.2"
"@storybook/theming": "npm:8.6.15"
better-opn: "npm:^3.0.2"
browser-assert: "npm:^1.2.1"
esbuild: "npm:^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0"
@@ -8310,18 +8311,18 @@ __metadata:
peerDependenciesMeta:
prettier:
optional: true
checksum: 10/cd95a51437135dd3c4333b14acefd528d8064b2cea7789f859ba80783c115c92ed4be51d4a7bd6236888fdd5f46f488a379e0c71bc1a712ffe6dc1353fb4e648
checksum: 10/fd635098effe4ae87122ac706394dc89a26b3cae74d7d126897852a9da1960d617c76dc1be6d6d8a4c2d02c19e694c7222a3cfe7a6beb7daa605b30ffbf41644
languageName: node
linkType: hard
"@storybook/csf-plugin@npm:8.6.2":
version: 8.6.2
resolution: "@storybook/csf-plugin@npm:8.6.2"
"@storybook/csf-plugin@npm:8.6.15":
version: 8.6.15
resolution: "@storybook/csf-plugin@npm:8.6.15"
dependencies:
unplugin: "npm:^1.3.1"
peerDependencies:
storybook: ^8.6.2
checksum: 10/6d71101640975cbe08d5dc9bae30938337b0e999f5724c4802713cf3d8c34671b646158ffde659e9cbcd005153844223cd4f9a4af2aa0d6f5a15b3db8d31f85d
storybook: ^8.6.15
checksum: 10/c544089d7a675d19e226e331a791db6c2e2cede893a2955578959086e7c35e846319a9080d91968ec81a2115e2c396fe3d76d2f50d74baca098dd5b03ad939b0
languageName: node
linkType: hard
@@ -8342,24 +8343,24 @@ __metadata:
languageName: node
linkType: hard
"@storybook/instrumenter@npm:8.6.2":
version: 8.6.2
resolution: "@storybook/instrumenter@npm:8.6.2"
"@storybook/instrumenter@npm:8.6.15":
version: 8.6.15
resolution: "@storybook/instrumenter@npm:8.6.15"
dependencies:
"@storybook/global": "npm:^5.0.0"
"@vitest/utils": "npm:^2.1.1"
peerDependencies:
storybook: ^8.6.2
checksum: 10/40d028d6f8b5ab51eb112bb5b903f64438eaf818c501a78ef77a5e946676e13f15ad8f5188a85e40bf91f987c6ab15c6a33b4e8bef8b8f8e05011013dcda092a
storybook: ^8.6.15
checksum: 10/5f56da838ccd47b9a262e5aa54a5574985295994fb1b6fb165d4c3a66d96367588fa93c60fe55892ffa450ea3e41e0abcc313a12a873d5b4be7625a1c3b0d883
languageName: node
linkType: hard
"@storybook/manager-api@npm:8.6.2, @storybook/manager-api@npm:^8.6.2":
version: 8.6.2
resolution: "@storybook/manager-api@npm:8.6.2"
"@storybook/manager-api@npm:8.6.15, @storybook/manager-api@npm:^8.6.15":
version: 8.6.15
resolution: "@storybook/manager-api@npm:8.6.15"
peerDependencies:
storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0
checksum: 10/d344c88c6cad0bcc54767a8ef1d269a9df868c76ae22927c31936e46aaeb47075783d84f8815b4d0c7812ed50b2d4616417429479e3a9ea36043c557eb141590
checksum: 10/0b378fc657830c48b7c304ea915883161e8271d76b8f90b90e86e4260f6e513ae9f0f87eee6cad057a6c38e8a9faf7bac0ba169e586aabce2b4e48d36a0d27c3
languageName: node
linkType: hard
@@ -8394,12 +8395,12 @@ __metadata:
languageName: node
linkType: hard
"@storybook/preset-react-webpack@npm:8.6.2":
version: 8.6.2
resolution: "@storybook/preset-react-webpack@npm:8.6.2"
"@storybook/preset-react-webpack@npm:8.6.15":
version: 8.6.15
resolution: "@storybook/preset-react-webpack@npm:8.6.15"
dependencies:
"@storybook/core-webpack": "npm:8.6.2"
"@storybook/react": "npm:8.6.2"
"@storybook/core-webpack": "npm:8.6.15"
"@storybook/react": "npm:8.6.15"
"@storybook/react-docgen-typescript-plugin": "npm:1.0.6--canary.9.0c3f3b7.0"
"@types/semver": "npm:^7.3.4"
find-up: "npm:^5.0.0"
@@ -8412,11 +8413,11 @@ __metadata:
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta
storybook: ^8.6.2
storybook: ^8.6.15
peerDependenciesMeta:
typescript:
optional: true
checksum: 10/d903a14e6e65bdfb56568962f456a103ec3f64d339d4ed3d091befe66dddcff2e31d7a2acee1bbf52cf9a33d09564af2dc36483961f73c812eacb34d71f7a951
checksum: 10/e3c2bf792a3dc051f27f8723b1b2c9671ecda57f6f20950fe30d65c17ff3751c382ffcf606a2efd85d6b5266356e0b62b57e23560b44cc497d78335dd5ae6462
languageName: node
linkType: hard
@@ -8431,12 +8432,12 @@ __metadata:
languageName: node
linkType: hard
"@storybook/preview-api@npm:8.6.2, @storybook/preview-api@npm:^8.6.2":
version: 8.6.2
resolution: "@storybook/preview-api@npm:8.6.2"
"@storybook/preview-api@npm:8.6.15, @storybook/preview-api@npm:^8.6.15":
version: 8.6.15
resolution: "@storybook/preview-api@npm:8.6.15"
peerDependencies:
storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0
checksum: 10/5d286ed8c266a8aa63361bbb0245163e6fe3fd85a5e60555a79a579535faf36c30a9ad07bff30b0d9d536d35ec8f5ee78484629b174ecc965cbe0726e749d6de
checksum: 10/70df6006ce7340371e207f7f077d52c8684743a64f172d54f3e99fb6d2e190f46a4321c40be7ea813b9bd407530f971372156b3dd6ba1f61681f7b3acc498010
languageName: node
linkType: hard
@@ -8469,14 +8470,14 @@ __metadata:
languageName: node
linkType: hard
"@storybook/react-dom-shim@npm:8.6.2":
version: 8.6.2
resolution: "@storybook/react-dom-shim@npm:8.6.2"
"@storybook/react-dom-shim@npm:8.6.15":
version: 8.6.15
resolution: "@storybook/react-dom-shim@npm:8.6.15"
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta
storybook: ^8.6.2
checksum: 10/f32718a49ccbd7c01233c83d738479eb60c41a3f8855066b85593fb7a07129b1b18a0c93fe64b01dce9c9105293b40c1e131410169d5aaaa2f8b7a7c00f836ee
storybook: ^8.6.15
checksum: 10/7625cfa2a385315851cd0aeb36f517e75679262c2942a5a53dd2909483b62602b9e5157ba51a8e464eb70112a4916278fba63b05178beef3d2ff493a464a2c64
languageName: node
linkType: hard
@@ -8499,22 +8500,22 @@ __metadata:
languageName: node
linkType: hard
"@storybook/react-webpack5@npm:^8.6.2":
version: 8.6.2
resolution: "@storybook/react-webpack5@npm:8.6.2"
"@storybook/react-webpack5@npm:^8.6.15":
version: 8.6.15
resolution: "@storybook/react-webpack5@npm:8.6.15"
dependencies:
"@storybook/builder-webpack5": "npm:8.6.2"
"@storybook/preset-react-webpack": "npm:8.6.2"
"@storybook/react": "npm:8.6.2"
"@storybook/builder-webpack5": "npm:8.6.15"
"@storybook/preset-react-webpack": "npm:8.6.15"
"@storybook/react": "npm:8.6.15"
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta
storybook: ^8.6.2
storybook: ^8.6.15
typescript: ">= 4.2.x"
peerDependenciesMeta:
typescript:
optional: true
checksum: 10/1d8c745d21da7853328a1870797808982864be68783c2a56248ab642a81eaad667f5522cc9f0eba74a9b96658d6fcd9b6a5b9296e235ff8e1566edbc48e5d146
checksum: 10/0468aa5ca0ed76170cbdef6b2a211625e64b74a8012aabeca03d38c8fa8f34013b2068f68dd173bb5db32aa9ccaa87c923344a506c561ad98d0afb0f88856b79
languageName: node
linkType: hard
@@ -8536,41 +8537,41 @@ __metadata:
languageName: node
linkType: hard
"@storybook/react@npm:8.6.2, @storybook/react@npm:^8.6.2":
version: 8.6.2
resolution: "@storybook/react@npm:8.6.2"
"@storybook/react@npm:8.6.15, @storybook/react@npm:^8.6.15":
version: 8.6.15
resolution: "@storybook/react@npm:8.6.15"
dependencies:
"@storybook/components": "npm:8.6.2"
"@storybook/components": "npm:8.6.15"
"@storybook/global": "npm:^5.0.0"
"@storybook/manager-api": "npm:8.6.2"
"@storybook/preview-api": "npm:8.6.2"
"@storybook/react-dom-shim": "npm:8.6.2"
"@storybook/theming": "npm:8.6.2"
"@storybook/manager-api": "npm:8.6.15"
"@storybook/preview-api": "npm:8.6.15"
"@storybook/react-dom-shim": "npm:8.6.15"
"@storybook/theming": "npm:8.6.15"
peerDependencies:
"@storybook/test": 8.6.2
"@storybook/test": 8.6.15
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta
storybook: ^8.6.2
storybook: ^8.6.15
typescript: ">= 4.2.x"
peerDependenciesMeta:
"@storybook/test":
optional: true
typescript:
optional: true
checksum: 10/b8a91e6a8aeb9e32e05e12db4df1dafd59b54d82954e481ea9224316bd1e432093259b474f773911e90aeebe441cbb17e8dc0a6940b79b5899eff1f2b4aae334
checksum: 10/a7e33bd68e25bf04fac77c9573c51d76d556c51a4329e84dae2a4e2afd39950e4d3ab9a49787ac465e9e81522b40ea12dc86cd0b60a2aab1ee2f88a753bc9b43
languageName: node
linkType: hard
"@storybook/source-loader@npm:8.6.2":
version: 8.6.2
resolution: "@storybook/source-loader@npm:8.6.2"
"@storybook/source-loader@npm:8.6.15":
version: 8.6.15
resolution: "@storybook/source-loader@npm:8.6.15"
dependencies:
es-toolkit: "npm:^1.22.0"
estraverse: "npm:^5.2.0"
prettier: "npm:^3.1.1"
peerDependencies:
storybook: ^8.6.2
checksum: 10/8cf43eb6ce2df997272c71f3d9f8e85f3fecb6ca1d176c87fd349ea17e1d821dadb43496af5d10a2c6163fd6d920940a253a075a7774fc9bb5f724c132cfcaf0
storybook: ^8.6.15
checksum: 10/37e6749a8bd633aa7b84546eb8f3bf4f259614846589fe5cfe9b461926b57bbda013948b95ccb12108324f1580d7330fcab7828ab90936b0567dba27e0a626a1
languageName: node
linkType: hard
@@ -8604,29 +8605,29 @@ __metadata:
languageName: node
linkType: hard
"@storybook/test@npm:8.6.2":
version: 8.6.2
resolution: "@storybook/test@npm:8.6.2"
"@storybook/test@npm:8.6.15":
version: 8.6.15
resolution: "@storybook/test@npm:8.6.15"
dependencies:
"@storybook/global": "npm:^5.0.0"
"@storybook/instrumenter": "npm:8.6.2"
"@storybook/instrumenter": "npm:8.6.15"
"@testing-library/dom": "npm:10.4.0"
"@testing-library/jest-dom": "npm:6.5.0"
"@testing-library/user-event": "npm:14.5.2"
"@vitest/expect": "npm:2.0.5"
"@vitest/spy": "npm:2.0.5"
peerDependencies:
storybook: ^8.6.2
checksum: 10/4cb89e254143374716fcd72c3a9a4c603a9f664162c5755ead7257ce130bc33d3c48c9cbaa35274023f4b961d66c4e84733e35ca663019d770fcfa6ef5b21b84
storybook: ^8.6.15
checksum: 10/5f54b9ef1910011813059708f6d2c32f4db5f4e719de9de2a7f2280efe7535c45c36668cf3e346b4788f4bbe6b71a3ab169ccdc17f0d8e634443d92849b46bf1
languageName: node
linkType: hard
"@storybook/theming@npm:8.6.2, @storybook/theming@npm:^8.6.2":
version: 8.6.2
resolution: "@storybook/theming@npm:8.6.2"
"@storybook/theming@npm:8.6.15, @storybook/theming@npm:^8.6.15":
version: 8.6.15
resolution: "@storybook/theming@npm:8.6.15"
peerDependencies:
storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0
checksum: 10/81ff1f740edaa000d6abaab5a47b038b46cfc54ddad308335b8d26d7a6f1ee100f617f52d51cd6596f424a534dbda0d9801e3928b4b6f758d9a3e8da6f9d40f5
checksum: 10/f02760831a13d7af9dbfeb6feea949f4c13c897861cbc75253a6776d133891567889c8d99c7e91a99124d0772c3bde1f978984c83b19117d1d7c908ed7eb8409
languageName: node
linkType: hard
@@ -31431,11 +31432,11 @@ __metadata:
languageName: node
linkType: hard
"storybook@npm:^8.6.2":
version: 8.6.2
resolution: "storybook@npm:8.6.2"
"storybook@npm:^8.6.15":
version: 8.6.15
resolution: "storybook@npm:8.6.15"
dependencies:
"@storybook/core": "npm:8.6.2"
"@storybook/core": "npm:8.6.15"
peerDependencies:
prettier: ^2 || ^3
peerDependenciesMeta:
@@ -31445,7 +31446,7 @@ __metadata:
getstorybook: ./bin/index.cjs
sb: ./bin/index.cjs
storybook: ./bin/index.cjs
checksum: 10/81884ce80d36bfe3170c46ce08168f24a2a27b2698a9ec66ebb1a9727502ab9db3e6e221121a10606ef06db466966b47a12b48b99e519755c508bc183a2de815
checksum: 10/15762c79ec8444a46bc14cddfadbdd54dfd379828acd38555887a246c01e7c9ebb61e4eafafe04efb3ddf6278fb47035216e7f7d9f94fc205da148870173abdf
languageName: node
linkType: hard