services: load external service plugins from .so files
Add a NewServicePlugin loader that registers the returned creator, analyzer, infos, weight and aliases through internal/service.RegisterService. The frontend root layout now refreshes the service specs on load so plugin-provided services appear in the UI alongside the built-in ones.
This commit is contained in:
parent
405dce1050
commit
99181c591d
5 changed files with 394 additions and 2 deletions
141
docs/plugins/service-plugin.md
Normal file
141
docs/plugins/service-plugin.md
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
# Building a happyDomain Service Plugin
|
||||||
|
|
||||||
|
This page documents how to ship a happyDomain **service** (a high-level
|
||||||
|
abstraction over a set of DNS records (e.g. "Mailbox", "Web server",
|
||||||
|
"Matrix homeserver") as an in-process Go plugin. Read the
|
||||||
|
[provider plugin guide](provider-plugin.md) first if you've never built a
|
||||||
|
happyDomain plugin.
|
||||||
|
|
||||||
|
> ⚠️ **Security note.** A `.so` plugin is loaded into the happyDomain process
|
||||||
|
> and runs with the same privileges. happyDomain refuses to load plugins from
|
||||||
|
> a directory that is group- or world-writable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What a service plugin must export
|
||||||
|
|
||||||
|
happyDomain's loader looks for a single exported symbol named
|
||||||
|
`NewServicePlugin` with this exact signature:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func NewServicePlugin() (
|
||||||
|
happydns.ServiceCreator,
|
||||||
|
svcs.ServiceAnalyzer,
|
||||||
|
happydns.ServiceInfos,
|
||||||
|
uint32, // weight (analyzer priority, lower runs first)
|
||||||
|
[]string, // optional aliases
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `ServiceCreator` is `func() happydns.ServiceBody`. Each call must return a
|
||||||
|
fresh, zero-value instance of your service struct.
|
||||||
|
- `ServiceAnalyzer` is the optional analyzer that recognises this service in
|
||||||
|
an existing zone (`nil` is allowed for "manual-only" services).
|
||||||
|
- `aliases` lets a single struct be reachable under several legacy names; the
|
||||||
|
loader will refuse to register an alias that collides with an existing one.
|
||||||
|
|
||||||
|
### Sub-services and the `pathToSvcsModule` filter
|
||||||
|
|
||||||
|
happyDomain's built-in service registry walks each registered struct and
|
||||||
|
records every nested struct type as a *sub-service* so the storage layer can
|
||||||
|
(de)serialise polymorphic payloads later. To avoid registering random types
|
||||||
|
pulled in from third-party libraries, that walk is restricted to types whose
|
||||||
|
package path starts with `git.happydns.org/happyDomain/services`.
|
||||||
|
|
||||||
|
Plugin services live in a completely different module path. The plugin loader
|
||||||
|
calls a dedicated walker (`svcs.RegisterPluginSubServices`) on every plugin
|
||||||
|
service so that nested types declared by the plugin **are** registered. You
|
||||||
|
get the same nested-struct support as a built-in service: there is nothing
|
||||||
|
to do on your side. The only constraint is that nested types must be **named
|
||||||
|
struct types** (anonymous structs cannot be looked up by name later).
|
||||||
|
|
||||||
|
### Collisions
|
||||||
|
|
||||||
|
If your plugin tries to register a service or alias whose name already
|
||||||
|
exists, the registration is **refused with a warning** rather than
|
||||||
|
overwriting the previous entry. The first one wins.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Minimal example (`service-dummy/plugin/plugin.go`)
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Build with:
|
||||||
|
// go build -buildmode=plugin -o service-dummy.so ./plugin
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
svcs "git.happydns.org/happyDomain/internal/service"
|
||||||
|
"git.happydns.org/happyDomain/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DummyDetail struct {
|
||||||
|
Note string `json:"note"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DummyService struct {
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
Detail DummyDetail `json:"detail"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DummyService) GetNbResources() int { return 1 }
|
||||||
|
func (d *DummyService) GenComment() string { return d.Hostname }
|
||||||
|
func (d *DummyService) GetRecords(domain string, ttl uint32, origin string) ([]happydns.Record, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServicePlugin() (
|
||||||
|
happydns.ServiceCreator,
|
||||||
|
svcs.ServiceAnalyzer,
|
||||||
|
happydns.ServiceInfos,
|
||||||
|
uint32,
|
||||||
|
[]string,
|
||||||
|
error,
|
||||||
|
) {
|
||||||
|
creator := func() happydns.ServiceBody { return &DummyService{} }
|
||||||
|
infos := happydns.ServiceInfos{
|
||||||
|
Name: "Dummy service",
|
||||||
|
Description: "Example service plugin, replace with real logic.",
|
||||||
|
}
|
||||||
|
return creator, nil, infos, 100, nil, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Build and deploy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build -buildmode=plugin -o service-dummy.so ./plugin
|
||||||
|
sudo install -m 0644 -o happydomain service-dummy.so /var/lib/happydomain/plugins/
|
||||||
|
sudo systemctl restart happydomain
|
||||||
|
```
|
||||||
|
|
||||||
|
happyDomain will log:
|
||||||
|
|
||||||
|
```
|
||||||
|
Registering new service: main.DummyService
|
||||||
|
Registering new plugin subservice: main.DummyDetail
|
||||||
|
Plugin service "Dummy service" (.../service-dummy.so) loaded
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build constraints
|
||||||
|
|
||||||
|
The same Go plugin caveats (toolchain version, dependency versions,
|
||||||
|
`CGO_ENABLED=1`, `GOOS`/`GOARCH`) apply to service plugins. See
|
||||||
|
[checker-plugin.md](checker-plugin.md#build-constraints-and-platform-support)
|
||||||
|
for the full list.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Licensing
|
||||||
|
|
||||||
|
Service plugins import both `git.happydns.org/happyDomain/model` **and**
|
||||||
|
`git.happydns.org/happyDomain/internal/service`, both of which are AGPL-3.0.
|
||||||
|
A `.so` linked against them is therefore considered a derivative work of
|
||||||
|
happyDomain and must itself be AGPL-compatible.
|
||||||
|
|
||||||
|
For checker plugins see [checker-plugin.md](checker-plugin.md#licensing),
|
||||||
|
which uses a separate (Apache-2.0) SDK module and is not subject to these
|
||||||
|
AGPL constraints.
|
||||||
|
|
@ -35,6 +35,7 @@ import (
|
||||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
"git.happydns.org/happyDomain/internal/checker"
|
"git.happydns.org/happyDomain/internal/checker"
|
||||||
providerReg "git.happydns.org/happyDomain/internal/provider"
|
providerReg "git.happydns.org/happyDomain/internal/provider"
|
||||||
|
svcs "git.happydns.org/happyDomain/internal/service"
|
||||||
"git.happydns.org/happyDomain/model"
|
"git.happydns.org/happyDomain/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -73,6 +74,7 @@ func safeCall(symbol string, fname string, fn func() error) (err error) {
|
||||||
var pluginLoaders = []pluginLoader{
|
var pluginLoaders = []pluginLoader{
|
||||||
loadCheckerPlugin,
|
loadCheckerPlugin,
|
||||||
loadProviderPlugin,
|
loadProviderPlugin,
|
||||||
|
loadServicePlugin,
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadCheckerPlugin handles the NewCheckerPlugin symbol exported by checkers
|
// loadCheckerPlugin handles the NewCheckerPlugin symbol exported by checkers
|
||||||
|
|
@ -155,6 +157,53 @@ func loadProviderPlugin(p pluginSymbols, fname string) (bool, error) {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loadServicePlugin handles the NewServicePlugin symbol exported by service
|
||||||
|
// plugins. The factory returns the creator/analyzer/infos triple along with
|
||||||
|
// the analyzer weight and any aliases the service should be reachable under.
|
||||||
|
func loadServicePlugin(p pluginSymbols, fname string) (bool, error) {
|
||||||
|
sym, err := p.Lookup("NewServicePlugin")
|
||||||
|
if err != nil {
|
||||||
|
// Symbol not present in this .so, not an error.
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
factory, ok := sym.(func() (happydns.ServiceCreator, svcs.ServiceAnalyzer, happydns.ServiceInfos, uint32, []string, error))
|
||||||
|
if !ok {
|
||||||
|
return true, fmt.Errorf("symbol NewServicePlugin has unexpected type %T", sym)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
creator happydns.ServiceCreator
|
||||||
|
analyzer svcs.ServiceAnalyzer
|
||||||
|
infos happydns.ServiceInfos
|
||||||
|
weight uint32
|
||||||
|
aliases []string
|
||||||
|
)
|
||||||
|
if err := safeCall("NewServicePlugin", fname, func() error {
|
||||||
|
var ferr error
|
||||||
|
creator, analyzer, infos, weight, aliases, ferr = factory()
|
||||||
|
return ferr
|
||||||
|
}); err != nil {
|
||||||
|
return true, err
|
||||||
|
}
|
||||||
|
if creator == nil {
|
||||||
|
return true, fmt.Errorf("NewServicePlugin returned a nil ServiceCreator")
|
||||||
|
}
|
||||||
|
|
||||||
|
svcs.RegisterService(creator, analyzer, infos, weight, aliases...)
|
||||||
|
|
||||||
|
// The built-in sub-service walker only descends into types whose package
|
||||||
|
// path lives under git.happydns.org/happyDomain/services. Plugin services
|
||||||
|
// live elsewhere, so we must explicitly walk their type tree to register
|
||||||
|
// any nested struct types as sub-services; otherwise (de)serialisation
|
||||||
|
// of plugin payloads breaks for anything more than a flat struct.
|
||||||
|
baseType := reflect.Indirect(reflect.ValueOf(creator())).Type()
|
||||||
|
svcs.RegisterPluginSubServices(baseType)
|
||||||
|
|
||||||
|
log.Printf("Plugin service %q (%s) loaded", infos.Name, fname)
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
// checkPluginDirectoryPermissions refuses to load plugins from a directory
|
// checkPluginDirectoryPermissions refuses to load plugins from a directory
|
||||||
// that any non-owner can write to. Loading a .so file is arbitrary code
|
// that any non-owner can write to. Loading a .so file is arbitrary code
|
||||||
// execution as the happyDomain process, so a world- or group-writable
|
// execution as the happyDomain process, so a world- or group-writable
|
||||||
|
|
|
||||||
143
internal/app/plugins_service_test.go
Normal file
143
internal/app/plugins_service_test.go
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
// This file is part of the happyDomain (R) project.
|
||||||
|
// Copyright (c) 2020-2026 happyDomain
|
||||||
|
// Authors: Pierre-Olivier Mercier, et al.
|
||||||
|
//
|
||||||
|
// This program is offered under a commercial and under the AGPL license.
|
||||||
|
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||||
|
//
|
||||||
|
// For AGPL licensing:
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
//go:build linux || darwin || freebsd
|
||||||
|
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"plugin"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
svcs "git.happydns.org/happyDomain/internal/service"
|
||||||
|
"git.happydns.org/happyDomain/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// dummyNested is referenced as a struct field by dummyServiceBody to verify
|
||||||
|
// that loadServicePlugin walks the type tree and registers nested types as
|
||||||
|
// sub-services, something the built-in walker refuses to do for types that
|
||||||
|
// live outside the happydomain/services module path.
|
||||||
|
type dummyNested struct {
|
||||||
|
Value string
|
||||||
|
}
|
||||||
|
|
||||||
|
type dummyServiceBody struct {
|
||||||
|
Hostname string
|
||||||
|
Detail dummyNested
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *dummyServiceBody) GetNbResources() int { return 1 }
|
||||||
|
func (d *dummyServiceBody) GenComment() string { return "dummy" }
|
||||||
|
func (d *dummyServiceBody) GetRecords(domain string, ttl uint32, origin string) ([]happydns.Record, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDummyServiceFactory() func() (happydns.ServiceCreator, svcs.ServiceAnalyzer, happydns.ServiceInfos, uint32, []string, error) {
|
||||||
|
return func() (happydns.ServiceCreator, svcs.ServiceAnalyzer, happydns.ServiceInfos, uint32, []string, error) {
|
||||||
|
creator := func() happydns.ServiceBody { return &dummyServiceBody{} }
|
||||||
|
return creator, nil, happydns.ServiceInfos{Name: "Dummy"}, 100, nil, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadServicePlugin_SymbolMissing(t *testing.T) {
|
||||||
|
found, err := loadServicePlugin(&fakeSymbols{}, "missing.so")
|
||||||
|
if found || err != nil {
|
||||||
|
t.Fatalf("expected (false, nil), got (%v, %v)", found, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadServicePlugin_WrongSymbolType(t *testing.T) {
|
||||||
|
fs := &fakeSymbols{syms: map[string]plugin.Symbol{
|
||||||
|
"NewServicePlugin": "not a function",
|
||||||
|
}}
|
||||||
|
found, err := loadServicePlugin(fs, "wrongtype.so")
|
||||||
|
if !found || err == nil || !strings.Contains(err.Error(), "unexpected type") {
|
||||||
|
t.Fatalf("expected wrong-type error, got (%v, %v)", found, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadServicePlugin_FactoryError(t *testing.T) {
|
||||||
|
factory := func() (happydns.ServiceCreator, svcs.ServiceAnalyzer, happydns.ServiceInfos, uint32, []string, error) {
|
||||||
|
return nil, nil, happydns.ServiceInfos{}, 0, nil, errors.New("boom")
|
||||||
|
}
|
||||||
|
fs := &fakeSymbols{syms: map[string]plugin.Symbol{"NewServicePlugin": factory}}
|
||||||
|
|
||||||
|
found, err := loadServicePlugin(fs, "factoryerr.so")
|
||||||
|
if !found || err == nil || !strings.Contains(err.Error(), "boom") {
|
||||||
|
t.Fatalf("expected factory error to propagate, got (%v, %v)", found, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadServicePlugin_NilCreator(t *testing.T) {
|
||||||
|
factory := func() (happydns.ServiceCreator, svcs.ServiceAnalyzer, happydns.ServiceInfos, uint32, []string, error) {
|
||||||
|
return nil, nil, happydns.ServiceInfos{Name: "Dummy"}, 0, nil, nil
|
||||||
|
}
|
||||||
|
fs := &fakeSymbols{syms: map[string]plugin.Symbol{"NewServicePlugin": factory}}
|
||||||
|
|
||||||
|
found, err := loadServicePlugin(fs, "nilcreator.so")
|
||||||
|
if !found || err == nil || !strings.Contains(err.Error(), "nil ServiceCreator") {
|
||||||
|
t.Fatalf("expected nil-creator error, got (%v, %v)", found, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadServicePlugin_FactoryPanics(t *testing.T) {
|
||||||
|
factory := func() (happydns.ServiceCreator, svcs.ServiceAnalyzer, happydns.ServiceInfos, uint32, []string, error) {
|
||||||
|
panic("kaboom")
|
||||||
|
}
|
||||||
|
fs := &fakeSymbols{syms: map[string]plugin.Symbol{"NewServicePlugin": factory}}
|
||||||
|
|
||||||
|
found, err := loadServicePlugin(fs, "panic.so")
|
||||||
|
if !found || err == nil || !strings.Contains(err.Error(), "panicked") {
|
||||||
|
t.Fatalf("expected wrapped panic error, got (%v, %v)", found, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadServicePlugin_SuccessRegistersSubServices(t *testing.T) {
|
||||||
|
factory := newDummyServiceFactory()
|
||||||
|
fs := &fakeSymbols{syms: map[string]plugin.Symbol{"NewServicePlugin": factory}}
|
||||||
|
|
||||||
|
found, err := loadServicePlugin(fs, "first.so")
|
||||||
|
if !found || err != nil {
|
||||||
|
t.Fatalf("expected success, got (%v, %v)", found, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The service itself must be reachable through the registry.
|
||||||
|
const svcKey = "app.dummyServiceBody"
|
||||||
|
if _, err := svcs.FindService(svcKey); err != nil {
|
||||||
|
t.Fatalf("expected service %q to be registered: %v", svcKey, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And so must the nested struct: this is the regression-prevention test
|
||||||
|
// for the built-in walker's pathToSvcsModule prefix check, which would
|
||||||
|
// otherwise refuse to register types from outside happydomain/services.
|
||||||
|
const nestedKey = "app.dummyNested"
|
||||||
|
if _, err := svcs.FindSubService(nestedKey); err != nil {
|
||||||
|
t.Errorf("expected nested type %q to be registered as a sub-service: %v", nestedKey, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading the same plugin twice must be a no-op (collision warning).
|
||||||
|
found, err = loadServicePlugin(fs, "second.so")
|
||||||
|
if !found || err != nil {
|
||||||
|
t.Fatalf("expected duplicate load to be silently ignored, got (%v, %v)", found, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -44,6 +44,15 @@ func (a ByWeight) Len() int { return len(a) }
|
||||||
func (a ByWeight) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
|
func (a ByWeight) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
|
||||||
func (a ByWeight) Less(i, j int) bool { return a[i].Weight < a[j].Weight }
|
func (a ByWeight) Less(i, j int) bool { return a[i].Weight < a[j].Weight }
|
||||||
|
|
||||||
|
// The service and sub-service registries below are intentionally unguarded.
|
||||||
|
// All writes (RegisterService, RegisterPluginSubServices, RegisterSubServices)
|
||||||
|
// happen from App.initPlugins() at startup, *before* App.initUsecases() and
|
||||||
|
// before any goroutine that could read them (see internal/app/app.go). From
|
||||||
|
// that point on the maps are read-only for the rest of the process lifetime,
|
||||||
|
// so concurrent reads are safe without locking. Any future code path that
|
||||||
|
// needs to mutate these maps after startup must introduce its own
|
||||||
|
// synchronisation (sync.RWMutex around services, subServices and
|
||||||
|
// ordered_services together).
|
||||||
var (
|
var (
|
||||||
services map[string]*Svc = map[string]*Svc{}
|
services map[string]*Svc = map[string]*Svc{}
|
||||||
subServices map[string]happydns.SubServiceCreator = map[string]happydns.SubServiceCreator{}
|
subServices map[string]happydns.SubServiceCreator = map[string]happydns.SubServiceCreator{}
|
||||||
|
|
@ -52,11 +61,20 @@ var (
|
||||||
)
|
)
|
||||||
|
|
||||||
func RegisterService(creator happydns.ServiceCreator, analyzer ServiceAnalyzer, infos happydns.ServiceInfos, weight uint32, aliases ...string) {
|
func RegisterService(creator happydns.ServiceCreator, analyzer ServiceAnalyzer, infos happydns.ServiceInfos, weight uint32, aliases ...string) {
|
||||||
|
baseType := reflect.Indirect(reflect.ValueOf(creator())).Type()
|
||||||
|
name := baseType.String()
|
||||||
|
|
||||||
|
// A second registration of the same name almost always means a plugin is
|
||||||
|
// shadowing a built-in (or another plugin) by accident. Log loudly and
|
||||||
|
// keep the existing entry rather than silently overwriting it.
|
||||||
|
if _, exists := services[name]; exists {
|
||||||
|
log.Printf("Warning: service %q is already registered; ignoring duplicate registration", name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Invalidate ordered_services, which serve as cache
|
// Invalidate ordered_services, which serve as cache
|
||||||
ordered_services = nil
|
ordered_services = nil
|
||||||
|
|
||||||
baseType := reflect.Indirect(reflect.ValueOf(creator())).Type()
|
|
||||||
name := baseType.String()
|
|
||||||
log.Println("Registering new service:", name)
|
log.Println("Registering new service:", name)
|
||||||
|
|
||||||
// Override given parameters by true one
|
// Override given parameters by true one
|
||||||
|
|
@ -72,6 +90,10 @@ func RegisterService(creator happydns.ServiceCreator, analyzer ServiceAnalyzer,
|
||||||
|
|
||||||
// Register aliases
|
// Register aliases
|
||||||
for _, alias := range aliases {
|
for _, alias := range aliases {
|
||||||
|
if _, exists := services[alias]; exists {
|
||||||
|
log.Printf("Warning: service alias %q is already registered; ignoring", alias)
|
||||||
|
continue
|
||||||
|
}
|
||||||
services[alias] = svc
|
services[alias] = svc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -79,6 +101,40 @@ func RegisterService(creator happydns.ServiceCreator, analyzer ServiceAnalyzer,
|
||||||
RegisterSubServices(baseType)
|
RegisterSubServices(baseType)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RegisterPluginSubServices walks the type tree rooted at t and registers
|
||||||
|
// every nested struct type as a sub-service, regardless of its package path.
|
||||||
|
//
|
||||||
|
// The built-in RegisterSubServices intentionally restricts itself to types
|
||||||
|
// declared under git.happydns.org/happyDomain/services to avoid registering
|
||||||
|
// random struct types pulled in from third-party libraries by built-in
|
||||||
|
// services. Plugin services live in a completely different module path, so
|
||||||
|
// that filter would skip every nested type they declare and break
|
||||||
|
// (de)serialisation of any non-flat plugin payload. The plugin loader calls
|
||||||
|
// this function explicitly to opt the plugin's own types into the registry.
|
||||||
|
func RegisterPluginSubServices(t reflect.Type) {
|
||||||
|
switch t.Kind() {
|
||||||
|
case reflect.Ptr, reflect.Slice, reflect.Array, reflect.Map:
|
||||||
|
RegisterPluginSubServices(t.Elem())
|
||||||
|
return
|
||||||
|
case reflect.Struct:
|
||||||
|
// Anonymous structs have no name and cannot be looked up later.
|
||||||
|
if t.Name() == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := t.String()
|
||||||
|
if _, ok := subServices[key]; ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Println("Registering new plugin subservice:", key)
|
||||||
|
subServices[key] = func() any {
|
||||||
|
return reflect.New(t).Interface()
|
||||||
|
}
|
||||||
|
for i := 0; i < t.NumField(); i++ {
|
||||||
|
RegisterPluginSubServices(t.Field(i).Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func RegisterSubServices(t reflect.Type) {
|
func RegisterSubServices(t reflect.Type) {
|
||||||
if t.Kind() == reflect.Struct && strings.HasPrefix(t.PkgPath(), pathToSvcsModule) {
|
if t.Kind() == reflect.Struct && strings.HasPrefix(t.PkgPath(), pathToSvcsModule) {
|
||||||
if _, ok := subServices[t.String()]; !ok {
|
if _, ok := subServices[t.String()]; !ok {
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@
|
||||||
import { redirect, type Load } from "@sveltejs/kit";
|
import { redirect, type Load } from "@sveltejs/kit";
|
||||||
import { get } from "svelte/store";
|
import { get } from "svelte/store";
|
||||||
|
|
||||||
|
import { refreshServicesSpecs } from "$lib/stores/services";
|
||||||
import { toasts } from "$lib/stores/toasts";
|
import { toasts } from "$lib/stores/toasts";
|
||||||
import { refreshUserSession } from "$lib/stores/usersession";
|
import { refreshUserSession } from "$lib/stores/usersession";
|
||||||
import { config as tsConfig, locale, loadTranslations, t } from "$lib/translations";
|
import { config as tsConfig, locale, loadTranslations, t } from "$lib/translations";
|
||||||
|
|
@ -111,6 +112,8 @@ export const load: Load = async ({ route, url }) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
refreshServicesSpecs();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sw_state,
|
sw_state,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue