chore: merge executor worktree (worktree-agent-01-02)
This commit is contained in:
173
.planning/phases/01-framework-kernel-foundation/01-02-SUMMARY.md
Normal file
173
.planning/phases/01-framework-kernel-foundation/01-02-SUMMARY.md
Normal 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*
|
||||
@@ -1,13 +1,74 @@
|
||||
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.
|
||||
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.
|
||||
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]()
|
||||
}
|
||||
|
||||
@@ -7,8 +7,21 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"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) {
|
||||
entries, err := os.ReadDir(".")
|
||||
if err != nil {
|
||||
|
||||
60
backpack/services.go
Normal file
60
backpack/services.go
Normal 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
72
backpack/services_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -2,62 +2,348 @@ package compass
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/knadh/koanf/parsers/yaml"
|
||||
"github.com/knadh/koanf/providers/confmap"
|
||||
"github.com/knadh/koanf/providers/file"
|
||||
"github.com/knadh/koanf/v2"
|
||||
)
|
||||
|
||||
// Config is a YAML configuration tree with dot-path access.
|
||||
type Config struct {
|
||||
k *koanf.Koanf
|
||||
// Options controls how configuration is loaded.
|
||||
type Options struct {
|
||||
// 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
|
||||
// the top-level section, so config/app.yaml key name is app.name.
|
||||
type pluginFS struct {
|
||||
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) {
|
||||
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(".")
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("compass: read config dir %s: %w", dir, err)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("compass: read config dir: %w", err)
|
||||
}
|
||||
parser := yaml.Parser()
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
section, ok := sectionName(name)
|
||||
if !ok {
|
||||
if skipOverrides && isOverridesFile(name) {
|
||||
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)
|
||||
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 {
|
||||
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)
|
||||
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 {
|
||||
parsed = map[string]any{}
|
||||
}
|
||||
if err := k.Set(section, parsed); err != nil {
|
||||
return nil, fmt.Errorf("compass: set section %s: %w", section, err)
|
||||
tmp := koanf.New(".")
|
||||
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 (c *Config) String(path string) string {
|
||||
if c == nil || c.k == nil {
|
||||
return ""
|
||||
func pluginMergePath(id, path string) string {
|
||||
rel := filepath.ToSlash(path)
|
||||
base := filepath.Base(rel)
|
||||
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) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
func TestLoadStringFromAppSection(t *testing.T) {
|
||||
@@ -21,3 +22,222 @@ func TestLoadStringFromAppSection(t *testing.T) {
|
||||
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", §ion); 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
124
compass/env.go
Normal 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
120
compass/env_test.go
Normal 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
107
compass/persist.go
Normal 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
117
compass/persist_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
name: hello-app
|
||||
debug: false
|
||||
|
||||
2
examples/hello/config/env/development/app.yaml
vendored
Normal file
2
examples/hello/config/env/development/app.yaml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
name: hello-dev
|
||||
debug: true
|
||||
@@ -8,23 +8,30 @@ require (
|
||||
git.golem15.com/golem15/summercms v0.0.0
|
||||
git.golem15.com/golem15/summercms/examples/hello/plugins/base v0.0.0
|
||||
git.golem15.com/golem15/summercms/examples/hello/plugins/greeter v0.0.0
|
||||
git.golem15.com/golem15/summercms/examples/hello/plugins/optional v0.0.0
|
||||
)
|
||||
|
||||
require (
|
||||
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 => ../..
|
||||
git.golem15.com/golem15/summercms/examples/hello/plugins/base => ./plugins/base
|
||||
git.golem15.com/golem15/summercms/examples/hello/plugins/greeter => ./plugins/greeter
|
||||
git.golem15.com/golem15/summercms/examples/hello/plugins/optional => ./plugins/optional
|
||||
)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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=
|
||||
@@ -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/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=
|
||||
@@ -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=
|
||||
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=
|
||||
|
||||
@@ -7,18 +7,89 @@ import (
|
||||
"os/exec"
|
||||
"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"
|
||||
"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
|
||||
if err := run([]string{"greeter:hello"}, &buf); err != nil {
|
||||
t.Fatalf("greeter:hello: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(buf.String()); got != "hello-app" {
|
||||
t.Fatalf("output = %q, want %q", got, "hello-app")
|
||||
return strings.TrimSpace(buf.String())
|
||||
}
|
||||
|
||||
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) {
|
||||
first := runSummerBuild(t)
|
||||
h1 := generatedHash(t)
|
||||
@@ -33,8 +104,8 @@ func TestBuiltBinaryGreeterHello(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("bin/hello greeter:hello: %v\n%s", err, out)
|
||||
}
|
||||
if got := strings.TrimSpace(string(out)); got != "hello-app" {
|
||||
t.Fatalf("output = %q, want %q", 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 layered production config with optional extra and events", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,10 +5,12 @@ package main
|
||||
import (
|
||||
_ "git.golem15.com/golem15/summercms/examples/hello/plugins/base"
|
||||
_ "git.golem15.com/golem15/summercms/examples/hello/plugins/greeter"
|
||||
_ "git.golem15.com/golem15/summercms/examples/hello/plugins/optional"
|
||||
)
|
||||
|
||||
// PluginIDs is the ordered plugin list from summer.yaml.
|
||||
var PluginIDs = []string{
|
||||
"golem15.hello",
|
||||
"golem15.greeter",
|
||||
"golem15.optional",
|
||||
}
|
||||
|
||||
1
examples/hello/plugins/base/config/config.yaml
Normal file
1
examples/hello/plugins/base/config/config.yaml
Normal file
@@ -0,0 +1 @@
|
||||
posts_per_page: 10
|
||||
@@ -7,13 +7,21 @@ 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 => ../../../..
|
||||
|
||||
@@ -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/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=
|
||||
@@ -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/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=
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
package base
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
|
||||
"git.golem15.com/golem15/summercms/backpack"
|
||||
"git.golem15.com/golem15/summercms/pact"
|
||||
"git.golem15.com/golem15/summercms/party"
|
||||
)
|
||||
|
||||
var _ pact.HasConfig = (*Plugin)(nil)
|
||||
|
||||
//go:embed config
|
||||
var configFS embed.FS
|
||||
|
||||
// Plugin is the golem15.hello plugin.
|
||||
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) Boot(*backpack.App) error { return nil }
|
||||
|
||||
func (p *Plugin) ConfigFS() fs.FS { return configFS }
|
||||
|
||||
func init() {
|
||||
party.Register(&Plugin{})
|
||||
}
|
||||
|
||||
@@ -7,16 +7,21 @@ 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 => ../../../..
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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=
|
||||
@@ -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/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=
|
||||
@@ -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=
|
||||
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=
|
||||
|
||||
@@ -5,12 +5,19 @@ import (
|
||||
|
||||
"git.golem15.com/golem15/summercms/backpack"
|
||||
"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/towel"
|
||||
)
|
||||
|
||||
var _ festival.Collectable = (*HelloEvent)(nil)
|
||||
var _ festival.Handleable = (*HelloEvent)(nil)
|
||||
|
||||
// Plugin is the golem15.greeter plugin. It requires golem15.hello.
|
||||
type Plugin struct {
|
||||
app *backpack.App
|
||||
app *backpack.App
|
||||
extra string
|
||||
}
|
||||
|
||||
func (p *Plugin) ID() string { return "golem15.greeter" }
|
||||
@@ -20,6 +27,25 @@ func (p *Plugin) Register(*backpack.App) error { return nil }
|
||||
|
||||
func (p *Plugin) Boot(app *backpack.App) error {
|
||||
p.app = app
|
||||
p.extra = ""
|
||||
if app != nil && app.HasPlugin("golem15.optional") {
|
||||
if msg, ok := app.Lookup[pact.OptionalMessage](); ok {
|
||||
p.extra = msg.Message()
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -29,15 +55,59 @@ func (p *Plugin) Commands() []bonfire.Command {
|
||||
Description: "Print the configured application name",
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
name := ""
|
||||
posts := 0
|
||||
debug := false
|
||||
if p.app != nil && p.app.Config != nil {
|
||||
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
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
// 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() {
|
||||
party.Register(&Plugin{})
|
||||
}
|
||||
|
||||
86
examples/hello/plugins/greeter/plugin_test.go
Normal file
86
examples/hello/plugins/greeter/plugin_test.go
Normal 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())
|
||||
}
|
||||
1
examples/hello/plugins/optional/config/config.yaml
Normal file
1
examples/hello/plugins/optional/config/config.yaml
Normal file
@@ -0,0 +1 @@
|
||||
enabled: true
|
||||
27
examples/hello/plugins/optional/go.mod
Normal file
27
examples/hello/plugins/optional/go.mod
Normal 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 => ../../../..
|
||||
47
examples/hello/plugins/optional/go.sum
Normal file
47
examples/hello/plugins/optional/go.sum
Normal 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=
|
||||
43
examples/hello/plugins/optional/plugin.go
Normal file
43
examples/hello/plugins/optional/plugin.go
Normal 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{})
|
||||
}
|
||||
@@ -5,3 +5,5 @@ plugins:
|
||||
module: git.golem15.com/golem15/summercms/examples/hello/plugins/base
|
||||
- id: golem15.greeter
|
||||
module: git.golem15.com/golem15/summercms/examples/hello/plugins/greeter
|
||||
- id: golem15.optional
|
||||
module: git.golem15.com/golem15/summercms/examples/hello/plugins/optional
|
||||
|
||||
149
festival/bus.go
Normal file
149
festival/bus.go
Normal 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
166
festival/bus_test.go
Normal 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
5
go.mod
@@ -6,11 +6,15 @@ toolchain go1.27.0
|
||||
|
||||
require (
|
||||
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/spf13/cobra v1.10.2
|
||||
)
|
||||
|
||||
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
|
||||
@@ -18,4 +22,5 @@ require (
|
||||
github.com/mitchellh/reflectwalk v1.0.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
|
||||
)
|
||||
|
||||
10
go.sum
10
go.sum
@@ -1,6 +1,8 @@
|
||||
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=
|
||||
@@ -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/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=
|
||||
@@ -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=
|
||||
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=
|
||||
|
||||
1
go.work
1
go.work
@@ -7,4 +7,5 @@ use (
|
||||
./examples/hello
|
||||
./examples/hello/plugins/base
|
||||
./examples/hello/plugins/greeter
|
||||
./examples/hello/plugins/optional
|
||||
)
|
||||
|
||||
@@ -16,7 +16,7 @@ func TestLoadHelloManifest(t *testing.T) {
|
||||
if m.Binary != "hello" {
|
||||
t.Fatalf("binary = %q", m.Binary)
|
||||
}
|
||||
if len(m.Plugins) != 2 || m.Plugins[0].ID != "golem15.hello" || m.Plugins[1].ID != "golem15.greeter" {
|
||||
if len(m.Plugins) != 3 || m.Plugins[0].ID != "golem15.hello" || m.Plugins[1].ID != "golem15.greeter" || m.Plugins[2].ID != "golem15.optional" {
|
||||
t.Fatalf("plugins = %+v", m.Plugins)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,16 @@ type HasConfig interface {
|
||||
ConfigFS() fs.FS
|
||||
}
|
||||
|
||||
// Reserved KERN-03 capability names, declared when their first consumers exist:
|
||||
// OptionalMessage is a service an optional plugin may publish so other
|
||||
// plugins can integrate without importing that plugin's package.
|
||||
type OptionalMessage interface {
|
||||
Message() string
|
||||
}
|
||||
|
||||
// Future KERN-03 capability families are type-asserted when their first
|
||||
// consumer packages exist. Method payloads are not declared here so pact
|
||||
// does not import surf, lagoon, festival, or other packages that Phase 1
|
||||
// does not yet ship:
|
||||
//
|
||||
// HasModels
|
||||
// HasMigrations
|
||||
@@ -32,3 +41,6 @@ type HasConfig interface {
|
||||
// HasSchedule
|
||||
// HasMailTemplates
|
||||
// HasLang
|
||||
//
|
||||
// Phase 1 type-asserts only HasConfig (party, before Register) and
|
||||
// HasCommands (generated app main, after Boot).
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"sync"
|
||||
|
||||
"git.golem15.com/golem15/summercms/backpack"
|
||||
"git.golem15.com/golem15/summercms/pact"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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 {
|
||||
if err := p.Register(app); err != nil {
|
||||
return nil, fmt.Errorf("party: register %s: %w", p.ID(), err)
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package party
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"git.golem15.com/golem15/summercms/backpack"
|
||||
"git.golem15.com/golem15/summercms/compass"
|
||||
@@ -27,6 +31,173 @@ func (p *tracePlugin) Boot(*backpack.App) error {
|
||||
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) {
|
||||
var log []string
|
||||
hello := &tracePlugin{id: "golem15.hello", log: &log}
|
||||
|
||||
63
towel/context.go
Normal file
63
towel/context.go
Normal 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
63
towel/context_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user