1// SPDX-FileCopyrightText: 2025 Romain Maneschi <romain@gitroot.dev>
2//
3// SPDX-License-Identifier: EUPL-1.2
4
5package plugin
6
7import (
8 "encoding/json"
9 "errors"
10 "fmt"
11 "net/url"
12 "path/filepath"
13 "reflect"
14 "regexp"
15 "slices"
16 "strings"
17
18 "github.com/goccy/go-yaml"
19 "github.com/hashicorp/go-getter"
20 "github.com/package-url/packageurl-go"
21 "github.com/samber/oops"
22 "github.com/tetratelabs/wazero"
23 "gitroot.dev/libs/golang/glob"
24 "gitroot.dev/libs/golang/plugin/model"
25 "gitroot.dev/server/logger"
26 "gitroot.dev/server/user"
27)
28
29type Plugin struct {
30 PURL *packageurl.PackageURL
31 Active bool
32 compiledModule wazero.CompiledModule
33 commiter *user.Commiter
34 Run []PluginRun
35}
36
37func (p Plugin) Match(plugin string) bool {
38 pkg, err := packageurl.FromString(plugin)
39 if err != nil {
40 return false
41 }
42 if pkg.Version != "" {
43 if pkg.Version != p.PURL.Version {
44 return false
45 }
46 }
47 return pkg.Namespace == p.PURL.Namespace && pkg.Name == p.PURL.Name
48}
49
50func (p Plugin) Equal(p2 Plugin) bool {
51 return p.uuid() == p2.uuid()
52}
53
54func (p Plugin) Log() string {
55 return p.uuid()
56}
57
58func (p Plugin) NamespaceAndName() string {
59 return fmt.Sprintf("%s-%s", p.PURL.Namespace, p.PURL.Name)
60}
61
62func (p Plugin) Directory() string {
63 return fmt.Sprintf("%s-%s", p.PURL.Namespace, p.PURL.Name)
64}
65
66func (p Plugin) Commiter() *user.Commiter {
67 return p.commiter
68}
69
70func (p Plugin) uuid() string {
71 return fmt.Sprintf("%s-%s-%s", p.PURL.Namespace, p.PURL.Name, p.PURL.Version)
72}
73
74func (p Plugin) Url() string {
75 qualifiers := p.PURL.Qualifiers.Map()
76 registry := qualifiers["registry"]
77 checksum := qualifiers["checksum"]
78 if checksum == "none" {
79 return fmt.Sprintf("%s/%s.wasm", registry, p.uuid())
80 }
81 return fmt.Sprintf("%s/%s.wasm?checksum=%s", registry, p.uuid(), checksum)
82}
83
84type PluginRun struct {
85 model.PluginRun `yaml:",inline"`
86 glob *glob.Glob
87 write PluginWrite
88}
89
90type PluginWrite struct {
91 git []PluginWriteRight
92 web []PluginWriteRight
93 exec []PluginExecRight
94 callFunc []model.PluginCallFuncRight
95}
96
97type PluginWriteRight struct {
98 model.PluginWriteRight
99 glob *glob.Glob
100}
101
102type PluginExecRight struct {
103 model.PluginExecRight
104 regexp *regexp.Regexp
105}
106
107func pluginLibToPlugin(run []model.PluginRun) ([]PluginRun, error) {
108 p := make([]PluginRun, len(run))
109 for i, pl := range run {
110 g, err := glob.NewGlob(pl.Path)
111 if err != nil {
112 return nil, oops.Wrapf(err, "can't glob %s", pl.Path)
113 }
114 pluginWrite := PluginWrite{
115 git: make([]PluginWriteRight, len(pl.Write.Git)),
116 web: make([]PluginWriteRight, len(pl.Write.Web)),
117 exec: make([]PluginExecRight, len(pl.Write.Exec)),
118 callFunc: pl.Write.CallFunc,
119 }
120 for j, pw := range pl.Write.Git {
121 g, err := glob.NewGlob(pw.Path)
122 if err != nil {
123 return nil, oops.Wrapf(err, "can't glob %s", pw.Path)
124 }
125 pluginWrite.git[j] = PluginWriteRight{PluginWriteRight: pw, glob: g}
126 }
127 for j, pw := range pl.Write.Web {
128 g, err := glob.NewGlob(pw.Path)
129 if err != nil {
130 return nil, oops.Wrapf(err, "can't glob %s", pw.Path)
131 }
132 pluginWrite.web[j] = PluginWriteRight{PluginWriteRight: pw, glob: g}
133 }
134 for j, pw := range pl.Write.Exec {
135 g, err := regexp.Compile(pw.Command)
136 if err != nil {
137 return nil, oops.Wrapf(err, "can't regexp %s", pw.Command)
138 }
139 pluginWrite.exec[j] = PluginExecRight{PluginExecRight: pw, regexp: g}
140 }
141 p[i] = PluginRun{PluginRun: pl, glob: g, write: pluginWrite}
142 }
143 return p, nil
144}
145
146func ParsePlugins(fileContent []byte, withRun bool) ([]Plugin, error) {
147 plugins := make([]Plugin, 0)
148 if err := yaml.UnmarshalWithOptions(fileContent, &plugins, yaml.CustomUnmarshaler(func(t *packageurl.PackageURL, b []byte) error {
149 pkg, err := packageurl.FromString(string(b))
150 if err != nil {
151 return err
152 }
153 t.Name = pkg.Name
154 t.Namespace = pkg.Namespace
155 t.Subpath = pkg.Subpath
156 t.Type = pkg.Type
157 t.Version = pkg.Version
158
159 if len(pkg.Qualifiers) < 2 {
160 return errors.New("must have 2 qualifiers")
161 }
162
163 qualifiers := make([]packageurl.Qualifier, 2)
164 mandatoryData := pkg.Qualifiers.Map()
165 if val, ok := mandatoryData["registry"]; !ok || val == "" {
166 return errors.New("PURL must have registry in qualifiers")
167 } else {
168 urlval, err := url.Parse(strings.TrimSpace(val))
169 if err != nil {
170 return err
171 }
172 qualifiers[0] = packageurl.Qualifier{Key: "registry", Value: urlval.String()}
173 }
174 if val, ok := mandatoryData["checksum"]; !ok || val == "" {
175 return errors.New("PURL must have checksum in qualifiers")
176 } else {
177 qualifiers[1] = packageurl.Qualifier{Key: "checksum", Value: strings.TrimSpace(val)}
178 }
179
180 t.Qualifiers = qualifiers
181 return nil
182 })); err != nil {
183 return nil, oops.Wrapf(err, "can't parse yaml config")
184 }
185
186 //TODO plugins without PURL can't be installed/run so delete them but how to tell to user?
187 plugins = slices.DeleteFunc(plugins, func(p Plugin) bool {
188 return p.PURL == nil
189 })
190
191 if withRun {
192 for i, plugin := range plugins {
193 for j, pluginRun := range plugin.Run {
194 g, err := glob.NewGlob(pluginRun.Path)
195 if err != nil {
196 return nil, oops.Wrapf(err, "can't glob %s", pluginRun.Path)
197 }
198 plugins[i].Run[j].glob = g
199 for h, pluginWrite := range pluginRun.Write.Git {
200 g, err = glob.NewGlob(pluginWrite.Path)
201 if err != nil {
202 return nil, oops.Wrapf(err, "can't glob git %s", pluginRun.Path)
203 }
204 if plugins[i].Run[j].write.git == nil {
205 plugins[i].Run[j].write.git = make([]PluginWriteRight, len(pluginRun.Write.Git))
206 }
207 plugins[i].Run[j].write.git[h] = PluginWriteRight{PluginWriteRight: pluginWrite, glob: g}
208 }
209 for h, pluginWrite := range pluginRun.Write.Web {
210 g, err = glob.NewGlob(pluginWrite.Path)
211 if err != nil {
212 return nil, oops.Wrapf(err, "can't glob git %s", pluginRun.Path)
213 }
214 if plugins[i].Run[j].write.web == nil {
215 plugins[i].Run[j].write.web = make([]PluginWriteRight, len(pluginRun.Write.Web))
216 }
217 plugins[i].Run[j].write.web[h] = PluginWriteRight{PluginWriteRight: pluginWrite, glob: g}
218 }
219 for h, pluginWrite := range pluginRun.Write.Exec {
220 g, err := regexp.Compile(pluginWrite.Command)
221 if err != nil {
222 return nil, oops.Wrapf(err, "can't regexp %s", pluginWrite.Command)
223 }
224 if plugins[i].Run[j].write.exec == nil {
225 plugins[i].Run[j].write.exec = make([]PluginExecRight, len(pluginRun.Write.Exec))
226 }
227 plugins[i].Run[j].write.exec[h] = PluginExecRight{PluginExecRight: pluginWrite, regexp: g}
228 }
229 plugins[i].Run[j].write.callFunc = pluginRun.Write.CallFunc
230 }
231 }
232 }
233 return plugins, nil
234}
235
236func (p Plugin) SetActive(active bool) Plugin {
237 return Plugin{
238 PURL: p.PURL,
239 Active: active,
240 compiledModule: p.compiledModule,
241 commiter: p.commiter,
242 Run: p.Run,
243 }
244}
245
246func (p Plugin) SetRun(run []PluginRun) Plugin {
247 return Plugin{
248 PURL: p.PURL,
249 Active: p.Active,
250 compiledModule: p.compiledModule,
251 commiter: p.commiter,
252 Run: run,
253 }
254}
255
256func (p Plugin) OverrideWith(other Plugin) Plugin {
257 copy := Plugin{
258 PURL: p.PURL,
259 Active: p.Active,
260 compiledModule: p.compiledModule,
261 commiter: p.commiter,
262 Run: p.Run,
263 }
264 if copy.PURL == nil {
265 copy.PURL = other.PURL
266 }
267 if !copy.Active {
268 copy.Active = other.Active
269 }
270 if len(copy.Run) == 0 {
271 copy.Run = other.Run
272 }
273 if copy.compiledModule == nil {
274 copy.compiledModule = other.compiledModule
275 }
276 if copy.commiter == nil || copy.commiter.Signer == nil {
277 copy.commiter = other.commiter
278 }
279 return copy
280}
281
282func (pr PluginRun) Marshal() ([]byte, error) {
283 return json.Marshal(pr.Configuration)
284}
285
286func (m *Manager) PathWasm(p Plugin) string {
287 return filepath.Join(m.conf.GetDirPathDataPlugin(p.Directory()), fmt.Sprintf("%s.wasm", p.uuid()))
288}
289
290func (m *Manager) Install(p Plugin) error {
291 url := p.Url()
292 path := m.PathWasm(p)
293 m.logger.Debug("install plugin", logger.NewLoggerPair("url", url), logger.NewLoggerPair("path", path))
294 httpGetter := &getter.HttpGetter{
295 Netrc: true,
296 }
297 err := getter.GetFile(path, url, getter.WithGetters(map[string]getter.Getter{
298 "file": &getter.FileGetter{Copy: true},
299 "http": httpGetter,
300 "https": httpGetter,
301 }))
302 return err
303}
304
305func Equal(aPlugin, bPlugin Plugin) bool {
306 return aPlugin.PURL.String() == bPlugin.PURL.String() &&
307 aPlugin.Active == bPlugin.Active &&
308 equalPluginRun(aPlugin.Run, bPlugin.Run)
309}
310
311func equalPluginRun(aPlugin, bPlugin []PluginRun) bool {
312 if len(aPlugin) == len(bPlugin) {
313 for i, w := range aPlugin {
314 isEqual := w.Path == bPlugin[i].Path &&
315 slices.Equal(w.Branch, bPlugin[i].Branch) &&
316 slices.Equal(w.When, bPlugin[i].When) &&
317 equalPluginWrite(w.Write.Git, bPlugin[i].Write.Git) &&
318 equalPluginWrite(w.Write.Web, bPlugin[i].Write.Web) &&
319 equalPluginCallFunc(w.Write.CallFunc, bPlugin[i].Write.CallFunc) &&
320 reflect.DeepEqual(w.Configuration, bPlugin[i].Configuration)
321 if !isEqual {
322 return false
323 }
324 }
325 return true
326 }
327 return false
328}
329
330func equalPluginWrite(aPlugin, bPlugin []model.PluginWriteRight) bool {
331 if len(aPlugin) == len(bPlugin) {
332 for i, w := range aPlugin {
333 if w.Path != bPlugin[i].Path || !slices.Equal(w.Can, bPlugin[i].Can) {
334 return false
335 }
336 }
337 return true
338 }
339 return false
340}
341
342func equalPluginCallFunc(aPlugin, bPlugin []model.PluginCallFuncRight) bool {
343 if len(aPlugin) == len(bPlugin) {
344 for i, w := range aPlugin {
345 if w.FuncName != bPlugin[i].FuncName || w.PluginPURL != bPlugin[i].PluginPURL || len(w.Configuration) != len(bPlugin[i].Configuration) {
346 return false
347 }
348 }
349 return true
350 }
351 return false
352}