mirror of
https://github.com/grafana/grafana.git
synced 2024-11-24 18:00:31 -06:00
ae69b1d99c
* Generate .cue model for histogram panel * Docs * Refactor PR
356 lines
9.7 KiB
Go
356 lines
9.7 KiB
Go
package codegen
|
|
|
|
import (
|
|
"bytes"
|
|
gerrors "errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"path/filepath"
|
|
"strings"
|
|
"text/template"
|
|
|
|
"cuelang.org/go/cue"
|
|
"cuelang.org/go/cue/ast"
|
|
"cuelang.org/go/cue/build"
|
|
"cuelang.org/go/cue/cuecontext"
|
|
"cuelang.org/go/cue/errors"
|
|
"cuelang.org/go/cue/load"
|
|
"cuelang.org/go/cue/parser"
|
|
"github.com/grafana/cuetsy"
|
|
"github.com/grafana/grafana"
|
|
"github.com/grafana/thema"
|
|
tload "github.com/grafana/thema/load"
|
|
)
|
|
|
|
// The only import statement we currently allow in any models.cue file
|
|
const schemasPath = "github.com/grafana/grafana/packages/grafana-schema/src/schema"
|
|
|
|
// CUE import paths, mapped to corresponding TS import paths. An empty value
|
|
// indicates the import path should be dropped in the conversion to TS. Imports
|
|
// not present in the list are not not allowed, and code generation will fail.
|
|
var importMap = map[string]string{
|
|
"github.com/grafana/thema": "",
|
|
schemasPath: "@grafana/schema",
|
|
}
|
|
|
|
// Hard-coded list of paths to skip. Remove a particular file as we're ready
|
|
// to rely on the TypeScript auto-generated by cuetsy for that particular file.
|
|
var skipPaths = []string{
|
|
"public/app/plugins/panel/canvas/models.cue",
|
|
"public/app/plugins/panel/heatmap/models.cue",
|
|
"public/app/plugins/panel/heatmap-old/models.cue",
|
|
"public/app/plugins/panel/candlestick/models.cue",
|
|
"public/app/plugins/panel/state-timeline/models.cue",
|
|
"public/app/plugins/panel/status-history/models.cue",
|
|
"public/app/plugins/panel/table/models.cue",
|
|
"public/app/plugins/panel/timeseries/models.cue",
|
|
}
|
|
|
|
const prefix = "/"
|
|
|
|
// CuetsifyPlugins runs cuetsy against plugins' models.cue files.
|
|
func CuetsifyPlugins(ctx *cue.Context, root string) (WriteDiffer, error) {
|
|
lib := thema.NewLibrary(ctx)
|
|
// TODO this whole func has a lot of old, crufty behavior from the scuemata era; needs TLC
|
|
overlay := make(map[string]load.Source)
|
|
err := toOverlay(prefix, grafana.CueSchemaFS, overlay)
|
|
// err := tload.ToOverlay(prefix, grafana.CueSchemaFS, overlay)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
exclude := func(path string) bool {
|
|
for _, p := range skipPaths {
|
|
if path == p {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return filepath.Dir(path) == "cue.mod"
|
|
}
|
|
|
|
// Prep the cue load config
|
|
clcfg := &load.Config{
|
|
Overlay: overlay,
|
|
// FIXME these module paths won't work for things not under our cue.mod - AKA third-party plugins
|
|
ModuleRoot: prefix,
|
|
Module: "github.com/grafana/grafana",
|
|
}
|
|
|
|
outfiles := NewWriteDiffer()
|
|
|
|
cuetsify := func(in fs.FS) error {
|
|
seen := make(map[string]bool)
|
|
return fs.WalkDir(in, ".", func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
dir := filepath.Dir(path)
|
|
|
|
if d.IsDir() || filepath.Ext(d.Name()) != ".cue" || seen[dir] || exclude(path) {
|
|
return nil
|
|
}
|
|
seen[dir] = true
|
|
clcfg.Dir = filepath.Join(root, dir)
|
|
|
|
var b []byte
|
|
f := &tsFile{}
|
|
|
|
switch {
|
|
default:
|
|
insts := load.Instances(nil, clcfg)
|
|
if len(insts) > 1 {
|
|
return fmt.Errorf("%s: resulted in more than one instance", path)
|
|
}
|
|
v := ctx.BuildInstance(insts[0])
|
|
|
|
b, err = cuetsy.Generate(v, cuetsy.Config{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
case strings.Contains(path, "public/app/plugins"): // panel plugin models.cue files
|
|
// The simple - and preferable - thing would be to have plugins use the same
|
|
// package name for their models.cue as their containing dir. That's not
|
|
// possible, though, because we allow dashes in plugin names, but CUE does not
|
|
// allow them in package names. Yuck.
|
|
inst, err := loadInstancesWithThema(in, dir, "grafanaschema")
|
|
if err != nil {
|
|
return fmt.Errorf("could not load CUE instance for %s: %w", dir, err)
|
|
}
|
|
|
|
// Also parse file directly to extract imports.
|
|
// NOTE this will need refactoring to support working with more than one file at a time
|
|
of, _ := in.Open(path)
|
|
pf, _ := parser.ParseFile(filepath.Base(path), of, parser.ParseComments)
|
|
|
|
iseen := make(map[string]bool)
|
|
for _, im := range pf.Imports {
|
|
ip := strings.Trim(im.Path.Value, "\"")
|
|
mappath, has := importMap[ip]
|
|
if !has {
|
|
// TODO make a specific error type for this
|
|
var all []string
|
|
for im := range importMap {
|
|
all = append(all, fmt.Sprintf("\t%s", im))
|
|
}
|
|
return errors.Newf(im.Pos(), "%s: import %q not allowed, panel plugins may only import from:\n%s\n", path, ip, strings.Join(all, "\n"))
|
|
}
|
|
// TODO this approach will silently swallow the unfixable
|
|
// error case where multiple files in the same dir import
|
|
// the same package to a different ident
|
|
if mappath != "" && !iseen[ip] {
|
|
iseen[ip] = true
|
|
f.Imports = append(f.Imports, convertImport(im))
|
|
}
|
|
}
|
|
|
|
v := ctx.BuildInstance(inst)
|
|
|
|
lin, err := thema.BindLineage(v.LookupPath(cue.ParsePath("Panel")), lib)
|
|
if err != nil {
|
|
return fmt.Errorf("%s: failed to bind lineage: %w", path, err)
|
|
}
|
|
f.V = thema.LatestVersion(lin)
|
|
f.WriteModelVersion = true
|
|
|
|
b, err = cuetsy.Generate(thema.SchemaP(lin, f.V).UnwrapCUE(), cuetsy.Config{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
f.Body = string(b)
|
|
|
|
var buf bytes.Buffer
|
|
err = tsTemplate.Execute(&buf, f)
|
|
outfiles[filepath.Join(root, strings.Replace(path, ".cue", ".gen.ts", -1))] = buf.Bytes()
|
|
return err
|
|
})
|
|
}
|
|
|
|
err = cuetsify(grafana.CueSchemaFS)
|
|
if err != nil {
|
|
return nil, gerrors.New(errors.Details(err, nil))
|
|
}
|
|
|
|
return outfiles, nil
|
|
}
|
|
|
|
func convertImport(im *ast.ImportSpec) *tsImport {
|
|
tsim := &tsImport{
|
|
Pkg: importMap[schemasPath],
|
|
}
|
|
if im.Name != nil && im.Name.String() != "" {
|
|
tsim.Ident = im.Name.String()
|
|
} else {
|
|
sl := strings.Split(im.Path.Value, "/")
|
|
final := sl[len(sl)-1]
|
|
if idx := strings.Index(final, ":"); idx != -1 {
|
|
tsim.Pkg = final[idx:]
|
|
} else {
|
|
tsim.Pkg = final
|
|
}
|
|
}
|
|
return tsim
|
|
}
|
|
|
|
var themamodpath string = filepath.Join("cue.mod", "pkg", "github.com", "grafana", "thema")
|
|
|
|
// all copied and hacked up from Thema's LoadInstancesWithThema, simply to allow setting the
|
|
// package name
|
|
func loadInstancesWithThema(modFS fs.FS, dir string, pkgname string) (*build.Instance, error) {
|
|
var modname string
|
|
err := fs.WalkDir(modFS, "cue.mod", func(path string, d fs.DirEntry, err error) error {
|
|
// fs.FS implementations tend to not use path separators as expected. Use a
|
|
// normalized one for comparisons, but retain the original for calls back into modFS.
|
|
normpath := filepath.FromSlash(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if d.IsDir() {
|
|
switch normpath {
|
|
case filepath.Join("cue.mod", "gen"), filepath.Join("cue.mod", "usr"):
|
|
return fs.SkipDir
|
|
case themamodpath:
|
|
return fmt.Errorf("path %q already exists in modFS passed to InstancesWithThema, must be absent for dynamic dependency injection", themamodpath)
|
|
}
|
|
return nil
|
|
} else if normpath == filepath.Join("cue.mod", "module.cue") {
|
|
modf, err := modFS.Open(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer modf.Close() // nolint: errcheck
|
|
|
|
b, err := io.ReadAll(modf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
modname, err = cuecontext.New().CompileBytes(b).LookupPath(cue.MakePath(cue.Str("module"))).String()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if modname == "" {
|
|
return fmt.Errorf("InstancesWithThema requires non-empty module name in modFS' cue.mod/module.cue")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if modname == "" {
|
|
return nil, errors.New("cue.mod/module.cue did not exist")
|
|
}
|
|
|
|
modroot := filepath.FromSlash(filepath.Join("/", modname))
|
|
overlay := make(map[string]load.Source)
|
|
if err := tload.ToOverlay(modroot, modFS, overlay); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Special case for when we're calling this loader with paths inside the thema module
|
|
if modname == "github.com/grafana/thema" {
|
|
if err := tload.ToOverlay(modroot, thema.CueJointFS, overlay); err != nil {
|
|
return nil, err
|
|
}
|
|
} else {
|
|
if err := tload.ToOverlay(filepath.Join(modroot, themamodpath), thema.CueFS, overlay); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if dir == "" {
|
|
dir = "."
|
|
}
|
|
|
|
cfg := &load.Config{
|
|
Overlay: overlay,
|
|
ModuleRoot: modroot,
|
|
Module: modname,
|
|
Dir: filepath.Join(modroot, dir),
|
|
Package: pkgname,
|
|
}
|
|
if dir == "." {
|
|
cfg.Package = filepath.Base(modroot)
|
|
cfg.Dir = modroot
|
|
}
|
|
|
|
inst := load.Instances(nil, cfg)[0]
|
|
if inst.Err != nil {
|
|
return nil, inst.Err
|
|
}
|
|
|
|
return inst, nil
|
|
}
|
|
|
|
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
|
|
}
|
|
defer func(f fs.File) {
|
|
err := f.Close()
|
|
if err != nil {
|
|
return
|
|
}
|
|
}(f)
|
|
|
|
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
|
|
}
|
|
|
|
type tsFile struct {
|
|
V thema.SyntacticVersion
|
|
WriteModelVersion bool
|
|
Imports []*tsImport
|
|
Body string
|
|
}
|
|
|
|
type tsImport struct {
|
|
Ident string
|
|
Pkg string
|
|
}
|
|
|
|
var tsTemplate = template.Must(template.New("cuetsygen").Parse(`//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
// This file is autogenerated. DO NOT EDIT.
|
|
//
|
|
// To regenerate, run "make gen-cue" from the repository root.
|
|
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
{{range .Imports}}
|
|
import * as {{.Ident}} from '{{.Pkg}}';{{end}}
|
|
{{if .WriteModelVersion }}
|
|
export const modelVersion = Object.freeze([{{index .V 0}}, {{index .V 1}}]);
|
|
{{end}}
|
|
{{.Body}}`))
|