From 86969739ae35d8a6c38788e55cef2183d85344c0 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 16 Sep 2026 13:24:30 +0200 Subject: [PATCH] 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 --- backpack/app.go | 64 ++++++++++++++++++- backpack/services.go | 60 +++++++++++++++++ examples/hello/go.mod | 2 + examples/hello/plugins.gen.go | 2 + examples/hello/plugins/base/plugin.go | 3 + examples/hello/plugins/greeter/plugin.go | 14 +++- examples/hello/plugins/greeter/plugin_test.go | 11 ++++ .../hello/plugins/optional/config/config.yaml | 1 + examples/hello/plugins/optional/go.mod | 27 ++++++++ examples/hello/plugins/optional/go.sum | 47 ++++++++++++++ examples/hello/plugins/optional/plugin.go | 43 +++++++++++++ examples/hello/summer.yaml | 2 + go.work | 1 + internal/build/build_test.go | 2 +- pact/capabilities.go | 14 +++- party/registry.go | 6 ++ party/registry_test.go | 10 +-- 17 files changed, 297 insertions(+), 12 deletions(-) create mode 100644 backpack/services.go create mode 100644 examples/hello/plugins/optional/config/config.yaml create mode 100644 examples/hello/plugins/optional/go.mod create mode 100644 examples/hello/plugins/optional/go.sum create mode 100644 examples/hello/plugins/optional/plugin.go diff --git a/backpack/app.go b/backpack/app.go index dbf96aa..fd1511d 100644 --- a/backpack/app.go +++ b/backpack/app.go @@ -1,13 +1,71 @@ 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. 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. 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]() } diff --git a/backpack/services.go b/backpack/services.go new file mode 100644 index 0000000..b358685 --- /dev/null +++ b/backpack/services.go @@ -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 +} diff --git a/examples/hello/go.mod b/examples/hello/go.mod index 460742b..1e88209 100644 --- a/examples/hello/go.mod +++ b/examples/hello/go.mod @@ -8,6 +8,7 @@ require ( 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/greeter v0.0.0 + git.golem15.com/golem15/summercms/examples/hello/plugins/optional v0.0.0 ) require ( @@ -32,4 +33,5 @@ replace ( git.golem15.com/golem15/summercms => ../.. 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/optional => ./plugins/optional ) diff --git a/examples/hello/plugins.gen.go b/examples/hello/plugins.gen.go index f2a78bc..f90d6ae 100644 --- a/examples/hello/plugins.gen.go +++ b/examples/hello/plugins.gen.go @@ -5,10 +5,12 @@ package main import ( _ "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/optional" ) // PluginIDs is the ordered plugin list from summer.yaml. var PluginIDs = []string{ "golem15.hello", "golem15.greeter", + "golem15.optional", } diff --git a/examples/hello/plugins/base/plugin.go b/examples/hello/plugins/base/plugin.go index c39e819..167d225 100644 --- a/examples/hello/plugins/base/plugin.go +++ b/examples/hello/plugins/base/plugin.go @@ -5,9 +5,12 @@ import ( "io/fs" "git.golem15.com/golem15/summercms/backpack" + "git.golem15.com/golem15/summercms/pact" "git.golem15.com/golem15/summercms/party" ) +var _ pact.HasConfig = (*Plugin)(nil) + //go:embed config var configFS embed.FS diff --git a/examples/hello/plugins/greeter/plugin.go b/examples/hello/plugins/greeter/plugin.go index ab28d3c..184a665 100644 --- a/examples/hello/plugins/greeter/plugin.go +++ b/examples/hello/plugins/greeter/plugin.go @@ -5,12 +5,14 @@ import ( "git.golem15.com/golem15/summercms/backpack" "git.golem15.com/golem15/summercms/bonfire" + "git.golem15.com/golem15/summercms/pact" "git.golem15.com/golem15/summercms/party" ) // Plugin is the golem15.greeter plugin. It requires golem15.hello. type Plugin struct { - app *backpack.App + app *backpack.App + extra string } 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 { 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 } @@ -36,12 +44,14 @@ func (p *Plugin) Commands() []bonfire.Command { posts = p.app.Config.Int("golem15.hello.posts_per_page") 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 }, }} } +var _ pact.HasCommands = (*Plugin)(nil) + func init() { party.Register(&Plugin{}) } diff --git a/examples/hello/plugins/greeter/plugin_test.go b/examples/hello/plugins/greeter/plugin_test.go index f2b6280..7bc5e59 100644 --- a/examples/hello/plugins/greeter/plugin_test.go +++ b/examples/hello/plugins/greeter/plugin_test.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "io" + "os" "strings" "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 { t.Helper() cmds := p.Commands() diff --git a/examples/hello/plugins/optional/config/config.yaml b/examples/hello/plugins/optional/config/config.yaml new file mode 100644 index 0000000..d4ca941 --- /dev/null +++ b/examples/hello/plugins/optional/config/config.yaml @@ -0,0 +1 @@ +enabled: true diff --git a/examples/hello/plugins/optional/go.mod b/examples/hello/plugins/optional/go.mod new file mode 100644 index 0000000..2be5e7b --- /dev/null +++ b/examples/hello/plugins/optional/go.mod @@ -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 => ../../../.. diff --git a/examples/hello/plugins/optional/go.sum b/examples/hello/plugins/optional/go.sum new file mode 100644 index 0000000..ffb529b --- /dev/null +++ b/examples/hello/plugins/optional/go.sum @@ -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= diff --git a/examples/hello/plugins/optional/plugin.go b/examples/hello/plugins/optional/plugin.go new file mode 100644 index 0000000..160dd72 --- /dev/null +++ b/examples/hello/plugins/optional/plugin.go @@ -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{}) +} diff --git a/examples/hello/summer.yaml b/examples/hello/summer.yaml index 6405c98..c795e9c 100644 --- a/examples/hello/summer.yaml +++ b/examples/hello/summer.yaml @@ -5,3 +5,5 @@ plugins: module: git.golem15.com/golem15/summercms/examples/hello/plugins/base - id: golem15.greeter module: git.golem15.com/golem15/summercms/examples/hello/plugins/greeter + - id: golem15.optional + module: git.golem15.com/golem15/summercms/examples/hello/plugins/optional diff --git a/go.work b/go.work index 31d9812..fb7ffed 100644 --- a/go.work +++ b/go.work @@ -7,4 +7,5 @@ use ( ./examples/hello ./examples/hello/plugins/base ./examples/hello/plugins/greeter + ./examples/hello/plugins/optional ) diff --git a/internal/build/build_test.go b/internal/build/build_test.go index 28af067..db07913 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -16,7 +16,7 @@ func TestLoadHelloManifest(t *testing.T) { if m.Binary != "hello" { 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) } } diff --git a/pact/capabilities.go b/pact/capabilities.go index 45a454e..19ef4c5 100644 --- a/pact/capabilities.go +++ b/pact/capabilities.go @@ -18,7 +18,16 @@ type HasConfig interface { 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 // HasMigrations @@ -32,3 +41,6 @@ type HasConfig interface { // HasSchedule // HasMailTemplates // HasLang +// +// Phase 1 type-asserts only HasConfig (party, before Register) and +// HasCommands (generated app main, after Boot). diff --git a/party/registry.go b/party/registry.go index 8f5abe4..8a00787 100644 --- a/party/registry.go +++ b/party/registry.go @@ -98,6 +98,12 @@ func activate(registered []Plugin, app *backpack.App, ids []string) ([]Plugin, e return nil, err } + orderedIDs := make([]string, len(ordered)) + for i, p := range ordered { + orderedIDs[i] = p.ID() + } + app.SetPlugins(orderedIDs) + for _, p := range ordered { if hc, ok := p.(pact.HasConfig); ok && app.Config != nil { if err := app.Config.MergePlugin(p.ID(), hc.ConfigFS()); err != nil { diff --git a/party/registry_test.go b/party/registry_test.go index 21ef950..f9a915e 100644 --- a/party/registry_test.go +++ b/party/registry_test.go @@ -126,11 +126,11 @@ func TestActivateDuplicateIDRejected(t *testing.T) { } type bootProbe struct { - id string - req []string - log *[]string - sawOpt *bool - booted *[]string + id string + req []string + log *[]string + sawOpt *bool + booted *[]string } func (p *bootProbe) ID() string { return p.id }