2022-08-22 11:11:45 -05:00
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"
2022-11-15 07:48:31 -06:00
"github.com/grafana/grafana/pkg/kindsys"
"github.com/grafana/grafana/pkg/plugins/plugindef"
2022-08-22 11:11:45 -05:00
"github.com/grafana/thema"
"github.com/grafana/thema/load"
2022-10-11 03:45:07 -05:00
"github.com/grafana/thema/vmux"
2022-08-22 11:11:45 -05:00
"github.com/yalue/merged_fs"
)
// PermittedCUEImports returns the list of packages that may be imported in a
// plugin models.cue file.
2022-11-15 07:48:31 -06:00
//
// TODO probably move this into kindsys
2022-08-22 11:11:45 -05:00
func PermittedCUEImports ( ) [ ] string {
return [ ] string {
"github.com/grafana/thema" ,
2023-01-18 11:40:22 -06:00
"github.com/grafana/grafana/packages/grafana-schema/src/common" ,
2022-08-22 11:11:45 -05:00
}
}
func importAllowed ( path string ) bool {
for _ , p := range PermittedCUEImports ( ) {
if p == path {
return true
}
}
return false
}
var allowedImportsStr string
type slotandname struct {
name string
2023-01-06 11:37:32 -06:00
slot kindsys . SchemaInterface
2022-08-22 11:11:45 -05:00
}
var allslots [ ] slotandname
func init ( ) {
2022-11-28 06:10:24 -06:00
all := make ( [ ] string , 0 , len ( PermittedCUEImports ( ) ) )
2022-08-22 11:11:45 -05:00
for _ , im := range PermittedCUEImports ( ) {
all = append ( all , fmt . Sprintf ( "\t%s" , im ) )
}
allowedImportsStr = strings . Join ( all , "\n" )
2023-01-06 11:37:32 -06:00
for n , s := range kindsys . SchemaInterfaces ( nil ) {
2022-08-22 11:11:45 -05:00
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 {
2022-09-14 09:15:09 -05:00
// 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
2023-01-06 11:37:32 -06:00
// particular named Grafana slot (See ["github.com/grafana/grafana/pkg/framework/coremodel".SchemaInterface]).
2022-09-14 09:15:09 -05:00
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
2022-08-22 11:11:45 -05:00
}
// PluginInfo represents everything knowable about a single plugin from static
// analysis of its filesystem tree contents.
type PluginInfo struct {
2022-11-15 07:48:31 -06:00
meta plugindef . PluginDef
2022-08-22 11:11:45 -05:00
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.
2022-11-15 07:48:31 -06:00
func ( pi PluginInfo ) Meta ( ) plugindef . PluginDef {
2022-08-22 11:11:45 -05:00
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.
//
2022-09-14 09:15:09 -05:00
// It does not descend into subdirectories to search for additional plugin.json
// files.
2022-11-15 07:48:31 -06:00
//
// Calling this with a nil thema.Runtime will take advantage of memoization.
// Prefer this approach unless a different thema.Runtime is specifically
// required.
//
2022-08-22 11:11:45 -05:00
// TODO no descent is ok for core plugins, but won't cut it in general
2022-10-11 03:45:07 -05:00
func ParsePluginFS ( f fs . FS , rt * thema . Runtime ) ( * Tree , error ) {
2022-08-22 11:11:45 -05:00
if f == nil {
return nil , ErrEmptyFS
}
2022-11-15 07:48:31 -06:00
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" ) )
2022-10-11 03:45:07 -05:00
ctx := rt . Context ( )
2022-08-22 11:11:45 -05:00
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
2022-11-15 07:48:31 -06:00
// 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)
2022-10-11 03:45:07 -05:00
pmeta , _ , err := mux ( b )
2022-08-22 11:11:45 -05:00
if err != nil {
// TODO more nuanced error handling by class of Thema failure
return nil , ewrap ( err , ErrInvalidRootFile )
}
2022-10-11 03:45:07 -05:00
r . meta = * pmeta
2022-08-22 11:11:45 -05:00
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 )
2022-11-15 07:48:31 -06:00
// Note that this actually will load any .cue files in the fs.FS root dir in the plugindef.PkgName.
2022-08-22 11:11:45 -05:00
// That's...maybe good? But not what it says on the tin
2022-11-15 07:48:31 -06:00
bi , err := load . InstanceWithThema ( mfs , "" , load . Package ( plugindef . PkgName ) )
2022-08-22 11:11:45 -05:00
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 )
2022-09-14 09:15:09 -05:00
if val . Err ( ) != nil {
return nil , ewrap ( fmt . Errorf ( "models.cue is invalid CUE: %w" , val . Err ( ) ) , ErrInvalidCUE )
}
2022-08-22 11:11:45 -05:00
for _ , s := range allslots {
iv := val . LookupPath ( cue . ParsePath ( s . slot . Name ( ) ) )
2023-01-06 11:37:32 -06:00
if iv . Exists ( ) {
lin , err := bindSlotLineage ( iv , s . slot , r . meta , rt )
if lin != nil {
r . slotimpls [ s . slot . Name ( ) ] = lin
}
if err != nil {
return nil , err
}
2022-08-22 11:11:45 -05:00
}
}
}
return tree , nil
}
2023-01-06 11:37:32 -06:00
func bindSlotLineage ( v cue . Value , s kindsys . SchemaInterface , meta plugindef . PluginDef , rt * thema . Runtime , opts ... thema . BindOption ) ( thema . Lineage , error ) {
// temporarily keep this around, there are IMMEDIATE plans to refactor
var required bool
accept := s . Should ( string ( meta . Type ) )
2022-08-22 11:11:45 -05:00
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
2022-10-11 03:45:07 -05:00
// lin, err := thema.BindLineage(iv, rt, thema.SatisfiesJoinSchema(s.MetaSchema()))
lin , err := thema . BindLineage ( v , rt , opts ... )
2022-08-22 11:11:45 -05:00
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 ( )
}