mirror of https://github.com/grafana/grafana
plugins: New static scanner and validator, with Thema slot support (#53754)
* coremodels: Convert plugin-metadata schema to a coremodel * Newer cuetsy; try quoting field name * Add slot definitions * Start sketching out pfs package * Rerun codegen with fixes, new cuetsy * Catch up dashboard with new cuetsy * Update to go1.18 * Use new vmuxers in thema * Add slot system in Go * Draft finished implementation of pfs * Collapse slot pkg into coremodel dir; add PluginInfo * Add the mux type on top of kernel * Refactor plugin generator for extensibility * Change models.cue package, numerous debugs * Bring new output to parity with old * Remove old plugin generation logic * Misc tweaking * Reintroduce generation of shared schemas * Drop back to go1.17 * Add globbing to tsconfig exclude * Introduce pfs test on existing testdata * Make most existing testdata tests pass with pfs * coremodels: Convert plugin-metadata schema to a coremodel * Newer cuetsy; try quoting field name * Add APIType control concept, regen pluginmeta * Use proper numeric types for schema fields * Make pluginmeta schema follow Go type breakdown * More decomposition into distinct types * Add test case for no plugin.json file * Fix missing ref to #Dependencies * Remove generated TS for pluginmeta * Update dependencies, rearrange go.mod * Regenerate without Model prefix * Use updated thema loader; this is now runnable * Skip app plugin with weird include * Make plugin tree extractor reusable * Split out slot lineage load/validate logic * Add myriad tests for new plugin validation failures * Add test for zip fixtures * One last run of codegen * Proper delinting * Ensure validation order is deterministic * Let there actually be sorting * Undo reliance on builtIn field (#54009) * undo builtIn reliance * fix tests Co-authored-by: Will Browne <wbrowne@users.noreply.github.com>pull/54030/head^2
parent
828497447a
commit
4d433084a5
@ -1 +1,176 @@ |
||||
package coremodel |
||||
|
||||
import ( |
||||
"embed" |
||||
"fmt" |
||||
"io/fs" |
||||
"path/filepath" |
||||
"testing/fstest" |
||||
|
||||
"cuelang.org/go/cue" |
||||
"cuelang.org/go/cue/load" |
||||
"github.com/grafana/grafana/pkg/cuectx" |
||||
"github.com/grafana/thema/kernel" |
||||
tload "github.com/grafana/thema/load" |
||||
) |
||||
|
||||
// Embed for all framework-related CUE files in this directory
|
||||
//
|
||||
//go:embed *.cue
|
||||
var cueFS embed.FS |
||||
|
||||
var defaultFramework cue.Value |
||||
|
||||
func init() { |
||||
var err error |
||||
defaultFramework, err = doLoadFrameworkCUE(cuectx.ProvideCUEContext()) |
||||
if err != nil { |
||||
panic(err) |
||||
} |
||||
} |
||||
|
||||
var prefix = filepath.Join("/pkg", "framework", "coremodel") |
||||
|
||||
//nolint:nakedret
|
||||
func doLoadFrameworkCUE(ctx *cue.Context) (v cue.Value, err error) { |
||||
m := make(fstest.MapFS) |
||||
|
||||
err = fs.WalkDir(cueFS, ".", func(path string, d fs.DirEntry, err error) error { |
||||
if err != nil { |
||||
return err |
||||
} |
||||
if d.IsDir() { |
||||
return nil |
||||
} |
||||
b, err := fs.ReadFile(cueFS, path) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
m[path] = &fstest.MapFile{Data: b} |
||||
return nil |
||||
}) |
||||
if err != nil { |
||||
return |
||||
} |
||||
|
||||
over := make(map[string]load.Source) |
||||
err = tload.ToOverlay(prefix, m, over) |
||||
if err != nil { |
||||
return |
||||
} |
||||
|
||||
bi := load.Instances(nil, &load.Config{ |
||||
Dir: prefix, |
||||
Package: "coremodel", |
||||
Overlay: over, |
||||
}) |
||||
v = ctx.BuildInstance(bi[0]) |
||||
|
||||
if v.Err() != nil { |
||||
return cue.Value{}, fmt.Errorf("coremodel framework loaded cue.Value has err: %w", v.Err()) |
||||
} |
||||
|
||||
return |
||||
} |
||||
|
||||
// CUEFramework returns a cue.Value representing all the coremodel framework
|
||||
// raw CUE files.
|
||||
//
|
||||
// For low-level use in constructing other types and APIs, while still letting
|
||||
// us declare all the frameworky CUE bits in a single package. Other types and
|
||||
// subpackages make the constructs in this value easy to use.
|
||||
//
|
||||
// The returned cue.Value is built from Grafana's standard central CUE context,
|
||||
// ["github.com/grafana/grafana/pkg/cuectx".ProvideCueContext].
|
||||
func CUEFramework() cue.Value { |
||||
return defaultFramework |
||||
} |
||||
|
||||
// CUEFrameworkWithContext is the same as CUEFramework, but allows control over
|
||||
// the cue.Context that's used.
|
||||
//
|
||||
// Prefer CUEFramework unless you understand cue.Context, and absolutely need
|
||||
// this control.
|
||||
func CUEFrameworkWithContext(ctx *cue.Context) cue.Value { |
||||
// Error guaranteed to be nil here because erroring would have caused init() to panic
|
||||
v, _ := doLoadFrameworkCUE(ctx) // nolint:errcheck
|
||||
return v |
||||
} |
||||
|
||||
// Mux takes a coremodel and returns a Thema version muxer that, given a byte
|
||||
// slice containing any version of schema for that coremodel, will translate it
|
||||
// to the Interface.CurrentSchema() version, and optionally decode it onto the
|
||||
// Interface.GoType().
|
||||
//
|
||||
// By default, JSON decoding will be used, and the filename given to any input
|
||||
// bytes (shown in errors, which may be user-facing) will be
|
||||
// "<name>.<encoding>", e.g. dashboard.json.
|
||||
func Mux(cm Interface, opts ...MuxOption) kernel.InputKernel { |
||||
c := &muxConfig{} |
||||
for _, opt := range opts { |
||||
opt(c) |
||||
} |
||||
|
||||
cfg := kernel.InputKernelConfig{ |
||||
Typ: cm.GoType(), |
||||
Lineage: cm.Lineage(), |
||||
To: cm.CurrentSchema().Version(), |
||||
} |
||||
|
||||
switch c.decodetyp { |
||||
case "", "json": // json by default
|
||||
if c.filename == "" { |
||||
c.filename = fmt.Sprintf("%s.json", cm.Lineage().Name()) |
||||
} |
||||
cfg.Loader = kernel.NewJSONDecoder(c.filename) |
||||
case "yaml": |
||||
if c.filename == "" { |
||||
c.filename = fmt.Sprintf("%s.yaml", cm.Lineage().Name()) |
||||
} |
||||
cfg.Loader = kernel.NewYAMLDecoder(c.filename) |
||||
default: |
||||
panic("") |
||||
} |
||||
|
||||
mux, err := kernel.NewInputKernel(cfg) |
||||
if err != nil { |
||||
// Barring a fundamental bug in Thema's schema->Go type assignability checker or
|
||||
// a direct attempt by a Grafana dev to get around the invariants of coremodel codegen,
|
||||
// this should be unreachable. (And even the latter case should be caught elsewhere
|
||||
// by tests).
|
||||
panic(err) |
||||
} |
||||
return mux |
||||
} |
||||
|
||||
// A MuxOption defines options that may be specified only at initial
|
||||
// construction of a Lineage via BindLineage.
|
||||
type MuxOption muxOption |
||||
|
||||
// Internal representation of MuxOption.
|
||||
type muxOption func(c *muxConfig) |
||||
|
||||
type muxConfig struct { |
||||
filename string |
||||
decodetyp string |
||||
} |
||||
|
||||
// YAML indicates that the resulting Mux should look for YAML in input bytes,
|
||||
// rather than the default JSON.
|
||||
func YAML() MuxOption { |
||||
return func(c *muxConfig) { |
||||
c.decodetyp = "yaml" |
||||
} |
||||
} |
||||
|
||||
// Filename specifies the filename that is given to input bytes passing through
|
||||
// the mux.
|
||||
//
|
||||
// The filename has no impact on mux behavior, but is used in user-facing error
|
||||
// output, such as schema validation failures. Thus, it is recommended to pick a
|
||||
// name that will make sense in the context a user is expected to see the error.
|
||||
func Filename(name string) MuxOption { |
||||
return func(c *muxConfig) { |
||||
c.filename = name |
||||
} |
||||
} |
||||
|
||||
@ -0,0 +1,87 @@ |
||||
package coremodel |
||||
|
||||
import ( |
||||
"cuelang.org/go/cue" |
||||
) |
||||
|
||||
// Slot represents one of Grafana's named Thema composition slot definitions.
|
||||
type Slot struct { |
||||
name string |
||||
raw cue.Value |
||||
plugins map[string]bool |
||||
} |
||||
|
||||
// Name returns the name of the Slot. The name is also used as the path at which
|
||||
// a Slot lineage is defined in a plugin models.cue file.
|
||||
func (s Slot) Name() string { |
||||
return s.name |
||||
} |
||||
|
||||
// MetaSchema returns the meta-schema that is the contract between coremodels
|
||||
// that compose the Slot, and plugins that implement it.
|
||||
func (s Slot) MetaSchema() cue.Value { |
||||
return s.raw |
||||
} |
||||
|
||||
// ForPluginType indicates whether for this Slot, plugins of the given type may
|
||||
// provide a slot implementation (first return value), and for those types that
|
||||
// may, whether they must produce one (second return value).
|
||||
//
|
||||
// Expected values here are those in the set of
|
||||
// ["github.com/grafana/grafana/pkg/coremodel/pluginmeta".Type], though passing
|
||||
// a string not in that set will harmlessly return {false, false}. That type is
|
||||
// not used here to avoid import cycles.
|
||||
//
|
||||
// Note that, at least for now, plugins are not required to provide any slot
|
||||
// implementations, and do so by simply not containing a models.cue file.
|
||||
// Consequently, the "must" return value here is best understood as, "IF a
|
||||
// plugin provides a models.cue file, it MUST contain an implementation of this
|
||||
// slot."
|
||||
func (s Slot) ForPluginType(plugintype string) (may, must bool) { |
||||
must, may = s.plugins[plugintype] |
||||
return |
||||
} |
||||
|
||||
func AllSlots() map[string]*Slot { |
||||
fw := CUEFramework() |
||||
slots := make(map[string]*Slot) |
||||
|
||||
// Ignore err, can only happen if we change structure of fw files, and all we'd
|
||||
// do is panic and that's what the next line will do anyway. Same for similar ignored
|
||||
// errors later in this func
|
||||
iter, _ := fw.LookupPath(cue.ParsePath("pluginTypeMetaSchema")).Fields(cue.Optional(true)) |
||||
type nameopt struct { |
||||
name string |
||||
req bool |
||||
} |
||||
plugslots := make(map[string][]nameopt) |
||||
for iter.Next() { |
||||
plugin := iter.Selector().String() |
||||
iiter, _ := iter.Value().Fields(cue.Optional(true)) |
||||
for iiter.Next() { |
||||
slotname := iiter.Selector().String() |
||||
plugslots[slotname] = append(plugslots[slotname], nameopt{ |
||||
name: plugin, |
||||
req: !iiter.IsOptional(), |
||||
}) |
||||
} |
||||
} |
||||
|
||||
iter, _ = fw.LookupPath(cue.ParsePath("slots")).Fields(cue.Optional(true)) |
||||
for iter.Next() { |
||||
n := iter.Selector().String() |
||||
sl := Slot{ |
||||
name: n, |
||||
raw: iter.Value(), |
||||
plugins: make(map[string]bool), |
||||
} |
||||
|
||||
for _, no := range plugslots[n] { |
||||
sl.plugins[no.name] = no.req |
||||
} |
||||
|
||||
slots[n] = &sl |
||||
} |
||||
|
||||
return slots |
||||
} |
||||
@ -0,0 +1,2 @@ |
||||
// Package Slot exposes Grafana's coremodel composition Slot definitions for use in Go.
|
||||
package slot |
||||
@ -0,0 +1,71 @@ |
||||
package coremodel |
||||
|
||||
// The slots named and specified in this file are meta-schemas that act as a |
||||
// shared contract between Grafana plugins (producers) and coremodel types |
||||
// (consumers). |
||||
// |
||||
// On the consumer side, any coremodel Thema lineage can choose to define a |
||||
// standard Thema composition slot that specifies one of these named slots as |
||||
// its meta-schema. Such a specification entails that all schemas in any lineage |
||||
// placed into that composition slot must adhere to the meta-schema. |
||||
// |
||||
// On the producer side, Grafana's plugin system enforces that certain plugin |
||||
// types are expected to provide Thema lineages for these named slots which |
||||
// adhere to the slot meta-schema. |
||||
// |
||||
// For example, the Panel slot is consumed by the dashboard coremodel, and is |
||||
// expected to be produced by panel plugins. |
||||
// |
||||
// The name given to each slot in this file must be used as the name of the |
||||
// slot in the coremodel, and the name of the field under which the lineage |
||||
// is provided in a plugin's models.cue file. |
||||
// |
||||
// Conformance to meta-schema is achieved by Thema's native lineage joinSchema, |
||||
// which Thema internals automatically enforce across all schemas in a lineage. |
||||
|
||||
// Meta-schema for the Panel slot, as implemented in Grafana panel plugins. |
||||
// |
||||
// This is a grouped meta-schema, intended solely for use in composition. Object |
||||
// literals conforming to it are not expected to exist. |
||||
slots: Panel: { |
||||
// Defines plugin-specific options for a panel that should be persisted. Required, |
||||
// though a panel without any options may specify an empty struct. |
||||
// |
||||
// Currently mapped to #Panel.options within the dashboard schema. |
||||
PanelOptions: {...} |
||||
// Plugin-specific custom field properties. Optional. |
||||
// |
||||
// Currently mapped to #Panel.fieldConfig.defaults.custom within the dashboard schema. |
||||
PanelFieldConfig?: {...} |
||||
} |
||||
|
||||
// Meta-schema for the Query slot, as implemented in Grafana datasource plugins. |
||||
slots: Query: {...} |
||||
|
||||
// Meta-schema for the DSOptions slot, as implemented in Grafana datasource plugins. |
||||
// |
||||
// This is a grouped meta-schema, intended solely for use in composition. Object |
||||
// literals conforming to it are not expected to exist. |
||||
slots: DSOptions: { |
||||
// Normal datasource configuration options. |
||||
Options: {...} |
||||
// Sensitive datasource configuration options that require encryption. |
||||
SecureOptions: {...} |
||||
} |
||||
|
||||
// pluginTypeMetaSchema defines which plugin types should use which metaschemas |
||||
// as joinSchema for the lineages declared at which paths. |
||||
pluginTypeMetaSchema: [string]: {...} |
||||
pluginTypeMetaSchema: { |
||||
// Panel plugins are expected to provide a lineage at path Panel conforming to |
||||
// the Panel joinSchema. |
||||
panel: { |
||||
Panel: slots.Panel |
||||
} |
||||
// Datasource plugins are expected to provide lineages at paths Query and |
||||
// DSOptions, conforming to those joinSchemas respectively. |
||||
datasource: { |
||||
Query: slots.Query |
||||
DSOptions: slots.DSOptions |
||||
} |
||||
} |
||||
@ -0,0 +1,23 @@ |
||||
package grafanaplugin |
||||
|
||||
import ( |
||||
"github.com/grafana/thema" |
||||
"github.com/grafana/grafana/pkg/framework/coremodel" |
||||
) |
||||
|
||||
_dummy: coremodel.slots |
||||
|
||||
Panel: thema.#Lineage & { |
||||
name: "disallowed_cue_import" |
||||
seqs: [ |
||||
{ |
||||
schemas: [ |
||||
{ |
||||
PanelOptions: { |
||||
foo: string |
||||
} @cuetsy(kind="interface") |
||||
}, |
||||
] |
||||
}, |
||||
] |
||||
} |
||||
@ -0,0 +1,14 @@ |
||||
{ |
||||
"type": "panel", |
||||
"name": "Disallowed CUE import", |
||||
"id": "disallowed-import-panel", |
||||
"backend": true, |
||||
"state": "alpha", |
||||
"info": { |
||||
"description": "Test", |
||||
"author": { |
||||
"name": "Grafana Labs", |
||||
"url": "https://grafana.com" |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,18 @@ |
||||
package grafanaplugin |
||||
|
||||
import "github.com/grafana/thema" |
||||
|
||||
Panel: thema.#Lineage & { |
||||
name: "doesnamatch" |
||||
seqs: [ |
||||
{ |
||||
schemas: [ |
||||
{ |
||||
PanelOptions: { |
||||
foo: string |
||||
} @cuetsy(kind="interface") |
||||
}, |
||||
] |
||||
}, |
||||
] |
||||
} |
||||
@ -0,0 +1,14 @@ |
||||
{ |
||||
"type": "panel", |
||||
"name": "Slot impl testing", |
||||
"id": "mismatch-panel", |
||||
"backend": true, |
||||
"state": "alpha", |
||||
"info": { |
||||
"description": "Test", |
||||
"author": { |
||||
"name": "Grafana Labs", |
||||
"url": "https://grafana.com" |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,16 @@ |
||||
package grafanaplugin |
||||
|
||||
import "github.com/grafana/thema" |
||||
|
||||
Query: thema.#Lineage & { |
||||
name: "missing_slot_impl" |
||||
seqs: [ |
||||
{ |
||||
schemas: [ |
||||
{ |
||||
foo: string |
||||
}, |
||||
] |
||||
}, |
||||
] |
||||
} |
||||
@ -0,0 +1,14 @@ |
||||
{ |
||||
"type": "datasource", |
||||
"name": "Missing slot impl", |
||||
"id": "missing-slot-datasource", |
||||
"backend": true, |
||||
"state": "alpha", |
||||
"info": { |
||||
"description": "Test", |
||||
"author": { |
||||
"name": "Grafana Labs", |
||||
"url": "https://grafana.com" |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,18 @@ |
||||
package grafanaplugin |
||||
|
||||
import "github.com/grafana/thema" |
||||
|
||||
Panel: thema.#Lineage & { |
||||
name: "mismatch" |
||||
seqs: [ |
||||
{ |
||||
schemas: [ |
||||
{ |
||||
PanelOptions: { |
||||
foo: string |
||||
} @cuetsy(kind="interface") |
||||
}, |
||||
] |
||||
}, |
||||
] |
||||
} |
||||
@ -0,0 +1,14 @@ |
||||
{ |
||||
"type": "panel", |
||||
"name": "ID/Name mismatch", |
||||
"id": "name-mismatch-panel", |
||||
"backend": true, |
||||
"state": "alpha", |
||||
"info": { |
||||
"description": "Test", |
||||
"author": { |
||||
"name": "Grafana Labs", |
||||
"url": "https://grafana.com" |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,23 @@ |
||||
package grafanaplugin |
||||
|
||||
import "github.com/grafana/thema" |
||||
|
||||
Panel: thema.#Lineage & { |
||||
joinSchema: { |
||||
PanelOptions: {...} |
||||
PanelFieldConfig: string |
||||
} |
||||
name: "panel_conflicting_joinschema" |
||||
seqs: [ |
||||
{ |
||||
schemas: [ |
||||
{ |
||||
PanelOptions: { |
||||
foo: string |
||||
} @cuetsy(kind="interface") |
||||
PanelFieldConfig: string |
||||
}, |
||||
] |
||||
}, |
||||
] |
||||
} |
||||
@ -0,0 +1,14 @@ |
||||
{ |
||||
"type": "panel", |
||||
"name": "Slot impl testing", |
||||
"id": "panel-conflicting-joinschema", |
||||
"backend": true, |
||||
"state": "alpha", |
||||
"info": { |
||||
"description": "Test", |
||||
"author": { |
||||
"name": "Grafana Labs", |
||||
"url": "https://grafana.com" |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,19 @@ |
||||
package grafanaplugin |
||||
|
||||
import "github.com/grafana/thema" |
||||
|
||||
Panel: thema.#Lineage & { |
||||
name: "panel_does_not_follow_slot_joinschema" |
||||
seqs: [ |
||||
{ |
||||
schemas: [ |
||||
{ |
||||
PanelOptions: { |
||||
foo: string |
||||
} @cuetsy(kind="interface") |
||||
PanelFieldConfig: string |
||||
}, |
||||
] |
||||
}, |
||||
] |
||||
} |
||||
@ -0,0 +1,14 @@ |
||||
{ |
||||
"type": "panel", |
||||
"name": "Slot impl testing", |
||||
"id": "panel-does-not-follow-slot-joinschema", |
||||
"backend": true, |
||||
"state": "alpha", |
||||
"info": { |
||||
"description": "Test", |
||||
"author": { |
||||
"name": "Grafana Labs", |
||||
"url": "https://grafana.com" |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,34 @@ |
||||
package grafanaplugin |
||||
|
||||
import "github.com/grafana/thema" |
||||
|
||||
Query: thema.#Lineage & { |
||||
name: "valid_model_datasource" |
||||
seqs: [ |
||||
{ |
||||
schemas: [ |
||||
{ |
||||
foo: string |
||||
}, |
||||
] |
||||
}, |
||||
] |
||||
} |
||||
|
||||
DSOptions: thema.#Lineage & { |
||||
name: "valid_model_datasource" |
||||
seqs: [ |
||||
{ |
||||
schemas: [ |
||||
{ |
||||
Options: { |
||||
foo: string |
||||
} |
||||
SecureOptions: { |
||||
bar: string |
||||
} |
||||
}, |
||||
] |
||||
}, |
||||
] |
||||
} |
||||
@ -0,0 +1,14 @@ |
||||
{ |
||||
"type": "datasource", |
||||
"name": "Datasource models valid", |
||||
"id": "valid-model-datasource", |
||||
"backend": true, |
||||
"state": "alpha", |
||||
"info": { |
||||
"description": "Test", |
||||
"author": { |
||||
"name": "Grafana Labs", |
||||
"url": "https://grafana.com" |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,18 @@ |
||||
package grafanaplugin |
||||
|
||||
import "github.com/grafana/thema" |
||||
|
||||
Panel: thema.#Lineage & { |
||||
name: "valid_model_panel" |
||||
seqs: [ |
||||
{ |
||||
schemas: [ |
||||
{ |
||||
PanelOptions: { |
||||
foo: string |
||||
} @cuetsy(kind="interface") |
||||
}, |
||||
] |
||||
}, |
||||
] |
||||
} |
||||
@ -0,0 +1,14 @@ |
||||
{ |
||||
"type": "panel", |
||||
"name": "Panel models valid", |
||||
"id": "valid-model-panel", |
||||
"backend": true, |
||||
"state": "alpha", |
||||
"info": { |
||||
"description": "Test", |
||||
"author": { |
||||
"name": "Grafana Labs", |
||||
"url": "https://grafana.com" |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,31 @@ |
||||
package grafanaplugin |
||||
|
||||
import "github.com/grafana/thema" |
||||
|
||||
Query: thema.#Lineage & { |
||||
name: "wrong_slot_panel" |
||||
seqs: [ |
||||
{ |
||||
schemas: [ |
||||
{ |
||||
foo: string |
||||
}, |
||||
] |
||||
}, |
||||
] |
||||
} |
||||
|
||||
Panel: thema.#Lineage & { |
||||
name: "wrong_slot_panel" |
||||
seqs: [ |
||||
{ |
||||
schemas: [ |
||||
{ |
||||
PanelOptions: { |
||||
foo: string |
||||
} @cuetsy(kind="interface") |
||||
}, |
||||
] |
||||
}, |
||||
] |
||||
} |
||||
@ -0,0 +1,14 @@ |
||||
{ |
||||
"type": "panel", |
||||
"name": "Wrong slot for type", |
||||
"id": "wrong-slot-panel", |
||||
"backend": true, |
||||
"state": "alpha", |
||||
"info": { |
||||
"description": "Test", |
||||
"author": { |
||||
"name": "Grafana Labs", |
||||
"url": "https://grafana.com" |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,3 @@ |
||||
// Package pfs ("Plugin FS") defines a virtual filesystem representation of Grafana plugins.
|
||||
|
||||
package pfs |
||||
@ -0,0 +1,30 @@ |
||||
package pfs |
||||
|
||||
import "errors" |
||||
|
||||
// ErrEmptyFS indicates that the fs.FS provided to ParsePluginFS was empty.
|
||||
var ErrEmptyFS = errors.New("provided fs.FS is empty") |
||||
|
||||
// ErrNoRootFile indicates that no root plugin.json file exists.
|
||||
var ErrNoRootFile = errors.New("no plugin.json at root of fs.fS") |
||||
|
||||
// ErrInvalidRootFile indicates that the root plugin.json file is invalid.
|
||||
var ErrInvalidRootFile = errors.New("plugin.json is invalid") |
||||
|
||||
// ErrImplementedSlots indicates that a plugin has implemented the wrong set of
|
||||
// slots for its type in models.cue. Either:
|
||||
// - A slot is implemented that is not allowed for its type (e.g. datasource plugin implements Panel)
|
||||
// - A required slot for its type is not implemented (e.g. panel plugin does not implemented Panel)
|
||||
var ErrImplementedSlots = errors.New("slot implementation not allowed for this plugin type") |
||||
|
||||
// ErrInvalidLineage indicates that the plugin contains an invalid lineage
|
||||
// declaration, according to Thema's validation rules in
|
||||
// ["github.com/grafana/thema".BindLineage].
|
||||
var ErrInvalidLineage = errors.New("invalid lineage") |
||||
|
||||
// ErrLineageNameMismatch indicates a plugin slot lineage name did not match the id of the plugin.
|
||||
var ErrLineageNameMismatch = errors.New("lineage name not the same as plugin id") |
||||
|
||||
// ErrDisallowedCUEImport indicates that a plugin's models.cue file imports a
|
||||
// CUE package that is not on the whitelist for safe imports.
|
||||
var ErrDisallowedCUEImport = errors.New("CUE import is not allowed") |
||||
@ -0,0 +1,336 @@ |
||||
package pfs |
||||
|
||||
import ( |
||||
"fmt" |
||||
"io/fs" |
||||
"sort" |
||||
"strings" |
||||
"sync" |
||||
|
||||
"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/coremodel/pluginmeta" |
||||
"github.com/grafana/grafana/pkg/framework/coremodel" |
||||
"github.com/grafana/grafana/pkg/framework/coremodel/registry" |
||||
"github.com/grafana/thema" |
||||
"github.com/grafana/thema/kernel" |
||||
"github.com/grafana/thema/load" |
||||
"github.com/yalue/merged_fs" |
||||
) |
||||
|
||||
// PermittedCUEImports returns the list of packages that may be imported in a
|
||||
// plugin models.cue file.
|
||||
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 |
||||
|
||||
// Name expected to be used for all models.cue files in Grafana plugins
|
||||
const pkgname = "grafanaplugin" |
||||
|
||||
type slotandname struct { |
||||
name string |
||||
slot *coremodel.Slot |
||||
} |
||||
|
||||
var allslots []slotandname |
||||
|
||||
var plugmux kernel.InputKernel |
||||
|
||||
// TODO re-enable after go1.18
|
||||
// var tsch thema.TypedSchema[pluginmeta.Model]
|
||||
// var plugmux vmux.ValueMux[pluginmeta.Model]
|
||||
|
||||
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 coremodel.AllSlots() { |
||||
allslots = append(allslots, slotandname{ |
||||
name: n, |
||||
slot: s, |
||||
}) |
||||
} |
||||
|
||||
sort.Slice(allslots, func(i, j int) bool { |
||||
return allslots[i].name < allslots[j].name |
||||
}) |
||||
} |
||||
|
||||
var muxonce sync.Once |
||||
|
||||
func loadMux() kernel.InputKernel { |
||||
muxonce.Do(func() { |
||||
plugmux = coremodel.Mux(registry.NewBase().Pluginmeta(), coremodel.Filename("plugin.json")) |
||||
}) |
||||
return plugmux |
||||
} |
||||
|
||||
// This used to be in init(), but that creates a risk for codegen.
|
||||
//
|
||||
// thema.BindType ensures that Go type and Thema schema are aligned. If we were
|
||||
// to call it during init(), then the code generator that fixes misalignments
|
||||
// between those two could trigger it if it depends on this package. That would
|
||||
// mean that schema changes to pluginmeta get caught in a loop where the codegen
|
||||
// process can't heal itself.
|
||||
//
|
||||
// In theory, that dependency shouldn't exist - this package should only be
|
||||
// imported for plugin codegen, which should all happen after coremodel codegen.
|
||||
// But in practice, it might exist. And it's really brittle and confusing to
|
||||
// fix if that does happen.
|
||||
//
|
||||
// Better to be resilient to the possibility instead. So, this is a standalone function,
|
||||
// called as needed to get our muxer, and internally relies on a sync.Once to avoid
|
||||
// repeated processing of thema.BindType.
|
||||
// TODO mux loading is easily generalizable in pkg/f/coremodel, shouldn't need one-off
|
||||
// TODO switch to this generic signature after go1.18
|
||||
// func loadMux() (thema.TypedSchema[pluginmeta.Model], vmux.ValueMux[pluginmeta.Model]) {
|
||||
// muxonce.Do(func() {
|
||||
// var err error
|
||||
// var t pluginmeta.Model
|
||||
// tsch, err = thema.BindType[pluginmeta.Model](pm.CurrentSchema(), t)
|
||||
// if err != nil {
|
||||
// panic(err)
|
||||
// }
|
||||
// plugmux = vmux.NewValueMux(tsch, vmux.NewJSONEndec("plugin.json"))
|
||||
// })
|
||||
// return tsch, plugmux
|
||||
// }
|
||||
|
||||
// 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 { |
||||
panic("TODO") |
||||
} |
||||
|
||||
// PluginInfo represents everything knowable about a single plugin from static
|
||||
// analysis of its filesystem tree contents.
|
||||
type PluginInfo struct { |
||||
meta pluginmeta.Model |
||||
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() pluginmeta.Model { |
||||
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.
|
||||
// TODO no descent is ok for core plugins, but won't cut it in general
|
||||
func ParsePluginFS(f fs.FS, lib thema.Library) (*Tree, error) { |
||||
if f == nil { |
||||
return nil, ErrEmptyFS |
||||
} |
||||
// _, mux := loadMux()
|
||||
mux := loadMux() |
||||
ctx := lib.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 Model 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 pluginmeta lineage)
|
||||
metaany, _, err := mux.Converge(b) |
||||
if err != nil { |
||||
// TODO more nuanced error handling by class of Thema failure
|
||||
// return nil, fmt.Errorf("plugin.json was invalid: %w", err)
|
||||
return nil, ewrap(err, ErrInvalidRootFile) |
||||
} |
||||
r.meta = *metaany.(*pluginmeta.Model) |
||||
|
||||
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 pkgname.
|
||||
// That's...maybe good? But not what it says on the tin
|
||||
bi, err := load.InstancesWithThema(mfs, "", load.Package(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) |
||||
for _, s := range allslots { |
||||
iv := val.LookupPath(cue.ParsePath(s.slot.Name())) |
||||
lin, err := bindSlotLineage(iv, s.slot, r.meta, lib) |
||||
if lin != nil { |
||||
r.slotimpls[s.slot.Name()] = lin |
||||
} |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
} |
||||
} |
||||
|
||||
return tree, nil |
||||
} |
||||
|
||||
func bindSlotLineage(v cue.Value, s *coremodel.Slot, meta pluginmeta.Model, lib thema.Library) (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, lib, thema.SatisfiesJoinSchema(s.MetaSchema()))
|
||||
lin, err := thema.BindLineage(v, lib) |
||||
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() |
||||
} |
||||
@ -0,0 +1,283 @@ |
||||
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 TestParseTreeTestdata(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", |
||||
}, |
||||
"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", |
||||
}, |
||||
"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", |
||||
}, |
||||
"no-rootfile": { |
||||
err: ErrNoRootFile, |
||||
}, |
||||
"valid-model-panel": {}, |
||||
"valid-model-datasource": {}, |
||||
"wrong-slot-panel": { |
||||
err: ErrImplementedSlots, |
||||
}, |
||||
"missing-slot-impl": { |
||||
err: ErrImplementedSlots, |
||||
}, |
||||
"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", |
||||
}, |
||||
"name-id-mismatch": { |
||||
err: ErrLineageNameMismatch, |
||||
}, |
||||
"mismatch": { |
||||
err: ErrLineageNameMismatch, |
||||
}, |
||||
"disallowed-cue-import": { |
||||
err: ErrDisallowedCUEImport, |
||||
}, |
||||
} |
||||
|
||||
staticRootPath, err := filepath.Abs("../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.ProvideThemaLibrary() |
||||
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) |
||||
} |
||||
|
||||
tree, err := ParsePluginFS(tst.tfs, lib) |
||||
if tst.err == nil { |
||||
require.NoError(t, err, "unexpected error while parsing plugin tree") |
||||
} else { |
||||
require.ErrorIs(t, err, tst.err, "unexpected error type while parsing plugin tree") |
||||
return |
||||
} |
||||
|
||||
if tst.rootid == "" { |
||||
tst.rootid = name |
||||
} |
||||
|
||||
rootp := tree.RootPlugin() |
||||
require.Equal(t, tst.rootid, rootp.Meta().Id, "expected root plugin id and actual root 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("../manager/installer/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.ProvideThemaLibrary() |
||||
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) |
||||
} |
||||
|
||||
tree, err := ParsePluginFS(tst.tfs, lib) |
||||
if tst.err == nil { |
||||
require.NoError(t, err, "unexpected error while parsing plugin tree") |
||||
} else { |
||||
require.ErrorIs(t, err, tst.err, "unexpected error type while parsing plugin tree") |
||||
return |
||||
} |
||||
|
||||
if tst.rootid == "" { |
||||
tst.rootid = name |
||||
} |
||||
|
||||
rootp := tree.RootPlugin() |
||||
require.Equal(t, tst.rootid, rootp.Meta().Id, "expected root plugin id and actual root plugin id differ") |
||||
}) |
||||
} |
||||
} |
||||
Loading…
Reference in new issue