chore: merge executor worktree (worktree-agent-01-02)

This commit is contained in:
Jakub Zych
2026-09-16 13:32:55 +02:00
41 changed files with 2461 additions and 32 deletions

View File

@@ -0,0 +1,173 @@
---
phase: 01-framework-kernel-foundation
plan: 02
subsystem: kernel
tags: [go, koanf, config, plugins, events, context, services]
requires:
- phase: 01-framework-kernel-foundation
provides: Hello app boot path, party.Activate, compass.Load, HasConfig/HasCommands
provides:
- Six-level compass config with Set/Persist/Reload
- Optional plugin composition via HasPlugin and typed backpack services
- Typed festival event bus with Fire/Collect/UntilHandled
- towel request-context accessors
affects: [01-03, 01-04, 03]
tech-stack:
added:
- github.com/knadh/koanf/providers/file v1.2.1
- github.com/knadh/koanf/providers/confmap v1.0.1
- github.com/knadh/koanf/providers/env/v2 v2.0.1
patterns:
- Config priority: runtime Set, persisted overrides, SUMMER_ env, env YAML, base YAML, plugin defaults
- Optional integration without importing the optional package (HasPlugin + pact interface)
- App-owned service registry and event bus; no process-global request state
key-files:
created:
- compass/env.go
- compass/persist.go
- backpack/services.go
- festival/bus.go
- towel/context.go
- examples/hello/plugins/optional/plugin.go
modified:
- compass/config.go
- party/registry.go
- backpack/app.go
- pact/capabilities.go
- examples/hello/plugins/greeter/plugin.go
- examples/hello/summer.yaml
key-decisions:
- "Runtime Set lives in a separate koanf tree so Reload drops it and rebuilds disk layers"
- "pact.OptionalMessage is the shared service interface; greeter never imports the optional module"
- "Collect/UntilHandled read Collectable/Handleable on the event so Listen stays func(ctx, T) error"
- "HasPlugin uses the full activated ID set recorded before Register, not the Booted subset"
- "Panic recovery names the owner plugin ID and omits event payload (T-01-05)"
patterns-established:
- "Pattern: plugin embed.FS config/config.yaml merged at the bare plugin ID via MergeAt"
- "Pattern: SUMMER_ keys split only on __; single underscores stay in the leaf"
- "Pattern: Publish in Register, Lookup in Boot so optional services are visible regardless of Boot order"
requirements-completed: [KERN-01, KERN-02, KERN-03, KERN-05, KERN-06, KERN-07, KERN-08]
duration: 25min
completed: 2026-09-16
---
# Phase 1 Plan 2: Layered Config, Services and Events Summary
**Six-level koanf config with Set/Persist/Reload, optional plugin services without a hard import, and typed festival dispatch with context-scoped request state**
## Performance
- **Duration:** 25 min
- **Started:** 2026-09-16T11:06:17Z
- **Completed:** 2026-09-16T11:31:20Z
- **Tasks:** 3
- **Files modified:** 40
## Accomplishments
- Compass loads plugin defaults, sorted base YAML, env overlays, `SUMMER_` variables (split on `__`), persisted overrides and runtime Set in D-09 order; Persist is atomic with `0600` files; Reload clears Set
- `golem15.optional` is a third hello plugin that publishes `pact.OptionalMessage`; greeter uses `HasPlugin` plus typed lookup and boots with or without it
- App-owned `festival.Bus` implements Fire (join errors), Collect (later key wins, partial payload) and UntilHandled, recovers panics with owner IDs, and `towel` carries actor/org/collection/locale on `context.Context`
## Task Commits
Each task was committed atomically (TDD RED then GREEN):
1. **Task 1 RED: failing layered config tests** - `255de60` (test)
2. **Task 1 GREEN: layered compass config** - `cc44a1f` (feat)
3. **Task 2 RED: failing optional service tests** - `5f22918` (test)
4. **Task 2 GREEN: optional plugin services and HasPlugin** - `8696973` (feat)
5. **Task 3 RED: failing event and context tests** - `36c152f` (test)
6. **Task 3 GREEN: typed events with request context** - `a654e96` (feat)
**Plan metadata:** pending (docs: complete plan)
## Files Created/Modified
- `compass/config.go` / `env.go` / `persist.go` - layered load, env mapping, `.env` fill, Set/Persist/Reload
- `party/registry.go` - HasConfig merge and SetPlugins before Register
- `backpack/app.go` / `services.go` - HasPlugin, typed Publish/Lookup, app-owned Events bus
- `pact/capabilities.go` - OptionalMessage plus documented future KERN-03 families
- `festival/bus.go` - typed Listen/Fire/Collect/UntilHandled
- `towel/context.go` - unexported-key request accessors
- `examples/hello/plugins/optional/` - optional plugin module
- `examples/hello/plugins/greeter/plugin.go` - optional lookup and hello event demo
- `examples/hello/summer.yaml` / `plugins.gen.go` / `go.work` - third plugin wired
## Decisions Made
- Keep runtime Set off the disk tree so Reload is a full rebuild plus an empty overlay
- Put the optional service interface in `pact`, not in the optional plugin package
- Model Collect/UntilHandled as event interfaces (`Collected`, `IsHandled`) so one `Listen[T]` serves all three modes
- Record activated plugin IDs on the app before Register so HasPlugin sees not-yet-Booted plugins
- Recover panics as `festival: plugin <id> panicked` without event contents
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 2 - Missing Critical] Restore toolchain go1.27.0 after go mod tidy**
- **Found during:** Task 1 (provider install)
- **Issue:** `go mod tidy` dropped `toolchain go1.27.0` (same as Plan 01)
- **Fix:** Re-added the directive on root and example modules
- **Files modified:** `go.mod`, example `go.mod` files
- **Verification:** files contain `toolchain go1.27.0`
- **Committed in:** `cc44a1f` (Task 1 GREEN)
**2. [Rule 1 - Bug] Greeter extra leaked across Activate on the init singleton**
- **Found during:** Task 2 (hello without-optional test)
- **Issue:** `party.Register` stores one plugin instance; Boot with optional set `extra`, later Boot without optional left the old string
- **Fix:** Clear `extra` at the start of Boot
- **Files modified:** `examples/hello/plugins/greeter/plugin.go`
- **Verification:** `TestGreeterRunsWithoutOptionalPlugin` passes
- **Committed in:** `8696973` (Task 2 GREEN)
**3. [Rule 1 - Bug] Hello manifest test still expected two plugins**
- **Found during:** Task 2 (`go test ./...`)
- **Issue:** `internal/build.TestLoadHelloManifest` hard-coded plugin count 2
- **Fix:** Expect `golem15.optional` as the third manifest entry
- **Files modified:** `internal/build/build_test.go`
- **Verification:** `go test ./internal/build` passes
- **Committed in:** `8696973` (Task 2 GREEN)
---
**Total deviations:** 3 auto-fixed (1 missing critical, 2 bugs)
**Impact on plan:** Required for D-05, correct optional skip, and a green suite. No scope creep.
## Issues Encountered
- `go get` of koanf file/env/confmap providers pulled `fsnotify` transitively through the file provider; it is indirect only and unused by compass (no Watch). Plan 03 still owns `summer dev`.
## Authentication Gates
None.
## User Setup Required
None - no external service configuration required.
## Next Phase Readiness
Ready for `01-03-PLAN.md` (watch loop and CLI UX). Config, optional composition, services and events are in place on the hello path. Do not add HTTP/DB/auth here.
## Self-Check: PASSED
- Created files exist on disk (`compass/env.go`, `compass/persist.go`, `backpack/services.go`, `festival/bus.go`, `towel/context.go`, optional plugin)
- Commits `255de60`, `cc44a1f`, `5f22918`, `8696973`, `36c152f`, `a654e96` exist
- Root and hello `go vet ./...` and `go test ./...` passed
- `go test -race ./compass ./party ./backpack ./festival ./towel` passed
- `go run . greeter:hello` prints layered config, optional extra, and event results
- Greeter source has no import of the optional plugin module
- STATE.md and ROADMAP.md were not updated in this worktree
---
*Phase: 01-framework-kernel-foundation*
*Completed: 2026-09-16*

View File

@@ -1,13 +1,74 @@
package backpack package backpack
import "git.golem15.com/golem15/summercms/compass" import (
"fmt"
"sync"
"git.golem15.com/golem15/summercms/compass"
"git.golem15.com/golem15/summercms/festival"
)
// 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
Events *festival.Bus
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(),
Events: festival.New(),
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]()
} }

View File

@@ -7,8 +7,21 @@ import (
"strconv" "strconv"
"strings" "strings"
"testing" "testing"
"git.golem15.com/golem15/summercms/compass"
) )
func TestEventBusesAreAppScoped(t *testing.T) {
a := New(&compass.Config{})
b := New(&compass.Config{})
if a.Events == nil || b.Events == nil {
t.Fatal("App must own an event bus")
}
if a.Events == b.Events {
t.Fatal("apps must not share a bus")
}
}
func TestDoesNotImportParty(t *testing.T) { func TestDoesNotImportParty(t *testing.T) {
entries, err := os.ReadDir(".") entries, err := os.ReadDir(".")
if err != nil { if err != nil {

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
}

72
backpack/services_test.go Normal file
View File

@@ -0,0 +1,72 @@
package backpack
import (
"testing"
"git.golem15.com/golem15/summercms/compass"
)
type ping interface {
Ping() string
}
type pingImpl struct{ s string }
func (p pingImpl) Ping() string { return p.s }
func TestPublishLookup(t *testing.T) {
app := New(&compass.Config{})
if err := app.Publish[ping](pingImpl{s: "pong"}); err != nil {
t.Fatalf("Publish: %v", err)
}
got, ok := app.Lookup[ping]()
if !ok {
t.Fatal("Lookup returned false")
}
if got.Ping() != "pong" {
t.Fatalf("Ping = %q, want pong", got.Ping())
}
}
func TestLookupAbsentInterface(t *testing.T) {
app := New(&compass.Config{})
got, ok := app.Lookup[ping]()
if ok {
t.Fatalf("Lookup = (%v, true), want false", got)
}
if got != nil {
t.Fatalf("absent lookup value = %v, want nil", got)
}
}
func TestPublishDuplicateRejected(t *testing.T) {
app := New(&compass.Config{})
if err := app.Publish[ping](pingImpl{s: "a"}); err != nil {
t.Fatalf("Publish: %v", err)
}
if err := app.Publish[ping](pingImpl{s: "b"}); err == nil {
t.Fatal("expected duplicate publish error")
}
}
func TestServiceRegistriesAreAppScoped(t *testing.T) {
a := New(&compass.Config{})
b := New(&compass.Config{})
if err := a.Publish[ping](pingImpl{s: "a"}); err != nil {
t.Fatal(err)
}
if _, ok := b.Lookup[ping](); ok {
t.Fatal("app B should not see app A's service")
}
}
func TestHasPluginUsesCompleteSet(t *testing.T) {
app := New(&compass.Config{})
app.SetPlugins([]string{"golem15.hello", "golem15.optional"})
if !app.HasPlugin("golem15.optional") {
t.Fatal("HasPlugin(golem15.optional) = false")
}
if app.HasPlugin("golem15.missing") {
t.Fatal("HasPlugin(golem15.missing) = true")
}
}

View File

@@ -2,62 +2,348 @@ package compass
import ( import (
"fmt" "fmt"
"io/fs"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"sync"
"github.com/knadh/koanf/parsers/yaml" "github.com/knadh/koanf/parsers/yaml"
"github.com/knadh/koanf/providers/confmap"
"github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/v2" "github.com/knadh/koanf/v2"
) )
// Config is a YAML configuration tree with dot-path access. // Options controls how configuration is loaded.
type Config struct { type Options struct {
k *koanf.Koanf // Dir is the app config directory, typically "config".
Dir string
// Env is the environment name. Empty means detect from SUMMER_ENV,
// defaulting to "production". An explicit value wins over SUMMER_ENV.
Env string
// Environ is the KEY=VALUE list used for SUMMER_ variables and
// SUMMER_ENV detection. Nil means os.Environ(). A .env file fills
// only keys absent from this list and never mutates the process
// environment.
Environ []string
} }
// Load reads config/<section>.yaml files from dir. Each filename stem becomes type pluginFS struct {
// the top-level section, so config/app.yaml key name is app.name. id string
fsys fs.FS
}
// Config is a YAML configuration tree with layered overrides and
// dot-path access.
type Config struct {
mu sync.RWMutex
opts Options
dir string
env string
environ []string
plugins []pluginFS
k *koanf.Koanf
runtime *koanf.Koanf
}
// Load reads config from dir using SUMMER_ENV (default production).
func Load(dir string) (*Config, error) { func Load(dir string) (*Config, error) {
return Open(Options{Dir: dir})
}
// Open loads configuration using opts.
func Open(opts Options) (*Config, error) {
if opts.Dir == "" {
return nil, fmt.Errorf("compass: config directory is empty")
}
c := &Config{
opts: opts,
dir: opts.Dir,
runtime: koanf.New("."),
}
if err := c.refreshSources(); err != nil {
return nil, err
}
if err := c.rebuild(); err != nil {
return nil, err
}
return c, nil
}
// Environment returns the active environment name.
func (c *Config) Environment() string {
if c == nil {
return ""
}
c.mu.RLock()
defer c.mu.RUnlock()
return c.env
}
// MergePlugin merges an embedded plugin config filesystem at the bare
// plugin ID path. config/config.yaml keys become <id>.<key>.
func (c *Config) MergePlugin(id string, fsys fs.FS) error {
if c == nil {
return fmt.Errorf("compass: config is nil")
}
if id == "" {
return fmt.Errorf("compass: plugin id is empty")
}
c.mu.Lock()
defer c.mu.Unlock()
replaced := false
for i, p := range c.plugins {
if p.id == id {
c.plugins[i] = pluginFS{id: id, fsys: fsys}
replaced = true
break
}
}
if !replaced {
c.plugins = append(c.plugins, pluginFS{id: id, fsys: fsys})
}
if c.runtime == nil {
c.runtime = koanf.New(".")
}
return c.rebuild()
}
// Lookup returns the value at path and whether it exists.
func (c *Config) Lookup(path string) (any, bool) {
v := c.view()
if v == nil || !v.Exists(path) {
return nil, false
}
return v.Get(path), true
}
// String returns the string value at path, or "" if unset.
func (c *Config) String(path string) string {
v := c.view()
if v == nil {
return ""
}
return v.String(path)
}
// Int returns the int value at path, or 0 if unset.
func (c *Config) Int(path string) int {
v := c.view()
if v == nil {
return 0
}
return v.Int(path)
}
// Bool returns the bool value at path, or false if unset.
func (c *Config) Bool(path string) bool {
v := c.view()
if v == nil {
return false
}
return v.Bool(path)
}
// Has reports whether path exists in the merged tree.
func (c *Config) Has(path string) bool {
v := c.view()
if v == nil {
return false
}
return v.Exists(path)
}
// LoadSection unmarshals the map at path into out using `koanf` tags.
func (c *Config) LoadSection(path string, out any) error {
v := c.view()
if v == nil {
return fmt.Errorf("compass: config is empty")
}
return v.UnmarshalWithConf(path, out, koanf.UnmarshalConf{Tag: "koanf"})
}
func (c *Config) view() *koanf.Koanf {
if c == nil {
return nil
}
c.mu.RLock()
defer c.mu.RUnlock()
if c.k == nil {
return nil
}
out := c.k.Copy()
if c.runtime != nil {
_ = out.Merge(c.runtime)
}
return out
}
func (c *Config) refreshSources() error {
base := c.opts.Environ
if c.opts.Environ == nil {
base = os.Environ()
}
dotenv, err := readDotEnv(filepath.Join(filepath.Dir(c.dir), ".env"))
if err != nil {
return err
}
c.environ = mergeEnviron(base, dotenv)
envName := c.opts.Env
if envName == "" {
envName = lookupEnv(c.environ, "SUMMER_ENV")
}
if envName == "" {
envName = "production"
}
envName, err = sanitizeEnv(envName)
if err != nil {
return err
}
c.env = envName
return nil
}
func (c *Config) rebuild() error {
k := koanf.New(".") k := koanf.New(".")
for _, p := range c.plugins {
if err := mergePluginFS(k, p.id, p.fsys); err != nil {
return err
}
}
if c.dir != "" {
if err := loadSortedSections(k, c.dir, false); err != nil {
return err
}
envDir := filepath.Join(c.dir, "env", c.env)
if err := loadSortedSections(k, envDir, true); err != nil {
return err
}
}
if err := loadEnvVars(k, c.environ); err != nil {
return err
}
if c.dir != "" && c.env != "" {
overrides := filepath.Join(c.dir, "env", c.env, "overrides.yaml")
if err := loadOverrides(k, overrides); err != nil {
return err
}
}
c.k = k
return nil
}
func loadSortedSections(k *koanf.Koanf, dir string, skipOverrides bool) error {
entries, err := os.ReadDir(dir) entries, err := os.ReadDir(dir)
if err != nil { if err != nil {
return nil, fmt.Errorf("compass: read config dir %s: %w", dir, err) if os.IsNotExist(err) {
return nil
} }
parser := yaml.Parser() return fmt.Errorf("compass: read config dir: %w", err)
}
names := make([]string, 0, len(entries))
for _, entry := range entries { for _, entry := range entries {
if entry.IsDir() { if entry.IsDir() {
continue continue
} }
name := entry.Name() name := entry.Name()
section, ok := sectionName(name) if skipOverrides && isOverridesFile(name) {
if !ok {
continue continue
} }
if _, ok := sectionName(name); !ok {
continue
}
names = append(names, name)
}
sort.Strings(names)
parser := yaml.Parser()
for _, name := range names {
section, _ := sectionName(name)
path := filepath.Join(dir, name) path := filepath.Join(dir, name)
raw, err := os.ReadFile(path) tmp := koanf.New(".")
if err := tmp.Load(file.Provider(path), parser); err != nil {
return fmt.Errorf("compass: parse %s: %w", filepath.Base(path), err)
}
if err := k.MergeAt(tmp, section); err != nil {
return fmt.Errorf("compass: merge %s: %w", filepath.Base(path), err)
}
}
return nil
}
func loadOverrides(k *koanf.Koanf, path string) error {
if _, err := os.Stat(path); err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("compass: stat overrides: %w", err)
}
if err := k.Load(file.Provider(path), yaml.Parser()); err != nil {
return fmt.Errorf("compass: parse overrides: %w", err)
}
return nil
}
func mergePluginFS(k *koanf.Koanf, id string, fsys fs.FS) error {
if fsys == nil {
return nil
}
var files []string
err := fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil { if err != nil {
return nil, fmt.Errorf("compass: read %s: %w", path, err) return err
}
if d.IsDir() {
return nil
}
if _, ok := sectionName(filepath.Base(path)); !ok {
return nil
}
files = append(files, path)
return nil
})
if err != nil {
return fmt.Errorf("compass: read plugin config %s: %w", id, err)
}
sort.Strings(files)
parser := yaml.Parser()
for _, path := range files {
raw, err := fs.ReadFile(fsys, path)
if err != nil {
return fmt.Errorf("compass: read plugin config %s: %w", id, err)
} }
parsed, err := parser.Unmarshal(raw) parsed, err := parser.Unmarshal(raw)
if err != nil { if err != nil {
return nil, fmt.Errorf("compass: parse %s: %w", path, err) return fmt.Errorf("compass: parse plugin config %s: %w", id, err)
} }
if parsed == nil { if parsed == nil {
parsed = map[string]any{} parsed = map[string]any{}
} }
if err := k.Set(section, parsed); err != nil { tmp := koanf.New(".")
return nil, fmt.Errorf("compass: set section %s: %w", section, err) if err := tmp.Load(confmap.Provider(parsed, ""), nil); err != nil {
return fmt.Errorf("compass: load plugin config %s: %w", id, err)
}
at := pluginMergePath(id, path)
if err := k.MergeAt(tmp, at); err != nil {
return fmt.Errorf("compass: merge plugin config %s: %w", id, err)
} }
} }
return &Config{k: k}, nil return nil
} }
// String returns the string value at path, or "" if unset. func pluginMergePath(id, path string) string {
func (c *Config) String(path string) string { rel := filepath.ToSlash(path)
if c == nil || c.k == nil { base := filepath.Base(rel)
return "" section, _ := sectionName(base)
if section == "config" {
return id
} }
return c.k.String(path) return id + "." + section
}
func isOverridesFile(name string) bool {
return name == "overrides.yaml" || name == "overrides.yml"
} }
func sectionName(filename string) (string, bool) { func sectionName(filename string) (string, bool) {

View File

@@ -4,6 +4,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"testing/fstest"
) )
func TestLoadStringFromAppSection(t *testing.T) { func TestLoadStringFromAppSection(t *testing.T) {
@@ -21,3 +22,222 @@ func TestLoadStringFromAppSection(t *testing.T) {
t.Fatalf("String(app.name) = %q, want hello-app", got) t.Fatalf("String(app.name) = %q, want hello-app", got)
} }
} }
func TestOpenDefaultsEnvToProduction(t *testing.T) {
dir := writeConfigTree(t, map[string]string{
"app.yaml": "name: base\ndebug: false\n",
"env/production/app.yaml": "debug: true\n",
"env/development/app.yaml": "name: dev\n",
})
cfg, err := Open(Options{Dir: dir, Environ: []string{}})
if err != nil {
t.Fatalf("Open: %v", err)
}
if cfg.Environment() != "production" {
t.Fatalf("Environment = %q, want production", cfg.Environment())
}
if got := cfg.String("app.name"); got != "base" {
t.Fatalf("name = %q, want base", got)
}
if !cfg.Bool("app.debug") {
t.Fatal("production overlay should set app.debug")
}
}
func TestExplicitEnvWinsOverSUMMER_ENV(t *testing.T) {
dir := writeConfigTree(t, map[string]string{
"app.yaml": "name: base\n",
"env/development/app.yaml": "name: from-dev\n",
"env/staging/app.yaml": "name: from-staging\n",
})
cfg, err := Open(Options{
Dir: dir,
Env: "staging",
Environ: []string{"SUMMER_ENV=development"},
})
if err != nil {
t.Fatalf("Open: %v", err)
}
if cfg.Environment() != "staging" {
t.Fatalf("Environment = %q, want staging", cfg.Environment())
}
if got := cfg.String("app.name"); got != "from-staging" {
t.Fatalf("name = %q, want from-staging", got)
}
}
func TestDeepMergeKeepsUntouchedNestedKeys(t *testing.T) {
dir := writeConfigTree(t, map[string]string{
"database.yaml": `
host: localhost
port: 5432
pool:
max: 10
idle: 2
`,
"env/development/database.yaml": `
host: dev-db
pool:
max: 20
`,
})
cfg, err := Open(Options{Dir: dir, Env: "development", Environ: []string{}})
if err != nil {
t.Fatalf("Open: %v", err)
}
if got := cfg.String("database.host"); got != "dev-db" {
t.Fatalf("host = %q, want dev-db", got)
}
if got := cfg.Int("database.port"); got != 5432 {
t.Fatalf("port = %d, want 5432", got)
}
if got := cfg.Int("database.pool.max"); got != 20 {
t.Fatalf("pool.max = %d, want 20", got)
}
if got := cfg.Int("database.pool.idle"); got != 2 {
t.Fatalf("pool.idle = %d, want 2", got)
}
}
func TestMergePluginConfigAtBarePluginID(t *testing.T) {
dir := writeConfigTree(t, map[string]string{
"app.yaml": "name: hello-app\n",
})
cfg, err := Open(Options{Dir: dir, Environ: []string{}})
if err != nil {
t.Fatalf("Open: %v", err)
}
fsys := fstest.MapFS{
"config/config.yaml": {Data: []byte("posts_per_page: 10\nshow_author: true\n")},
}
if err := cfg.MergePlugin("golem15.hello", fsys); err != nil {
t.Fatalf("MergePlugin: %v", err)
}
if got := cfg.Int("golem15.hello.posts_per_page"); got != 10 {
t.Fatalf("posts_per_page = %d, want 10", got)
}
if !cfg.Bool("golem15.hello.show_author") {
t.Fatal("show_author should be true")
}
if !cfg.Has("golem15.hello.posts_per_page") {
t.Fatal("Has(golem15.hello.posts_per_page) = false")
}
got, ok := cfg.Lookup("golem15.hello.posts_per_page")
if !ok {
t.Fatal("Lookup missing posts_per_page")
}
switch v := got.(type) {
case int:
if v != 10 {
t.Fatalf("Lookup = %v, want 10", got)
}
case int64:
if v != 10 {
t.Fatalf("Lookup = %v, want 10", got)
}
case uint64:
if v != 10 {
t.Fatalf("Lookup = %v, want 10", got)
}
default:
// YAML may decode as int or the getter path may return the raw value.
if cfg.Int("golem15.hello.posts_per_page") != 10 {
t.Fatalf("Lookup type %T value %v", got, got)
}
}
var section struct {
PostsPerPage int `koanf:"posts_per_page"`
ShowAuthor bool `koanf:"show_author"`
}
if err := cfg.LoadSection("golem15.hello", &section); err != nil {
t.Fatalf("LoadSection: %v", err)
}
if section.PostsPerPage != 10 || !section.ShowAuthor {
t.Fatalf("LoadSection = %+v", section)
}
}
func TestEnvVarsOverridePluginDefaults(t *testing.T) {
dir := writeConfigTree(t, map[string]string{
"app.yaml": "name: hello-app\n",
})
cfg, err := Open(Options{
Dir: dir,
Environ: []string{
"SUMMER_GOLEM15__HELLO__POSTS_PER_PAGE=25",
},
})
if err != nil {
t.Fatalf("Open: %v", err)
}
fsys := fstest.MapFS{
"config/config.yaml": {Data: []byte("posts_per_page: 10\n")},
}
if err := cfg.MergePlugin("golem15.hello", fsys); err != nil {
t.Fatalf("MergePlugin: %v", err)
}
if got := cfg.Int("golem15.hello.posts_per_page"); got != 25 {
t.Fatalf("posts_per_page = %d, want 25 (env over plugin default)", got)
}
}
func TestPriorityRuntimeSetThenPersistThenEnvThenOverlayThenBaseThenPlugin(t *testing.T) {
dir := writeConfigTree(t, map[string]string{
"app.yaml": "name: base\n",
"env/development/app.yaml": "name: overlay\n",
"env/development/overrides.yaml": "app:\n name: persisted\n",
})
cfg, err := Open(Options{
Dir: dir,
Env: "development",
Environ: []string{"SUMMER_APP__NAME=from-env"},
})
if err != nil {
t.Fatalf("Open: %v", err)
}
if got := cfg.String("app.name"); got != "persisted" {
t.Fatalf("before Set, name = %q, want persisted", got)
}
if err := cfg.Set("app.name", "runtime"); err != nil {
t.Fatalf("Set: %v", err)
}
if got := cfg.String("app.name"); got != "runtime" {
t.Fatalf("after Set, name = %q, want runtime", got)
}
}
func TestSortedSectionFilesLoadDeterministically(t *testing.T) {
dir := writeConfigTree(t, map[string]string{
"b.yaml": "value: b\n",
"a.yaml": "value: a\nshared: 1\n",
})
cfg, err := Open(Options{Dir: dir, Environ: []string{}})
if err != nil {
t.Fatalf("Open: %v", err)
}
if got := cfg.String("a.value"); got != "a" {
t.Fatalf("a.value = %q, want a", got)
}
if got := cfg.String("b.value"); got != "b" {
t.Fatalf("b.value = %q, want b", got)
}
if got := cfg.Int("a.shared"); got != 1 {
t.Fatalf("a.shared = %d, want 1", got)
}
}
func writeConfigTree(t *testing.T, files map[string]string) string {
t.Helper()
dir := t.TempDir()
for name, body := range files {
path := filepath.Join(dir, name)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
return dir
}

124
compass/env.go Normal file
View File

@@ -0,0 +1,124 @@
package compass
import (
"fmt"
"os"
"strings"
"unicode"
"github.com/knadh/koanf/providers/env/v2"
"github.com/knadh/koanf/v2"
)
const envPrefix = "SUMMER_"
func loadEnvVars(k *koanf.Koanf, environ []string) error {
if k == nil {
return nil
}
return k.Load(env.Provider(".", env.Opt{
Prefix: envPrefix,
EnvironFunc: func() []string {
if environ == nil {
return []string{}
}
return environ
},
TransformFunc: transformSummerKey,
}), nil)
}
func transformSummerKey(key, value string) (string, any) {
if key == "SUMMER_ENV" {
return "", nil
}
rest, ok := strings.CutPrefix(key, envPrefix)
if !ok || rest == "" {
return "", nil
}
parts := strings.Split(rest, "__")
for i, part := range parts {
parts[i] = strings.ToLower(part)
}
return strings.Join(parts, "."), value
}
func readDotEnv(path string) ([]string, error) {
raw, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("compass: read .env: %w", err)
}
var out []string
for _, line := range strings.Split(string(raw), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if after, ok := strings.CutPrefix(line, "export "); ok {
line = strings.TrimSpace(after)
}
key, val, ok := strings.Cut(line, "=")
if !ok {
continue
}
key = strings.TrimSpace(key)
if key == "" {
continue
}
val = strings.TrimSpace(val)
if len(val) >= 2 {
if (val[0] == '"' && val[len(val)-1] == '"') || (val[0] == '\'' && val[len(val)-1] == '\'') {
val = val[1 : len(val)-1]
}
}
out = append(out, key+"="+val)
}
return out, nil
}
func mergeEnviron(real, dotenv []string) []string {
present := make(map[string]struct{}, len(real))
out := make([]string, 0, len(real)+len(dotenv))
for _, kv := range real {
key, _, _ := strings.Cut(kv, "=")
present[key] = struct{}{}
out = append(out, kv)
}
for _, kv := range dotenv {
key, _, _ := strings.Cut(kv, "=")
if _, ok := present[key]; ok {
continue
}
out = append(out, kv)
}
return out
}
func lookupEnv(environ []string, key string) string {
for i := len(environ) - 1; i >= 0; i-- {
k, v, ok := strings.Cut(environ[i], "=")
if ok && k == key {
return v
}
}
return ""
}
func sanitizeEnv(env string) (string, error) {
if env == "" {
return "", fmt.Errorf("compass: environment name is empty")
}
if env == "." || env == ".." || strings.Contains(env, "..") || strings.ContainsAny(env, `/\`) {
return "", fmt.Errorf("compass: invalid environment name")
}
for _, r := range env {
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' || r == '_' {
continue
}
return "", fmt.Errorf("compass: invalid environment name")
}
return env, nil
}

120
compass/env_test.go Normal file
View File

@@ -0,0 +1,120 @@
package compass
import (
"os"
"path/filepath"
"testing"
)
func TestEnvDoubleUnderscoreMapsToDotPath(t *testing.T) {
dir := writeConfigTree(t, map[string]string{
"database.yaml": "host: localhost\n",
"app.yaml": "name: base\n",
})
cfg, err := Open(Options{
Dir: dir,
Environ: []string{
"SUMMER_DATABASE__HOST=from-env",
"SUMMER_GOLEM15__HELLO__POSTS_PER_PAGE=25",
"IGNORED_VALUE=nope",
},
})
if err != nil {
t.Fatalf("Open: %v", err)
}
if got := cfg.String("database.host"); got != "from-env" {
t.Fatalf("database.host = %q, want from-env", got)
}
if got := cfg.Int("golem15.hello.posts_per_page"); got != 25 {
t.Fatalf("golem15.hello.posts_per_page = %d, want 25", got)
}
if cfg.Has("ignored_value") || cfg.Has("IGNORED_VALUE") {
t.Fatal("non-SUMMER_ variables must not enter config")
}
}
func TestSingleUnderscoreRemainsLiteralLeaf(t *testing.T) {
dir := writeConfigTree(t, map[string]string{
"app.yaml": "name: base\n",
})
cfg, err := Open(Options{
Dir: dir,
Environ: []string{
"SUMMER_GOLEM15__HELLO__POSTS_PER_PAGE=9",
},
})
if err != nil {
t.Fatalf("Open: %v", err)
}
if !cfg.Has("golem15.hello.posts_per_page") {
t.Fatal("expected golem15.hello.posts_per_page")
}
if cfg.Has("golem15.hello.posts.per.page") {
t.Fatal("single underscores must not become path separators")
}
if got := cfg.Int("golem15.hello.posts_per_page"); got != 9 {
t.Fatalf("posts_per_page = %d, want 9", got)
}
}
func TestDotEnvFillsOnlyAbsentRealEnvKeysAndDoesNotMutateProcess(t *testing.T) {
root := t.TempDir()
dir := filepath.Join(root, "config")
if err := os.Mkdir(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: base\n"), 0o644); err != nil {
t.Fatal(err)
}
dotenv := "SUMMER_APP__NAME=from-dotenv\nSUMMER_DATABASE__HOST=dotenv-host\n"
if err := os.WriteFile(filepath.Join(root, ".env"), []byte(dotenv), 0o600); err != nil {
t.Fatal(err)
}
const sentinel = "SUMMER_COMPASS_DOTENV_SENTINEL"
if _, ok := os.LookupEnv(sentinel); ok {
t.Fatalf("%s already set in process env", sentinel)
}
cfg, err := Open(Options{
Dir: dir,
Environ: []string{
"SUMMER_APP__NAME=from-real",
},
})
if err != nil {
t.Fatalf("Open: %v", err)
}
if got := cfg.String("app.name"); got != "from-real" {
t.Fatalf("app.name = %q, want from-real (real env wins over .env)", got)
}
if got := cfg.String("database.host"); got != "dotenv-host" {
t.Fatalf("database.host = %q, want dotenv-host (.env fills absent keys)", got)
}
if _, ok := os.LookupEnv("SUMMER_DATABASE__HOST"); ok {
t.Fatal("Open must not mutate process environment with .env values")
}
if _, ok := os.LookupEnv(sentinel); ok {
t.Fatal("Open must not mutate process environment")
}
}
func TestSUMMER_ENVFromProcessSelectsOverlay(t *testing.T) {
dir := writeConfigTree(t, map[string]string{
"app.yaml": "name: base\n",
"env/development/app.yaml": "name: hello-dev\n",
})
cfg, err := Open(Options{
Dir: dir,
Environ: []string{"SUMMER_ENV=development"},
})
if err != nil {
t.Fatalf("Open: %v", err)
}
if cfg.Environment() != "development" {
t.Fatalf("Environment = %q, want development", cfg.Environment())
}
if got := cfg.String("app.name"); got != "hello-dev" {
t.Fatalf("name = %q, want hello-dev", got)
}
}

107
compass/persist.go Normal file
View File

@@ -0,0 +1,107 @@
package compass
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/knadh/koanf/parsers/yaml"
"github.com/knadh/koanf/v2"
)
// Set stores an in-memory override that wins over every file and env layer.
func (c *Config) Set(path string, value any) error {
if c == nil {
return fmt.Errorf("compass: config is nil")
}
if path == "" {
return fmt.Errorf("compass: empty config path")
}
c.mu.Lock()
defer c.mu.Unlock()
if c.runtime == nil {
c.runtime = koanf.New(".")
}
return c.runtime.Set(path, value)
}
// Persist writes runtime overrides to config/env/<env>/overrides.yaml
// using an atomic replace and restrictive file permissions.
func (c *Config) Persist() error {
if c == nil {
return fmt.Errorf("compass: config is nil")
}
c.mu.Lock()
defer c.mu.Unlock()
if c.dir == "" {
return fmt.Errorf("compass: config directory is empty")
}
env, err := sanitizeEnv(c.env)
if err != nil {
return err
}
destDir, dest, err := overridesPath(c.dir, env)
if err != nil {
return err
}
if err := os.MkdirAll(destDir, 0o700); err != nil {
return fmt.Errorf("compass: create overrides dir: %w", err)
}
raw := []byte("{}\n")
if c.runtime != nil {
body, err := c.runtime.Marshal(yaml.Parser())
if err != nil {
return fmt.Errorf("compass: marshal overrides: %w", err)
}
raw = body
}
tmp := dest + ".tmp"
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
return fmt.Errorf("compass: write overrides: %w", err)
}
if err := os.Rename(tmp, dest); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("compass: persist overrides: %w", err)
}
return nil
}
// Reload rebuilds the tree from disk and plugin sources and clears runtime Set.
func (c *Config) Reload() error {
if c == nil {
return fmt.Errorf("compass: config is nil")
}
c.mu.Lock()
defer c.mu.Unlock()
c.runtime = koanf.New(".")
if err := c.refreshSources(); err != nil {
return err
}
return c.rebuild()
}
func overridesPath(dir, env string) (destDir, dest string, err error) {
root, err := filepath.Abs(dir)
if err != nil {
return "", "", fmt.Errorf("compass: resolve config dir: %w", err)
}
root = filepath.Clean(root)
destDir = filepath.Clean(filepath.Join(root, "env", env))
if !within(root, destDir) {
return "", "", fmt.Errorf("compass: persist path escapes config directory")
}
dest = filepath.Clean(filepath.Join(destDir, "overrides.yaml"))
if !within(destDir, dest) {
return "", "", fmt.Errorf("compass: persist path escapes config directory")
}
return destDir, dest, nil
}
func within(root, path string) bool {
rel, err := filepath.Rel(root, path)
if err != nil {
return false
}
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)))
}

117
compass/persist_test.go Normal file
View File

@@ -0,0 +1,117 @@
package compass
import (
"os"
"path/filepath"
"strings"
"testing"
"testing/fstest"
)
func TestSetWinsOverPersistedOverrides(t *testing.T) {
dir := writeConfigTree(t, map[string]string{
"app.yaml": "name: base\n",
"env/development/overrides.yaml": "app:\n name: persisted\n",
})
cfg, err := Open(Options{Dir: dir, Env: "development", Environ: []string{}})
if err != nil {
t.Fatalf("Open: %v", err)
}
if got := cfg.String("app.name"); got != "persisted" {
t.Fatalf("loaded persisted name = %q, want persisted", got)
}
if err := cfg.Set("app.name", "runtime"); err != nil {
t.Fatalf("Set: %v", err)
}
if got := cfg.String("app.name"); got != "runtime" {
t.Fatalf("Set should win, got %q", got)
}
}
func TestPersistWritesEnvOverridesAtomicallyWithRestrictivePerms(t *testing.T) {
dir := writeConfigTree(t, map[string]string{
"app.yaml": "name: base\n",
})
cfg, err := Open(Options{Dir: dir, Env: "development", Environ: []string{}})
if err != nil {
t.Fatalf("Open: %v", err)
}
if err := cfg.Set("app.name", "saved"); err != nil {
t.Fatalf("Set: %v", err)
}
if err := cfg.Persist(); err != nil {
t.Fatalf("Persist: %v", err)
}
path := filepath.Join(dir, "env", "development", "overrides.yaml")
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat overrides: %v", err)
}
if info.Mode().Perm() != 0o600 {
t.Fatalf("overrides perm = %o, want 0600", info.Mode().Perm())
}
body, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(body), "saved") {
t.Fatalf("overrides.yaml = %q, want to contain saved", body)
}
tmp := filepath.Join(dir, "env", "development", "overrides.yaml.tmp")
if _, err := os.Stat(tmp); !os.IsNotExist(err) {
t.Fatalf("temporary persist file should not remain: %v", err)
}
}
func TestReloadClearsSetAndRereadsFiles(t *testing.T) {
dir := writeConfigTree(t, map[string]string{
"app.yaml": "name: base\n",
})
cfg, err := Open(Options{Dir: dir, Env: "development", Environ: []string{}})
if err != nil {
t.Fatalf("Open: %v", err)
}
fsys := fstest.MapFS{
"config/config.yaml": {Data: []byte("posts_per_page: 10\n")},
}
if err := cfg.MergePlugin("golem15.hello", fsys); err != nil {
t.Fatalf("MergePlugin: %v", err)
}
if err := cfg.Set("app.name", "runtime"); err != nil {
t.Fatalf("Set: %v", err)
}
if err := cfg.Persist(); err != nil {
t.Fatalf("Persist: %v", err)
}
if err := cfg.Set("app.name", "runtime2"); err != nil {
t.Fatalf("Set runtime2: %v", err)
}
if err := cfg.Set("golem15.hello.posts_per_page", 99); err != nil {
t.Fatalf("Set posts: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: disk\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := cfg.Reload(); err != nil {
t.Fatalf("Reload: %v", err)
}
if got := cfg.String("app.name"); got != "runtime" {
t.Fatalf("after Reload, name = %q, want persisted runtime (Set cleared)", got)
}
if got := cfg.Int("golem15.hello.posts_per_page"); got != 10 {
t.Fatalf("after Reload, posts_per_page = %d, want plugin default 10", got)
}
}
func TestPersistRejectsInvalidEnvName(t *testing.T) {
dir := writeConfigTree(t, map[string]string{
"app.yaml": "name: base\n",
})
_, err := Open(Options{Dir: dir, Env: "../etc", Environ: []string{}})
if err == nil {
t.Fatal("expected invalid environment name to fail")
}
if strings.Contains(strings.ToLower(err.Error()), "etc") {
t.Fatalf("error should not echo unsanitized env path: %v", err)
}
}

View File

@@ -1 +1,2 @@
name: hello-app name: hello-app
debug: false

View File

@@ -0,0 +1,2 @@
name: hello-dev
debug: true

View File

@@ -8,23 +8,30 @@ 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 (
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/knadh/koanf/maps v0.1.2 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect
github.com/knadh/koanf/parsers/yaml v1.1.1 // 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/knadh/koanf/v2 v2.3.6 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/pflag v1.0.9 // indirect github.com/spf13/pflag v1.0.9 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/sys v0.32.0 // indirect
) )
replace ( 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

@@ -1,6 +1,8 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 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 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -9,6 +11,12 @@ github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpb
github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= 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 h1:u70vV5IyaM0HvONh8HoqBC97oTgO33KcpZbTLiKVinU=
github.com/knadh/koanf/parsers/yaml v1.1.1/go.mod h1:HHmcHXUrp9cOPcuC+2wrr44GTUB0EC+PyfN3HZD9tFg= 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 h1:JoQPSJmvS4aP0xNc8xMDr5tcrkSEInL23/Il7pITAKo=
github.com/knadh/koanf/v2 v2.3.6/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= 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 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
@@ -30,6 +38,8 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 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 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 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 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 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -7,18 +7,89 @@ import (
"os/exec" "os/exec"
"strings" "strings"
"testing" "testing"
"git.golem15.com/golem15/summercms/backpack"
"git.golem15.com/golem15/summercms/bonfire"
"git.golem15.com/golem15/summercms/compass"
"git.golem15.com/golem15/summercms/pact"
"git.golem15.com/golem15/summercms/party"
) )
func TestGreeterHelloPrintsAppName(t *testing.T) { func TestGreeterHelloPrintsLayeredConfig(t *testing.T) {
got := runHello(t, nil)
want := "name=hello-app posts_per_page=10 debug=false extra=hello-from-optional events=ok collected=greeter handled=true"
if got != want {
t.Fatalf("output = %q, want %q", got, want)
}
}
func TestGreeterHelloSeesDevelopmentOverlay(t *testing.T) {
got := runHello(t, map[string]string{"SUMMER_ENV": "development"})
want := "name=hello-dev posts_per_page=10 debug=true extra=hello-from-optional events=ok collected=greeter handled=true"
if got != want {
t.Fatalf("output = %q, want %q", got, want)
}
}
func TestGreeterHelloSeesEnvVarOverrideOfPluginDefault(t *testing.T) {
got := runHello(t, map[string]string{
"SUMMER_GOLEM15__HELLO__POSTS_PER_PAGE": "25",
})
want := "name=hello-app posts_per_page=25 debug=false extra=hello-from-optional events=ok collected=greeter handled=true"
if got != want {
t.Fatalf("output = %q, want %q", got, want)
}
}
func runHello(t *testing.T, env map[string]string) string {
t.Helper()
for k, v := range env {
t.Setenv(k, v)
}
var buf bytes.Buffer var buf bytes.Buffer
if err := run([]string{"greeter:hello"}, &buf); err != nil { if err := run([]string{"greeter:hello"}, &buf); err != nil {
t.Fatalf("greeter:hello: %v", err) t.Fatalf("greeter:hello: %v", err)
} }
if got := strings.TrimSpace(buf.String()); got != "hello-app" { return strings.TrimSpace(buf.String())
t.Fatalf("output = %q, want %q", got, "hello-app") }
func TestGreeterRunsWithoutOptionalPlugin(t *testing.T) {
got := runHelloIDs(t, []string{"golem15.hello", "golem15.greeter"}, nil)
want := "name=hello-app posts_per_page=10 debug=false extra= events=ok collected=greeter handled=true"
if got != want {
t.Fatalf("output = %q, want %q", got, want)
} }
} }
func runHelloIDs(t *testing.T, ids []string, env map[string]string) string {
t.Helper()
for k, v := range env {
t.Setenv(k, v)
}
cfg, err := compass.Load("config")
if err != nil {
t.Fatalf("Load: %v", err)
}
app := backpack.New(cfg)
plugins, err := party.Activate(app, ids)
if err != nil {
t.Fatalf("Activate: %v", err)
}
var commands []bonfire.Command
for _, plugin := range plugins {
if hasCommands, ok := plugin.(pact.HasCommands); ok {
commands = append(commands, hasCommands.Commands()...)
}
}
var buf bytes.Buffer
root := bonfire.NewRoot("hello", commands, &buf)
root.SetArgs([]string{"greeter:hello"})
if err := root.Execute(); err != nil {
t.Fatalf("execute: %v", err)
}
return strings.TrimSpace(buf.String())
}
func TestBuiltBinaryGreeterHello(t *testing.T) { func TestBuiltBinaryGreeterHello(t *testing.T) {
first := runSummerBuild(t) first := runSummerBuild(t)
h1 := generatedHash(t) h1 := generatedHash(t)
@@ -33,8 +104,8 @@ func TestBuiltBinaryGreeterHello(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("bin/hello greeter:hello: %v\n%s", err, out) t.Fatalf("bin/hello greeter:hello: %v\n%s", err, out)
} }
if got := strings.TrimSpace(string(out)); got != "hello-app" { if got := strings.TrimSpace(string(out)); got != "name=hello-app posts_per_page=10 debug=false extra=hello-from-optional events=ok collected=greeter handled=true" {
t.Fatalf("output = %q, want %q", got, "hello-app") t.Fatalf("output = %q, want layered production config with optional extra and events", got)
} }
} }

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

@@ -0,0 +1 @@
posts_per_page: 10

View File

@@ -7,13 +7,21 @@ toolchain go1.27.0
require git.golem15.com/golem15/summercms v0.0.0 require git.golem15.com/golem15/summercms v0.0.0
require ( require (
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.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/maps v0.1.2 // indirect
github.com/knadh/koanf/parsers/yaml v1.1.1 // 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/knadh/koanf/v2 v2.3.6 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // 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 go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/sys v0.32.0 // indirect
) )
replace git.golem15.com/golem15/summercms => ../../../.. replace git.golem15.com/golem15/summercms => ../../../..

View File

@@ -1,11 +1,22 @@
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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 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 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= 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 h1:u70vV5IyaM0HvONh8HoqBC97oTgO33KcpZbTLiKVinU=
github.com/knadh/koanf/parsers/yaml v1.1.1/go.mod h1:HHmcHXUrp9cOPcuC+2wrr44GTUB0EC+PyfN3HZD9tFg= 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 h1:JoQPSJmvS4aP0xNc8xMDr5tcrkSEInL23/Il7pITAKo=
github.com/knadh/koanf/v2 v2.3.6/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= 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 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
@@ -18,10 +29,17 @@ github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zx
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 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 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 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 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 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -1,10 +1,19 @@
package base package base
import ( import (
"embed"
"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
var configFS embed.FS
// Plugin is the golem15.hello plugin. // Plugin is the golem15.hello plugin.
type Plugin struct{} type Plugin struct{}
@@ -14,6 +23,8 @@ func (p *Plugin) Requires() []string { return nil }
func (p *Plugin) Register(*backpack.App) error { return nil } func (p *Plugin) Register(*backpack.App) error { return nil }
func (p *Plugin) Boot(*backpack.App) error { return nil } func (p *Plugin) Boot(*backpack.App) error { return nil }
func (p *Plugin) ConfigFS() fs.FS { return configFS }
func init() { func init() {
party.Register(&Plugin{}) party.Register(&Plugin{})
} }

View File

@@ -7,16 +7,21 @@ toolchain go1.27.0
require git.golem15.com/golem15/summercms v0.0.0 require git.golem15.com/golem15/summercms v0.0.0
require ( require (
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/knadh/koanf/maps v0.1.2 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect
github.com/knadh/koanf/parsers/yaml v1.1.1 // 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/knadh/koanf/v2 v2.3.6 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/pflag v1.0.9 // indirect github.com/spf13/pflag v1.0.9 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/sys v0.32.0 // indirect
) )
replace git.golem15.com/golem15/summercms => ../../../.. replace git.golem15.com/golem15/summercms => ../../../..

View File

@@ -1,6 +1,8 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 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 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -9,6 +11,12 @@ github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpb
github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= 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 h1:u70vV5IyaM0HvONh8HoqBC97oTgO33KcpZbTLiKVinU=
github.com/knadh/koanf/parsers/yaml v1.1.1/go.mod h1:HHmcHXUrp9cOPcuC+2wrr44GTUB0EC+PyfN3HZD9tFg= 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 h1:JoQPSJmvS4aP0xNc8xMDr5tcrkSEInL23/Il7pITAKo=
github.com/knadh/koanf/v2 v2.3.6/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= 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 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
@@ -30,6 +38,8 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 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 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 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 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 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -5,12 +5,19 @@ 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/festival"
"git.golem15.com/golem15/summercms/pact"
"git.golem15.com/golem15/summercms/party" "git.golem15.com/golem15/summercms/party"
"git.golem15.com/golem15/summercms/towel"
) )
var _ festival.Collectable = (*HelloEvent)(nil)
var _ festival.Handleable = (*HelloEvent)(nil)
// 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 +27,25 @@ 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()
}
}
if app != nil && app.Events != nil {
app.Events.Listen[*HelloEvent]("golem15.greeter", func(ctx context.Context, e *HelloEvent) error {
if e.data == nil {
e.data = map[string]any{}
}
e.data["source"] = "greeter"
if actor, ok := towel.Actor(ctx); ok {
e.data["actor"] = actor
}
e.handled = true
return nil
})
}
return nil return nil
} }
@@ -29,15 +55,59 @@ func (p *Plugin) Commands() []bonfire.Command {
Description: "Print the configured application name", Description: "Print the configured application name",
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
name := "" name := ""
posts := 0
debug := false
if p.app != nil && p.app.Config != nil { if p.app != nil && p.app.Config != nil {
name = p.app.Config.String("app.name") name = p.app.Config.String("app.name")
posts = p.app.Config.Int("golem15.hello.posts_per_page")
debug = p.app.Config.Bool("app.debug")
} }
out.Println(name) events := "ok"
collected := ""
handled := false
if p.app != nil && p.app.Events != nil {
if err := p.app.Events.Fire(ctx, &HelloEvent{}); err != nil {
events = "err"
}
payload, err := p.app.Events.Collect(ctx, &HelloEvent{})
if err != nil {
events = "err"
}
if src, ok := payload["source"].(string); ok {
collected = src
}
ok, err := p.app.Events.UntilHandled(ctx, &HelloEvent{})
if err != nil {
events = "err"
}
handled = ok
}
out.Printf("name=%s posts_per_page=%d debug=%t extra=%s events=%s collected=%s handled=%t\n", name, posts, debug, p.extra, events, collected, handled)
return nil return nil
}, },
}} }}
} }
// HelloEvent is a typed greeter event used to demonstrate Fire, Collect
// and UntilHandled on the app bus.
type HelloEvent struct {
data map[string]any
handled bool
}
func (e *HelloEvent) Collected() map[string]any {
if e == nil {
return nil
}
return e.data
}
func (e *HelloEvent) IsHandled() bool {
return e != nil && e.handled
}
var _ pact.HasCommands = (*Plugin)(nil)
func init() { func init() {
party.Register(&Plugin{}) party.Register(&Plugin{})
} }

View File

@@ -0,0 +1,86 @@
package greeter
import (
"bytes"
"context"
"fmt"
"io"
"os"
"strings"
"testing"
"git.golem15.com/golem15/summercms/backpack"
"git.golem15.com/golem15/summercms/bonfire"
"git.golem15.com/golem15/summercms/compass"
"git.golem15.com/golem15/summercms/pact"
)
type testOut struct{ bytes.Buffer }
func (o *testOut) Write(p []byte) (int, error) { return o.Buffer.Write(p) }
func (o *testOut) Println(a ...any) { fmt.Fprintln(&o.Buffer, a...) }
func (o *testOut) Printf(format string, a ...any) {
fmt.Fprintf(&o.Buffer, format, a...)
}
var _ bonfire.Output = (*testOut)(nil)
var _ io.Writer = (*testOut)(nil)
type extraMsg struct{ msg string }
func (e extraMsg) Message() string { return e.msg }
func TestBootSkipsAbsentOptionalPlugin(t *testing.T) {
app := backpack.New(&compass.Config{})
app.SetPlugins([]string{"golem15.hello", "golem15.greeter"})
p := &Plugin{}
if err := p.Boot(app); err != nil {
t.Fatalf("Boot: %v", err)
}
out := runHelloCmd(t, p)
if strings.Contains(out, "hello-from-optional") {
t.Fatalf("absent optional leaked into output: %q", out)
}
if !strings.Contains(out, "extra=") {
t.Fatalf("output %q should include extra=", out)
}
}
func TestBootUsesPublishedOptionalService(t *testing.T) {
app := backpack.New(&compass.Config{})
app.SetPlugins([]string{"golem15.hello", "golem15.greeter", "golem15.optional"})
if err := app.Publish[pact.OptionalMessage](extraMsg{msg: "hello-from-optional"}); err != nil {
t.Fatalf("Publish: %v", err)
}
p := &Plugin{}
if err := p.Boot(app); err != nil {
t.Fatalf("Boot: %v", err)
}
out := runHelloCmd(t, p)
if !strings.Contains(out, "extra=hello-from-optional") {
t.Fatalf("output = %q, want extra=hello-from-optional", out)
}
}
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()
if len(cmds) == 0 {
t.Fatal("no commands")
}
var out testOut
if err := cmds[0].Run(context.Background(), nil, &out); err != nil {
t.Fatalf("Run: %v", err)
}
return strings.TrimSpace(out.String())
}

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

149
festival/bus.go Normal file
View File

@@ -0,0 +1,149 @@
package festival
import (
"context"
"errors"
"fmt"
"reflect"
"sort"
"sync"
)
// Collectable is implemented by events that expose a mergeable Collect payload.
type Collectable interface {
Collected() map[string]any
}
// Handleable is implemented by events that can stop UntilHandled.
type Handleable interface {
IsHandled() bool
}
type listener struct {
owner string
priority int
order int
fn func(context.Context, any) error
}
// Bus is an app-owned typed event dispatcher. Dispatch is synchronous on
// the caller goroutine.
type Bus struct {
mu sync.Mutex
seq int
byType map[reflect.Type][]listener
}
// New returns an empty bus.
func New() *Bus {
return &Bus{byType: make(map[reflect.Type][]listener)}
}
// Listen registers fn at priority 0.
func (b *Bus) Listen[T any](owner string, fn func(context.Context, T) error) {
b.ListenPriority(owner, 0, fn)
}
// ListenPriority registers fn. Higher priority runs first; equal priority
// keeps registration order.
func (b *Bus) ListenPriority[T any](owner string, priority int, fn func(context.Context, T) error) {
if b == nil || fn == nil {
return
}
key := reflect.TypeFor[T]()
b.mu.Lock()
defer b.mu.Unlock()
if b.byType == nil {
b.byType = make(map[reflect.Type][]listener)
}
b.seq++
b.byType[key] = append(b.byType[key], listener{
owner: owner,
priority: priority,
order: b.seq,
fn: func(ctx context.Context, event any) error {
v, ok := event.(T)
if !ok {
return nil
}
return fn(ctx, v)
},
})
}
// Fire invokes every listener and returns errors.Join of their errors.
func (b *Bus) Fire[T any](ctx context.Context, event T) error {
var errs []error
for _, l := range b.snapshot[T]() {
if err := invoke(l, ctx, event); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
// Collect runs every listener, merges Collected() maps (later wins on
// duplicate keys), and returns the partial payload with joined errors.
func (b *Bus) Collect[T any](ctx context.Context, event T) (map[string]any, error) {
merged := map[string]any{}
var errs []error
for _, l := range b.snapshot[T]() {
if err := invoke(l, ctx, event); err != nil {
errs = append(errs, err)
}
mergeCollected(merged, event)
}
return merged, errors.Join(errs...)
}
// UntilHandled stops at the first handled event or the first error.
func (b *Bus) UntilHandled[T any](ctx context.Context, event T) (bool, error) {
for _, l := range b.snapshot[T]() {
if err := invoke(l, ctx, event); err != nil {
return false, err
}
if h, ok := any(event).(Handleable); ok && h.IsHandled() {
return true, nil
}
}
return false, nil
}
func (b *Bus) snapshot[T any]() []listener {
if b == nil {
return nil
}
key := reflect.TypeFor[T]()
b.mu.Lock()
list := append([]listener(nil), b.byType[key]...)
b.mu.Unlock()
sort.SliceStable(list, func(i, j int) bool {
if list[i].priority != list[j].priority {
return list[i].priority > list[j].priority
}
return list[i].order < list[j].order
})
return list
}
func invoke(l listener, ctx context.Context, event any) (err error) {
defer func() {
if rec := recover(); rec != nil {
err = fmt.Errorf("festival: plugin %s panicked", l.owner)
}
}()
if ctx == nil {
ctx = context.Background()
}
return l.fn(ctx, event)
}
func mergeCollected(dst map[string]any, event any) {
c, ok := any(event).(Collectable)
if !ok {
return
}
for k, v := range c.Collected() {
dst[k] = v
}
}

166
festival/bus_test.go Normal file
View File

@@ -0,0 +1,166 @@
package festival
import (
"context"
"errors"
"strings"
"testing"
)
type evt struct {
data map[string]any
handled bool
}
func (e *evt) Collected() map[string]any { return e.data }
func (e *evt) IsHandled() bool { return e.handled }
func TestFireRunsAllListenersAndJoinsErrors(t *testing.T) {
bus := New()
var order []string
bus.Listen[*evt]("golem15.a", func(ctx context.Context, e *evt) error {
order = append(order, "a")
return errors.New("err-a")
})
bus.Listen[*evt]("golem15.b", func(ctx context.Context, e *evt) error {
order = append(order, "b")
return errors.New("err-b")
})
err := bus.Fire(context.Background(), &evt{})
if err == nil {
t.Fatal("expected joined errors")
}
if !strings.Contains(err.Error(), "err-a") || !strings.Contains(err.Error(), "err-b") {
t.Fatalf("joined error = %v", err)
}
if strings.Join(order, ",") != "a,b" {
t.Fatalf("order = %v, want a,b", order)
}
}
func TestCollectMergesPayloadsLaterWinsAndKeepsPartialOnError(t *testing.T) {
bus := New()
bus.Listen[*evt]("golem15.a", func(ctx context.Context, e *evt) error {
e.data = map[string]any{"k": "a", "only": "a"}
return nil
})
bus.Listen[*evt]("golem15.b", func(ctx context.Context, e *evt) error {
e.data = map[string]any{"k": "b"}
return errors.New("collect-b")
})
got, err := bus.Collect(context.Background(), &evt{})
if err == nil || !strings.Contains(err.Error(), "collect-b") {
t.Fatalf("expected collect error, got %v", err)
}
if got["k"] != "b" {
t.Fatalf("later listener should win k, got %v", got)
}
if got["only"] != "a" {
t.Fatalf("partial payload missing only=a: %v", got)
}
}
func TestUntilHandledStopsOnFirstHandled(t *testing.T) {
bus := New()
var order []string
bus.Listen[*evt]("golem15.a", func(ctx context.Context, e *evt) error {
order = append(order, "a")
e.handled = true
return nil
})
bus.Listen[*evt]("golem15.b", func(ctx context.Context, e *evt) error {
order = append(order, "b")
return nil
})
ok, err := bus.UntilHandled(context.Background(), &evt{})
if err != nil {
t.Fatal(err)
}
if !ok {
t.Fatal("expected handled")
}
if strings.Join(order, ",") != "a" {
t.Fatalf("order = %v, want only a", order)
}
}
func TestUntilHandledStopsOnFirstError(t *testing.T) {
bus := New()
var order []string
bus.Listen[*evt]("golem15.a", func(ctx context.Context, e *evt) error {
order = append(order, "a")
return errors.New("stop")
})
bus.Listen[*evt]("golem15.b", func(ctx context.Context, e *evt) error {
order = append(order, "b")
e.handled = true
return nil
})
ok, err := bus.UntilHandled(context.Background(), &evt{})
if err == nil || !strings.Contains(err.Error(), "stop") {
t.Fatalf("expected first error, got ok=%v err=%v", ok, err)
}
if ok {
t.Fatal("error should not report handled")
}
if strings.Join(order, ",") != "a" {
t.Fatalf("order = %v, want only a", order)
}
}
func TestPriorityDescendingStableTies(t *testing.T) {
bus := New()
var order []string
bus.ListenPriority[*evt]("golem15.low", 1, func(ctx context.Context, e *evt) error {
order = append(order, "low")
return nil
})
bus.Listen[*evt]("golem15.default-a", func(ctx context.Context, e *evt) error {
order = append(order, "default-a")
return nil
})
bus.Listen[*evt]("golem15.default-b", func(ctx context.Context, e *evt) error {
order = append(order, "default-b")
return nil
})
bus.ListenPriority[*evt]("golem15.high", 10, func(ctx context.Context, e *evt) error {
order = append(order, "high")
return nil
})
if err := bus.Fire(context.Background(), &evt{}); err != nil {
t.Fatal(err)
}
if strings.Join(order, ",") != "high,low,default-a,default-b" {
t.Fatalf("order = %v", order)
}
}
func TestPanicRecoveredNamesOwnerPlugin(t *testing.T) {
bus := New()
bus.Listen[*evt]("golem15.boom", func(ctx context.Context, e *evt) error {
panic("kapow")
})
err := bus.Fire(context.Background(), &evt{})
if err == nil {
t.Fatal("expected panic error")
}
if !strings.Contains(err.Error(), "golem15.boom") {
t.Fatalf("error should name owner plugin, got %v", err)
}
}
func TestBusesAreIsolated(t *testing.T) {
a := New()
b := New()
called := false
a.Listen[*evt]("golem15.a", func(ctx context.Context, e *evt) error {
called = true
return nil
})
if err := b.Fire(context.Background(), &evt{}); err != nil {
t.Fatal(err)
}
if called {
t.Fatal("bus B should not invoke bus A's listeners")
}
}

5
go.mod
View File

@@ -6,11 +6,15 @@ toolchain go1.27.0
require ( require (
github.com/knadh/koanf/parsers/yaml v1.1.1 github.com/knadh/koanf/parsers/yaml v1.1.1
github.com/knadh/koanf/providers/confmap v1.0.1
github.com/knadh/koanf/providers/env/v2 v2.0.1
github.com/knadh/koanf/providers/file v1.2.1
github.com/knadh/koanf/v2 v2.3.6 github.com/knadh/koanf/v2 v2.3.6
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
) )
require ( require (
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/knadh/koanf/maps v0.1.2 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect
@@ -18,4 +22,5 @@ require (
github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/spf13/pflag v1.0.9 // indirect github.com/spf13/pflag v1.0.9 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/sys v0.32.0 // indirect
) )

10
go.sum
View File

@@ -1,6 +1,8 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 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 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -9,6 +11,12 @@ github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpb
github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= 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 h1:u70vV5IyaM0HvONh8HoqBC97oTgO33KcpZbTLiKVinU=
github.com/knadh/koanf/parsers/yaml v1.1.1/go.mod h1:HHmcHXUrp9cOPcuC+2wrr44GTUB0EC+PyfN3HZD9tFg= 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 h1:JoQPSJmvS4aP0xNc8xMDr5tcrkSEInL23/Il7pITAKo=
github.com/knadh/koanf/v2 v2.3.6/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= 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 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
@@ -30,6 +38,8 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 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 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 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 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 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

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

@@ -6,6 +6,7 @@ import (
"sync" "sync"
"git.golem15.com/golem15/summercms/backpack" "git.golem15.com/golem15/summercms/backpack"
"git.golem15.com/golem15/summercms/pact"
) )
// Plugin is the required descriptor every compiled plugin implements. // Plugin is the required descriptor every compiled plugin implements.
@@ -97,6 +98,19 @@ 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 {
if hc, ok := p.(pact.HasConfig); ok && app.Config != nil {
if err := app.Config.MergePlugin(p.ID(), hc.ConfigFS()); err != nil {
return nil, fmt.Errorf("party: config %s: %w", p.ID(), err)
}
}
}
for _, p := range ordered { for _, p := range ordered {
if err := p.Register(app); err != nil { if err := p.Register(app); err != nil {
return nil, fmt.Errorf("party: register %s: %w", p.ID(), err) return nil, fmt.Errorf("party: register %s: %w", p.ID(), err)

View File

@@ -1,8 +1,12 @@
package party package party
import ( import (
"io/fs"
"os"
"path/filepath"
"strings" "strings"
"testing" "testing"
"testing/fstest"
"git.golem15.com/golem15/summercms/backpack" "git.golem15.com/golem15/summercms/backpack"
"git.golem15.com/golem15/summercms/compass" "git.golem15.com/golem15/summercms/compass"
@@ -27,6 +31,173 @@ func (p *tracePlugin) Boot(*backpack.App) error {
return nil return nil
} }
type configPlugin struct {
id string
fsys fs.FS
got *int
}
func (p *configPlugin) ID() string { return p.id }
func (p *configPlugin) Requires() []string { return nil }
func (p *configPlugin) ConfigFS() fs.FS { return p.fsys }
func (p *configPlugin) Register(app *backpack.App) error {
if app != nil && app.Config != nil {
*p.got = app.Config.Int("golem15.hello.posts_per_page")
}
return nil
}
func (p *configPlugin) Boot(*backpack.App) error { return nil }
func TestActivateMergesHasConfigBeforeRegister(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: app\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Load(dir)
if err != nil {
t.Fatalf("Load: %v", err)
}
var got int
p := &configPlugin{
id: "golem15.hello",
got: &got,
fsys: fstest.MapFS{
"config/config.yaml": {Data: []byte("posts_per_page: 10\n")},
},
}
app := backpack.New(cfg)
if _, err := activate([]Plugin{p}, app, []string{"golem15.hello"}); err != nil {
t.Fatalf("activate: %v", err)
}
if got != 10 {
t.Fatalf("posts_per_page during Register = %d, want 10 (HasConfig must merge before Register)", got)
}
}
func TestActivateMissingRequiresNamesIDsAndSkipsBoot(t *testing.T) {
var log []string
p := &tracePlugin{id: "golem15.greeter", req: []string{"golem15.hello"}, log: &log}
app := backpack.New(&compass.Config{})
_, err := activate([]Plugin{p}, app, []string{"golem15.greeter"})
if err == nil {
t.Fatal("expected missing dependency error")
}
msg := err.Error()
if !strings.Contains(msg, "golem15.greeter") || !strings.Contains(msg, "golem15.hello") {
t.Fatalf("error %q should name both plugin IDs", msg)
}
if strings.Contains(strings.Join(log, ","), "boot") {
t.Fatalf("Boot ran despite missing dependency: %v", log)
}
}
func TestActivateCycleNamesIDsAndSkipsBoot(t *testing.T) {
var log []string
a := &tracePlugin{id: "golem15.a", req: []string{"golem15.b"}, log: &log}
b := &tracePlugin{id: "golem15.b", req: []string{"golem15.a"}, log: &log}
app := backpack.New(&compass.Config{})
_, err := activate([]Plugin{a, b}, app, []string{"golem15.a", "golem15.b"})
if err == nil {
t.Fatal("expected cycle error")
}
msg := err.Error()
if !strings.Contains(msg, "golem15.a") || !strings.Contains(msg, "golem15.b") {
t.Fatalf("error %q should name cycle members", msg)
}
if strings.Contains(strings.Join(log, ","), "boot") || strings.Contains(strings.Join(log, ","), "register") {
t.Fatalf("lifecycle ran despite cycle: %v", log)
}
}
func TestActivateDuplicateIDRejected(t *testing.T) {
var log []string
a := &tracePlugin{id: "golem15.hello", log: &log}
dup := &tracePlugin{id: "golem15.hello", log: &log}
app := backpack.New(&compass.Config{})
_, err := activate([]Plugin{a, dup}, app, []string{"golem15.hello"})
if err == nil {
t.Fatal("expected duplicate id error")
}
if !strings.Contains(err.Error(), "golem15.hello") {
t.Fatalf("error %q should name the duplicate id", err)
}
}
type bootProbe struct {
id string
req []string
log *[]string
sawOpt *bool
booted *[]string
}
func (p *bootProbe) ID() string { return p.id }
func (p *bootProbe) Requires() []string { return p.req }
func (p *bootProbe) Register(*backpack.App) error {
*p.log = append(*p.log, p.id+":register")
return nil
}
func (p *bootProbe) Boot(app *backpack.App) error {
if p.sawOpt != nil && p.id == "golem15.hello" {
*p.sawOpt = app.HasPlugin("golem15.optional")
}
*p.log = append(*p.log, p.id+":boot")
if p.booted != nil {
*p.booted = append(*p.booted, p.id)
}
return nil
}
func TestActivateHasPluginSeesNotYetBootedPlugins(t *testing.T) {
var log []string
var saw bool
hello := &bootProbe{id: "golem15.hello", log: &log, sawOpt: &saw}
opt := &bootProbe{id: "golem15.optional", log: &log}
app := backpack.New(&compass.Config{})
if _, err := activate([]Plugin{opt, hello}, app, []string{"golem15.hello", "golem15.optional"}); err != nil {
t.Fatalf("activate: %v", err)
}
if !saw {
t.Fatal("HasPlugin(golem15.optional) was false during hello Boot; complete set must be visible before Boot")
}
want := []string{
"golem15.hello:register",
"golem15.optional:register",
"golem15.hello:boot",
"golem15.optional:boot",
}
if strings.Join(log, ",") != strings.Join(want, ",") {
t.Fatalf("lifecycle = %v, want %v", log, want)
}
}
func TestActivateIndependentPluginsKeepManifestOrder(t *testing.T) {
var log []string
a := &tracePlugin{id: "golem15.a", log: &log}
b := &tracePlugin{id: "golem15.b", log: &log}
c := &tracePlugin{id: "golem15.c", log: &log}
app := backpack.New(&compass.Config{})
got, err := activate([]Plugin{c, a, b}, app, []string{"golem15.c", "golem15.a", "golem15.b"})
if err != nil {
t.Fatalf("activate: %v", err)
}
if len(got) != 3 || got[0].ID() != "golem15.c" || got[1].ID() != "golem15.a" || got[2].ID() != "golem15.b" {
t.Fatalf("order = %v", idsOf(got))
}
}
func idsOf(plugins []Plugin) []string {
out := make([]string, len(plugins))
for i, p := range plugins {
out[i] = p.ID()
}
return out
}
func TestActivateRunsAllRegisterBeforeAnyBoot(t *testing.T) { func TestActivateRunsAllRegisterBeforeAnyBoot(t *testing.T) {
var log []string var log []string
hello := &tracePlugin{id: "golem15.hello", log: &log} hello := &tracePlugin{id: "golem15.hello", log: &log}

63
towel/context.go Normal file
View File

@@ -0,0 +1,63 @@
package towel
import "context"
type actorKey struct{}
type organizationKey struct{}
type collectionKey struct{}
type localeKey struct{}
func withValue(ctx context.Context, key, value any) context.Context {
if ctx == nil {
ctx = context.Background()
}
return context.WithValue(ctx, key, value)
}
func stringValue(ctx context.Context, key any) (string, bool) {
if ctx == nil {
return "", false
}
v, ok := ctx.Value(key).(string)
return v, ok
}
// WithActor stores the request actor on ctx.
func WithActor(ctx context.Context, actor string) context.Context {
return withValue(ctx, actorKey{}, actor)
}
// Actor returns the request actor from ctx.
func Actor(ctx context.Context) (string, bool) {
return stringValue(ctx, actorKey{})
}
// WithOrganization stores the request organization on ctx.
func WithOrganization(ctx context.Context, org string) context.Context {
return withValue(ctx, organizationKey{}, org)
}
// Organization returns the request organization from ctx.
func Organization(ctx context.Context) (string, bool) {
return stringValue(ctx, organizationKey{})
}
// WithCollection stores the request collection on ctx.
func WithCollection(ctx context.Context, collection string) context.Context {
return withValue(ctx, collectionKey{}, collection)
}
// Collection returns the request collection from ctx.
func Collection(ctx context.Context) (string, bool) {
return stringValue(ctx, collectionKey{})
}
// WithLocale stores the request locale on ctx.
func WithLocale(ctx context.Context, locale string) context.Context {
return withValue(ctx, localeKey{}, locale)
}
// Locale returns the request locale from ctx.
func Locale(ctx context.Context) (string, bool) {
return stringValue(ctx, localeKey{})
}

63
towel/context_test.go Normal file
View File

@@ -0,0 +1,63 @@
package towel
import (
"context"
"os"
"strings"
"testing"
)
func TestContextAccessorsRoundTrip(t *testing.T) {
ctx := context.Background()
ctx = WithActor(ctx, "user-1")
ctx = WithOrganization(ctx, "org-9")
ctx = WithCollection(ctx, "col-3")
ctx = WithLocale(ctx, "pl")
if got, ok := Actor(ctx); !ok || got != "user-1" {
t.Fatalf("Actor = (%q, %v)", got, ok)
}
if got, ok := Organization(ctx); !ok || got != "org-9" {
t.Fatalf("Organization = (%q, %v)", got, ok)
}
if got, ok := Collection(ctx); !ok || got != "col-3" {
t.Fatalf("Collection = (%q, %v)", got, ok)
}
if got, ok := Locale(ctx); !ok || got != "pl" {
t.Fatalf("Locale = (%q, %v)", got, ok)
}
other := context.Background()
if _, ok := Actor(other); ok {
t.Fatal("Actor leaked across contexts")
}
}
func TestNoPackageGlobalRequestState(t *testing.T) {
entries, err := os.ReadDir(".")
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
continue
}
src, err := os.ReadFile(name)
if err != nil {
t.Fatal(err)
}
// Reject package-level request-state variables. Type keys are ok.
for _, line := range strings.Split(string(src), "\n") {
trim := strings.TrimSpace(line)
if strings.HasPrefix(trim, "var ") && !strings.Contains(trim, "struct{}") && !strings.Contains(trim, "key") {
if strings.Contains(strings.ToLower(trim), "actor") ||
strings.Contains(strings.ToLower(trim), "locale") ||
strings.Contains(strings.ToLower(trim), "organization") ||
strings.Contains(strings.ToLower(trim), "collection") {
t.Fatalf("package-global request state in %s: %s", name, trim)
}
}
}
}
}