GitRoot

Craft your forge // Build your project // Grow your community freely
  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}