feat(01-02): add optional plugin services and HasPlugin

- App-scoped typed Publish/Lookup with duplicate-provider errors
- Set plugin IDs before Register so HasPlugin sees the full set
- Greeter uses pact.OptionalMessage without importing optional
This commit is contained in:
Jakub Zych
2026-09-16 13:24:30 +02:00
parent 5f2291849d
commit 86969739ae
17 changed files with 297 additions and 12 deletions

View File

@@ -1,13 +1,71 @@
package backpack package backpack
import "git.golem15.com/golem15/summercms/compass" import (
"fmt"
"sync"
"git.golem15.com/golem15/summercms/compass"
)
// App is the per-instance application container. It must not import party. // App is the per-instance application container. It must not import party.
type App struct { type App struct {
Config *compass.Config Config *compass.Config
Services *Registry
mu sync.RWMutex
plugins map[string]struct{}
} }
// New returns an App that owns cfg. // New returns an App that owns cfg.
func New(cfg *compass.Config) *App { func New(cfg *compass.Config) *App {
return &App{Config: cfg} return &App{
Config: cfg,
Services: NewRegistry(),
plugins: make(map[string]struct{}),
}
}
// SetPlugins records the complete activated plugin set. HasPlugin uses this
// list, including plugins that have not yet Booted.
func (a *App) SetPlugins(ids []string) {
if a == nil {
return
}
next := make(map[string]struct{}, len(ids))
for _, id := range ids {
if id != "" {
next[id] = struct{}{}
}
}
a.mu.Lock()
a.plugins = next
a.mu.Unlock()
}
// HasPlugin reports whether id is in the complete registered set.
func (a *App) HasPlugin(id string) bool {
if a == nil {
return false
}
a.mu.RLock()
defer a.mu.RUnlock()
_, ok := a.plugins[id]
return ok
}
// Publish stores an app-scoped service under T.
func (a *App) Publish[T any](value T) error {
if a == nil || a.Services == nil {
return fmt.Errorf("backpack: app has no service registry")
}
return a.Services.Publish(value)
}
// Lookup returns the app-scoped service published under T.
func (a *App) Lookup[T any]() (T, bool) {
var zero T
if a == nil || a.Services == nil {
return zero, false
}
return a.Services.Lookup[T]()
} }

60
backpack/services.go Normal file
View File

@@ -0,0 +1,60 @@
package backpack
import (
"fmt"
"reflect"
"sync"
)
// Registry is an app-scoped typed service catalog. It has no process-global state.
type Registry struct {
mu sync.RWMutex
m map[reflect.Type]any
}
// NewRegistry returns an empty service registry.
func NewRegistry() *Registry {
return &Registry{m: make(map[reflect.Type]any)}
}
// Publish stores value under the concrete type argument T. Publishing the
// same T twice is an error.
func (r *Registry) Publish[T any](value T) error {
if r == nil {
return fmt.Errorf("backpack: registry is nil")
}
if any(value) == nil {
return fmt.Errorf("backpack: publish nil service")
}
key := reflect.TypeFor[T]()
r.mu.Lock()
defer r.mu.Unlock()
if r.m == nil {
r.m = make(map[reflect.Type]any)
}
if _, exists := r.m[key]; exists {
return fmt.Errorf("backpack: duplicate provider for %s", key)
}
r.m[key] = value
return nil
}
// Lookup returns the service published under T, or (zero, false).
func (r *Registry) Lookup[T any]() (T, bool) {
var zero T
if r == nil {
return zero, false
}
key := reflect.TypeFor[T]()
r.mu.RLock()
defer r.mu.RUnlock()
v, ok := r.m[key]
if !ok {
return zero, false
}
got, ok := v.(T)
if !ok {
return zero, false
}
return got, true
}

View File

@@ -8,6 +8,7 @@ require (
git.golem15.com/golem15/summercms v0.0.0 git.golem15.com/golem15/summercms v0.0.0
git.golem15.com/golem15/summercms/examples/hello/plugins/base v0.0.0 git.golem15.com/golem15/summercms/examples/hello/plugins/base v0.0.0
git.golem15.com/golem15/summercms/examples/hello/plugins/greeter v0.0.0 git.golem15.com/golem15/summercms/examples/hello/plugins/greeter v0.0.0
git.golem15.com/golem15/summercms/examples/hello/plugins/optional v0.0.0
) )
require ( require (
@@ -32,4 +33,5 @@ replace (
git.golem15.com/golem15/summercms => ../.. git.golem15.com/golem15/summercms => ../..
git.golem15.com/golem15/summercms/examples/hello/plugins/base => ./plugins/base git.golem15.com/golem15/summercms/examples/hello/plugins/base => ./plugins/base
git.golem15.com/golem15/summercms/examples/hello/plugins/greeter => ./plugins/greeter git.golem15.com/golem15/summercms/examples/hello/plugins/greeter => ./plugins/greeter
git.golem15.com/golem15/summercms/examples/hello/plugins/optional => ./plugins/optional
) )

View File

@@ -5,10 +5,12 @@ package main
import ( import (
_ "git.golem15.com/golem15/summercms/examples/hello/plugins/base" _ "git.golem15.com/golem15/summercms/examples/hello/plugins/base"
_ "git.golem15.com/golem15/summercms/examples/hello/plugins/greeter" _ "git.golem15.com/golem15/summercms/examples/hello/plugins/greeter"
_ "git.golem15.com/golem15/summercms/examples/hello/plugins/optional"
) )
// PluginIDs is the ordered plugin list from summer.yaml. // PluginIDs is the ordered plugin list from summer.yaml.
var PluginIDs = []string{ var PluginIDs = []string{
"golem15.hello", "golem15.hello",
"golem15.greeter", "golem15.greeter",
"golem15.optional",
} }

View File

@@ -5,9 +5,12 @@ import (
"io/fs" "io/fs"
"git.golem15.com/golem15/summercms/backpack" "git.golem15.com/golem15/summercms/backpack"
"git.golem15.com/golem15/summercms/pact"
"git.golem15.com/golem15/summercms/party" "git.golem15.com/golem15/summercms/party"
) )
var _ pact.HasConfig = (*Plugin)(nil)
//go:embed config //go:embed config
var configFS embed.FS var configFS embed.FS

View File

@@ -5,12 +5,14 @@ import (
"git.golem15.com/golem15/summercms/backpack" "git.golem15.com/golem15/summercms/backpack"
"git.golem15.com/golem15/summercms/bonfire" "git.golem15.com/golem15/summercms/bonfire"
"git.golem15.com/golem15/summercms/pact"
"git.golem15.com/golem15/summercms/party" "git.golem15.com/golem15/summercms/party"
) )
// Plugin is the golem15.greeter plugin. It requires golem15.hello. // Plugin is the golem15.greeter plugin. It requires golem15.hello.
type Plugin struct { type Plugin struct {
app *backpack.App app *backpack.App
extra string
} }
func (p *Plugin) ID() string { return "golem15.greeter" } func (p *Plugin) ID() string { return "golem15.greeter" }
@@ -20,6 +22,12 @@ func (p *Plugin) Register(*backpack.App) error { return nil }
func (p *Plugin) Boot(app *backpack.App) error { func (p *Plugin) Boot(app *backpack.App) error {
p.app = app p.app = app
p.extra = ""
if app != nil && app.HasPlugin("golem15.optional") {
if msg, ok := app.Lookup[pact.OptionalMessage](); ok {
p.extra = msg.Message()
}
}
return nil return nil
} }
@@ -36,12 +44,14 @@ func (p *Plugin) Commands() []bonfire.Command {
posts = p.app.Config.Int("golem15.hello.posts_per_page") posts = p.app.Config.Int("golem15.hello.posts_per_page")
debug = p.app.Config.Bool("app.debug") debug = p.app.Config.Bool("app.debug")
} }
out.Printf("name=%s posts_per_page=%d debug=%t\n", name, posts, debug) out.Printf("name=%s posts_per_page=%d debug=%t extra=%s\n", name, posts, debug, p.extra)
return nil return nil
}, },
}} }}
} }
var _ pact.HasCommands = (*Plugin)(nil)
func init() { func init() {
party.Register(&Plugin{}) party.Register(&Plugin{})
} }

View File

@@ -5,6 +5,7 @@ import (
"context" "context"
"fmt" "fmt"
"io" "io"
"os"
"strings" "strings"
"testing" "testing"
@@ -61,6 +62,16 @@ func TestBootUsesPublishedOptionalService(t *testing.T) {
} }
} }
func TestGreeterSourceDoesNotImportOptional(t *testing.T) {
body, err := os.ReadFile("plugin.go")
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(body), "plugins/optional") {
t.Fatal("greeter must not import the optional plugin module")
}
}
func runHelloCmd(t *testing.T, p *Plugin) string { func runHelloCmd(t *testing.T, p *Plugin) string {
t.Helper() t.Helper()
cmds := p.Commands() cmds := p.Commands()

View File

@@ -0,0 +1 @@
enabled: true

View File

@@ -0,0 +1,27 @@
module git.golem15.com/golem15/summercms/examples/hello/plugins/optional
go 1.27.0
toolchain go1.27.0
require git.golem15.com/golem15/summercms v0.0.0
require (
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/knadh/koanf/maps v0.1.2 // indirect
github.com/knadh/koanf/parsers/yaml v1.1.1 // indirect
github.com/knadh/koanf/providers/confmap v1.0.1 // indirect
github.com/knadh/koanf/providers/env/v2 v2.0.1 // indirect
github.com/knadh/koanf/providers/file v1.2.1 // indirect
github.com/knadh/koanf/v2 v2.3.6 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/pflag v1.0.9 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/sys v0.32.0 // indirect
)
replace git.golem15.com/golem15/summercms => ../../../..

View File

@@ -0,0 +1,47 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
github.com/knadh/koanf/parsers/yaml v1.1.1 h1:u70vV5IyaM0HvONh8HoqBC97oTgO33KcpZbTLiKVinU=
github.com/knadh/koanf/parsers/yaml v1.1.1/go.mod h1:HHmcHXUrp9cOPcuC+2wrr44GTUB0EC+PyfN3HZD9tFg=
github.com/knadh/koanf/providers/confmap v1.0.1 h1:L15hbvMqlvhwUuCtL9BkL+rqiMAjk6cZc8O9XoDtE3A=
github.com/knadh/koanf/providers/confmap v1.0.1/go.mod h1:txHYHiI2hAtF0/0sCmcuol4IDcuQbKTybiB1nOcUo1A=
github.com/knadh/koanf/providers/env/v2 v2.0.1 h1:a3KagndPqhcWHQv6Pz4OZmwkI/yMeTjkiZye6ZCkyW0=
github.com/knadh/koanf/providers/env/v2 v2.0.1/go.mod h1:1g01PE+Ve1gBfWNNw2wmULRP0tc8RJrjn5p2N/jNCIc=
github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP3AESOJYp9wM=
github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA=
github.com/knadh/koanf/v2 v2.3.6 h1:JoQPSJmvS4aP0xNc8xMDr5tcrkSEInL23/Il7pITAKo=
github.com/knadh/koanf/v2 v2.3.6/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28=
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,43 @@
package optional
import (
"embed"
"io/fs"
"git.golem15.com/golem15/summercms/backpack"
"git.golem15.com/golem15/summercms/pact"
"git.golem15.com/golem15/summercms/party"
)
//go:embed config
var configFS embed.FS
// Plugin is the golem15.optional plugin. Greeter may use it without importing this package.
type Plugin struct{}
func (p *Plugin) ID() string { return "golem15.optional" }
func (p *Plugin) Requires() []string { return nil }
func (p *Plugin) Register(app *backpack.App) error {
if app == nil {
return nil
}
return app.Publish[pact.OptionalMessage](extra{})
}
func (p *Plugin) Boot(*backpack.App) error { return nil }
func (p *Plugin) ConfigFS() fs.FS { return configFS }
type extra struct{}
func (extra) Message() string { return "hello-from-optional" }
var (
_ pact.HasConfig = (*Plugin)(nil)
_ pact.OptionalMessage = extra{}
)
func init() {
party.Register(&Plugin{})
}

View File

@@ -5,3 +5,5 @@ plugins:
module: git.golem15.com/golem15/summercms/examples/hello/plugins/base module: git.golem15.com/golem15/summercms/examples/hello/plugins/base
- id: golem15.greeter - id: golem15.greeter
module: git.golem15.com/golem15/summercms/examples/hello/plugins/greeter module: git.golem15.com/golem15/summercms/examples/hello/plugins/greeter
- id: golem15.optional
module: git.golem15.com/golem15/summercms/examples/hello/plugins/optional

View File

@@ -7,4 +7,5 @@ use (
./examples/hello ./examples/hello
./examples/hello/plugins/base ./examples/hello/plugins/base
./examples/hello/plugins/greeter ./examples/hello/plugins/greeter
./examples/hello/plugins/optional
) )

View File

@@ -16,7 +16,7 @@ func TestLoadHelloManifest(t *testing.T) {
if m.Binary != "hello" { if m.Binary != "hello" {
t.Fatalf("binary = %q", m.Binary) t.Fatalf("binary = %q", m.Binary)
} }
if len(m.Plugins) != 2 || m.Plugins[0].ID != "golem15.hello" || m.Plugins[1].ID != "golem15.greeter" { if len(m.Plugins) != 3 || m.Plugins[0].ID != "golem15.hello" || m.Plugins[1].ID != "golem15.greeter" || m.Plugins[2].ID != "golem15.optional" {
t.Fatalf("plugins = %+v", m.Plugins) t.Fatalf("plugins = %+v", m.Plugins)
} }
} }

View File

@@ -18,7 +18,16 @@ type HasConfig interface {
ConfigFS() fs.FS ConfigFS() fs.FS
} }
// Reserved KERN-03 capability names, declared when their first consumers exist: // OptionalMessage is a service an optional plugin may publish so other
// plugins can integrate without importing that plugin's package.
type OptionalMessage interface {
Message() string
}
// Future KERN-03 capability families are type-asserted when their first
// consumer packages exist. Method payloads are not declared here so pact
// does not import surf, lagoon, festival, or other packages that Phase 1
// does not yet ship:
// //
// HasModels // HasModels
// HasMigrations // HasMigrations
@@ -32,3 +41,6 @@ type HasConfig interface {
// HasSchedule // HasSchedule
// HasMailTemplates // HasMailTemplates
// HasLang // HasLang
//
// Phase 1 type-asserts only HasConfig (party, before Register) and
// HasCommands (generated app main, after Boot).

View File

@@ -98,6 +98,12 @@ func activate(registered []Plugin, app *backpack.App, ids []string) ([]Plugin, e
return nil, err return nil, err
} }
orderedIDs := make([]string, len(ordered))
for i, p := range ordered {
orderedIDs[i] = p.ID()
}
app.SetPlugins(orderedIDs)
for _, p := range ordered { for _, p := range ordered {
if hc, ok := p.(pact.HasConfig); ok && app.Config != nil { if hc, ok := p.(pact.HasConfig); ok && app.Config != nil {
if err := app.Config.MergePlugin(p.ID(), hc.ConfigFS()); err != nil { if err := app.Config.MergePlugin(p.ID(), hc.ConfigFS()); err != nil {

View File

@@ -126,11 +126,11 @@ func TestActivateDuplicateIDRejected(t *testing.T) {
} }
type bootProbe struct { type bootProbe struct {
id string id string
req []string req []string
log *[]string log *[]string
sawOpt *bool sawOpt *bool
booted *[]string booted *[]string
} }
func (p *bootProbe) ID() string { return p.id } func (p *bootProbe) ID() string { return p.id }