package pfs import ( "fmt" "io/fs" "path/filepath" "sort" "strings" "sync" "testing/fstest" "cuelang.org/go/cue" "cuelang.org/go/cue/build" "cuelang.org/go/cue/cuecontext" "cuelang.org/go/cue/errors" "cuelang.org/go/cue/parser" "cuelang.org/go/cue/token" "github.com/grafana/kindsys" "github.com/grafana/thema" "github.com/grafana/thema/load" "github.com/grafana/thema/vmux" "github.com/yalue/merged_fs" "github.com/grafana/grafana/pkg/cuectx" "github.com/grafana/grafana/pkg/plugins/plugindef" ) // PackageName is the name of the CUE package that Grafana will load when // looking for a Grafana plugin's kind declarations. const PackageName = "grafanaplugin" var onceGP sync.Once var defaultGP cue.Value func doLoadGP(ctx *cue.Context) cue.Value { v, err := cuectx.BuildGrafanaInstance(ctx, filepath.Join("pkg", "plugins", "pfs"), "pfs", nil) if err != nil { // should be unreachable panic(err) } return v.LookupPath(cue.MakePath(cue.Str("GrafanaPlugin"))) } func loadGP(ctx *cue.Context) cue.Value { if ctx == nil || ctx == cuectx.GrafanaCUEContext() { onceGP.Do(func() { defaultGP = doLoadGP(ctx) }) return defaultGP } return doLoadGP(ctx) } // PermittedCUEImports returns the list of import paths that may be used in a // plugin's grafanaplugin cue package. var PermittedCUEImports = cuectx.PermittedCUEImports func importAllowed(path string) bool { for _, p := range PermittedCUEImports() { if p == path { return true } } return false } var allowedImportsStr string var allsi []kindsys.SchemaInterface func init() { all := make([]string, 0, len(PermittedCUEImports())) for _, im := range PermittedCUEImports() { all = append(all, fmt.Sprintf("\t%s", im)) } allowedImportsStr = strings.Join(all, "\n") for _, s := range kindsys.SchemaInterfaces(nil) { allsi = append(allsi, s) } sort.Slice(allsi, func(i, j int) bool { return allsi[i].Name() < allsi[j].Name() }) } // ParsePluginFS takes a virtual filesystem and checks that it contains a valid // set of files that statically define a Grafana plugin. // // The fsys must contain a plugin.json at the root, which must be valid // according to the [plugindef] schema. If any .cue files exist in the // grafanaplugin package, these will also be loaded and validated according to // the [GrafanaPlugin] specification. This includes the validation of any custom // or composable kinds and their contained lineages, via [thema.BindLineage]. // // This function parses exactly one plugin. It does not descend into // subdirectories to search for additional plugin.json or .cue files. // // Calling this with a nil [thema.Runtime] (the singleton returned from // [cuectx.GrafanaThemaRuntime] is used) will memoize certain CUE operations. // Prefer passing nil unless a different thema.Runtime is specifically required. // // [GrafanaPlugin]: https://github.com/grafana/grafana/blob/main/pkg/plugins/pfs/grafanaplugin.cue func ParsePluginFS(fsys fs.FS, rt *thema.Runtime) (ParsedPlugin, error) { if fsys == nil { return ParsedPlugin{}, ErrEmptyFS } if rt == nil { rt = cuectx.GrafanaThemaRuntime() } lin, err := plugindef.Lineage(rt) if err != nil { panic(fmt.Sprintf("plugindef lineage is invalid or broken, needs dev attention: %s", err)) } ctx := rt.Context() b, err := fs.ReadFile(fsys, "plugin.json") if err != nil { if errors.Is(err, fs.ErrNotExist) { return ParsedPlugin{}, ErrNoRootFile } return ParsedPlugin{}, fmt.Errorf("error reading plugin.json: %w", err) } pp := ParsedPlugin{ ComposableKinds: make(map[string]kindsys.Composable), // CustomKinds: make(map[string]kindsys.Custom), } // 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) pinst, _, err := vmux.NewTypedMux(lin.TypedSchema(), vmux.NewJSONCodec("plugin.json"))(b) if err != nil { return ParsedPlugin{}, errors.Wrap(errors.Promote(err, ""), ErrInvalidRootFile) } pp.Properties = *(pinst.ValueP()) // FIXME remove this once it's being correctly populated coming out of lineage if pp.Properties.PascalName == "" { pp.Properties.PascalName = plugindef.DerivePascalName(pp.Properties) } if cuefiles, err := fs.Glob(fsys, "*.cue"); err != nil { return ParsedPlugin{}, fmt.Errorf("error globbing for cue files in fsys: %w", err) } else if len(cuefiles) == 0 { return pp, nil } gpv := loadGP(rt.Context()) fsys, err = ensureCueMod(fsys, pp.Properties) if err != nil { return ParsedPlugin{}, fmt.Errorf("%s has invalid cue.mod: %w", pp.Properties.Id, err) } bi, err := cuectx.LoadInstanceWithGrafana(fsys, "", load.Package(PackageName)) if err != nil || bi.Err != nil { if err == nil { err = bi.Err } return ParsedPlugin{}, errors.Wrap(errors.Newf(token.NoPos, "%s did not load", pp.Properties.Id), err) } f, _ := parser.ParseFile("plugin.json", fmt.Sprintf(`{ "id": %q, "pascalName": %q }`, pp.Properties.Id, pp.Properties.PascalName)) for _, f := range bi.Files { for _, im := range f.Imports { ip := strings.Trim(im.Path.Value, "\"") if !importAllowed(ip) { return ParsedPlugin{}, errors.Wrap(errors.Newf(im.Pos(), "import of %q in grafanaplugin cue package not allowed, plugins may only import from:\n%s\n", ip, allowedImportsStr), ErrDisallowedCUEImport) } pp.CUEImports = append(pp.CUEImports, im) } } // build.Instance.Files has a comment indicating the CUE authors want to change // its behavior. This is a tripwire to tell us if/when they do that - otherwise, if // the change they make ends up making bi.Files empty, the above loop will silently // become a no-op, and we'd lose enforcement of import restrictions in plugins without // realizing it. if len(bi.Files) != len(bi.BuildFiles) { panic("Refactor required - upstream CUE implementation changed, bi.Files is no longer populated") } // Inject the JSON directly into the build so it gets loaded together bi.BuildFiles = append(bi.BuildFiles, &build.File{ Filename: "plugin.json", Encoding: build.JSON, Form: build.Data, Source: b, }) bi.Files = append(bi.Files, f) gpi := ctx.BuildInstance(bi) // Temporary hack while we figure out what in the elasticsearch lineage turns // this into an endless loop in thema, and why unifying twice is anything other // than a total no-op. if pp.Properties.Id != "elasticsearch" { gpi = gpi.Unify(gpv) } if gpi.Err() != nil { return ParsedPlugin{}, errors.Wrap(errors.Promote(ErrInvalidGrafanaPluginInstance, pp.Properties.Id), gpi.Err()) } for _, si := range allsi { iv := gpi.LookupPath(cue.MakePath(cue.Str("composableKinds"), cue.Str(si.Name()))) if !iv.Exists() { continue } props, err := kindsys.ToKindProps[kindsys.ComposableProperties](iv) if err != nil { return ParsedPlugin{}, err } compo, err := kindsys.BindComposable(rt, kindsys.Def[kindsys.ComposableProperties]{ Properties: props, V: iv, }) if err != nil { return ParsedPlugin{}, err } pp.ComposableKinds[si.Name()] = compo } // TODO custom kinds return pp, nil } // LoadComposableKindDef loads and validates a composable kind definition. // On success, it returns a [Def] which contains the entire contents of the kind definition. // // defpath is the path to the directory containing the composable kind definition, // relative to the root of the caller's repository. // // NOTE This function will be deprecated in favor of a more generic loader when kind // providers will be implemented. func LoadComposableKindDef(fsys fs.FS, rt *thema.Runtime, defpath string) (kindsys.Def[kindsys.ComposableProperties], error) { pp := ParsedPlugin{ ComposableKinds: make(map[string]kindsys.Composable), Properties: plugindef.PluginDef{ Id: defpath, }, } fsys, err := ensureCueMod(fsys, pp.Properties) if err != nil { return kindsys.Def[kindsys.ComposableProperties]{}, fmt.Errorf("%s has invalid cue.mod: %w", pp.Properties.Id, err) } bi, err := cuectx.LoadInstanceWithGrafana(fsys, "", load.Package(PackageName)) if err != nil { return kindsys.Def[kindsys.ComposableProperties]{}, err } ctx := rt.Context() v := ctx.BuildInstance(bi) if v.Err() != nil { return kindsys.Def[kindsys.ComposableProperties]{}, fmt.Errorf("%s not a valid CUE instance: %w", defpath, v.Err()) } props, err := kindsys.ToKindProps[kindsys.ComposableProperties](v) if err != nil { return kindsys.Def[kindsys.ComposableProperties]{}, err } return kindsys.Def[kindsys.ComposableProperties]{ V: v, Properties: props, }, nil } func ensureCueMod(fsys fs.FS, pdef plugindef.PluginDef) (fs.FS, error) { if modf, err := fs.ReadFile(fsys, "cue.mod/module.cue"); err != nil { if !errors.Is(err, fs.ErrNotExist) { return nil, err } return merged_fs.NewMergedFS(fsys, fstest.MapFS{ "cue.mod/module.cue": &fstest.MapFile{Data: []byte(fmt.Sprintf(`module: "grafana.com/grafana/plugins/%s"`, pdef.Id))}, }), nil } else if _, err := cuecontext.New().CompileBytes(modf).LookupPath(cue.MakePath(cue.Str("module"))).String(); err != nil { return nil, fmt.Errorf("error reading cue module name: %w", err) } return fsys, nil }