Introduce "scuemata" system for CUE-based specification of Grafana objects (#32527)
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
package schema
|
||||
|
||||
// TODO tests for this stuff! Everything in this package is totally generic,
|
||||
// nothing is specific to Grafana
|
||||
Reference in New Issue
Block a user