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>
This commit is contained in:
sam boyer
2022-08-22 12:11:45 -04:00
committed by GitHub
co-authored by Will Browne
parent 828497447a
commit 4d433084a5
85 changed files with 1775 additions and 479 deletions
+39 -39
View File
@@ -108,7 +108,7 @@ func TestLoader_Load(t *testing.T) {
want: []*plugins.Plugin{
{
JSONData: plugins.JSONData{
ID: "test",
ID: "test-datasource",
Type: "datasource",
Name: "Test",
Info: plugins.Info{
@@ -131,8 +131,8 @@ func TestLoader_Load(t *testing.T) {
Backend: true,
State: "alpha",
},
Module: "plugins/test/module",
BaseURL: "public/plugins/test",
Module: "plugins/test-datasource/module",
BaseURL: "public/plugins/test-datasource",
PluginDir: filepath.Join(parentDir, "testdata/valid-v2-signature/plugin/"),
Signature: "valid",
SignatureType: plugins.GrafanaSignature,
@@ -229,7 +229,7 @@ func TestLoader_Load(t *testing.T) {
want: []*plugins.Plugin{
{
JSONData: plugins.JSONData{
ID: "test",
ID: "test-datasource",
Type: "datasource",
Name: "Test",
Info: plugins.Info{
@@ -251,8 +251,8 @@ func TestLoader_Load(t *testing.T) {
State: plugins.AlphaRelease,
},
Class: plugins.External,
Module: "plugins/test/module",
BaseURL: "public/plugins/test",
Module: "plugins/test-datasource/module",
BaseURL: "public/plugins/test-datasource",
PluginDir: filepath.Join(parentDir, "testdata/unsigned-datasource/plugin"),
Signature: "unsigned",
},
@@ -266,8 +266,8 @@ func TestLoader_Load(t *testing.T) {
pluginPaths: []string{"../testdata/unsigned-datasource"},
want: []*plugins.Plugin{},
pluginErrors: map[string]*plugins.Error{
"test": {
PluginID: "test",
"test-datasource": {
PluginID: "test-datasource",
ErrorCode: "signatureMissing",
},
},
@@ -277,13 +277,13 @@ func TestLoader_Load(t *testing.T) {
class: plugins.External,
cfg: &plugins.Cfg{
PluginsPath: filepath.Join(parentDir),
PluginsAllowUnsigned: []string{"test"},
PluginsAllowUnsigned: []string{"test-datasource"},
},
pluginPaths: []string{"../testdata/unsigned-datasource"},
want: []*plugins.Plugin{
{
JSONData: plugins.JSONData{
ID: "test",
ID: "test-datasource",
Type: "datasource",
Name: "Test",
Info: plugins.Info{
@@ -305,8 +305,8 @@ func TestLoader_Load(t *testing.T) {
State: plugins.AlphaRelease,
},
Class: plugins.External,
Module: "plugins/test/module",
BaseURL: "public/plugins/test",
Module: "plugins/test-datasource/module",
BaseURL: "public/plugins/test-datasource",
PluginDir: filepath.Join(parentDir, "testdata/unsigned-datasource/plugin"),
Signature: plugins.SignatureUnsigned,
},
@@ -321,8 +321,8 @@ func TestLoader_Load(t *testing.T) {
pluginPaths: []string{"../testdata/lacking-files"},
want: []*plugins.Plugin{},
pluginErrors: map[string]*plugins.Error{
"test": {
PluginID: "test",
"test-datasource": {
PluginID: "test-datasource",
ErrorCode: "signatureModified",
},
},
@@ -332,13 +332,13 @@ func TestLoader_Load(t *testing.T) {
class: plugins.External,
cfg: &plugins.Cfg{
PluginsPath: filepath.Join(parentDir),
PluginsAllowUnsigned: []string{"test"},
PluginsAllowUnsigned: []string{"test-datasource"},
},
pluginPaths: []string{"../testdata/lacking-files"},
want: []*plugins.Plugin{},
pluginErrors: map[string]*plugins.Error{
"test": {
PluginID: "test",
"test-datasource": {
PluginID: "test-datasource",
ErrorCode: "signatureModified",
},
},
@@ -348,13 +348,13 @@ func TestLoader_Load(t *testing.T) {
class: plugins.External,
cfg: &plugins.Cfg{
PluginsPath: filepath.Join(parentDir),
PluginsAllowUnsigned: []string{"test"},
PluginsAllowUnsigned: []string{"test-datasource"},
},
pluginPaths: []string{"../testdata/invalid-v2-missing-file"},
want: []*plugins.Plugin{},
pluginErrors: map[string]*plugins.Error{
"test": {
PluginID: "test",
"test-datasource": {
PluginID: "test-datasource",
ErrorCode: "signatureModified",
},
},
@@ -364,13 +364,13 @@ func TestLoader_Load(t *testing.T) {
class: plugins.External,
cfg: &plugins.Cfg{
PluginsPath: filepath.Join(parentDir),
PluginsAllowUnsigned: []string{"test"},
PluginsAllowUnsigned: []string{"test-datasource"},
},
pluginPaths: []string{"../testdata/invalid-v2-extra-file"},
want: []*plugins.Plugin{},
pluginErrors: map[string]*plugins.Error{
"test": {
PluginID: "test",
"test-datasource": {
PluginID: "test-datasource",
ErrorCode: "signatureModified",
},
},
@@ -530,7 +530,7 @@ func TestLoader_Load_MultiplePlugins(t *testing.T) {
want: []*plugins.Plugin{
{
JSONData: plugins.JSONData{
ID: "test",
ID: "test-datasource",
Type: "datasource",
Name: "Test",
Info: plugins.Info{
@@ -554,8 +554,8 @@ func TestLoader_Load_MultiplePlugins(t *testing.T) {
State: plugins.AlphaRelease,
},
Class: plugins.External,
Module: "plugins/test/module",
BaseURL: "public/plugins/test",
Module: "plugins/test-datasource/module",
BaseURL: "public/plugins/test-datasource",
PluginDir: filepath.Join(parentDir, "testdata/valid-v2-pvt-signature/plugin"),
Signature: "valid",
SignatureType: plugins.PrivateSignature,
@@ -621,7 +621,7 @@ func TestLoader_Signature_RootURL(t *testing.T) {
expected := []*plugins.Plugin{
{
JSONData: plugins.JSONData{
ID: "test",
ID: "test-datasource",
Type: "datasource",
Name: "Test",
Info: plugins.Info{
@@ -643,8 +643,8 @@ func TestLoader_Signature_RootURL(t *testing.T) {
Signature: plugins.SignatureValid,
SignatureType: plugins.PrivateSignature,
SignatureOrg: "Will Browne",
Module: "plugins/test/module",
BaseURL: "public/plugins/test",
Module: "plugins/test-datasource/module",
BaseURL: "public/plugins/test-datasource",
},
}
@@ -738,7 +738,7 @@ func TestLoader_loadNestedPlugins(t *testing.T) {
}
parent := &plugins.Plugin{
JSONData: plugins.JSONData{
ID: "test-ds",
ID: "test-datasource",
Type: "datasource",
Name: "Parent",
Info: plugins.Info{
@@ -760,8 +760,8 @@ func TestLoader_loadNestedPlugins(t *testing.T) {
},
Backend: true,
},
Module: "plugins/test-ds/module",
BaseURL: "public/plugins/test-ds",
Module: "plugins/test-datasource/module",
BaseURL: "public/plugins/test-datasource",
PluginDir: filepath.Join(rootDir, "testdata/nested-plugins/parent"),
Signature: plugins.SignatureValid,
SignatureType: plugins.GrafanaSignature,
@@ -1149,23 +1149,23 @@ func Test_validatePluginJSON(t *testing.T) {
func Test_setPathsBasedOnApp(t *testing.T) {
t.Run("When setting paths based on core plugin on Windows", func(t *testing.T) {
child := &plugins.Plugin{
PluginDir: "c:\\grafana\\public\\app\\plugins\\app\\testdata\\datasources\\datasource",
PluginDir: "c:\\grafana\\public\\app\\plugins\\app\\testdata-app\\datasources\\datasource",
}
parent := &plugins.Plugin{
JSONData: plugins.JSONData{
Type: plugins.App,
ID: "testdata",
ID: "testdata-app",
},
Class: plugins.Core,
PluginDir: "c:\\grafana\\public\\app\\plugins\\app\\testdata",
BaseURL: "public/app/plugins/app/testdata",
PluginDir: "c:\\grafana\\public\\app\\plugins\\app\\testdata-app",
BaseURL: "public/app/plugins/app/testdata-app",
}
configureAppChildOPlugin(parent, child)
assert.Equal(t, "app/plugins/app/testdata/datasources/datasource/module", child.Module)
assert.Equal(t, "testdata", child.IncludedInAppID)
assert.Equal(t, "public/app/plugins/app/testdata", child.BaseURL)
assert.Equal(t, "app/plugins/app/testdata-app/datasources/datasource/module", child.Module)
assert.Equal(t, "testdata-app", child.IncludedInAppID)
assert.Equal(t, "public/app/plugins/app/testdata-app", child.BaseURL)
})
}
@@ -153,7 +153,7 @@ func TestCalculate(t *testing.T) {
sig, err := Calculate(log.NewNopLogger(), &plugins.Plugin{
JSONData: plugins.JSONData{
ID: "test",
ID: "test-datasource",
Info: plugins.Info{
Version: "1.0.0",
},
@@ -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"
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"type": "datasource",
"name": "Test",
"id": "test",
"id": "test-datasource",
"backend": true,
"executable": "test",
"state": "alpha",
@@ -1,7 +1,7 @@
{
"type": "datasource",
"name": "Test",
"id": "test",
"id": "test-datasource",
"backend": true,
"state": "alpha",
"info": {
@@ -1,7 +1,7 @@
{
"type": "datasource",
"name": "Test",
"id": "test",
"id": "test-datasource",
"backend": true,
"executable": "test",
"state": "alpha",
@@ -1,7 +1,7 @@
{
"type": "datasource",
"name": "Test",
"id": "test",
"id": "test-datasource",
"backend": true,
"executable": "test",
"state": "alpha",
@@ -1,7 +1,7 @@
{
"type": "datasource",
"name": "Test",
"id": "test",
"id": "test-datasource",
"backend": true,
"info": {
"description": "Test",
+18
View File
@@ -0,0 +1,18 @@
package grafanaplugin
import "github.com/grafana/thema"
Panel: thema.#Lineage & {
name: "doesnamatch"
seqs: [
{
schemas: [
{
PanelOptions: {
foo: string
} @cuetsy(kind="interface")
},
]
},
]
}
+14
View File
@@ -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"
}
}
}
@@ -7,22 +7,23 @@ Hash: SHA512
"signatureType": "grafana",
"signedByOrg": "grafana",
"signedByOrgName": "Grafana Labs",
"plugin": "test-ds",
"plugin": "test-datasource",
"version": "1.0.0",
"time": 1629461930434,
"time": 1661172777367,
"keyId": "7e4d0c6a708866e7",
"files": {
"plugin.json": "64e98031f30cfada473e0ad4b989ac10cd0c86844aab8c0d3fc36d8a9537a0b8",
"plugin.json": "a029469ace740e9502bfb0d40924d1cccae73d0b18adcd8f1ceb7f17bf36beb8",
"nested/plugin.json": "e64abd35cd211e0e4682974ad5cdd1be7a0b7cd24951d302a16d9e2cb6cefea4"
}
}
-----BEGIN PGP SIGNATURE-----
Version: OpenPGP.js v4.10.1
Version: OpenPGP.js v4.10.10
Comment: https://openpgpjs.org
wqIEARMKAAYFAmEfnaoACgkQfk0ManCIZufwYgIJAZULZ72BKYehVw362aOJ
IkUhCaIceQT6rSmWw60Ksxs8xkeCebMPfuxm6xqpvoquVmD2zIirCFUXE41M
SQBys7/aAgkBaaVZvVPLUMYHIGNQXQ0wJ0j6JGn5Mn25GH4lH4vttaCFpQmx
zwV8J/s7Ho612fU1ijH/nFM97I4nfxonQUEyEbA=
=7sr3
wrgEARMKAAYFAmMDfCkAIQkQfk0ManCIZucWIQTzOyW2kQdOhGNlcPN+TQxq
cIhm56w5AgkBeX3H13KSFfSs6i6aJLOIPyqYICT9EQWKxmZIz4vlgnOBOvdA
cf5jtG/CFYikBAHN6PAH6/Jir+4017w1JNHNtxICBj5xERqPkjb3GqT1sNb3
MJizG0LSveo6dRaap8uC4VPbubiUa7qGu6LTEi/8kpOemMNOLHBI+2/GlY3B
i8zqeBLU
=lRFr
-----END PGP SIGNATURE-----
@@ -1,7 +1,7 @@
{
"type": "datasource",
"name": "Parent",
"id": "test-ds",
"id": "test-datasource",
"backend": true,
"info": {
"description": "Parent plugin",
View File
@@ -10,22 +10,22 @@ Hash: SHA512
"rootUrls": [
"https://dev.grafana.com/"
],
"plugin": "test",
"plugin": "test-datasource",
"version": "1.0.0",
"time": 1657888677250,
"time": 1661173657946,
"keyId": "7e4d0c6a708866e7",
"files": {
"plugin.json": "2bb467c0bfd6c454551419efe475b8bf8573734e73c7bab52b14842adb62886f"
"plugin.json": "203ef4a613c5693c437a665cd67f95e2756a0f71b336b2ffb265db7c180d0b19"
}
}
-----BEGIN PGP SIGNATURE-----
Version: OpenPGP.js v4.10.10
Comment: https://openpgpjs.org
wrgEARMKAAYFAmLRX6UAIQkQfk0ManCIZucWIQTzOyW2kQdOhGNlcPN+TQxq
cIhm5wu9Agjhh5II2OyqsYDUqajO9KtwMzAnEMwaT5Kj0oCOsjJruoT/jLz6
HO7ioenfCwqNxaJswuFkvpN+5BnrrbIwXDo1mgIJARFtKuRg1t4TK2DPcMiQ
IiEWNrFGK0jCFaofroH1sGnhjNqUy6JAIUQlUn17BHwiJdBqpsihW1HvPhMa
8KOdLWED
=D70r
wrgEARMKAAYFAmMDf5oAIQkQfk0ManCIZucWIQTzOyW2kQdOhGNlcPN+TQxq
cIhm54/fAgkBVr9FXILsku+PsG86pZbxSbB/5/OeDsoqq9vJ30R3yaBYJC0N
tcS1PtWPzc3yMqJY1zi5pem0WfmYdH3j++NqB3QCCIUz1eAjgbilvIvoyj/j
Ia9Vcje1c3xApMFAeD4DdUBgFljAUFzz48IjZacjSNFm+gaNPhWJzYmo83wz
VqEbGL1A
=SzNa
-----END PGP SIGNATURE-----
@@ -1,7 +1,7 @@
{
"type": "datasource",
"name": "Test",
"id": "test",
"id": "test-datasource",
"backend": true,
"executable": "test",
"state": "alpha",
@@ -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"
}
}
}
@@ -1,7 +1,7 @@
{
"type": "datasource",
"name": "Test",
"id": "test",
"id": "test-datasource",
"backend": true,
"state": "alpha",
"info": {
@@ -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"
}
}
}
@@ -8,23 +8,24 @@ Hash: SHA512
"signedByOrg": "willbrowne",
"signedByOrgName": "Will Browne",
"rootUrls": [
"http://localhost:3000/grafana/"
"http://localhost:3000/grafana"
],
"plugin": "test",
"plugin": "test-datasource",
"version": "1.0.0",
"time": 1623165794939,
"time": 1661171981629,
"keyId": "7e4d0c6a708866e7",
"files": {
"plugin.json": "2bb467c0bfd6c454551419efe475b8bf8573734e73c7bab52b14842adb62886f"
"plugin.json": "203ef4a613c5693c437a665cd67f95e2756a0f71b336b2ffb265db7c180d0b19"
}
}
-----BEGIN PGP SIGNATURE-----
Version: OpenPGP.js v4.10.1
Version: OpenPGP.js v4.10.10
Comment: https://openpgpjs.org
wqEEARMKAAYFAmC/i2MACgkQfk0ManCIZudCEgII80waYmySwVuB2cdeU3Vy
FvYrhViYYimvTy5EQbDfC955UpHphcr4V5S+09se7D2bK8XZ/MYufnUp9QIU
gOxCDrkCCQHTQ/aWxt8JAHGG/eoydKQEeAc9aFJyphdX57qXHVkAjvLzY5aO
y9UltPQKOAN/soDra2m39VUf6DBi9K/sXfjwaA==
=cd6n
wrcEARMKAAYFAmMDeQ0AIQkQfk0ManCIZucWIQTzOyW2kQdOhGNlcPN+TQxq
cIhm5ygmAgiUFIfZrpxCa5VajERXgejFRwGrWYILWXmmXWC4vqHiQaFEE1Ef
DtLz0JcdEMhvhydD+efJbWuUcv7fEMWMv6k0YAIGLG4xVsef4OhnfMYKjRBf
Obc4/RuzqbjLg04Z9XDq6gAY06NESYscSj+Vy3rKNo0IiVnjrm9qGvZmSqRx
sLyae5M=
=ZAe8
-----END PGP SIGNATURE-----
@@ -1,7 +1,7 @@
{
"type": "datasource",
"name": "Test",
"id": "test",
"id": "test-datasource",
"backend": true,
"executable": "test",
"state": "alpha",
@@ -10,21 +10,22 @@ Hash: SHA512
"rootUrls": [
"http://localhost:3000/"
],
"plugin": "test",
"plugin": "test-datasource",
"version": "1.0.0",
"time": 1605807018050,
"time": 1661171417046,
"keyId": "7e4d0c6a708866e7",
"files": {
"plugin.json": "2bb467c0bfd6c454551419efe475b8bf8573734e73c7bab52b14842adb62886f"
"plugin.json": "203ef4a613c5693c437a665cd67f95e2756a0f71b336b2ffb265db7c180d0b19"
}
}
-----BEGIN PGP SIGNATURE-----
Version: OpenPGP.js v4.10.1
Version: OpenPGP.js v4.10.10
Comment: https://openpgpjs.org
wqIEARMKAAYFAl+2q6oACgkQfk0ManCIZudmzwIJAXWz58cd/91rTXszKPnE
xbVEvERCbjKTtPBQBNQyqEvV+Ig3MuBSNOVy2SOGrMsdbS6lONgvgt4Cm+iS
wV+vYifkAgkBJtg/9DMB7/iX5O0h49CtSltcpfBFXlGqIeOwRac/yENzRzAA
khdr/tZ1PDgRxMqB/u+Vtbpl0xSxgblnrDOYMSI=
=rLIE
wrgEARMKAAYFAmMDdtkAIQkQfk0ManCIZucWIQTzOyW2kQdOhGNlcPN+TQxq
cIhm577/AgkBnbauM7s/8jLrdJvr+b9B2ZK7EipwI9GFClBdGfxhBzw/QcHS
ete9DAB0j9V5ilShlg3O4gmbiFUFUKGWByHt/VUCB3TXblS7cf5kJFjB9v0r
fv5a8NfV8x8ao/WoKTmXRUB7HSScOvb/3KmkNqzcHtZPQS1T0P6l9EUA1QT1
l+GB3Wdq
=pe3h
-----END PGP SIGNATURE-----
@@ -1,7 +1,7 @@
{
"type": "datasource",
"name": "Test",
"id": "test",
"id": "test-datasource",
"backend": true,
"executable": "test",
"state": "alpha",
@@ -7,21 +7,22 @@ Hash: SHA512
"signatureType": "grafana",
"signedByOrg": "grafana",
"signedByOrgName": "Grafana Labs",
"plugin": "test",
"plugin": "test-datasource",
"version": "1.0.0",
"time": 1605807330546,
"time": 1661171059101,
"keyId": "7e4d0c6a708866e7",
"files": {
"plugin.json": "2bb467c0bfd6c454551419efe475b8bf8573734e73c7bab52b14842adb62886f"
"plugin.json": "203ef4a613c5693c437a665cd67f95e2756a0f71b336b2ffb265db7c180d0b19"
}
}
-----BEGIN PGP SIGNATURE-----
Version: OpenPGP.js v4.10.1
Version: OpenPGP.js v4.10.10
Comment: https://openpgpjs.org
wqEEARMKAAYFAl+2rOIACgkQfk0ManCIZudNOwIJAT8FTzwnRFCSLTOaR3F3
2Fh96eRbghokXcQG9WqpQAg8ZiVfGXeWWRNtV+nuQ9VOZOTO0BovWLuMkym2
ci8ABpWOAgd46LkGn3Dd8XVnGmLI6UPqHAXflItOrCMRiGcYJn5PxP1aCz8h
D0JoNI9TIKrhMtM4voU3Qhf3mIOTHueuDNS48w==
=mu2j
wrgEARMKAAYFAmMDdXMAIQkQfk0ManCIZucWIQTzOyW2kQdOhGNlcPN+TQxq
cIhm54zLAgdfVimeut6Gw9MrIACBZUSH0ht9p9j+iG6MDjpmEFIpqVJrem6f
8wBv0/kmYU3LV9MWyPuUeRfBdccjQKSjEXlfEAIJAVmut9LcSKIykhWuQA+7
VMVvJPXzlPkeoYsGYvzAlxh8i2UomCU15UChe62Gzq5V5HgGYkX5layIb5XX
y2Pio0lc
=/TR0
-----END PGP SIGNATURE-----
@@ -1,7 +1,7 @@
{
"type": "datasource",
"name": "Test",
"id": "test",
"id": "test-datasource",
"backend": true,
"executable": "test",
"state": "alpha",
@@ -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"
}
}
}
+3
View File
@@ -0,0 +1,3 @@
// Package pfs ("Plugin FS") defines a virtual filesystem representation of Grafana plugins.
package pfs
+30
View File
@@ -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")
+336
View File
@@ -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()
}
+283
View File
@@ -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")
})
}
}