Introduce "scuemata" system for CUE-based specification of Grafana objects (#32527)

This commit is contained in:
sam boyer
2021-04-08 01:11:11 -07:00
committed by GitHub
parent 7351645d63
commit bba4d9bd7f
87 changed files with 2446 additions and 1136 deletions
+87
View File
@@ -0,0 +1,87 @@
package load
import (
"fmt"
"io"
"io/fs"
"path/filepath"
"cuelang.org/go/cue"
"cuelang.org/go/cue/load"
)
var rt = &cue.Runtime{}
// Families can have variants, where more typing information narrows the
// possible values for certain keys in schemas. These are a meta-property
// of the schema, effectively encoded in these loaders.
//
// We can generally define three variants:
// - "Base": strictly core schema files, no plugins. (go:embed-able)
// - "Dist": "Base" + plugins that ship with vanilla Grafana (go:embed-able)
// - "Instance": "Dist" + the non-core plugins available in an actual, running Grafana
// BaseLoadPaths contains the configuration for loading a DistDashboard
type BaseLoadPaths struct {
// BaseCueFS should be rooted at a directory containing the filesystem layout
// expected to exist at github.com/grafana/grafana/cue.
BaseCueFS fs.FS
// DistPluginCueFS should point to some fs path (TBD) under which all core
// plugins live.
DistPluginCueFS fs.FS
// InstanceCueFS should point to a root dir in which non-core plugins live.
// Normal case will be that this only happens when an actual Grafana
// instance is making the call, and has a plugin dir to offer - though
// external tools could always create their own dirs shaped like a Grafana
// plugin dir, and point to those.
InstanceCueFS fs.FS
}
// toOverlay converts all .cue files in the fs.FS into Source entries in an
// overlay map, as expected by load.Config.
//
// Each entry is placed in the map with the provided prefix - which must be an
// absolute path - ahead of the actual path of the added file within the fs.FS.
//
// The function writes into the provided overlay map, to facilitate the
// construction of a single overlay map from multiple fs.FS.
//
// All files reachable by walking the provided fs.FS are added to the overlay
// map, on the premise that control over the FS is sufficient to allow any
// desired filtering.
func toOverlay(prefix string, vfs fs.FS, overlay map[string]load.Source) error {
if !filepath.IsAbs(prefix) {
return fmt.Errorf("must provide absolute path prefix when generating cue overlay, got %q", prefix)
}
err := fs.WalkDir(vfs, ".", (func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
f, err := vfs.Open(path)
if err != nil {
return err
}
b, err := io.ReadAll(f)
if err != nil {
return err
}
overlay[filepath.Join(prefix, path)] = load.FromBytes(b)
return nil
}))
if err != nil {
return err
}
return nil
}
+200
View File
@@ -0,0 +1,200 @@
package load
import (
"errors"
"fmt"
"cuelang.org/go/cue"
"cuelang.org/go/cue/load"
"github.com/grafana/grafana/pkg/schema"
)
var panelSubpath cue.Path = cue.MakePath(cue.Def("#Panel"))
func defaultOverlay(p BaseLoadPaths) (map[string]load.Source, error) {
overlay := make(map[string]load.Source)
if err := toOverlay("/", p.BaseCueFS, overlay); err != nil {
return nil, err
}
if err := toOverlay("/", p.DistPluginCueFS, overlay); err != nil {
return nil, err
}
return overlay, nil
}
// BaseDashboardFamily loads the family of schema representing the "Base" variant of
// a Grafana dashboard: the core-defined dashboard schema that applies universally to
// all dashboards, independent of any plugins.
//
// The returned VersionedCueSchema will always be the oldest schema in the
// family: the 0.0 schema. schema.Find() provides easy traversal to newer schema
// versions.
func BaseDashboardFamily(p BaseLoadPaths) (schema.VersionedCueSchema, error) {
overlay, err := defaultOverlay(p)
if err != nil {
return nil, err
}
cfg := &load.Config{Overlay: overlay}
inst, err := rt.Build(load.Instances([]string{"/cue/data/gen.cue"}, cfg)[0])
if err != nil {
return nil, err
}
famval := inst.Value().LookupPath(cue.MakePath(cue.Str("Family")))
if !famval.Exists() {
return nil, errors.New("dashboard schema family did not exist at expected path in expected file")
}
return buildGenericScuemata(famval)
}
// DistDashboardFamily loads the family of schema representing the "Dist"
// variant of a Grafana dashboard: the "Base" variant (see
// BaseDashboardFamily()), but constrained such that all substructures (e.g.
// panels) must be valid with respect to the schemas provided by the core
// plugins that ship with Grafana.
//
// The returned VersionedCueSchema will always be the oldest schema in the
// family: the 0.0 schema. schema.Find() provides easy traversal to newer schema
// versions.
func DistDashboardFamily(p BaseLoadPaths) (schema.VersionedCueSchema, error) {
head, err := BaseDashboardFamily(p)
if err != nil {
return nil, err
}
scuemap, err := readPanelModels(p)
if err != nil {
return nil, err
}
dj, err := disjunctPanelScuemata(scuemap)
if err != nil {
return nil, err
}
// Stick this into a dummy struct so that we can unify it into place, as
// Value.Fill() can't target definitions. Need new method based on cue.Path;
// a CL has been merged that creates FillPath and will be in the next
// release of CUE.
dummy, _ := rt.Compile("mergeStruct", `
obj: {}
dummy: {
#Panel: obj
}
`)
filled := dummy.Value().Fill(dj, "obj")
ddj := filled.LookupPath(cue.MakePath(cue.Str("dummy")))
var first, prev *compositeDashboardSchema
for head != nil {
cds := &compositeDashboardSchema{
base: head,
actual: head.CUE().Unify(ddj),
panelFams: scuemap,
// TODO migrations
migration: terminalMigrationFunc,
}
if prev == nil {
first = cds
} else {
prev.next = cds
}
prev = cds
head = head.Successor()
}
return first, nil
}
type compositeDashboardSchema struct {
// The base/root dashboard schema
base schema.VersionedCueSchema
actual cue.Value
next *compositeDashboardSchema
migration migrationFunc
panelFams map[string]schema.VersionedCueSchema
}
// Validate checks that the resource is correct with respect to the schema.
func (cds *compositeDashboardSchema) Validate(r schema.Resource) error {
rv, err := rt.Compile("resource", r.Value)
if err != nil {
return err
}
return cds.actual.Unify(rv.Value()).Validate(cue.Concrete(true))
}
// ApplyDefaults returns a new, concrete copy of the Resource with all paths
// that are 1) missing in the Resource AND 2) specified by the schema,
// filled with default values specified by the schema.
func (cds *compositeDashboardSchema) ApplyDefaults(_ schema.Resource) (schema.Resource, error) {
panic("not implemented") // TODO: Implement
}
// TrimDefaults returns a new, concrete copy of the Resource where all paths
// in the where the values at those paths are the same as the default value
// given in the schema.
func (cds *compositeDashboardSchema) TrimDefaults(_ schema.Resource) (schema.Resource, error) {
panic("not implemented") // TODO: Implement
}
// CUE returns the cue.Value representing the actual schema.
func (cds *compositeDashboardSchema) CUE() cue.Value {
return cds.actual
}
// Version reports the major and minor versions of the schema.
func (cds *compositeDashboardSchema) Version() (major int, minor int) {
return cds.base.Version()
}
// Returns the next VersionedCueSchema
func (cds *compositeDashboardSchema) Successor() schema.VersionedCueSchema {
if cds.next == nil {
// Untyped nil, allows `<sch> == nil` checks to work as people expect
return nil
}
return cds.next
}
func (cds *compositeDashboardSchema) Migrate(x schema.Resource) (schema.Resource, schema.VersionedCueSchema, error) { // TODO restrict input/return type to concrete
r, sch, err := cds.migration(x.Value)
if err != nil || sch == nil {
// TODO fix sloppy types
r = x.Value.(cue.Value)
}
return schema.Resource{Value: r}, sch, nil
}
func (cds *compositeDashboardSchema) LatestPanelSchemaFor(id string) (schema.VersionedCueSchema, error) {
// So much slop rn, but it's OK because i FINALLY know where this is going!
psch, has := cds.panelFams[id]
if !has {
// TODO typed errors
return nil, fmt.Errorf("unknown panel plugin type %q", id)
}
latest := schema.Find(psch, schema.Latest())
sch := &genericVersionedSchema{
actual: cds.base.CUE().LookupPath(panelSubpath).Unify(mapPanelModel(id, latest)),
}
sch.major, sch.minor = latest.Version()
return sch, nil
}
// One-off special interface for dashboard composite schema, until the composite
// dashboard schema pattern is fully generalized.
//
// NOTE: THIS IS A TEMPORARY TYPE. IT WILL BE REPLACED WITH A GENERIC INTERFACE
// TO REPRESENT COMPOSITIONAL SCHEMA FAMILY PRIOR TO GRAFANA 8. UPDATING WILL
// SHOULD BE TRIVIAL, BUT IT WILL CAUSE BREAKAGES.
type CompositeDashboardSchema interface {
schema.VersionedCueSchema
LatestPanelSchemaFor(id string) (schema.VersionedCueSchema, error)
}
+180
View File
@@ -0,0 +1,180 @@
package load
import (
"cuelang.org/go/cue"
"cuelang.org/go/cue/load"
"github.com/grafana/grafana/pkg/schema"
)
// getBaseScuemata attempts to load the base scuemata family and schema
// definitions on which all Grafana scuemata rely.
//
// TODO probably cache this or something
func getBaseScuemata(p BaseLoadPaths) (*cue.Instance, error) {
overlay := make(map[string]load.Source)
if err := toOverlay("/grafana", p.BaseCueFS, overlay); err != nil {
return nil, err
}
cfg := &load.Config{
Overlay: overlay,
Package: "scuemata",
// TODO Semantics of loading instances is quite confusing. This 'Dir'
// field is a case in point. It must be set to "/" in order for the
// overlay to be searched and have all files loaded in the cue/scuemata
// directory. (This isn't necessary when loading individual .cue files.)
// But anchoring a search at root seems like we're begging for
// vulnerabilities where Grafana can read and print out anything on the
// filesystem, which can be a disclosure problem, unless we're
// absolutely sure the search is within a virtual filesystem. Which i'm
// not.
//
// And no, changing the toOverlay() to have a subpath and the
// load.Instances to mirror that subpath does not allow us to get rid of
// this "/".
Dir: "/",
}
return rt.Build(load.Instances([]string{"/grafana/cue/scuemata"}, cfg)[0])
}
func buildGenericScuemata(famval cue.Value) (schema.VersionedCueSchema, error) {
// TODO verify subsumption by #Family; renders many
// error checks below unnecessary
majiter, err := famval.LookupPath(cue.MakePath(cue.Str("lineages"))).List()
if err != nil {
return nil, err
}
var major int
var first, lastgvs *genericVersionedSchema
for majiter.Next() {
var minor int
miniter, _ := majiter.Value().List()
for miniter.Next() {
gvs := &genericVersionedSchema{
actual: miniter.Value(),
major: major,
minor: minor,
// This gets overwritten on all but the very final schema
migration: terminalMigrationFunc,
}
if minor != 0 {
// TODO Verify that this schema is backwards compat with prior.
// Create an implicit migration operation on the prior schema.
lastgvs.migration = implicitMigration(gvs.actual, gvs)
lastgvs.next = gvs
} else if major != 0 {
lastgvs.next = gvs
// x.0. There should exist an explicit migration definition;
// load it up and ready it for use, and place it on the final
// schema in the prior sequence.
//
// Also...should at least try to make sure it's pointing at the
// expected schema, to maintain our invariants?
// TODO impl
} else {
first = gvs
}
lastgvs = gvs
minor++
}
major++
}
return first, nil
}
type genericVersionedSchema struct {
actual cue.Value
major int
minor int
next *genericVersionedSchema
migration migrationFunc
}
// Validate checks that the resource is correct with respect to the schema.
func (gvs *genericVersionedSchema) Validate(r schema.Resource) error {
rv, err := rt.Compile("resource", r.Value)
if err != nil {
return err
}
return gvs.actual.Unify(rv.Value()).Validate(cue.Concrete(true))
}
// ApplyDefaults returns a new, concrete copy of the Resource with all paths
// that are 1) missing in the Resource AND 2) specified by the schema,
// filled with default values specified by the schema.
func (gvs *genericVersionedSchema) ApplyDefaults(_ schema.Resource) (schema.Resource, error) {
panic("not implemented") // TODO: Implement
}
// TrimDefaults returns a new, concrete copy of the Resource where all paths
// in the where the values at those paths are the same as the default value
// given in the schema.
func (gvs *genericVersionedSchema) TrimDefaults(_ schema.Resource) (schema.Resource, error) {
panic("not implemented") // TODO: Implement
}
// CUE returns the cue.Value representing the actual schema.
func (gvs *genericVersionedSchema) CUE() cue.Value {
return gvs.actual
}
// Version reports the major and minor versions of the schema.
func (gvs *genericVersionedSchema) Version() (major int, minor int) {
return gvs.major, gvs.minor
}
// Returns the next VersionedCueSchema
func (gvs *genericVersionedSchema) Successor() schema.VersionedCueSchema {
if gvs.next == nil {
// Untyped nil, allows `<sch> == nil` checks to work as people expect
return nil
}
return gvs.next
}
// Migrate transforms a resource into a new Resource that is correct with
// respect to its Successor schema.
func (gvs *genericVersionedSchema) Migrate(x schema.Resource) (schema.Resource, schema.VersionedCueSchema, error) { // TODO restrict input/return type to concrete
r, sch, err := gvs.migration(x.Value)
if err != nil || sch == nil {
r = x.Value.(cue.Value)
}
return schema.Resource{Value: r}, sch, nil
}
type migrationFunc func(x interface{}) (cue.Value, schema.VersionedCueSchema, error)
var terminalMigrationFunc = func(x interface{}) (cue.Value, schema.VersionedCueSchema, error) {
// TODO send back the input
return cue.Value{}, nil, nil
}
// panic if called
// var panicMigrationFunc = func(x interface{}) (cue.Value, schema.VersionedCueSchema, error) {
// panic("migrations are not yet implemented")
// }
// Creates a func to perform a "migration" that simply unifies the input
// artifact (which is expected to have already have been validated against an
// earlier schema) with a later schema.
func implicitMigration(v cue.Value, next schema.VersionedCueSchema) migrationFunc {
return func(x interface{}) (cue.Value, schema.VersionedCueSchema, error) {
w := v.Fill(x)
// TODO is it possible that migration would be successful, but there
// still exists some error here? Need to better understand internal CUE
// erroring rules? seems like incomplete cue.Value may always an Err()?
//
// TODO should check concreteness here? Or can we guarantee a priori it
// can be made concrete simply by looking at the schema, before
// implicitMigration() is called to create this function?
if w.Err() != nil {
return w, nil, w.Err()
}
return w, next, w.Err()
}
}
+125
View File
@@ -0,0 +1,125 @@
package load
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"testing"
"github.com/grafana/grafana"
"github.com/grafana/grafana/pkg/schema"
"github.com/stretchr/testify/require"
)
var p BaseLoadPaths = BaseLoadPaths{
BaseCueFS: grafana.CoreSchema,
DistPluginCueFS: grafana.PluginSchema,
}
// Basic well-formedness tests on core scuemata.
func TestScuemataBasics(t *testing.T) {
all := make(map[string]schema.VersionedCueSchema)
dash, err := BaseDashboardFamily(p)
require.NoError(t, err, "error while loading base dashboard scuemata")
all["basedash"] = dash
ddash, err := DistDashboardFamily(p)
require.NoError(t, err, "error while loading dist dashboard scuemata")
all["distdash"] = ddash
for set, sch := range all {
t.Run(set, func(t *testing.T) {
require.NotNil(t, sch, "scuemata for %q linked to empty chain", set)
maj, min := sch.Version()
t.Run(fmt.Sprintf("%v.%v", maj, min), func(t *testing.T) {
cv := sch.CUE()
t.Run("Exists", func(t *testing.T) {
require.True(t, cv.Exists(), "cue value for schema does not exist")
})
t.Run("Validate", func(t *testing.T) {
require.NoError(t, cv.Validate(), "all schema should be valid with respect to basic CUE rules")
})
})
})
}
}
func TestDashboardValidity(t *testing.T) {
// TODO FIXME remove this once we actually have dashboard schema filled in
// enough that the tests pass, lol
t.Skip()
validdir := os.DirFS(filepath.Join("testdata", "artifacts", "dashboards"))
dash, err := BaseDashboardFamily(p)
require.NoError(t, err, "error while loading base dashboard scuemata")
ddash, err := DistDashboardFamily(p)
require.NoError(t, err, "error while loading dist dashboard scuemata")
require.NoError(t, fs.WalkDir(validdir, ".", func(path string, d fs.DirEntry, err error) error {
require.NoError(t, err)
if d.IsDir() || filepath.Ext(d.Name()) != ".json" {
return nil
}
t.Run(path, func(t *testing.T) {
b, err := validdir.Open(path)
require.NoError(t, err, "failed to open dashboard file")
t.Run("base", func(t *testing.T) {
_, err := schema.SearchAndValidate(dash, b)
require.NoError(t, err, "dashboard failed validation")
})
t.Run("dist", func(t *testing.T) {
_, err := schema.SearchAndValidate(ddash, b)
require.NoError(t, err, "dashboard failed validation")
})
})
return nil
}))
}
func TestPanelValidity(t *testing.T) {
validdir := os.DirFS(filepath.Join("testdata", "artifacts", "panels"))
// dash, err := BaseDashboardFamily(p)
// require.NoError(t, err, "error while loading base dashboard scuemata")
ddash, err := DistDashboardFamily(p)
require.NoError(t, err, "error while loading dist dashboard scuemata")
// TODO hmm, it's awkward for this test's structure to have to pick just one
// type of panel plugin, but we can change the test structure. However, is
// there any other situation where we want the panel subschema with all
// possible disjunctions? If so, maybe the interface needs work. Or maybe
// just defer that until the proper generic composite scuemata impl.
dpan, err := ddash.(CompositeDashboardSchema).LatestPanelSchemaFor("table")
require.NoError(t, err, "error while loading panel subschema")
require.NoError(t, fs.WalkDir(validdir, ".", func(path string, d fs.DirEntry, err error) error {
require.NoError(t, err)
if d.IsDir() || filepath.Ext(d.Name()) != ".json" {
return nil
}
t.Run(path, func(t *testing.T) {
// TODO FIXME stop skipping once we actually have the schema filled in
// enough that the tests pass, lol
t.Skip()
b, err := validdir.Open(path)
require.NoError(t, err, "failed to open panel file")
err = dpan.Validate(schema.Resource{Value: b})
require.NoError(t, err, "panel failed validation")
})
return nil
}))
}
+161
View File
@@ -0,0 +1,161 @@
package load
import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"io/ioutil"
"path/filepath"
"cuelang.org/go/cue"
"cuelang.org/go/cue/load"
"github.com/grafana/grafana/pkg/schema"
)
// Returns a disjunction of structs representing each panel schema version
// (post-mapping from on-disk #PanelModel form) from each scuemata in the map.
func disjunctPanelScuemata(scuemap map[string]schema.VersionedCueSchema) (cue.Value, error) {
partsi, err := rt.Compile("panelDisjunction", `
allPanels: [Name=_]: {}
parts: or([for v in allPanels { v }])
`)
if err != nil {
return cue.Value{}, err
}
parts := partsi.Value()
for id, sch := range scuemap {
for sch != nil {
cv := mapPanelModel(id, sch)
mjv, miv := sch.Version()
parts = parts.Fill(cv, "allPanels", fmt.Sprintf("%s@%v.%v", id, mjv, miv))
sch = sch.Successor()
}
}
return parts.LookupPath(cue.MakePath(cue.Str("parts"))), nil
}
// mapPanelModel maps a schema from the #PanelModel form in which it's declared
// in a plugin's model.cue to the structure in which it actually appears in the
// dashboard schema.
func mapPanelModel(id string, vcs schema.VersionedCueSchema) cue.Value {
maj, min := vcs.Version()
// Ignore err return, this can't fail to compile
inter, _ := rt.Compile("typedPanel", fmt.Sprintf(`
in: {
type: %q
v: {
maj: %d
min: %d
}
model: {...}
}
result: {
type: in.type,
panelSchema: maj: in.v.maj
panelSchema: min: in.v.min
options: in.model.PanelOptions
fieldConfig: defaults: custom: in.model.PanelFieldConfig
}
`, id, maj, min))
// TODO validate, especially with #PanelModel
return inter.Value().Fill(vcs.CUE(), "in", "model").LookupPath(cue.MakePath(cue.Str(("result"))))
}
func readPanelModels(p BaseLoadPaths) (map[string]schema.VersionedCueSchema, error) {
overlay := make(map[string]load.Source)
if err := toOverlay("/", p.BaseCueFS, overlay); err != nil {
return nil, err
}
if err := toOverlay("/", p.DistPluginCueFS, overlay); err != nil {
return nil, err
}
base, err := getBaseScuemata(p)
if err != nil {
return nil, err
}
pmf := base.Value().LookupPath(cue.MakePath(cue.Def("#PanelFamily")))
if !pmf.Exists() {
return nil, errors.New("could not locate #PanelFamily definition")
}
all := make(map[string]schema.VersionedCueSchema)
err = fs.WalkDir(p.DistPluginCueFS, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || d.Name() != "plugin.json" {
return nil
}
dpath := filepath.Dir(path)
// For now, skip plugins without a models.cue
_, err = p.DistPluginCueFS.Open(filepath.Join(dpath, "models.cue"))
if err != nil {
return nil
}
fi, err := p.DistPluginCueFS.Open(path)
if err != nil {
return err
}
b, err := ioutil.ReadAll(fi)
if err != nil {
return err
}
jmap := make(map[string]interface{})
err = json.Unmarshal(b, &jmap)
if err != nil {
return err
}
iid, has := jmap["id"]
if !has || jmap["type"] != "panel" {
return errors.New("no type field in plugin.json or not a panel type plugin")
}
id := iid.(string)
cfg := &load.Config{
Package: "grafanaschema",
Overlay: overlay,
}
li := load.Instances([]string{filepath.Join("/", dpath, "models.cue")}, cfg)
imod, err := rt.Build(li[0])
if err != nil {
return err
}
// Get the Family declaration in the models.cue file...
pmod := imod.Value().LookupPath(cue.MakePath(cue.Str("Family")))
if !pmod.Exists() {
return fmt.Errorf("%s does not contain a declaration of its models at path 'Family'", path)
}
// Ensure the declared value is subsumed by/correct wrt #PanelFamily
if err := pmf.Subsume(pmod); err != nil {
return err
}
// Create a generic schema family to represent the whole of the
fam, err := buildGenericScuemata(pmod)
if err != nil {
return err
}
all[id] = fam
return nil
})
if err != nil {
return nil, err
}
return all, nil
}
+3
View File
@@ -0,0 +1,3 @@
All artifact JSON contained in these subdirectories should be valid. Invalid
JSON is handled elsewhere, as it must be coupled with expected error messages
for testing purposes.
+153
View File
@@ -0,0 +1,153 @@
{
"__inputs": [
{
"name": "DS_GDEV-TESTDATA",
"label": "gdev-testdata",
"description": "",
"type": "datasource",
"pluginId": "testdata",
"pluginName": "TestData DB"
}
],
"__requires": [
{
"type": "grafana",
"id": "grafana",
"name": "Grafana",
"version": "7.5.0-pre"
},
{
"type": "panel",
"id": "table",
"name": "Table",
"version": ""
},
{
"type": "datasource",
"id": "testdata",
"name": "TestData DB",
"version": "1.0.0"
}
],
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": "-- Grafana --",
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"rawQuery": "wtf",
"showIn": 0,
"type": "dashboard"
}
]
},
"editable": true,
"graphTooltip": 0,
"id": 42,
"links": [],
"panels": [
{
"datasource": "${DS_GDEV-TESTDATA}",
"fieldConfig": {
"defaults": {
"custom": {
"align": "right",
"filterable": false
},
"decimals": 3,
"mappings": [],
"unit": "watt"
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "Max"
},
"properties": [
{
"id": "custom.displayMode",
"value": "lcd-gauge"
}
]
},
{
"matcher": {
"id": "byName",
"options": "A"
},
"properties": [
{
"id": "custom.width",
"value": 200
}
]
}
]
},
"gridPos": {
"h": 9,
"w": 12,
"x": 0,
"y": 0
},
"id": 2,
"options": {
"showHeader": true,
"sortBy": []
},
"pluginVersion": "7.5.0-pre",
"targets": [
{
"alias": "",
"csvWave": {
"timeStep": 60,
"valuesCSV": "0,0,2,2,1,1"
},
"lines": 10,
"points": [],
"pulseWave": {
"offCount": 3,
"offValue": 1,
"onCount": 3,
"onValue": 2,
"timeStep": 60
},
"refId": "A",
"scenarioId": "random_walk_table",
"stream": {
"bands": 1,
"noise": 2.2,
"speed": 250,
"spread": 3.5,
"type": "signal"
},
"stringInput": ""
}
],
"title": "Panel Title",
"type": "table",
"panelSchema": {
"maj": 0,
"min": 0
}
}
],
"schemaVersion": 27,
"style": "dark",
"tags": [],
"templating": {
"list": []
},
"time": {
"from": "now-6h",
"to": "now"
},
"timezone": "browser",
"title": "with table",
"uid": "emal8gQMz",
"version": 2
}
@@ -0,0 +1,85 @@
{
"datasource": "${DS_GDEV-TESTDATA}",
"fieldConfig": {
"defaults": {
"custom": {
"align": "right",
"filterable": false
},
"decimals": 3,
"mappings": [],
"unit": "watt"
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "Max"
},
"properties": [
{
"id": "custom.displayMode",
"value": "lcd-gauge"
}
]
},
{
"matcher": {
"id": "byName",
"options": "A"
},
"properties": [
{
"id": "custom.width",
"value": 200
}
]
}
]
},
"gridPos": {
"h": 9,
"w": 12,
"x": 0,
"y": 0
},
"options": {
"showHeader": true,
"sortBy": []
},
"pluginVersion": "7.5.0-pre",
"targets": [
{
"alias": "",
"csvWave": {
"timeStep": 60,
"valuesCSV": "0,0,2,2,1,1"
},
"lines": 10,
"points": [],
"pulseWave": {
"offCount": 3,
"offValue": 1,
"onCount": 3,
"onValue": 2,
"timeStep": 60
},
"refId": "A",
"scenarioId": "random_walk_table",
"stream": {
"bands": 1,
"noise": 2.2,
"speed": 250,
"spread": 3.5,
"type": "signal"
},
"stringInput": ""
}
],
"title": "Panel Title",
"type": "table",
"panelSchema": {
"maj": 0,
"min": 0
}
}
@@ -0,0 +1,90 @@
package grafanaschema
import (
ui "github.com/grafana/grafana/cue/ui:grafanaschema"
)
// TODO should we remove Family, and make lineages and migrations top-level values?
// It's easy to do, and arguably increases clarity of this crucial file by
// reducing one layer of nesting. But it sorta requires understanding that CUE
// also thinks of an entire file (aka, an "instance") as a struct in order for
// it to make sense that the file itself is schematized by #PanelFamily. What's
// the best DX here?
// "Family" must be an instance of the #PanelFamily type, defined in
// cue/scuemata/panel-plugin.cue. This ensures some key invariants:
//
// - lineages is an array of arrays. Outer array is major version, inner is minor.
// (This IS NOT semver, though.)
// - Within a single seq, each successive schema is backwards compatible with
// the prior schema. (See, it's not semver. No special rules for v0.)
// - For each seq/major version after the first, there exists a migration
// that allows us to transform a resource compliant with the old version of
// the schema into one compliant with the new one.
//
// That's right, we've schematized our schema declarations. Not all above
// invariants are enforced right now, but they must be before launch.
//
// Grafana won't need to rely on multiple versions of schema until after this
// system is released with Grafana 8. But it needs to be in place at the moment
// Grafana 8 is released - especially for plugins, which have their own release
// cycle, and could need to make breaking changes very shortly after v8's release.
Family: {
lineages: [
[
{ // v0.0. The actual schema is the contents of this struct.
PanelOptions: {
frameIndex: number | *0
showHeader: bool | *true
sortBy?: [...ui.TableSortByFieldState]
}
PanelFieldConfig: {
width?: int
align?: *null | string
displayMode?: string | *"auto" // TODO? TableCellDisplayMode
filterable?: bool
}
},
{ // v0.1
lineages[0][0]
PanelOptions: foo: string | *"foo"
}
],
[
{ // v1.0 - breaking changes vs. v0.1 in this struct.
PanelOptions: {
frameIndex: number | *0
includeHeader: bool | *true
sortBy?: [...ui.TableSortByFieldState]
}
PanelFieldConfig: {
width?: int
align?: string
displayMode?: string
}
}
],
]
migrations: [
{ // maps from v0.1 to v1.0
// TODO it's not good that the user has to specify these. Should be
// implicit, since we don't want to allow any actual choice here.
// But NOT having it also means CUE can't actually tell if the
// _rel definition makes any sense at all. UGHHH. Would it be
// better to put these directly on the lineages?
from: lineages[0][1]
to: lineages[1][0]
rel: {
PanelOptions: {
frameIndex: from.PanelOptions.frameIndex
includeHeader: from.PanelOptions.showHeader
if from.PanelOptions.sortBy != _|_ {
sortBy: from.PanelOptions.sortBy | *null
}
}
PanelFieldConfig: from.PanelFieldConfig
}
result: rel & to
}
]
}
@@ -0,0 +1,9 @@
{
"type": "panel",
"name": "Sample plugin with lineage",
"id": "with-lineage",
"info": {
"description": "Show how complex history may work"
}
}
+274
View File
@@ -0,0 +1,274 @@
package schema
import (
"errors"
"fmt"
"math/bits"
"cuelang.org/go/cue"
)
// CueSchema represents a single, complete CUE-based schema that can perform
// operations on Resources.
//
// All CueSchema MUST EITHER:
// - Be a VersionedCueSchema, and be the latest version in the latest lineage in a Family
// - Return non-nil from Successor(), and a procedure to Migrate() a Resource to that successor schema
//
// By definition, VersionedCueSchema are within a lineage. As long as lineage
// backwards compatibility invariants hold, migration to a VersionedCueSchema to
// a successor schema in their lineage is trivial: simply unify the Resource
// with the successor schema.
type CueSchema interface {
// Validate checks that the resource is correct with respect to the schema.
Validate(Resource) error
// ApplyDefaults returns a new, concrete copy of the Resource with all paths
// that are 1) missing in the Resource AND 2) specified by the schema,
// filled with default values specified by the schema.
ApplyDefaults(Resource) (Resource, error)
// TrimDefaults returns a new, concrete copy of the Resource where all paths
// in the where the values at those paths are the same as the default value
// given in the schema.
TrimDefaults(Resource) (Resource, error)
// Migrate transforms a Resource into a new Resource that is correct with
// respect to its Successor schema. It returns the transformed resource,
// the schema to which the resource now conforms, and any errors that
// may have occurred during the migration.
//
// No migration occurs and the input Resource is returned in two cases:
//
// - The migration encountered an error; the third return is non-nil.
// - There exists no schema to migrate to; the second and third return are nil.
//
// Note that the returned schema is always a VersionedCueSchema. This
// reflects a key design invariant of the system: all migrations, whether
// they begin from a schema inside or outside of the Family, must land
// somewhere on a Family's sequence of schemata.
Migrate(Resource) (Resource, VersionedCueSchema, error)
// Successor returns the VersionedCueSchema to which this CueSchema can migrate a
// Resource.
Successor() VersionedCueSchema
// CUE returns the cue.Value representing the actual schema.
CUE() cue.Value
}
// VersionedCueSchema are CueSchema that are part of a backwards-compatible
// versioned lineage.
type VersionedCueSchema interface {
CueSchema
// Version reports the major and minor versions of the schema.
Version() (major, minor int)
}
// SearchAndValidate traverses the family of schemas reachable from the provided
// VersionedCueSchema. For each schema, it attempts to validate the provided
// value, which may be a byte slice representing valid JSON (TODO YAML), a Go
// struct, or cue.Value. If providing a cue.Value that is not fully concrete,
// the result is undefined.
//
// Traversal is performed from the newest schema to the oldest. However, because
// newer VersionedCueSchema have no way of directly accessing their predecessors
// (they form a singly-linked list), the oldest possible schema should always be
// provided - typically, the one returned from the family loader function.
//
// Failure to validate against any schema in the family is indicated by a
// non-nil error return. Success is indicated by a non-nil VersionedCueSchema.
// If successful, the returned VersionedCueSchema will be the first one against
// which the provided resource passed validation.
func SearchAndValidate(s VersionedCueSchema, v interface{}) (VersionedCueSchema, error) {
arr := AsArray(s)
// Work from latest to earliest
var err error
for o := len(arr) - 1; o >= 0; o-- {
for i := len(arr[o]) - 1; i >= 0; i-- {
if err = arr[o][i].Validate(Resource{Value: v}); err == nil {
return arr[o][i], nil
}
}
}
// TODO sloppy, return more than last error. Need our own error type that
// collates all the individual errors, relates them to the schema that
// produced them, and ideally deduplicates repeated errors across each
// schema.
return nil, err
}
// AsArray collates all VersionedCueSchema in a Family into a two-dimensional
// array. The outer array index corresponds to major version number and inner
// array index to minor version number.
func AsArray(sch VersionedCueSchema) [][]VersionedCueSchema {
var ret [][]VersionedCueSchema
var flat []VersionedCueSchema
// two loops. lazy day, today
for sch != nil {
flat = append(flat, sch)
sch = sch.Successor()
}
for _, sch := range flat {
maj, _ := sch.Version()
if len(ret) == maj {
ret = append(ret, []VersionedCueSchema{})
}
ret[maj] = append(ret[maj], sch)
}
return ret
}
// Find traverses the chain of VersionedCueSchema until the criteria in the
// SearchOption is met.
//
// If no schema is found that fulfills the criteria, nil is returned. Latest()
// and LatestInCurrentMajor() will always succeed, unless the input schema is
// nil.
func Find(s VersionedCueSchema, opt SearchOption) VersionedCueSchema {
if s == nil {
return nil
}
p := &ssopt{}
opt(p)
if err := p.validate(); err != nil {
panic(fmt.Sprint("unreachable:", err))
}
switch {
case p.latest:
for ; s.Successor() != nil; s = s.Successor() {
}
return s
case p.latestInCurrentMajor:
p.latestInMajor, _ = s.Version()
fallthrough
case p.hasLatestInMajor:
imaj, _ := s.Version()
if imaj > p.latestInMajor {
return nil
}
var last VersionedCueSchema
for imaj <= p.latestInMajor {
last, s = s, s.Successor()
if s == nil {
if imaj == p.latestInMajor {
return last
}
return nil
}
imaj, _ = s.Version()
}
return last
default: // exact
for s != nil {
maj, min := s.Version()
if p.exact == [2]int{maj, min} {
return s
}
s = s.Successor()
}
return nil
}
}
// SearchOption indicates how far along a chain of schemas an operation should
// proceed.
type SearchOption sso
type sso func(p *ssopt)
type ssopt struct {
latest bool
latestInMajor int
hasLatestInMajor bool
latestInCurrentMajor bool
exact [2]int
}
func (p *ssopt) validate() error {
var which uint16
if p.latest {
which = which + 1<<1
}
if p.exact != [2]int{0, 0} {
which = which + 1<<2
}
if p.hasLatestInMajor {
if p.latestInMajor != -1 {
which = which + 1<<3
}
} else if p.latestInMajor != 0 {
// Disambiguate real zero from default zero
return fmt.Errorf("latestInMajor should never be non-zero if hasLatestInMajor is false, got %v", p.latestInMajor)
}
if p.latestInCurrentMajor {
which = which + 1<<4
}
if bits.OnesCount16(which) != 1 {
return errors.New("may only pass one SchemaSearchOption")
}
return nil
}
// Latest indicates that traversal will continue to the newest schema in the
// newest lineage.
func Latest() SearchOption {
return func(p *ssopt) {
p.latest = true
}
}
// LatestInMajor will find the latest schema within the provided major version
// lineage. If no lineage exists corresponding to the provided number, traversal
// will terminate with an error.
func LatestInMajor(maj int) SearchOption {
return func(p *ssopt) {
p.latestInMajor = maj
}
}
// LatestInCurrentMajor will find the newest schema having the same major
// version as the schema from which the search begins.
func LatestInCurrentMajor() SearchOption {
return func(p *ssopt) {
p.latestInCurrentMajor = true
}
}
// Exact will find the schema with the exact major and minor version number
// provided.
func Exact(maj, min int) SearchOption {
return func(p *ssopt) {
p.exact = [2]int{maj, min}
}
}
// A Resource represents a concrete data object - e.g., JSON
// representing a dashboard.
//
// This type mostly exists to improve readability for users. Having a type that
// differentiates cue.Value that represent a schema from cue.Value that
// represent a concrete object is quite helpful. It also gives us a working type
// for a resource that can be reused across multiple calls, so that re-parsing
// isn't necessary.
//
// TODO this is a terrible way to do this, refactor
type Resource struct {
Value interface{}
}
// TODO add migrator with SearchOption for stopping criteria
+4
View File
@@ -0,0 +1,4 @@
package schema
// TODO tests for this stuff! Everything in this package is totally generic,
// nothing is specific to Grafana