mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
* Get pluginmeta mostly moved over to pkg/plugins/plugindef * Remove dead func * Fix up pfs, use sync.Once in plugindef * Update to latest thema * Chase Endec->Codec conversion in Thema * Comments on slash header gen; use ToSlash * Also generate JSON schema for plugindef * Generate JSON Schema as well * Fix slot loading from kindsys cue decls * Remove unused vars * skip generating plugin.schema.json for now Co-authored-by: Marcus Efraimsson <marcus.efraimsson@gmail.com>
319 lines
9.1 KiB
Go
319 lines
9.1 KiB
Go
package pfs
|
|
|
|
import (
|
|
"fmt"
|
|
"io/fs"
|
|
"sort"
|
|
"strings"
|
|
|
|
"cuelang.org/go/cue"
|
|
"cuelang.org/go/cue/ast"
|
|
"cuelang.org/go/cue/errors"
|
|
"cuelang.org/go/cue/parser"
|
|
"github.com/grafana/grafana"
|
|
"github.com/grafana/grafana/pkg/kindsys"
|
|
"github.com/grafana/grafana/pkg/plugins/plugindef"
|
|
"github.com/grafana/thema"
|
|
"github.com/grafana/thema/load"
|
|
"github.com/grafana/thema/vmux"
|
|
"github.com/yalue/merged_fs"
|
|
)
|
|
|
|
// PermittedCUEImports returns the list of packages that may be imported in a
|
|
// plugin models.cue file.
|
|
//
|
|
// TODO probably move this into kindsys
|
|
func PermittedCUEImports() []string {
|
|
return []string{
|
|
"github.com/grafana/thema",
|
|
"github.com/grafana/grafana/packages/grafana-schema/src/schema",
|
|
}
|
|
}
|
|
|
|
func importAllowed(path string) bool {
|
|
for _, p := range PermittedCUEImports() {
|
|
if p == path {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
var allowedImportsStr string
|
|
|
|
type slotandname struct {
|
|
name string
|
|
slot *kindsys.Slot
|
|
}
|
|
|
|
var allslots []slotandname
|
|
|
|
func init() {
|
|
var all []string
|
|
for _, im := range PermittedCUEImports() {
|
|
all = append(all, fmt.Sprintf("\t%s", im))
|
|
}
|
|
allowedImportsStr = strings.Join(all, "\n")
|
|
|
|
for n, s := range kindsys.AllSlots(nil) {
|
|
allslots = append(allslots, slotandname{
|
|
name: n,
|
|
slot: s,
|
|
})
|
|
}
|
|
|
|
sort.Slice(allslots, func(i, j int) bool {
|
|
return allslots[i].name < allslots[j].name
|
|
})
|
|
}
|
|
|
|
// Tree represents the contents of a plugin filesystem tree.
|
|
type Tree struct {
|
|
raw fs.FS
|
|
rootinfo PluginInfo
|
|
}
|
|
|
|
func (t *Tree) FS() fs.FS {
|
|
return t.raw
|
|
}
|
|
|
|
func (t *Tree) RootPlugin() PluginInfo {
|
|
return t.rootinfo
|
|
}
|
|
|
|
// SubPlugins returned a map of the PluginInfos for subplugins
|
|
// within the tree, if any, keyed by subpath.
|
|
func (t *Tree) SubPlugins() map[string]PluginInfo {
|
|
// TODO implement these once ParsePluginFS descends
|
|
return nil
|
|
}
|
|
|
|
// TreeList is a slice of validated plugin fs Trees with helper methods
|
|
// for filtering to particular subsets of its members.
|
|
type TreeList []*Tree
|
|
|
|
// LineagesForSlot returns the set of plugin-defined lineages that implement a
|
|
// particular named Grafana slot (See ["github.com/grafana/grafana/pkg/framework/coremodel".Slot]).
|
|
func (tl TreeList) LineagesForSlot(slotname string) map[string]thema.Lineage {
|
|
m := make(map[string]thema.Lineage)
|
|
for _, tree := range tl {
|
|
rootp := tree.RootPlugin()
|
|
rid := rootp.Meta().Id
|
|
|
|
if lin, has := rootp.SlotImplementations()[slotname]; has {
|
|
m[rid] = lin
|
|
}
|
|
}
|
|
|
|
return m
|
|
}
|
|
|
|
// PluginInfo represents everything knowable about a single plugin from static
|
|
// analysis of its filesystem tree contents.
|
|
type PluginInfo struct {
|
|
meta plugindef.PluginDef
|
|
slotimpls map[string]thema.Lineage
|
|
imports []*ast.ImportSpec
|
|
}
|
|
|
|
// CUEImports lists the CUE import statements in the plugin's models.cue file,
|
|
// if any.
|
|
func (pi PluginInfo) CUEImports() []*ast.ImportSpec {
|
|
return pi.imports
|
|
}
|
|
|
|
// SlotImplementations returns a map of the plugin's Thema lineages that
|
|
// implement particular slots, keyed by the name of the slot.
|
|
//
|
|
// Returns an empty map if the plugin has not implemented any slots.
|
|
func (pi PluginInfo) SlotImplementations() map[string]thema.Lineage {
|
|
return pi.slotimpls
|
|
}
|
|
|
|
// Meta returns the metadata declared in the plugin's plugin.json file.
|
|
func (pi PluginInfo) Meta() plugindef.PluginDef {
|
|
return pi.meta
|
|
}
|
|
|
|
// ParsePluginFS takes an fs.FS and checks that it represents exactly one valid
|
|
// plugin fs tree, with the fs.FS root as the root of the tree.
|
|
//
|
|
// It does not descend into subdirectories to search for additional plugin.json
|
|
// files.
|
|
//
|
|
// Calling this with a nil thema.Runtime will take advantage of memoization.
|
|
// Prefer this approach unless a different thema.Runtime is specifically
|
|
// required.
|
|
//
|
|
// TODO no descent is ok for core plugins, but won't cut it in general
|
|
func ParsePluginFS(f fs.FS, rt *thema.Runtime) (*Tree, error) {
|
|
if f == nil {
|
|
return nil, ErrEmptyFS
|
|
}
|
|
lin, err := plugindef.Lineage(rt)
|
|
if err != nil {
|
|
panic(fmt.Sprintf("plugindef lineage is invalid or broken, needs dev attention: %s", err))
|
|
}
|
|
mux := vmux.NewValueMux(lin.TypedSchema(), vmux.NewJSONCodec("plugin.json"))
|
|
ctx := rt.Context()
|
|
|
|
b, err := fs.ReadFile(f, "plugin.json")
|
|
if err != nil {
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return nil, ErrNoRootFile
|
|
}
|
|
return nil, fmt.Errorf("error reading plugin.json: %w", err)
|
|
}
|
|
|
|
tree := &Tree{
|
|
raw: f,
|
|
rootinfo: PluginInfo{
|
|
slotimpls: make(map[string]thema.Lineage),
|
|
},
|
|
}
|
|
r := &tree.rootinfo
|
|
|
|
// Pass the raw bytes into the muxer, get the populated PluginDef type out that we want.
|
|
// TODO stop ignoring second return. (for now, lacunas are a WIP and can't occur until there's >1 schema in the plugindef lineage)
|
|
pmeta, _, err := mux(b)
|
|
if err != nil {
|
|
// TODO more nuanced error handling by class of Thema failure
|
|
return nil, ewrap(err, ErrInvalidRootFile)
|
|
}
|
|
r.meta = *pmeta
|
|
|
|
if modbyt, err := fs.ReadFile(f, "models.cue"); err == nil {
|
|
// TODO introduce layered CUE dependency-injecting loader
|
|
//
|
|
// Until CUE has proper dependency management (and possibly even after), loading
|
|
// CUE files with non-stdlib imports requires injecting the imported packages
|
|
// into cue.mod/pkg/<import path>, unless the imports are within the same CUE
|
|
// module. Thema introduced a system for this for its dependers, which we use
|
|
// here, but we'll need to layer the same on top for importable Grafana packages.
|
|
// Needing to do this twice strongly suggests it needs a generic, standalone
|
|
// library.
|
|
|
|
mfs := merged_fs.NewMergedFS(f, grafana.CueSchemaFS)
|
|
|
|
// Note that this actually will load any .cue files in the fs.FS root dir in the plugindef.PkgName.
|
|
// That's...maybe good? But not what it says on the tin
|
|
bi, err := load.InstanceWithThema(mfs, "", load.Package(plugindef.PkgName))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("loading models.cue failed: %w", err)
|
|
}
|
|
|
|
pf, _ := parser.ParseFile("models.cue", modbyt, parser.ParseComments)
|
|
|
|
for _, im := range pf.Imports {
|
|
ip := strings.Trim(im.Path.Value, "\"")
|
|
if !importAllowed(ip) {
|
|
return nil, ewrap(errors.Newf(im.Pos(), "import %q in models.cue not allowed, plugins may only import from:\n%s\n", ip, allowedImportsStr), ErrDisallowedCUEImport)
|
|
}
|
|
r.imports = append(r.imports, im)
|
|
}
|
|
|
|
val := ctx.BuildInstance(bi)
|
|
if val.Err() != nil {
|
|
return nil, ewrap(fmt.Errorf("models.cue is invalid CUE: %w", val.Err()), ErrInvalidCUE)
|
|
}
|
|
for _, s := range allslots {
|
|
iv := val.LookupPath(cue.ParsePath(s.slot.Name()))
|
|
lin, err := bindSlotLineage(iv, s.slot, r.meta, rt)
|
|
if lin != nil {
|
|
r.slotimpls[s.slot.Name()] = lin
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
|
|
return tree, nil
|
|
}
|
|
|
|
func bindSlotLineage(v cue.Value, s *kindsys.Slot, meta plugindef.PluginDef, rt *thema.Runtime, opts ...thema.BindOption) (thema.Lineage, error) {
|
|
accept, required := s.ForPluginType(string(meta.Type))
|
|
exists := v.Exists()
|
|
|
|
if !accept {
|
|
if exists {
|
|
// If it's not accepted for the type, but is declared, error out. This keeps a
|
|
// precise boundary on what's actually expected for plugins to do, which makes
|
|
// for clearer docs and guarantees for users.
|
|
return nil, ewrap(fmt.Errorf("%s: %s plugins may not provide a %s slot implementation in models.cue", meta.Id, meta.Type, s.Name()), ErrImplementedSlots)
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
if !exists && required {
|
|
return nil, ewrap(fmt.Errorf("%s: %s plugins must provide a %s slot implementation in models.cue", meta.Id, meta.Type, s.Name()), ErrImplementedSlots)
|
|
}
|
|
|
|
// TODO make this opt real in thema, then uncomment to enforce joinSchema
|
|
// lin, err := thema.BindLineage(iv, rt, thema.SatisfiesJoinSchema(s.MetaSchema()))
|
|
lin, err := thema.BindLineage(v, rt, opts...)
|
|
if err != nil {
|
|
return nil, ewrap(fmt.Errorf("%s: invalid thema lineage for slot %s: %w", meta.Id, s.Name(), err), ErrInvalidLineage)
|
|
}
|
|
|
|
sanid := sanitizePluginId(meta.Id)
|
|
if lin.Name() != sanid {
|
|
errf := func(format string, args ...interface{}) error {
|
|
var errin error
|
|
if n := v.LookupPath(cue.ParsePath("name")).Source(); n != nil {
|
|
errin = errors.Newf(n.Pos(), format, args...)
|
|
} else {
|
|
errin = fmt.Errorf(format, args...)
|
|
}
|
|
return ewrap(errin, ErrLineageNameMismatch)
|
|
}
|
|
if sanid != meta.Id {
|
|
return nil, errf("%s: %q slot lineage name must be the sanitized plugin id (%q), got %q", meta.Id, s.Name(), sanid, lin.Name())
|
|
} else {
|
|
return nil, errf("%s: %q slot lineage name must be the plugin id, got %q", meta.Id, s.Name(), lin.Name())
|
|
}
|
|
}
|
|
return lin, nil
|
|
}
|
|
|
|
// Plugin IDs are allowed to contain characters that aren't allowed in thema
|
|
// Lineage names, CUE package names, Go package names, TS or Go type names, etc.
|
|
func sanitizePluginId(s string) string {
|
|
return strings.Map(func(r rune) rune {
|
|
switch {
|
|
case r >= 'a' && r <= 'z':
|
|
fallthrough
|
|
case r >= 'A' && r <= 'Z':
|
|
fallthrough
|
|
case r >= '0' && r <= '9':
|
|
fallthrough
|
|
case r == '_':
|
|
return r
|
|
case r == '-':
|
|
return '_'
|
|
default:
|
|
return -1
|
|
}
|
|
}, s)
|
|
}
|
|
|
|
func ewrap(actual, is error) error {
|
|
return &errPassthrough{
|
|
actual: actual,
|
|
is: is,
|
|
}
|
|
}
|
|
|
|
type errPassthrough struct {
|
|
actual error
|
|
is error
|
|
}
|
|
|
|
func (e *errPassthrough) Is(err error) bool {
|
|
return errors.Is(err, e.actual) || errors.Is(err, e.is)
|
|
}
|
|
|
|
func (e *errPassthrough) Error() string {
|
|
return e.actual.Error()
|
|
}
|