Dashboards: Add Dashboard Schema validation (2) (#103844)

* Activate schema validation and align underlying systems

* update to save as v0 if not the right schema version

* Resolve merge conflicts

* Move RequireApiErrorStatus to tests package

* Add mutation tests

* Fix lint

* Only do min version check if dashboard is v1

* Fix lint and disable provisioning test

* Revert provisioning changes

* Revert more tests and add schema test

* Reran gen

* SQL Dashboard save

* Adjust APIVERSION

* Fixed mutation test

* Add logging on downgrade

---------

Co-authored-by: Marco de Abreu <18629099+marcoabreu@users.noreply.github.com>
Co-authored-by: Stephanie Hingtgen <stephanie.hingtgen@grafana.com>
pull/103949/head
Marco de Abreu 8 months ago committed by GitHub
parent 07a225649d
commit c47ab101d1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 23
      apps/dashboard/kinds/v2alpha1/dashboard_spec.cue
  2. 33
      apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec.cue
  3. 8
      apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go
  4. 3
      apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go
  5. 1
      devenv/dev-dashboards/all-panels.json
  6. 2
      devenv/dev-dashboards/panel-text/text-options.json
  7. 6
      packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts
  8. 20
      pkg/apimachinery/utils/errors.go
  9. 14
      pkg/registry/apis/dashboard/legacy/sql_dashboards.go
  10. 137
      pkg/registry/apis/dashboard/legacy/sql_dashboards_test.go
  11. 2
      pkg/registry/apis/dashboard/legacy/storage_test.go
  12. 10
      pkg/registry/apis/dashboard/mutate.go
  13. 102
      pkg/registry/apis/dashboard/mutation_test.go
  14. 11
      pkg/registry/apis/dashboard/schema_validation.go
  15. 10
      pkg/registry/apis/provisioning/resources/dualwriter.go
  16. 7
      pkg/registry/apis/provisioning/resources/fileformat.go
  17. 101
      pkg/tests/apis/dashboard/integration/api_validation_test.go
  18. 20
      pkg/tests/apis/helper.go
  19. 1
      pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2alpha1.json
  20. 84
      pkg/tests/apis/provisioning/provisioning_test.go
  21. 29
      pkg/tests/apis/provisioning/testdata/invalid-dashboard-schema.json

@ -1,8 +1,7 @@
package v2alpha1 package v2alpha1
DashboardSpec: { DashboardSpec: {
// Title of dashboard. annotations: [...AnnotationQueryKind] | *[]
annotations: [...AnnotationQueryKind]
// Configuration of dashboard cursor sync behavior. // Configuration of dashboard cursor sync behavior.
// "Off" for no shared crosshair or tooltip (default). // "Off" for no shared crosshair or tooltip (default).
@ -16,12 +15,12 @@ DashboardSpec: {
// Whether a dashboard is editable or not. // Whether a dashboard is editable or not.
editable?: bool | *true editable?: bool | *true
elements: [ElementReference.name]: Element elements: [ElementReference.name]: Element | *{}
layout: GridLayoutKind | RowsLayoutKind | AutoGridLayoutKind | TabsLayoutKind layout: GridLayoutKind | RowsLayoutKind | AutoGridLayoutKind | TabsLayoutKind
// Links with references to other dashboards or external websites. // Links with references to other dashboards or external websites.
links: [...DashboardLink] links: [...DashboardLink] | *[]
// When set to true, the dashboard will redraw panels at an interval matching the pixel width. // When set to true, the dashboard will redraw panels at an interval matching the pixel width.
// This will keep data "moving left" regardless of the query refresh rate. This setting helps // This will keep data "moving left" regardless of the query refresh rate. This setting helps
@ -29,14 +28,14 @@ DashboardSpec: {
liveNow?: bool liveNow?: bool
// When set to true, the dashboard will load all panels in the dashboard when it's loaded. // When set to true, the dashboard will load all panels in the dashboard when it's loaded.
preload: bool preload: bool | *false
// Plugins only. The version of the dashboard installed together with the plugin. // Plugins only. The version of the dashboard installed together with the plugin.
// This is used to determine if the dashboard should be updated when the plugin is updated. // This is used to determine if the dashboard should be updated when the plugin is updated.
revision?: uint16 revision?: uint16
// Tags associated with dashboard. // Tags associated with dashboard.
tags: [...string] tags: [...string] | *[]
timeSettings: TimeSettingsSpec timeSettings: TimeSettingsSpec
@ -44,7 +43,7 @@ DashboardSpec: {
title: string title: string
// Configured template variables. // Configured template variables.
variables: [...VariableKind] variables: [...VariableKind] | *[]
} }
// Supported dashboard elements // Supported dashboard elements
@ -85,7 +84,7 @@ AnnotationPanelFilter: {
// "Off" for no shared crosshair or tooltip (default). // "Off" for no shared crosshair or tooltip (default).
// "Crosshair" for shared crosshair. // "Crosshair" for shared crosshair.
// "Tooltip" for shared crosshair AND shared tooltip. // "Tooltip" for shared crosshair AND shared tooltip.
DashboardCursorSync: "Off" | "Crosshair" | "Tooltip" DashboardCursorSync: "Crosshair" | "Tooltip" | *"Off"
// Links with references to other dashboards or external resources // Links with references to other dashboards or external resources
DashboardLink: { DashboardLink: {
@ -101,7 +100,7 @@ DashboardLink: {
// Link URL. Only required/valid if the type is link // Link URL. Only required/valid if the type is link
url?: string url?: string
// List of tags to limit the linked dashboards. If empty, all dashboards will be displayed. Only valid if the type is dashboards // List of tags to limit the linked dashboards. If empty, all dashboards will be displayed. Only valid if the type is dashboards
tags: [...string] tags: [...string] | *[]
// If true, all dashboards links will be displayed in a dropdown. If false, all dashboards links will be displayed side by side. Only valid if the type is dashboards // If true, all dashboards links will be displayed in a dropdown. If false, all dashboards links will be displayed side by side. Only valid if the type is dashboards
asDropdown: bool | *false asDropdown: bool | *false
// If true, the link will be opened in a new tab // If true, the link will be opened in a new tab
@ -466,17 +465,17 @@ TimeSettingsSpec: {
// Accepted values are relative time strings like "now-6h" or absolute time strings like "2020-07-10T08:00:00.000Z". // Accepted values are relative time strings like "now-6h" or absolute time strings like "2020-07-10T08:00:00.000Z".
to: string | *"now" to: string | *"now"
// Refresh rate of dashboard. Represented via interval string, e.g. "5s", "1m", "1h", "1d". // Refresh rate of dashboard. Represented via interval string, e.g. "5s", "1m", "1h", "1d".
autoRefresh: string // v1: refresh autoRefresh: string | *"" // v1: refresh
// Interval options available in the refresh picker dropdown. // Interval options available in the refresh picker dropdown.
autoRefreshIntervals: [...string] | *["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] // v1: timepicker.refresh_intervals autoRefreshIntervals: [...string] | *["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] // v1: timepicker.refresh_intervals
// Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard. // Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard.
quickRanges?: [...TimeRangeOption] // v1: timepicker.quick_ranges , not exposed in the UI quickRanges?: [...TimeRangeOption] // v1: timepicker.quick_ranges , not exposed in the UI
// Whether timepicker is visible or not. // Whether timepicker is visible or not.
hideTimepicker: bool // v1: timepicker.hidden hideTimepicker: bool | *false // v1: timepicker.hidden
// Day when the week starts. Expressed by the name of the day in lowercase, e.g. "monday". // Day when the week starts. Expressed by the name of the day in lowercase, e.g. "monday".
weekStart?: "saturday" | "monday" | "sunday" weekStart?: "saturday" | "monday" | "sunday"
// The month that the fiscal year starts on. 0 = January, 11 = December // The month that the fiscal year starts on. 0 = January, 11 = December
fiscalYearStartMonth: int fiscalYearStartMonth: int | *0
// Override the now time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values. // Override the now time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values.
nowDelay?: string // v1: timepicker.nowDelay nowDelay?: string // v1: timepicker.nowDelay
} }

@ -5,8 +5,7 @@
package v2alpha1 package v2alpha1
DashboardSpec: { DashboardSpec: {
// Title of dashboard. annotations: [...AnnotationQueryKind] | *[]
annotations: [...AnnotationQueryKind]
// Configuration of dashboard cursor sync behavior. // Configuration of dashboard cursor sync behavior.
// "Off" for no shared crosshair or tooltip (default). // "Off" for no shared crosshair or tooltip (default).
@ -20,12 +19,12 @@ DashboardSpec: {
// Whether a dashboard is editable or not. // Whether a dashboard is editable or not.
editable?: bool | *true editable?: bool | *true
elements: [ElementReference.name]: Element elements: [ElementReference.name]: Element | *{}
layout: GridLayoutKind | RowsLayoutKind | AutoGridLayoutKind | TabsLayoutKind layout: GridLayoutKind | RowsLayoutKind | AutoGridLayoutKind | TabsLayoutKind
// Links with references to other dashboards or external websites. // Links with references to other dashboards or external websites.
links: [...DashboardLink] links: [...DashboardLink] | *[]
// When set to true, the dashboard will redraw panels at an interval matching the pixel width. // When set to true, the dashboard will redraw panels at an interval matching the pixel width.
// This will keep data "moving left" regardless of the query refresh rate. This setting helps // This will keep data "moving left" regardless of the query refresh rate. This setting helps
@ -33,14 +32,14 @@ DashboardSpec: {
liveNow?: bool liveNow?: bool
// When set to true, the dashboard will load all panels in the dashboard when it's loaded. // When set to true, the dashboard will load all panels in the dashboard when it's loaded.
preload: bool preload: bool | *false
// Plugins only. The version of the dashboard installed together with the plugin. // Plugins only. The version of the dashboard installed together with the plugin.
// This is used to determine if the dashboard should be updated when the plugin is updated. // This is used to determine if the dashboard should be updated when the plugin is updated.
revision?: uint16 revision?: uint16
// Tags associated with dashboard. // Tags associated with dashboard.
tags: [...string] tags: [...string] | *[]
timeSettings: TimeSettingsSpec timeSettings: TimeSettingsSpec
@ -48,7 +47,7 @@ DashboardSpec: {
title: string title: string
// Configured template variables. // Configured template variables.
variables: [...VariableKind] variables: [...VariableKind] | *[]
} }
// Supported dashboard elements // Supported dashboard elements
@ -89,7 +88,7 @@ AnnotationPanelFilter: {
// "Off" for no shared crosshair or tooltip (default). // "Off" for no shared crosshair or tooltip (default).
// "Crosshair" for shared crosshair. // "Crosshair" for shared crosshair.
// "Tooltip" for shared crosshair AND shared tooltip. // "Tooltip" for shared crosshair AND shared tooltip.
DashboardCursorSync: "Off" | "Crosshair" | "Tooltip" DashboardCursorSync: "Crosshair" | "Tooltip" | *"Off"
// Links with references to other dashboards or external resources // Links with references to other dashboards or external resources
DashboardLink: { DashboardLink: {
@ -105,7 +104,7 @@ DashboardLink: {
// Link URL. Only required/valid if the type is link // Link URL. Only required/valid if the type is link
url?: string url?: string
// List of tags to limit the linked dashboards. If empty, all dashboards will be displayed. Only valid if the type is dashboards // List of tags to limit the linked dashboards. If empty, all dashboards will be displayed. Only valid if the type is dashboards
tags: [...string] tags: [...string] | *[]
// If true, all dashboards links will be displayed in a dropdown. If false, all dashboards links will be displayed side by side. Only valid if the type is dashboards // If true, all dashboards links will be displayed in a dropdown. If false, all dashboards links will be displayed side by side. Only valid if the type is dashboards
asDropdown: bool | *false asDropdown: bool | *false
// If true, the link will be opened in a new tab // If true, the link will be opened in a new tab
@ -470,17 +469,17 @@ TimeSettingsSpec: {
// Accepted values are relative time strings like "now-6h" or absolute time strings like "2020-07-10T08:00:00.000Z". // Accepted values are relative time strings like "now-6h" or absolute time strings like "2020-07-10T08:00:00.000Z".
to: string | *"now" to: string | *"now"
// Refresh rate of dashboard. Represented via interval string, e.g. "5s", "1m", "1h", "1d". // Refresh rate of dashboard. Represented via interval string, e.g. "5s", "1m", "1h", "1d".
autoRefresh: string // v1: refresh autoRefresh: string | *"" // v1: refresh
// Interval options available in the refresh picker dropdown. // Interval options available in the refresh picker dropdown.
autoRefreshIntervals: [...string] | *["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] // v1: timepicker.refresh_intervals autoRefreshIntervals: [...string] | *["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] // v1: timepicker.refresh_intervals
// Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard. // Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard.
quickRanges?: [...TimeRangeOption] // v1: timepicker.quick_ranges , not exposed in the UI quickRanges?: [...TimeRangeOption] // v1: timepicker.quick_ranges , not exposed in the UI
// Whether timepicker is visible or not. // Whether timepicker is visible or not.
hideTimepicker: bool // v1: timepicker.hidden hideTimepicker: bool | *false // v1: timepicker.hidden
// Day when the week starts. Expressed by the name of the day in lowercase, e.g. "monday". // Day when the week starts. Expressed by the name of the day in lowercase, e.g. "monday".
weekStart?: "saturday" | "monday" | "sunday" weekStart?: "saturday" | "monday" | "sunday"
// The month that the fiscal year starts on. 0 = January, 11 = December // The month that the fiscal year starts on. 0 = January, 11 = December
fiscalYearStartMonth: int fiscalYearStartMonth: int | *0
// Override the now time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values. // Override the now time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values.
nowDelay?: string // v1: timepicker.nowDelay nowDelay?: string // v1: timepicker.nowDelay
} }
@ -499,6 +498,11 @@ RowRepeatOptions: {
value: string value: string
} }
TabRepeatOptions: {
mode: RepeatMode
value: string
}
AutoGridRepeatOptions: { AutoGridRepeatOptions: {
mode: RepeatMode mode: RepeatMode
value: string value: string
@ -527,8 +531,8 @@ GridLayoutRowSpec: {
y: int y: int
collapsed: bool collapsed: bool
title: string title: string
elements: [...GridLayoutItemKind] // Grid items in the row will have their Y value be relative to the rows Y value. This means a panel positioned at Y: 0 in a row with Y: 10 will be positioned at Y: 11 (row header has a heigh of 1) in the dashboard. elements: [...GridLayoutItemKind] // Grid items in the row will have their Y value be relative to the rows Y value. This means a panel positioned at Y: 0 in a row with Y: 10 will be positioned at Y: 11 (row header has a heigh of 1) in the dashboard.
repeat?: RowRepeatOptions repeat?: RowRepeatOptions
} }
GridLayoutSpec: { GridLayoutSpec: {
@ -608,6 +612,7 @@ TabsLayoutTabSpec: {
title?: string title?: string
layout: GridLayoutKind | RowsLayoutKind | AutoGridLayoutKind | TabsLayoutKind layout: GridLayoutKind | RowsLayoutKind | AutoGridLayoutKind | TabsLayoutKind
conditionalRendering?: ConditionalRenderingGroupKind conditionalRendering?: ConditionalRenderingGroupKind
repeat?: TabRepeatOptions
} }
PanelSpec: { PanelSpec: {

@ -90,9 +90,9 @@ func NewDashboardAnnotationPanelFilter() *DashboardAnnotationPanelFilter {
type DashboardDashboardCursorSync string type DashboardDashboardCursorSync string
const ( const (
DashboardDashboardCursorSyncOff DashboardDashboardCursorSync = "Off"
DashboardDashboardCursorSyncCrosshair DashboardDashboardCursorSync = "Crosshair" DashboardDashboardCursorSyncCrosshair DashboardDashboardCursorSync = "Crosshair"
DashboardDashboardCursorSyncTooltip DashboardDashboardCursorSync = "Tooltip" DashboardDashboardCursorSyncTooltip DashboardDashboardCursorSync = "Tooltip"
DashboardDashboardCursorSyncOff DashboardDashboardCursorSync = "Off"
) )
// Supported dashboard elements // Supported dashboard elements
@ -1174,7 +1174,10 @@ func NewDashboardTimeSettingsSpec() *DashboardTimeSettingsSpec {
Timezone: (func(input string) *string { return &input })("browser"), Timezone: (func(input string) *string { return &input })("browser"),
From: "now-6h", From: "now-6h",
To: "now", To: "now",
AutoRefresh: "",
AutoRefreshIntervals: []string{"5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"}, AutoRefreshIntervals: []string{"5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"},
HideTimepicker: false,
FiscalYearStartMonth: 0,
} }
} }
@ -1694,7 +1697,6 @@ func NewDashboardMetricFindValue() *DashboardMetricFindValue {
// +k8s:openapi-gen=true // +k8s:openapi-gen=true
type DashboardSpec struct { type DashboardSpec struct {
// Title of dashboard.
Annotations []DashboardAnnotationQueryKind `json:"annotations"` Annotations []DashboardAnnotationQueryKind `json:"annotations"`
// Configuration of dashboard cursor sync behavior. // Configuration of dashboard cursor sync behavior.
// "Off" for no shared crosshair or tooltip (default). // "Off" for no shared crosshair or tooltip (default).
@ -1730,8 +1732,10 @@ type DashboardSpec struct {
// NewDashboardSpec creates a new DashboardSpec object. // NewDashboardSpec creates a new DashboardSpec object.
func NewDashboardSpec() *DashboardSpec { func NewDashboardSpec() *DashboardSpec {
return &DashboardSpec{ return &DashboardSpec{
CursorSync: DashboardDashboardCursorSyncOff,
Editable: (func(input bool) *bool { return &input })(true), Editable: (func(input bool) *bool { return &input })(true),
Layout: *NewDashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind(), Layout: *NewDashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind(),
Preload: false,
TimeSettings: *NewDashboardTimeSettingsSpec(), TimeSettings: *NewDashboardTimeSettingsSpec(),
} }
} }

@ -3678,8 +3678,7 @@ func schema_pkg_apis_dashboard_v2alpha1_DashboardSpec(ref common.ReferenceCallba
Properties: map[string]spec.Schema{ Properties: map[string]spec.Schema{
"annotations": { "annotations": {
SchemaProps: spec.SchemaProps{ SchemaProps: spec.SchemaProps{
Description: "Title of dashboard.", Type: []string{"array"},
Type: []string{"array"},
Items: &spec.SchemaOrArray{ Items: &spec.SchemaOrArray{
Schema: &spec.Schema{ Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{ SchemaProps: spec.SchemaProps{

@ -3,7 +3,6 @@
"list": [ "list": [
{ {
"builtIn": 1, "builtIn": 1,
"datasource": "-- Grafana --",
"enable": true, "enable": true,
"hide": true, "hide": true,
"iconColor": "rgba(0, 211, 255, 1)", "iconColor": "rgba(0, 211, 255, 1)",

@ -169,7 +169,7 @@
"type": "text" "type": "text"
} }
], ],
"refresh": false, "refresh": "",
"schemaVersion": 37, "schemaVersion": 37,
"tags": [], "tags": [],
"templating": { "templating": {

@ -67,7 +67,7 @@ export const defaultAnnotationPanelFilter = (): AnnotationPanelFilter => ({
// "Off" for no shared crosshair or tooltip (default). // "Off" for no shared crosshair or tooltip (default).
// "Crosshair" for shared crosshair. // "Crosshair" for shared crosshair.
// "Tooltip" for shared crosshair AND shared tooltip. // "Tooltip" for shared crosshair AND shared tooltip.
export type DashboardCursorSync = "Off" | "Crosshair" | "Tooltip"; export type DashboardCursorSync = "Crosshair" | "Tooltip" | "Off";
export const defaultDashboardCursorSync = (): DashboardCursorSync => ("Off"); export const defaultDashboardCursorSync = (): DashboardCursorSync => ("Off");
@ -282,7 +282,7 @@ export interface FieldConfig {
description?: string; description?: string;
// An explicit path to the field in the datasource. When the frame meta includes a path, // An explicit path to the field in the datasource. When the frame meta includes a path,
// This will default to `${frame.meta.path}/${field.name} // This will default to `${frame.meta.path}/${field.name}
// //
// When defined, this value can be used as an identifier within the datasource scope, and // When defined, this value can be used as an identifier within the datasource scope, and
// may be used to update the results // may be used to update the results
path?: string; path?: string;
@ -1367,7 +1367,6 @@ export const defaultMetricFindValue = (): MetricFindValue => ({
}); });
export interface Spec { export interface Spec {
// Title of dashboard.
annotations: AnnotationQueryKind[]; annotations: AnnotationQueryKind[];
// Configuration of dashboard cursor sync behavior. // Configuration of dashboard cursor sync behavior.
// "Off" for no shared crosshair or tooltip (default). // "Off" for no shared crosshair or tooltip (default).
@ -1413,3 +1412,4 @@ export const defaultSpec = (): Spec => ({
title: "", title: "",
variables: [], variables: [],
}); });

@ -0,0 +1,20 @@
package utils
import (
"errors"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// Extract the status from an APIStatus error
func ExtractApiErrorStatus(err error) (metav1.Status, bool) {
if err == nil {
return metav1.Status{}, false
}
if statusErr, ok := err.(apierrors.APIStatus); ok && errors.As(err, &statusErr) {
return statusErr.Status(), true
}
return metav1.Status{}, false
}

@ -15,11 +15,14 @@ import (
claims "github.com/grafana/authlib/types" claims "github.com/grafana/authlib/types"
dashboardOG "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard" dashboardOG "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard"
dashboardv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
dashboard "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1" dashboard "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1"
"github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion"
"github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/registry/apis/dashboard/legacysearcher" "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacysearcher"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
gapiutil "github.com/grafana/grafana/pkg/services/apiserver/utils" gapiutil "github.com/grafana/grafana/pkg/services/apiserver/utils"
@ -62,6 +65,7 @@ type dashboardSqlAccess struct {
// Typically one... the server wrapper // Typically one... the server wrapper
subscribers []chan *resource.WrittenEvent subscribers []chan *resource.WrittenEvent
mutex sync.Mutex mutex sync.Mutex
log log.Logger
} }
func NewDashboardAccess(sql legacysql.LegacyDatabaseProvider, func NewDashboardAccess(sql legacysql.LegacyDatabaseProvider,
@ -77,6 +81,7 @@ func NewDashboardAccess(sql legacysql.LegacyDatabaseProvider,
dashStore: dashStore, dashStore: dashStore,
provisioning: provisioning, provisioning: provisioning,
dashboardSearchClient: *dashboardSearchClient, dashboardSearchClient: *dashboardSearchClient,
log: log.New("dashboard.legacysql"),
} }
} }
@ -410,6 +415,15 @@ func (a *dashboardSqlAccess) buildSaveDashboardCommand(ctx context.Context, orgI
} }
} }
// v1 should be saved as schema version 41. v0 allows for older versions
if strings.HasSuffix(dash.APIVersion, "v1alpha1") {
schemaVersion := schemaversion.GetSchemaVersion(dash.Spec.Object)
if schemaVersion < int(schemaversion.LATEST_VERSION) {
dash.APIVersion = dashboardv0.VERSION
a.log.Info("Downgrading v1alpha1 dashboard to v0alpha1 due to schema version mismatch", "dashboard", dash.Name, "schema_version", schemaVersion)
}
}
apiVersion := strings.TrimPrefix(dash.APIVersion, dashboard.GROUP+"/") apiVersion := strings.TrimPrefix(dash.APIVersion, dashboard.GROUP+"/")
meta, err := utils.MetaAccessor(dash) meta, err := utils.MetaAccessor(dash)
if err != nil { if err != nil {

@ -14,6 +14,7 @@ import (
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/provisioning" "github.com/grafana/grafana/pkg/services/provisioning"
"github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user"
@ -30,6 +31,7 @@ func TestScanRow(t *testing.T) {
store := &dashboardSqlAccess{ store := &dashboardSqlAccess{
namespacer: func(_ int64) string { return "default" }, namespacer: func(_ int64) string { return "default" },
provisioning: provisioner, provisioning: provisioner,
log: log.New("test"),
} }
columns := []string{"orgId", "dashboard_id", "name", "folder_uid", "deleted", "plugin_id", "origin_name", "origin_path", "origin_hash", "origin_ts", "created", "createdBy", "createdByID", "updated", "updatedBy", "updatedByID", "version", "message", "data", "api_version"} columns := []string{"orgId", "dashboard_id", "name", "folder_uid", "deleted", "plugin_id", "origin_name", "origin_path", "origin_hash", "origin_ts", "created", "createdBy", "createdByID", "updated", "updatedBy", "updatedByID", "version", "message", "data", "api_version"}
@ -126,60 +128,95 @@ func TestScanRow(t *testing.T) {
} }
func TestBuildSaveDashboardCommand(t *testing.T) { func TestBuildSaveDashboardCommand(t *testing.T) {
mockStore := &dashboards.FakeDashboardStore{} testCases := []struct {
access := &dashboardSqlAccess{ name string
dashStore: mockStore, schemaVersion int
} expectedAPI string
dash := &dashboard.Dashboard{ }{
TypeMeta: metav1.TypeMeta{ {
APIVersion: dashboard.APIVERSION, name: "with schema version 36 should save as v0alpha1",
schemaVersion: 36,
expectedAPI: "v0alpha1",
}, },
ObjectMeta: metav1.ObjectMeta{ {
Name: "test-dash", name: "with schema version 41 should save as v1alpha1",
schemaVersion: 41,
expectedAPI: "v1alpha1",
}, },
Spec: common.Unstructured{ {
Object: map[string]interface{}{ name: "with empty schema version should save as v0alpha1",
"title": "Test Dashboard", schemaVersion: 0,
"id": 123, expectedAPI: "v0alpha1",
},
}, },
} }
// fail if no user in context for _, tc := range testCases {
_, _, err := access.buildSaveDashboardCommand(context.Background(), 1, dash) t.Run(tc.name, func(t *testing.T) {
require.Error(t, err) mockStore := &dashboards.FakeDashboardStore{}
access := &dashboardSqlAccess{
dashStore: mockStore,
log: log.New("test"),
}
ctx := identity.WithRequester(context.Background(), &user.SignedInUser{ dashSpec := map[string]interface{}{
OrgID: 1, "title": "Test Dashboard",
OrgRole: "Admin", "id": 123,
}) }
// create new dashboard
mockStore.On("GetDashboard", mock.Anything, mock.Anything).Return(nil, nil).Once() if tc.schemaVersion > 0 {
cmd, created, err := access.buildSaveDashboardCommand(ctx, 1, dash) dashSpec["schemaVersion"] = tc.schemaVersion
require.NoError(t, err) }
require.Equal(t, true, created)
require.NotNil(t, cmd) dash := &dashboard.Dashboard{
require.Equal(t, "test-dash", cmd.Dashboard.Get("uid").MustString()) TypeMeta: metav1.TypeMeta{
_, exists := cmd.Dashboard.CheckGet("id") APIVersion: dashboard.APIVERSION,
require.False(t, exists) // id should be removed },
require.Equal(t, cmd.OrgID, int64(1)) ObjectMeta: metav1.ObjectMeta{
require.True(t, cmd.Overwrite) Name: "test-dash",
},
// now update existing dashboard Spec: common.Unstructured{
mockStore.On("GetDashboard", mock.Anything, mock.Anything).Return( Object: dashSpec,
&dashboards.Dashboard{ },
ID: 1234, }
Version: 2,
APIVersion: dashboard.APIVERSION, // fail if no user in context
}, nil).Once() _, _, err := access.buildSaveDashboardCommand(context.Background(), 1, dash)
cmd, created, err = access.buildSaveDashboardCommand(ctx, 1, dash) require.Error(t, err)
require.NoError(t, err)
require.Equal(t, false, created) ctx := identity.WithRequester(context.Background(), &user.SignedInUser{
require.NotNil(t, cmd) OrgID: 1,
require.Equal(t, "test-dash", cmd.Dashboard.Get("uid").MustString()) OrgRole: "Admin",
require.Equal(t, cmd.Dashboard.Get("id").MustInt64(), int64(1234)) // should set to existing ID })
require.Equal(t, cmd.Dashboard.Get("version").MustFloat64(), float64(2)) // version must be set - otherwise seen as a new dashboard in NewDashboardFromJson // create new dashboard
require.Equal(t, cmd.APIVersion, "v1alpha1") // should trim prefix mockStore.On("GetDashboard", mock.Anything, mock.Anything).Return(nil, nil).Once()
require.Equal(t, cmd.OrgID, int64(1)) cmd, created, err := access.buildSaveDashboardCommand(ctx, 1, dash)
require.True(t, cmd.Overwrite) require.NoError(t, err)
require.Equal(t, true, created)
require.NotNil(t, cmd)
require.Equal(t, "test-dash", cmd.Dashboard.Get("uid").MustString())
_, exists := cmd.Dashboard.CheckGet("id")
require.False(t, exists) // id should be removed
require.Equal(t, cmd.OrgID, int64(1))
require.True(t, cmd.Overwrite)
require.Equal(t, tc.expectedAPI, cmd.APIVersion) // verify expected API version
// now update existing dashboard
mockStore.On("GetDashboard", mock.Anything, mock.Anything).Return(
&dashboards.Dashboard{
ID: 1234,
Version: 2,
APIVersion: dashboard.APIVERSION,
}, nil).Once()
cmd, created, err = access.buildSaveDashboardCommand(ctx, 1, dash)
require.NoError(t, err)
require.Equal(t, false, created)
require.NotNil(t, cmd)
require.Equal(t, "test-dash", cmd.Dashboard.Get("uid").MustString())
require.Equal(t, cmd.Dashboard.Get("id").MustInt64(), int64(1234)) // should set to existing ID
require.Equal(t, cmd.Dashboard.Get("version").MustFloat64(), float64(2)) // version must be set - otherwise seen as a new dashboard in NewDashboardFromJson
require.Equal(t, tc.expectedAPI, cmd.APIVersion) // verify expected API version
require.Equal(t, cmd.OrgID, int64(1))
require.True(t, cmd.Overwrite)
})
}
} }

@ -12,6 +12,7 @@ import (
dashboard "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1" dashboard "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1"
"github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/grafana/grafana/pkg/storage/unified/resource"
@ -119,6 +120,7 @@ func TestWriteProvisioningEvent(t *testing.T) {
access := &dashboardSqlAccess{ access := &dashboardSqlAccess{
dashStore: mockStore, dashStore: mockStore,
log: log.New("test"),
} }
ctx := identity.WithRequester(context.Background(), &user.SignedInUser{}) ctx := identity.WithRequester(context.Background(), &user.SignedInUser{})

@ -58,7 +58,17 @@ func (b *DashboardsAPIBuilder) Mutate(ctx context.Context, a admission.Attribute
} }
} }
case *dashboardV2.Dashboard: case *dashboardV2.Dashboard:
// Temporary fix: The generator fails to properly initialize this property, so we'll do it here
// until the generator is fixed.
if v.Spec.Layout.GridLayoutKind == nil && v.Spec.Layout.RowsLayoutKind == nil && v.Spec.Layout.AutoGridLayoutKind == nil && v.Spec.Layout.TabsLayoutKind == nil {
v.Spec.Layout.GridLayoutKind = &dashboardV2.DashboardGridLayoutKind{
Kind: "GridLayout",
Spec: dashboardV2.DashboardGridLayoutSpec{},
}
}
resourceInfo = dashboardV2.DashboardResourceInfo resourceInfo = dashboardV2.DashboardResourceInfo
// Noop for V2 // Noop for V2
default: default:
return fmt.Errorf("mutation error: expected to dashboard, got %T", obj) return fmt.Errorf("mutation error: expected to dashboard, got %T", obj)

@ -6,10 +6,13 @@ import (
dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
dashv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1" dashv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1"
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
"github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion" "github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/admission" "k8s.io/apiserver/pkg/admission"
@ -17,12 +20,14 @@ import (
func TestDashboardAPIBuilder_Mutate(t *testing.T) { func TestDashboardAPIBuilder_Mutate(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
inputObj runtime.Object inputObj runtime.Object
operation admission.Operation operation admission.Operation
expectedID int64 expectedID int64
migrationExpected bool migrationExpected bool
expectedError bool expectedTitle string
expectedError bool
fieldValidationMode string
}{ }{
{ {
name: "should skip non-create/update operations", name: "should skip non-create/update operations",
@ -48,6 +53,47 @@ func TestDashboardAPIBuilder_Mutate(t *testing.T) {
operation: admission.Create, operation: admission.Create,
expectedID: 123, expectedID: 123,
}, },
{
name: "v0 should not fail with invalid schema",
inputObj: &dashv0.Dashboard{
Spec: common.Unstructured{
Object: map[string]interface{}{
"id": float64(123),
"revision": "revision-is-a-number",
},
},
},
operation: admission.Create,
expectedID: 123,
},
{
name: "v1 should fail with invalid schema",
inputObj: &dashv1.Dashboard{
Spec: common.Unstructured{
Object: map[string]interface{}{
"id": float64(123),
"revision": "revision-is-a-number",
},
},
},
operation: admission.Create,
expectedError: true,
},
{
name: "v1 should not fail with invalid schema and FieldValidationIgnore is set",
inputObj: &dashv1.Dashboard{
Spec: common.Unstructured{
Object: map[string]interface{}{
"id": float64(123),
"revision": "revision-is-a-number",
},
},
},
operation: admission.Create,
fieldValidationMode: metav1.FieldValidationIgnore,
expectedError: false,
expectedID: 123,
},
{ {
name: "v1 should migrate dashboard to the latest version, if possible, and set as label", name: "v1 should migrate dashboard to the latest version, if possible, and set as label",
inputObj: &dashv1.Dashboard{ inputObj: &dashv1.Dashboard{
@ -75,11 +121,47 @@ func TestDashboardAPIBuilder_Mutate(t *testing.T) {
operation: admission.Create, operation: admission.Create,
expectedError: true, expectedError: true,
}, },
{
name: "v1 should not error mutation hook if migration fails and FieldValidationIgnore is set",
inputObj: &dashv1.Dashboard{
Spec: common.Unstructured{
Object: map[string]interface{}{
"id": float64(456),
"schemaVersion": schemaversion.MIN_VERSION - 1,
},
},
},
expectedID: 456,
operation: admission.Create,
fieldValidationMode: metav1.FieldValidationIgnore,
expectedError: false,
},
{
name: "v2 should set layout if it is not set",
inputObj: &v2alpha1.Dashboard{
Spec: v2alpha1.DashboardSpec{
Title: "test123",
},
},
operation: admission.Create,
expectedTitle: "test123",
},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
b := &DashboardsAPIBuilder{} b := &DashboardsAPIBuilder{
features: featuremgmt.WithFeatures(),
}
var operationOptions runtime.Object
switch tt.operation {
case admission.Create:
operationOptions = &metav1.CreateOptions{FieldValidation: tt.fieldValidationMode}
case admission.Update:
operationOptions = &metav1.UpdateOptions{FieldValidation: tt.fieldValidationMode}
default:
operationOptions = nil
}
err := b.Mutate(context.Background(), admission.NewAttributesRecord( err := b.Mutate(context.Background(), admission.NewAttributesRecord(
tt.inputObj, tt.inputObj,
nil, nil,
@ -89,7 +171,7 @@ func TestDashboardAPIBuilder_Mutate(t *testing.T) {
schema.GroupVersionResource{}, schema.GroupVersionResource{},
"", "",
tt.operation, tt.operation,
nil, operationOptions,
false, false,
nil, nil,
), nil) ), nil)
@ -117,6 +199,10 @@ func TestDashboardAPIBuilder_Mutate(t *testing.T) {
if tt.migrationExpected { if tt.migrationExpected {
require.Equal(t, schemaversion.LATEST_VERSION, schemaVersion, "dashboard should be migrated to the latest version") require.Equal(t, schemaversion.LATEST_VERSION, schemaVersion, "dashboard should be migrated to the latest version")
} }
case *v2alpha1.Dashboard:
require.Equal(t, tt.expectedTitle, v.Spec.Title, "title should be set")
require.NotNil(t, v.Spec.Layout, "layout should be set")
require.NotNil(t, v.Spec.Layout.GridLayoutKind, "layout should be a GridLayout")
} }
} }
}) })

@ -3,7 +3,6 @@ package dashboard
import ( import (
"context" "context"
_ "embed" _ "embed"
"errors"
"fmt" "fmt"
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
@ -14,16 +13,12 @@ import (
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1" "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
"github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/featuremgmt"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
) )
// ValidateDashboardSpec validates the dashboard spec and throws a detailed error if there are validation errors. // ValidateDashboardSpec validates the dashboard spec and throws a detailed error if there are validation errors.
func (b *DashboardsAPIBuilder) ValidateDashboardSpec(ctx context.Context, obj runtime.Object, fieldValidationMode string) (field.ErrorList, error) { func (b *DashboardsAPIBuilder) ValidateDashboardSpec(ctx context.Context, obj runtime.Object, fieldValidationMode string) (field.ErrorList, error) {
// This will be removed with the other PR
return nil, nil
// Unreachable code is intentional until the code above is removed
//nolint:govet
accessor, err := utils.MetaAccessor(obj) accessor, err := utils.MetaAccessor(obj)
if err != nil { if err != nil {
return nil, fmt.Errorf("error getting meta accessor: %w", err) return nil, fmt.Errorf("error getting meta accessor: %w", err)
@ -40,11 +35,11 @@ func (b *DashboardsAPIBuilder) ValidateDashboardSpec(ctx context.Context, obj ru
case *v2alpha1.Dashboard: case *v2alpha1.Dashboard:
errorOnSchemaMismatches = !b.features.IsEnabled(ctx, featuremgmt.FlagDashboardDisableSchemaValidationV2) errorOnSchemaMismatches = !b.features.IsEnabled(ctx, featuremgmt.FlagDashboardDisableSchemaValidationV2)
default: default:
return nil, fmt.Errorf("Invalid dashboard type: %T", obj) return nil, fmt.Errorf("invalid dashboard type: %T", obj)
} }
} }
if mode == metav1.FieldValidationWarn { if mode == metav1.FieldValidationWarn {
return nil, errors.New("FieldValidationWarn is not supported") return nil, apierrors.NewBadRequest("Not supported: FieldValidationMode: Warn")
} }
alwaysLogSchemaValidationErrors := b.features.IsEnabled(ctx, featuremgmt.FlagDashboardSchemaValidationLogging) alwaysLogSchemaValidationErrors := b.features.IsEnabled(ctx, featuremgmt.FlagDashboardSchemaValidationLogging)

@ -38,17 +38,21 @@ func (r *DualReadWriter) Read(ctx context.Context, path string, ref string) (*Pa
info, err := r.repo.Read(ctx, path, ref) info, err := r.repo.Read(ctx, path, ref)
if err != nil { if err != nil {
return nil, fmt.Errorf("read file: %w", err) _, ok := utils.ExtractApiErrorStatus(err)
if ok {
return nil, err
}
return nil, fmt.Errorf("Read file failed: %w", err)
} }
parsed, err := r.parser.Parse(ctx, info) parsed, err := r.parser.Parse(ctx, info)
if err != nil { if err != nil {
return nil, fmt.Errorf("parse file: %w", err) return nil, apierrors.NewBadRequest(fmt.Sprintf("Parse file failed: %v", err))
} }
// Fail as we use the dry run for this response and it's not about updating the resource // Fail as we use the dry run for this response and it's not about updating the resource
if err := parsed.DryRun(ctx); err != nil { if err := parsed.DryRun(ctx); err != nil {
return nil, fmt.Errorf("run dry run: %w", err) return nil, apierrors.NewBadRequest(fmt.Sprintf("Dry run failed: %v", err))
} }
// Authorize based on the existing resource // Authorize based on the existing resource

@ -19,8 +19,11 @@ import (
) )
var ( var (
ErrUnableToReadResourceBytes = errors.New("unable to read bytes as a resource") ErrUnableToReadResourceBytes = errors.New("unable to read bytes as a resource")
ErrClassicResourceIsAlreadyK8sForm = errors.New("classic resource is already structured with apiVersion and kind") ErrUnableToReadPanelsMissing = errors.New("panels property is required")
ErrUnableToReadSchemaVersionMissing = errors.New("schemaVersion property is required")
ErrUnableToReadTagsMissing = errors.New("tags property is required")
ErrClassicResourceIsAlreadyK8sForm = errors.New("classic resource is already structured with apiVersion and kind")
) )
// This reads a "classic" file format and will convert it to an unstructured k8s resource // This reads a "classic" file format and will convert it to an unstructured k8s resource

@ -7,7 +7,9 @@ import (
"strings" "strings"
"testing" "testing"
dashboardv0alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
dashboardv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1" dashboardv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1"
dashboardv2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
folders "github.com/grafana/grafana/pkg/apis/folder/v1" folders "github.com/grafana/grafana/pkg/apis/folder/v1"
"github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/featuremgmt"
@ -219,19 +221,88 @@ func runDashboardValidationTests(t *testing.T, ctx TestContext) {
t.Run("Dashboard schema validations", func(t *testing.T) { t.Run("Dashboard schema validations", func(t *testing.T) {
// Test invalid dashboard schema // Test invalid dashboard schema
t.Run("reject dashboard with invalid schema", func(t *testing.T) { t.Run("reject dashboard with invalid schema", func(t *testing.T) {
dashObj := &unstructured.Unstructured{ testCases := []struct {
Object: map[string]interface{}{ name string
"apiVersion": dashboardv1.DashboardResourceInfo.GroupVersion().String(), resourceInfo utils.ResourceInfo
"kind": dashboardv1.DashboardResourceInfo.GroupVersionKind().Kind, expectSpecErr bool
"metadata": map[string]interface{}{ testObject *unstructured.Unstructured
"generateName": "test-", }{
{
name: "v0alpha1 dashboard with wrong spec should not throw on v0",
resourceInfo: dashboardv0alpha1.DashboardResourceInfo,
expectSpecErr: false,
testObject: &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": dashboardv0alpha1.DashboardResourceInfo.TypeMeta().APIVersion,
"kind": "Dashboard",
"metadata": map[string]interface{}{
"generateName": "test-",
},
"spec": map[string]interface{}{
"title": "Dashboard Title",
"schemaVersion": 41,
"editable": "elephant",
"time": 9000,
"uid": strings.Repeat("a", 100),
},
},
},
},
{
name: "v1 dashboard with wrong spec should throw on v1",
resourceInfo: dashboardv1.DashboardResourceInfo,
expectSpecErr: true,
testObject: &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": dashboardv1.DashboardResourceInfo.TypeMeta().APIVersion,
"kind": "Dashboard",
"metadata": map[string]interface{}{
"generateName": "test-",
},
"spec": map[string]interface{}{
"title": "Dashboard Title",
"schemaVersion": 41,
"editable": "elephant",
"time": 9000,
"uid": strings.Repeat("a", 100),
},
},
},
},
{
name: "v2alpha1 dashboard with correct spec should not throw on v2",
resourceInfo: dashboardv2alpha1.DashboardResourceInfo,
expectSpecErr: false,
testObject: &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": dashboardv2alpha1.DashboardResourceInfo.TypeMeta().APIVersion,
"kind": "Dashboard",
"metadata": map[string]interface{}{
"generateName": "test-",
},
"spec": map[string]interface{}{
"title": "Dashboard Title",
"description": "valid description",
},
},
}, },
// Missing spec
}, },
} }
_, err := adminClient.Resource.Create(context.Background(), dashObj, v1.CreateOptions{}) for _, tc := range testCases {
require.Error(t, err) t.Run(tc.name, func(t *testing.T) {
resourceClient := getResourceClient(t, ctx.Helper, ctx.AdminUser, tc.resourceInfo.GroupVersionResource())
createdDashboard, err := resourceClient.Resource.Create(context.Background(), tc.testObject, v1.CreateOptions{})
if tc.expectSpecErr {
ctx.Helper.RequireApiErrorStatus(err, v1.StatusReasonInvalid, http.StatusUnprocessableEntity)
} else {
require.NoError(t, err)
require.NotNil(t, createdDashboard)
err = resourceClient.Resource.Delete(context.Background(), createdDashboard.GetName(), v1.DeleteOptions{})
require.NoError(t, err)
}
})
}
}) })
}) })
@ -683,20 +754,12 @@ func createTestContext(t *testing.T, helper *apis.K8sTestHelper, orgUsers apis.O
// getDashboardGVR returns the dashboard GroupVersionResource // getDashboardGVR returns the dashboard GroupVersionResource
func getDashboardGVR() schema.GroupVersionResource { func getDashboardGVR() schema.GroupVersionResource {
return schema.GroupVersionResource{ return dashboardv1.DashboardResourceInfo.GroupVersionResource()
Group: dashboardv1.DashboardResourceInfo.GroupVersion().Group,
Version: dashboardv1.DashboardResourceInfo.GroupVersion().Version,
Resource: dashboardv1.DashboardResourceInfo.GetName(),
}
} }
// getFolderGVR returns the folder GroupVersionResource // getFolderGVR returns the folder GroupVersionResource
func getFolderGVR() schema.GroupVersionResource { func getFolderGVR() schema.GroupVersionResource {
return schema.GroupVersionResource{ return folders.FolderResourceInfo.GroupVersionResource()
Group: folders.FolderResourceInfo.GroupVersion().Group,
Version: folders.FolderResourceInfo.GroupVersion().Version,
Resource: folders.FolderResourceInfo.GetName(),
}
} }
// Get a resource client for the specified user // Get a resource client for the specified user

@ -28,6 +28,7 @@ import (
"k8s.io/client-go/rest" "k8s.io/client-go/rest"
"github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/infra/localcache" "github.com/grafana/grafana/pkg/infra/localcache"
"github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/server" "github.com/grafana/grafana/pkg/server"
@ -894,3 +895,22 @@ func (c *K8sTestHelper) DeleteServiceAccount(user User, orgID int64, saID int64)
require.Equal(c.t, http.StatusOK, resp.Response.StatusCode, "failed to delete service account, body: %s", string(resp.Body)) require.Equal(c.t, http.StatusOK, resp.Response.StatusCode, "failed to delete service account, body: %s", string(resp.Body))
} }
// Ensures that the passed error is an APIStatus error and fails the test if it is not.
func (c *K8sTestHelper) RequireApiErrorStatus(err error, reason metav1.StatusReason, httpCode int) metav1.Status {
require.Error(c.t, err)
status, ok := utils.ExtractApiErrorStatus(err)
if !ok {
c.t.Fatalf("Expected error to be an APIStatus, but got %T", err)
}
if reason != metav1.StatusReasonUnknown {
require.Equal(c.t, status.Reason, reason)
}
if httpCode != 0 {
require.Equal(c.t, status.Code, int32(httpCode))
}
return status
}

@ -3294,7 +3294,6 @@
], ],
"properties": { "properties": {
"annotations": { "annotations": {
"description": "Title of dashboard.",
"type": "array", "type": "array",
"items": { "items": {
"default": {}, "default": {},

@ -9,6 +9,7 @@ import (
"regexp" "regexp"
"strings" "strings"
"testing" "testing"
"time"
gh "github.com/google/go-github/v70/github" gh "github.com/google/go-github/v70/github"
ghmock "github.com/migueleliasweb/go-github-mock/src/mock" ghmock "github.com/migueleliasweb/go-github-mock/src/mock"
@ -23,6 +24,7 @@ import (
"github.com/grafana/grafana/pkg/infra/slugify" "github.com/grafana/grafana/pkg/infra/slugify"
"github.com/grafana/grafana/pkg/infra/usagestats" "github.com/grafana/grafana/pkg/infra/usagestats"
"github.com/grafana/grafana/pkg/tests/apis" "github.com/grafana/grafana/pkg/tests/apis"
"k8s.io/apimachinery/pkg/runtime"
) )
func TestIntegrationProvisioning_CreatingAndGetting(t *testing.T) { func TestIntegrationProvisioning_CreatingAndGetting(t *testing.T) {
@ -119,6 +121,88 @@ func TestIntegrationProvisioning_CreatingAndGetting(t *testing.T) {
}) })
} }
func TestIntegrationProvisioning_FailInvalidSchema(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
t.Skip("Reenable this test once we enforce schema validation for provisioning")
helper := runGrafana(t)
ctx := context.Background()
const repo = "invalid-schema-tmp"
// Set up the repository and the file to import.
helper.CopyToProvisioningPath(t, "testdata/invalid-dashboard-schema.json", "invalid-dashboard-schema.json")
localTmp := helper.RenderObject(t, "testdata/local-write.json.tmpl", map[string]any{
"Name": repo,
"SyncEnabled": true,
})
_, err := helper.Repositories.Resource.Create(ctx, localTmp, metav1.CreateOptions{})
require.NoError(t, err)
// Make sure the repo can read and validate the file
_, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "invalid-dashboard-schema.json")
status := helper.RequireApiErrorStatus(err, metav1.StatusReasonBadRequest, http.StatusBadRequest)
require.Equal(t, status.Message, "Dry run failed: Dashboard.dashboard.grafana.app \"invalid-schema-uid\" is invalid: [spec.panels.0.repeatDirection: Invalid value: conflicting values \"h\" and \"this is not an allowed value\", spec.panels.0.repeatDirection: Invalid value: conflicting values \"v\" and \"this is not an allowed value\"]")
const invalidSchemaUid = "invalid-schema-uid"
_, err = helper.Dashboards.Resource.Get(ctx, invalidSchemaUid, metav1.GetOptions{})
require.Error(t, err, "invalid dashboard shouldn't exist")
require.True(t, apierrors.IsNotFound(err))
var jobObj *unstructured.Unstructured
assert.EventuallyWithT(t, func(collect *assert.CollectT) {
result := helper.AdminREST.Post().
Namespace("default").
Resource("repositories").
Name(repo).
SubResource("jobs").
Body(asJSON(&provisioning.JobSpec{
Action: provisioning.JobActionPull,
Pull: &provisioning.SyncJobOptions{},
})).
SetHeader("Content-Type", "application/json").
Do(t.Context())
require.NoError(collect, result.Error())
job, err := result.Get()
require.NoError(collect, err)
var ok bool
jobObj, ok = job.(*unstructured.Unstructured)
require.True(collect, ok, "expecting unstructured object, but got %T", job)
}, time.Second*10, time.Millisecond*10, "Expected to be able to start a sync job")
assert.EventuallyWithT(t, func(collect *assert.CollectT) {
//helper.TriggerJobProcessing(t)
result, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{},
"jobs", string(jobObj.GetUID()))
if apierrors.IsNotFound(err) {
assert.Fail(collect, "job '%s' not found yet yet", jobObj.GetName())
return // continue trying
}
// Can fail fast here -- the jobs are immutable
require.NoError(t, err)
require.NotNil(t, result)
job := &provisioning.Job{}
err = runtime.DefaultUnstructuredConverter.FromUnstructured(result.Object, job)
require.NoError(t, err, "should convert to Job object")
require.Equal(t, provisioning.JobStateError, job.Status.State)
require.Equal(t, job.Status.Message, "completed with errors")
require.Equal(t, job.Status.Errors[0], "Dashboard.dashboard.grafana.app \"invalid-schema-uid\" is invalid: [spec.panels.0.repeatDirection: Invalid value: conflicting values \"h\" and \"this is not an allowed value\", spec.panels.0.repeatDirection: Invalid value: conflicting values \"v\" and \"this is not an allowed value\"]")
}, time.Second*10, time.Millisecond*10, "Expected provisioning job to conclude with the status failed")
_, err = helper.Dashboards.Resource.Get(ctx, invalidSchemaUid, metav1.GetOptions{})
require.Error(t, err, "invalid dashboard shouldn't have been created")
require.True(t, apierrors.IsNotFound(err))
err = helper.Repositories.Resource.Delete(ctx, repo, metav1.DeleteOptions{}, "files", "invalid-dashboard-schema.json")
require.NoError(t, err, "should delete the resource file")
}
func TestIntegrationProvisioning_CreatingGitHubRepository(t *testing.T) { func TestIntegrationProvisioning_CreatingGitHubRepository(t *testing.T) {
if testing.Short() { if testing.Short() {
t.Skip("skipping integration test") t.Skip("skipping integration test")

@ -0,0 +1,29 @@
{
"title": "Provisioning test - invalid schema",
"uid": "invalid-schema-uid",
"schemaVersion": 41,
"tags": [
"tag"
],
"revision": "this-is-not-a-number",
"panels": [
{
"gridPos": {
"h": 3,
"w": 12,
"x": 0,
"y": 0
},
"id": 34,
"options": {
"content": "# All panels\n\nThis dashboard was created to quickly check accessiblity issues on a lot of panels at the same time ",
"mode": "markdown"
},
"pluginVersion": "8.1.0-pre",
"transparent": true,
"type": "text",
"repeat": "something",
"repeatDirection": "this is not an allowed value"
}
]
}
Loading…
Cancel
Save