mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Core: Remove thema and kindsys dependencies (#84499)
* Move some thema code inside grafana * Use new codegen instead of thema for core kinds * Replace TS generator * Use new generator for go types * Remove thema from oapi generator * Remove thema from generators * Don't use kindsys/thema for core kinds * Remove kindsys/thema from plugins * Remove last thema related * Remove most of cuectx and move utils_ts into codegen. It also deletes wire dependency * Merge plugins generators * Delete thema dependency 🎉 * Fix CODEOWNERS * Fix package name * Fix TS output names * More path fixes * Fix mod codeowners * Use original plugin's name * Remove kindsys dependency 🎉 * Modify oapi schema and create an apply function to fix elasticsearch errors * cue.mod was deleted by mistake * Fix TS panels * sort imports * Fixing elasticsearch output * Downgrade oapi-codegen library * Update output ts files * More fixes * Restore old elasticsearch generated file and skip its generation. Remove core imports into plugins * More lint fixes * Add codeowners * restore embed.go file * Fix embed.go
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/codejen"
|
||||
"github.com/grafana/grafana/pkg/plugins/pfs"
|
||||
)
|
||||
|
||||
var registryPath = filepath.Join("pkg", "registry", "schemas")
|
||||
@@ -27,21 +28,22 @@ func (jenny *PluginRegistryJenny) JennyName() string {
|
||||
return "PluginRegistryJenny"
|
||||
}
|
||||
|
||||
func (jenny *PluginRegistryJenny) Generate(files []string) (*codejen.File, error) {
|
||||
if len(files) == 0 {
|
||||
func (jenny *PluginRegistryJenny) Generate(decls ...*pfs.PluginDecl) (*codejen.File, error) {
|
||||
if len(decls) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
schemas := make([]Schema, len(files))
|
||||
for i, file := range files {
|
||||
name, err := getSchemaName(file)
|
||||
schemas := make([]Schema, len(decls))
|
||||
for i, decl := range decls {
|
||||
variant := fmt.Sprintf("%s.cue", strings.ToLower(decl.SchemaInterface.Name))
|
||||
name, err := getSchemaName(decl.PluginPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to find schema name: %s", err)
|
||||
}
|
||||
|
||||
schemas[i] = Schema{
|
||||
Name: name,
|
||||
Filename: filepath.Base(file),
|
||||
FilePath: file,
|
||||
Filename: variant,
|
||||
FilePath: "./" + filepath.Join("public", "app", "plugins", decl.PluginPath, variant),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +67,7 @@ func getSchemaName(path string) (string, error) {
|
||||
if len(parts) < 2 {
|
||||
return "", fmt.Errorf("path should contain more than 2 elements")
|
||||
}
|
||||
folderName := parts[len(parts)-2]
|
||||
folderName := parts[len(parts)-1]
|
||||
if renamed, ok := renamedPlugins[folderName]; ok {
|
||||
folderName = renamed
|
||||
}
|
||||
|
||||
@@ -6,12 +6,9 @@ import (
|
||||
"strings"
|
||||
|
||||
copenapi "cuelang.org/go/encoding/openapi"
|
||||
"github.com/dave/dst/dstutil"
|
||||
"github.com/grafana/codejen"
|
||||
corecodegen "github.com/grafana/grafana/pkg/codegen"
|
||||
"github.com/grafana/grafana/pkg/codegen/generators"
|
||||
"github.com/grafana/grafana/pkg/plugins/pfs"
|
||||
"github.com/grafana/thema/encoding/gocode"
|
||||
"github.com/grafana/thema/encoding/openapi"
|
||||
)
|
||||
|
||||
// TODO this is duplicative of other Go type jennies. Remove it in favor of a better-abstracted version in thema itself
|
||||
@@ -30,22 +27,22 @@ func (j *pgoJenny) JennyName() string {
|
||||
}
|
||||
|
||||
func (j *pgoJenny) Generate(decl *pfs.PluginDecl) (*codejen.File, error) {
|
||||
b := decl.PluginMeta.Backend
|
||||
if b == nil || !*b || !decl.HasSchema() {
|
||||
hasBackend := decl.PluginMeta.Backend
|
||||
// We skip elasticsearch since we have problems with the generated file.
|
||||
// This is temporal until we migrate to the new system.
|
||||
if hasBackend == nil || !*hasBackend || decl.PluginMeta.Id == "elasticsearch" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
slotname := strings.ToLower(decl.SchemaInterface.Name)
|
||||
byt, err := gocode.GenerateTypesOpenAPI(decl.Lineage.Latest(), &gocode.TypeConfigOpenAPI{
|
||||
Config: &openapi.Config{
|
||||
Group: decl.SchemaInterface.IsGroup,
|
||||
byt, err := generators.GenerateTypesGo(decl.CueFile, &generators.GoConfig{
|
||||
Config: &generators.OpenApiConfig{
|
||||
Config: &copenapi.Config{
|
||||
MaxCycleDepth: 10,
|
||||
},
|
||||
SplitSchema: true,
|
||||
IsGroup: decl.SchemaInterface.IsGroup,
|
||||
},
|
||||
PackageName: slotname,
|
||||
ApplyFuncs: []dstutil.ApplyFunc{corecodegen.PrefixDropper(decl.Lineage.Name())},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
tsast "github.com/grafana/cuetsy/ts/ast"
|
||||
"github.com/grafana/grafana/pkg/build"
|
||||
"github.com/grafana/grafana/pkg/codegen"
|
||||
"github.com/grafana/grafana/pkg/cuectx"
|
||||
"github.com/grafana/grafana/pkg/plugins/pfs"
|
||||
)
|
||||
|
||||
@@ -34,15 +33,11 @@ func (j *ptsJenny) JennyName() string {
|
||||
}
|
||||
|
||||
func (j *ptsJenny) Generate(decl *pfs.PluginDecl) (codejen.Files, error) {
|
||||
if !decl.HasSchema() {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
genFile := &tsast.File{}
|
||||
versionedFile := &tsast.File{}
|
||||
|
||||
for _, im := range decl.Imports {
|
||||
if tsim, err := cuectx.ConvertImport(im); err != nil {
|
||||
if tsim, err := codegen.ConvertImport(im); err != nil {
|
||||
return nil, err
|
||||
} else if tsim.From.Value != "" {
|
||||
genFile.Imports = append(genFile.Imports, tsim)
|
||||
@@ -94,18 +89,46 @@ func getPluginVersion(pluginVersion *string) string {
|
||||
|
||||
func adaptToPipeline(j codejen.OneToOne[codegen.SchemaForGen]) codejen.OneToOne[*pfs.PluginDecl] {
|
||||
return codejen.AdaptOneToOne(j, func(pd *pfs.PluginDecl) codegen.SchemaForGen {
|
||||
name := strings.ReplaceAll(pd.PluginMeta.Name, " ", "")
|
||||
if pd.SchemaInterface.Name == "DataQuery" {
|
||||
name = name + "DataQuery"
|
||||
}
|
||||
return codegen.SchemaForGen{
|
||||
Name: name,
|
||||
Schema: pd.Lineage.Latest(),
|
||||
Name: derivePascalName(pd.PluginMeta.Id, pd.PluginMeta.Name) + pd.SchemaInterface.Name,
|
||||
CueFile: pd.CueFile,
|
||||
IsGroup: pd.SchemaInterface.IsGroup,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func derivePascalName(id string, name string) string {
|
||||
sani := func(s string) string {
|
||||
ret := strings.Title(strings.Map(func(r rune) rune {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
return r
|
||||
case r >= 'A' && r <= 'Z':
|
||||
return r
|
||||
default:
|
||||
return -1
|
||||
}
|
||||
}, strings.Title(strings.Map(func(r rune) rune {
|
||||
switch r {
|
||||
case '-', '_':
|
||||
return ' '
|
||||
default:
|
||||
return r
|
||||
}
|
||||
}, s))))
|
||||
if len(ret) > 63 {
|
||||
return ret[:63]
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
fromname := sani(name)
|
||||
if len(fromname) != 0 {
|
||||
return fromname
|
||||
}
|
||||
return sani(strings.Split(id, "-")[1])
|
||||
}
|
||||
|
||||
func getGrafanaVersion() string {
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
package corelist
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/grafana/grafana/pkg/cuectx"
|
||||
"github.com/grafana/grafana/pkg/plugins/pfs"
|
||||
"github.com/grafana/thema"
|
||||
)
|
||||
|
||||
var coreTrees []pfs.ParsedPlugin
|
||||
var coreOnce sync.Once
|
||||
|
||||
// New returns a pfs.PluginList containing the plugin trees for all core plugins
|
||||
// in the current version of Grafana.
|
||||
//
|
||||
// Go code within the grafana codebase should only ever call this with nil.
|
||||
func New(rt *thema.Runtime) []pfs.ParsedPlugin {
|
||||
var pl []pfs.ParsedPlugin
|
||||
if rt == nil {
|
||||
coreOnce.Do(func() {
|
||||
coreTrees = corePlugins(cuectx.GrafanaThemaRuntime())
|
||||
})
|
||||
pl = make([]pfs.ParsedPlugin, len(coreTrees))
|
||||
copy(pl, coreTrees)
|
||||
} else {
|
||||
return corePlugins(rt)
|
||||
}
|
||||
return pl
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
|
||||
//
|
||||
// Generated by:
|
||||
// public/app/plugins/gen.go
|
||||
// Using jennies:
|
||||
// PluginTreeListJenny
|
||||
//
|
||||
// Run 'make gen-cue' from repository root to regenerate.
|
||||
|
||||
package corelist
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
|
||||
"github.com/grafana/grafana"
|
||||
"github.com/grafana/grafana/pkg/plugins/pfs"
|
||||
"github.com/grafana/thema"
|
||||
)
|
||||
|
||||
func parsePluginOrPanic(path string, pkgname string, rt *thema.Runtime) pfs.ParsedPlugin {
|
||||
sub, err := fs.Sub(grafana.CueSchemaFS, path)
|
||||
if err != nil {
|
||||
panic("could not create fs sub to " + path)
|
||||
}
|
||||
pp, err := pfs.ParsePluginFS(sub, rt)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("error parsing plugin metadata for %s: %s", pkgname, err))
|
||||
}
|
||||
return pp
|
||||
}
|
||||
|
||||
func corePlugins(rt *thema.Runtime) []pfs.ParsedPlugin {
|
||||
return []pfs.ParsedPlugin{
|
||||
parsePluginOrPanic("public/app/plugins/datasource/alertmanager", "alertmanager", rt),
|
||||
parsePluginOrPanic("public/app/plugins/datasource/azuremonitor", "grafana_azure_monitor_datasource", rt),
|
||||
parsePluginOrPanic("public/app/plugins/datasource/cloud-monitoring", "stackdriver", rt),
|
||||
parsePluginOrPanic("public/app/plugins/datasource/cloudwatch", "cloudwatch", rt),
|
||||
parsePluginOrPanic("public/app/plugins/datasource/dashboard", "dashboard", rt),
|
||||
parsePluginOrPanic("public/app/plugins/datasource/elasticsearch", "elasticsearch", rt),
|
||||
parsePluginOrPanic("public/app/plugins/datasource/grafana", "grafana", rt),
|
||||
parsePluginOrPanic("public/app/plugins/datasource/grafana-postgresql-datasource", "grafana_postgresql_datasource", rt),
|
||||
parsePluginOrPanic("public/app/plugins/datasource/grafana-pyroscope-datasource", "grafana_pyroscope_datasource", rt),
|
||||
parsePluginOrPanic("public/app/plugins/datasource/grafana-testdata-datasource", "grafana_testdata_datasource", rt),
|
||||
parsePluginOrPanic("public/app/plugins/datasource/graphite", "graphite", rt),
|
||||
parsePluginOrPanic("public/app/plugins/datasource/jaeger", "jaeger", rt),
|
||||
parsePluginOrPanic("public/app/plugins/datasource/loki", "loki", rt),
|
||||
parsePluginOrPanic("public/app/plugins/datasource/mssql", "mssql", rt),
|
||||
parsePluginOrPanic("public/app/plugins/datasource/mysql", "mysql", rt),
|
||||
parsePluginOrPanic("public/app/plugins/datasource/parca", "parca", rt),
|
||||
parsePluginOrPanic("public/app/plugins/datasource/prometheus", "prometheus", rt),
|
||||
parsePluginOrPanic("public/app/plugins/datasource/tempo", "tempo", rt),
|
||||
parsePluginOrPanic("public/app/plugins/datasource/zipkin", "zipkin", rt),
|
||||
parsePluginOrPanic("public/app/plugins/panel/alertlist", "alertlist", rt),
|
||||
parsePluginOrPanic("public/app/plugins/panel/annolist", "annolist", rt),
|
||||
parsePluginOrPanic("public/app/plugins/panel/barchart", "barchart", rt),
|
||||
parsePluginOrPanic("public/app/plugins/panel/bargauge", "bargauge", rt),
|
||||
parsePluginOrPanic("public/app/plugins/panel/candlestick", "candlestick", rt),
|
||||
parsePluginOrPanic("public/app/plugins/panel/canvas", "canvas", rt),
|
||||
parsePluginOrPanic("public/app/plugins/panel/dashlist", "dashlist", rt),
|
||||
parsePluginOrPanic("public/app/plugins/panel/datagrid", "datagrid", rt),
|
||||
parsePluginOrPanic("public/app/plugins/panel/debug", "debug", rt),
|
||||
parsePluginOrPanic("public/app/plugins/panel/flamegraph", "flamegraph", rt),
|
||||
parsePluginOrPanic("public/app/plugins/panel/gauge", "gauge", rt),
|
||||
parsePluginOrPanic("public/app/plugins/panel/geomap", "geomap", rt),
|
||||
parsePluginOrPanic("public/app/plugins/panel/gettingstarted", "gettingstarted", rt),
|
||||
parsePluginOrPanic("public/app/plugins/panel/graph", "graph", rt),
|
||||
parsePluginOrPanic("public/app/plugins/panel/heatmap", "heatmap", rt),
|
||||
parsePluginOrPanic("public/app/plugins/panel/histogram", "histogram", rt),
|
||||
parsePluginOrPanic("public/app/plugins/panel/live", "live", rt),
|
||||
parsePluginOrPanic("public/app/plugins/panel/logs", "logs", rt),
|
||||
parsePluginOrPanic("public/app/plugins/panel/news", "news", rt),
|
||||
parsePluginOrPanic("public/app/plugins/panel/nodeGraph", "nodeGraph", rt),
|
||||
parsePluginOrPanic("public/app/plugins/panel/piechart", "piechart", rt),
|
||||
parsePluginOrPanic("public/app/plugins/panel/stat", "stat", rt),
|
||||
parsePluginOrPanic("public/app/plugins/panel/state-timeline", "state_timeline", rt),
|
||||
parsePluginOrPanic("public/app/plugins/panel/status-history", "status_history", rt),
|
||||
parsePluginOrPanic("public/app/plugins/panel/table", "table", rt),
|
||||
parsePluginOrPanic("public/app/plugins/panel/table-old", "table_old", rt),
|
||||
parsePluginOrPanic("public/app/plugins/panel/text", "text", rt),
|
||||
parsePluginOrPanic("public/app/plugins/panel/timeseries", "timeseries", rt),
|
||||
parsePluginOrPanic("public/app/plugins/panel/traces", "traces", rt),
|
||||
parsePluginOrPanic("public/app/plugins/panel/trend", "trend", rt),
|
||||
parsePluginOrPanic("public/app/plugins/panel/welcome", "welcome", rt),
|
||||
parsePluginOrPanic("public/app/plugins/panel/xychart", "xychart", rt),
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,16 @@
|
||||
package pfs
|
||||
|
||||
import (
|
||||
"cuelang.org/go/cue"
|
||||
"cuelang.org/go/cue/ast"
|
||||
"github.com/grafana/kindsys"
|
||||
"github.com/grafana/thema"
|
||||
)
|
||||
|
||||
type PluginDecl struct {
|
||||
SchemaInterface *SchemaInterface
|
||||
Lineage thema.Lineage
|
||||
SchemaInterface SchemaInterface
|
||||
CueFile cue.Value
|
||||
Imports []*ast.ImportSpec
|
||||
PluginPath string
|
||||
PluginMeta Metadata
|
||||
KindDecl kindsys.Def[kindsys.ComposableProperties]
|
||||
}
|
||||
|
||||
type SchemaInterface struct {
|
||||
@@ -26,15 +24,3 @@ type Metadata struct {
|
||||
Backend *bool
|
||||
Version *string
|
||||
}
|
||||
|
||||
func EmptyPluginDecl(path string, meta Metadata) *PluginDecl {
|
||||
return &PluginDecl{
|
||||
PluginPath: path,
|
||||
PluginMeta: meta,
|
||||
Imports: make([]*ast.ImportSpec, 0),
|
||||
}
|
||||
}
|
||||
|
||||
func (decl *PluginDecl) HasSchema() bool {
|
||||
return decl.Lineage != nil && decl.SchemaInterface != nil
|
||||
}
|
||||
|
||||
@@ -6,35 +6,22 @@ import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
"github.com/grafana/thema"
|
||||
"cuelang.org/go/cue/cuecontext"
|
||||
)
|
||||
|
||||
type declParser struct {
|
||||
rt *thema.Runtime
|
||||
type DeclParser struct {
|
||||
skip map[string]bool
|
||||
}
|
||||
|
||||
// Extracted from kindsys repository
|
||||
var schemaInterfaces = map[string]*SchemaInterface{
|
||||
"PanelCfg": {
|
||||
Name: "PanelCfg",
|
||||
IsGroup: true,
|
||||
},
|
||||
"DataQuery": {
|
||||
Name: "DataQuery",
|
||||
IsGroup: false,
|
||||
},
|
||||
}
|
||||
|
||||
func NewDeclParser(rt *thema.Runtime, skip map[string]bool) *declParser {
|
||||
return &declParser{
|
||||
rt: rt,
|
||||
func NewDeclParser(skip map[string]bool) *DeclParser {
|
||||
return &DeclParser{
|
||||
skip: skip,
|
||||
}
|
||||
}
|
||||
|
||||
// TODO convert this to be the new parser for Tree
|
||||
func (psr *declParser) Parse(root fs.FS) ([]*PluginDecl, error) {
|
||||
func (psr *DeclParser) Parse(root fs.FS) ([]*PluginDecl, error) {
|
||||
ctx := cuecontext.New()
|
||||
// TODO remove hardcoded tree structure assumption, work from root of provided fs
|
||||
plugins, err := fs.Glob(root, "**/**/plugin.json")
|
||||
if err != nil {
|
||||
@@ -50,29 +37,22 @@ func (psr *declParser) Parse(root fs.FS) ([]*PluginDecl, error) {
|
||||
}
|
||||
|
||||
dir, _ := fs.Sub(root, path)
|
||||
pp, err := ParsePluginFS(dir, psr.rt)
|
||||
pp, err := ParsePluginFS(ctx, dir, path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing plugin failed for %s: %s", dir, err)
|
||||
}
|
||||
|
||||
if len(pp.ComposableKinds) == 0 {
|
||||
decls = append(decls, EmptyPluginDecl(path, pp.Properties))
|
||||
if !pp.CueFile.Exists() {
|
||||
continue
|
||||
}
|
||||
|
||||
for slotName, kind := range pp.ComposableKinds {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing plugin failed for %s: %s", dir, err)
|
||||
}
|
||||
decls = append(decls, &PluginDecl{
|
||||
SchemaInterface: schemaInterfaces[slotName],
|
||||
Lineage: kind.Lineage(),
|
||||
Imports: pp.CUEImports,
|
||||
PluginMeta: pp.Properties,
|
||||
PluginPath: path,
|
||||
KindDecl: kind.Def(),
|
||||
})
|
||||
}
|
||||
decls = append(decls, &PluginDecl{
|
||||
SchemaInterface: pp.Variant,
|
||||
CueFile: pp.CueFile,
|
||||
Imports: pp.CUEImports,
|
||||
PluginMeta: pp.Properties,
|
||||
PluginPath: path,
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(decls, func(i, j int) bool {
|
||||
|
||||
@@ -4,29 +4,32 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing/fstest"
|
||||
|
||||
"cuelang.org/go/cue"
|
||||
"cuelang.org/go/cue/cuecontext"
|
||||
"cuelang.org/go/cue/errors"
|
||||
"cuelang.org/go/cue/token"
|
||||
"github.com/grafana/kindsys"
|
||||
"github.com/grafana/thema"
|
||||
"github.com/grafana/thema/load"
|
||||
"github.com/yalue/merged_fs"
|
||||
|
||||
"github.com/grafana/grafana/pkg/cuectx"
|
||||
"cuelang.org/go/cue/load"
|
||||
"github.com/grafana/grafana/pkg/codegen"
|
||||
)
|
||||
|
||||
// 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 schemaInterface = map[string]SchemaInterface{
|
||||
"DataQuery": {
|
||||
Name: "DataQuery",
|
||||
IsGroup: false,
|
||||
},
|
||||
"PanelCfg": {
|
||||
Name: "PanelCfg",
|
||||
IsGroup: true,
|
||||
},
|
||||
}
|
||||
|
||||
// PermittedCUEImports returns the list of import paths that may be used in a
|
||||
// plugin's grafanaplugin cue package.
|
||||
var PermittedCUEImports = cuectx.PermittedCUEImports
|
||||
var PermittedCUEImports = codegen.PermittedCUEImports
|
||||
|
||||
func importAllowed(path string) bool {
|
||||
for _, p := range PermittedCUEImports() {
|
||||
@@ -39,22 +42,12 @@ func importAllowed(path string) bool {
|
||||
|
||||
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
|
||||
@@ -69,17 +62,17 @@ func init() {
|
||||
// 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) {
|
||||
func ParsePluginFS(ctx *cue.Context, fsys fs.FS, dir string) (ParsedPlugin, error) {
|
||||
if fsys == nil {
|
||||
return ParsedPlugin{}, ErrEmptyFS
|
||||
}
|
||||
if rt == nil {
|
||||
rt = cuectx.GrafanaThemaRuntime()
|
||||
|
||||
cuefiles, err := fs.Glob(fsys, "*.cue")
|
||||
if err != nil {
|
||||
return ParsedPlugin{}, fmt.Errorf("error globbing for cue files in fsys: %w", err)
|
||||
} else if len(cuefiles) == 0 {
|
||||
return ParsedPlugin{}, nil
|
||||
}
|
||||
|
||||
metadata, err := getPluginMetadata(fsys)
|
||||
@@ -88,27 +81,19 @@ func ParsePluginFS(fsys fs.FS, rt *thema.Runtime) (ParsedPlugin, error) {
|
||||
}
|
||||
|
||||
pp := ParsedPlugin{
|
||||
ComposableKinds: make(map[string]kindsys.Composable),
|
||||
Properties: metadata,
|
||||
Properties: metadata,
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
fsys, err = ensureCueMod(fsys, pp.Properties)
|
||||
if err != nil {
|
||||
return ParsedPlugin{}, fmt.Errorf("%s has invalid cue.mod: %w", pp.Properties.Id, err)
|
||||
return ParsedPlugin{}, 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)
|
||||
bi := load.Instances(cuefiles, &load.Config{
|
||||
Package: PackageName,
|
||||
Dir: dir,
|
||||
})[0]
|
||||
if bi.Err != nil {
|
||||
return ParsedPlugin{}, bi.Err
|
||||
}
|
||||
|
||||
for _, f := range bi.Files {
|
||||
@@ -132,105 +117,35 @@ func ParsePluginFS(fsys fs.FS, rt *thema.Runtime) (ParsedPlugin, error) {
|
||||
panic("Refactor required - upstream CUE implementation changed, bi.Files is no longer populated")
|
||||
}
|
||||
|
||||
gpi := rt.Context().BuildInstance(bi)
|
||||
gpi := ctx.BuildInstance(bi)
|
||||
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())))
|
||||
for name, si := range schemaInterface {
|
||||
iv := gpi.LookupPath(cue.MakePath(cue.Str("composableKinds"), cue.Str(name)))
|
||||
if !iv.Exists() {
|
||||
continue
|
||||
}
|
||||
|
||||
iv = iv.FillPath(cue.MakePath(cue.Str("schemaInterface")), si.Name())
|
||||
iv = iv.FillPath(cue.MakePath(cue.Str("name")), derivePascalName(pp.Properties.Id, pp.Properties.Name)+si.Name())
|
||||
iv = iv.FillPath(cue.MakePath(cue.Str("schemaInterface")), name)
|
||||
iv = iv.FillPath(cue.MakePath(cue.Str("name")), derivePascalName(pp.Properties.Id, pp.Properties.Name)+name)
|
||||
lineageNamePath := iv.LookupPath(cue.MakePath(cue.Str("lineage"), cue.Str("name")))
|
||||
if !lineageNamePath.Exists() {
|
||||
iv = iv.FillPath(cue.MakePath(cue.Str("lineage"), cue.Str("name")), derivePascalName(pp.Properties.Id, pp.Properties.Name)+si.Name())
|
||||
iv = iv.FillPath(cue.MakePath(cue.Str("lineage"), cue.Str("name")), derivePascalName(pp.Properties.Id, pp.Properties.Name)+name)
|
||||
}
|
||||
|
||||
validSchema := iv.LookupPath(cue.ParsePath("lineage.schemas[0].schema"))
|
||||
if !validSchema.Exists() {
|
||||
return ParsedPlugin{}, errors.Wrap(errors.Promote(ErrInvalidGrafanaPluginInstance, pp.Properties.Id), validSchema.Err())
|
||||
}
|
||||
|
||||
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
|
||||
pp.Variant = si
|
||||
pp.CueFile = iv
|
||||
}
|
||||
|
||||
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: Metadata{
|
||||
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, metadata Metadata) (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"`, metadata.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
|
||||
}
|
||||
|
||||
func getPluginMetadata(fsys fs.FS) (Metadata, error) {
|
||||
b, err := fs.ReadFile(fsys, "plugin.json")
|
||||
if err != nil {
|
||||
|
||||
@@ -1,297 +0,0 @@
|
||||
package pfs
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/grafana/grafana/pkg/cuectx"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParsePluginTestdata(t *testing.T) {
|
||||
type tt struct {
|
||||
tfs fs.FS
|
||||
// TODO could remove this by getting rid of inconsistent subdirs
|
||||
subpath string
|
||||
skip string
|
||||
err error
|
||||
// TODO could remove this by expecting that dirname == id
|
||||
rootid string
|
||||
}
|
||||
tab := map[string]tt{
|
||||
"app-with-child": {
|
||||
rootid: "myorgid-simple-app",
|
||||
subpath: "dist",
|
||||
skip: "schema violation, weirdness in info.version field",
|
||||
},
|
||||
"duplicate-plugins": {
|
||||
rootid: "test-app",
|
||||
subpath: "nested",
|
||||
skip: "schema violation, dependencies don't follow naming constraints",
|
||||
},
|
||||
"includes-symlinks": {
|
||||
skip: "schema violation, dependencies don't follow naming constraints",
|
||||
},
|
||||
"installer": {
|
||||
rootid: "test-datasource",
|
||||
subpath: "plugin",
|
||||
},
|
||||
"invalid-plugin-json": {
|
||||
rootid: "test-app",
|
||||
err: ErrInvalidRootFile,
|
||||
},
|
||||
"invalid-v1-signature": {
|
||||
rootid: "test-datasource",
|
||||
subpath: "plugin",
|
||||
},
|
||||
"invalid-v2-extra-file": {
|
||||
rootid: "test-datasource",
|
||||
subpath: "plugin",
|
||||
},
|
||||
"invalid-v2-missing-file": {
|
||||
rootid: "test-datasource",
|
||||
subpath: "plugin",
|
||||
},
|
||||
"lacking-files": {
|
||||
rootid: "test-datasource",
|
||||
subpath: "plugin",
|
||||
},
|
||||
"nested-plugins": {
|
||||
rootid: "test-datasource",
|
||||
subpath: "parent",
|
||||
},
|
||||
"non-pvt-with-root-url": {
|
||||
rootid: "test-datasource",
|
||||
subpath: "plugin",
|
||||
},
|
||||
"renderer-added-file": {
|
||||
rootid: "test-renderer",
|
||||
subpath: "plugin",
|
||||
},
|
||||
"symbolic-plugin-dirs": {
|
||||
skip: "io/fs-based scanner will not traverse symlinks; caller of ParsePluginFS() must do it",
|
||||
},
|
||||
"test-app": {
|
||||
skip: "schema violation, dependencies don't follow naming constraints",
|
||||
rootid: "test-app",
|
||||
},
|
||||
"test-app-with-includes": {
|
||||
rootid: "test-app",
|
||||
skip: "has a 'page'-type include which isn't a known part of spec",
|
||||
},
|
||||
"test-app-with-roles": {
|
||||
rootid: "test-app",
|
||||
},
|
||||
"unsigned-datasource": {
|
||||
rootid: "test-datasource",
|
||||
subpath: "plugin",
|
||||
},
|
||||
"unsigned-panel": {
|
||||
rootid: "test-panel",
|
||||
subpath: "plugin",
|
||||
},
|
||||
"valid-v2-pvt-signature": {
|
||||
rootid: "test-datasource",
|
||||
subpath: "plugin",
|
||||
},
|
||||
"valid-v2-pvt-signature-root-url-uri": {
|
||||
rootid: "test-datasource",
|
||||
subpath: "plugin",
|
||||
},
|
||||
"valid-v2-signature": {
|
||||
rootid: "test-datasource",
|
||||
subpath: "plugin",
|
||||
},
|
||||
"plugin-with-dist": {
|
||||
rootid: "test-datasource",
|
||||
subpath: "plugin",
|
||||
},
|
||||
"no-rootfile": {
|
||||
err: ErrNoRootFile,
|
||||
},
|
||||
"valid-model-panel": {},
|
||||
"valid-model-datasource": {},
|
||||
"missing-kind-datasource": {},
|
||||
"panel-conflicting-joinschema": {
|
||||
err: ErrInvalidLineage,
|
||||
skip: "TODO implement BindOption in thema, SatisfiesJoinSchema, then use it here",
|
||||
},
|
||||
"panel-does-not-follow-slot-joinschema": {
|
||||
err: ErrInvalidLineage,
|
||||
skip: "TODO implement BindOption in thema, SatisfiesJoinSchema, then use it here",
|
||||
},
|
||||
"pluginRootWithDist": {
|
||||
err: ErrNoRootFile,
|
||||
skip: "This folder is used to test multiple plugins in the same folder",
|
||||
},
|
||||
"name-mismatch-panel": {
|
||||
err: ErrInvalidGrafanaPluginInstance,
|
||||
},
|
||||
"disallowed-cue-import": {
|
||||
err: ErrDisallowedCUEImport,
|
||||
},
|
||||
"cdn": {
|
||||
rootid: "grafana-worldmap-panel",
|
||||
subpath: "plugin",
|
||||
},
|
||||
"external-registration": {
|
||||
rootid: "grafana-test-datasource",
|
||||
},
|
||||
}
|
||||
|
||||
staticRootPath, err := filepath.Abs(filepath.Join("..", "manager", "testdata"))
|
||||
require.NoError(t, err)
|
||||
dfs := os.DirFS(staticRootPath)
|
||||
ents, err := fs.ReadDir(dfs, ".")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Ensure table test and dir list are ==
|
||||
var dirs, tts []string
|
||||
for k := range tab {
|
||||
tts = append(tts, k)
|
||||
}
|
||||
for _, ent := range ents {
|
||||
dirs = append(dirs, ent.Name())
|
||||
}
|
||||
sort.Strings(tts)
|
||||
sort.Strings(dirs)
|
||||
if !cmp.Equal(tts, dirs) {
|
||||
t.Fatalf("table test map (-) and pkg/plugins/manager/testdata dirs (+) differ: %s", cmp.Diff(tts, dirs))
|
||||
}
|
||||
|
||||
for _, ent := range ents {
|
||||
tst := tab[ent.Name()]
|
||||
tst.tfs, err = fs.Sub(dfs, filepath.Join(ent.Name(), tst.subpath))
|
||||
require.NoError(t, err)
|
||||
tab[ent.Name()] = tst
|
||||
}
|
||||
|
||||
lib := cuectx.GrafanaThemaRuntime()
|
||||
for name, otst := range tab {
|
||||
tst := otst // otherwise var is shadowed within func by looping
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if tst.skip != "" {
|
||||
t.Skip(tst.skip)
|
||||
}
|
||||
|
||||
pp, err := ParsePluginFS(tst.tfs, lib)
|
||||
if tst.err == nil {
|
||||
require.NoError(t, err, "unexpected error while parsing plugin tree")
|
||||
} else {
|
||||
require.Error(t, err)
|
||||
t.Logf("%T %s", err, err)
|
||||
require.ErrorIs(t, err, tst.err, "unexpected error type while parsing plugin tree")
|
||||
return
|
||||
}
|
||||
|
||||
if tst.rootid == "" {
|
||||
tst.rootid = name
|
||||
}
|
||||
|
||||
require.Equal(t, tst.rootid, pp.Properties.Id, "expected plugin id and actual plugin id differ")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTreeZips(t *testing.T) {
|
||||
type tt struct {
|
||||
tfs fs.FS
|
||||
// TODO could remove this by getting rid of inconsistent subdirs
|
||||
subpath string
|
||||
skip string
|
||||
err error
|
||||
// TODO could remove this by expecting that dirname == id
|
||||
rootid string
|
||||
}
|
||||
|
||||
tab := map[string]tt{
|
||||
"grafana-simple-json-datasource-ec18fa4da8096a952608a7e4c7782b4260b41bcf.zip": {
|
||||
skip: "binary plugin",
|
||||
},
|
||||
"plugin-with-absolute-member.zip": {
|
||||
skip: "not actually a plugin, no plugin.json?",
|
||||
},
|
||||
"plugin-with-absolute-symlink-dir.zip": {
|
||||
skip: "not actually a plugin, no plugin.json?",
|
||||
},
|
||||
"plugin-with-absolute-symlink.zip": {
|
||||
skip: "not actually a plugin, no plugin.json?",
|
||||
},
|
||||
"plugin-with-parent-member.zip": {
|
||||
skip: "not actually a plugin, no plugin.json?",
|
||||
},
|
||||
"plugin-with-symlink-dir.zip": {
|
||||
skip: "not actually a plugin, no plugin.json?",
|
||||
},
|
||||
"plugin-with-symlink.zip": {
|
||||
skip: "not actually a plugin, no plugin.json?",
|
||||
},
|
||||
"plugin-with-symlinks.zip": {
|
||||
subpath: "test-app",
|
||||
rootid: "test-app",
|
||||
},
|
||||
}
|
||||
|
||||
staticRootPath, err := filepath.Abs(filepath.Join("..", "storage", "testdata"))
|
||||
require.NoError(t, err)
|
||||
ents, err := os.ReadDir(staticRootPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Ensure table test and dir list are ==
|
||||
var dirs, tts []string
|
||||
for k := range tab {
|
||||
tts = append(tts, k)
|
||||
}
|
||||
for _, ent := range ents {
|
||||
dirs = append(dirs, ent.Name())
|
||||
}
|
||||
sort.Strings(tts)
|
||||
sort.Strings(dirs)
|
||||
if !cmp.Equal(tts, dirs) {
|
||||
t.Fatalf("table test map (-) and pkg/plugins/installer/testdata dirs (+) differ: %s", cmp.Diff(tts, dirs))
|
||||
}
|
||||
|
||||
for _, ent := range ents {
|
||||
tst := tab[ent.Name()]
|
||||
r, err := zip.OpenReader(filepath.Join(staticRootPath, ent.Name()))
|
||||
require.NoError(t, err)
|
||||
defer r.Close() //nolint:errcheck
|
||||
if tst.subpath != "" {
|
||||
tst.tfs, err = fs.Sub(r, tst.subpath)
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
tst.tfs = r
|
||||
}
|
||||
|
||||
tab[ent.Name()] = tst
|
||||
}
|
||||
|
||||
lib := cuectx.GrafanaThemaRuntime()
|
||||
for name, otst := range tab {
|
||||
tst := otst // otherwise var is shadowed within func by looping
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if tst.skip != "" {
|
||||
t.Skip(tst.skip)
|
||||
}
|
||||
|
||||
pp, err := ParsePluginFS(tst.tfs, lib)
|
||||
if tst.err == nil {
|
||||
require.NoError(t, err, "unexpected error while parsing plugin fs")
|
||||
} else {
|
||||
require.ErrorIs(t, err, tst.err, "unexpected error type while parsing plugin fs")
|
||||
return
|
||||
}
|
||||
|
||||
if tst.rootid == "" {
|
||||
tst.rootid = name
|
||||
}
|
||||
|
||||
require.Equal(t, tst.rootid, pp.Properties.Id, "expected plugin id and actual plugin id differ")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
package pfs
|
||||
|
||||
import (
|
||||
"cuelang.org/go/cue"
|
||||
"cuelang.org/go/cue/ast"
|
||||
"github.com/grafana/kindsys"
|
||||
)
|
||||
|
||||
// ParsedPlugin represents everything knowable about a single plugin from static
|
||||
@@ -13,35 +13,10 @@ import (
|
||||
type ParsedPlugin struct {
|
||||
// Properties contains the plugin's definition, as declared in plugin.json.
|
||||
Properties Metadata
|
||||
|
||||
// ComposableKinds is a map of all the composable kinds declared in this plugin.
|
||||
// Keys are the name of the [kindsys.SchemaInterface] implemented by the value.
|
||||
//
|
||||
// Composable kind defs are only populated in this map by [ParsePluginFS] if
|
||||
// they are implementations of a known schema interface, or are for
|
||||
// an unknown schema interface.
|
||||
ComposableKinds map[string]kindsys.Composable
|
||||
|
||||
// CustomKinds is a map of all the custom kinds declared in this plugin.
|
||||
// Keys are the machineName of the custom kind.
|
||||
// CustomKinds map[string]kindsys.Custom
|
||||
CueFile cue.Value
|
||||
Variant SchemaInterface
|
||||
|
||||
// CUEImports lists the CUE import statements in the plugin's grafanaplugin CUE
|
||||
// package, if any.
|
||||
CUEImports []*ast.ImportSpec
|
||||
}
|
||||
|
||||
// TODO is this static approach worth using, akin to core generated registries? instead of the ParsedPlugins.ComposableKinds map? in addition to it?
|
||||
// ComposableKinds represents all the possible composable kinds that may be
|
||||
// defined in a Grafana plugin.
|
||||
//
|
||||
// The value of each field, if non-nil, is a standard [kindsys.Def]
|
||||
// representing a CUE definition of a composable kind that implements the
|
||||
// schema interface corresponding to the field's name. (This invariant is
|
||||
// only enforced in [ComposableKinds] returned from [ParsePluginFS].)
|
||||
//
|
||||
// type ComposableKinds struct {
|
||||
// PanelCfg kindsys.Def[kindsys.ComposableProperties]
|
||||
// Queries kindsys.Def[kindsys.ComposableProperties]
|
||||
// DSCfg kindsys.Def[kindsys.ComposableProperties]
|
||||
// }
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
package pfs
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/grafana/kindsys"
|
||||
)
|
||||
|
||||
// This is a brick-dumb test that just ensures known schema interfaces are being
|
||||
// loaded correctly from their declarations in .cue files.
|
||||
//
|
||||
// If this test fails, it's either because:
|
||||
// - They're not being loaded correctly - there's a bug in kindsys or pfs somewhere, fix it
|
||||
// - The set of schema interfaces has been modified - update the static list here
|
||||
func TestSchemaInterfacesAreLoaded(t *testing.T) {
|
||||
knownSI := []string{"PanelCfg", "DataQuery", "DataSourceCfg"}
|
||||
all := kindsys.SchemaInterfaces(nil)
|
||||
var loadedSI []string
|
||||
for k := range all {
|
||||
loadedSI = append(loadedSI, k)
|
||||
}
|
||||
|
||||
sort.Strings(knownSI)
|
||||
sort.Strings(loadedSI)
|
||||
|
||||
if diff := cmp.Diff(knownSI, loadedSI); diff != "" {
|
||||
t.Fatalf("kindsys cue-declared schema interfaces differ from ComposableKinds go struct:\n%s", diff)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user