From 255de605b1398755634c6cf7ed364f5b3fe0f70f Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 16 Sep 2026 13:13:02 +0200 Subject: [PATCH 1/7] test(01-02): add failing test for layered compass config - Cover six-level precedence, env mapping, .env, Set/Persist/Reload - Require HasConfig merge before plugin Register - Assert hello command observes overlay and plugin defaults --- compass/config_test.go | 220 +++++++++++++++++++++++++++++++++++ compass/env_test.go | 120 +++++++++++++++++++ compass/persist_test.go | 117 +++++++++++++++++++ examples/hello/hello_test.go | 40 ++++++- party/registry_test.go | 49 ++++++++ 5 files changed, 540 insertions(+), 6 deletions(-) create mode 100644 compass/env_test.go create mode 100644 compass/persist_test.go diff --git a/compass/config_test.go b/compass/config_test.go index 7255f9d..d956498 100644 --- a/compass/config_test.go +++ b/compass/config_test.go @@ -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 +} diff --git a/compass/env_test.go b/compass/env_test.go new file mode 100644 index 0000000..5228005 --- /dev/null +++ b/compass/env_test.go @@ -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) + } +} diff --git a/compass/persist_test.go b/compass/persist_test.go new file mode 100644 index 0000000..e5b5c72 --- /dev/null +++ b/compass/persist_test.go @@ -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) + } +} diff --git a/examples/hello/hello_test.go b/examples/hello/hello_test.go index cf694dd..5bbfcdb 100644 --- a/examples/hello/hello_test.go +++ b/examples/hello/hello_test.go @@ -9,14 +9,42 @@ import ( "testing" ) -func TestGreeterHelloPrintsAppName(t *testing.T) { +func TestGreeterHelloPrintsLayeredConfig(t *testing.T) { + got := runHello(t, nil) + want := "name=hello-app posts_per_page=10 debug=false" + 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" + 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" + 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 TestBuiltBinaryGreeterHello(t *testing.T) { @@ -33,8 +61,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" { + t.Fatalf("output = %q, want layered production config", got) } } diff --git a/party/registry_test.go b/party/registry_test.go index d928fc6..f914571 100644 --- a/party/registry_test.go +++ b/party/registry_test.go @@ -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,51 @@ 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 TestActivateRunsAllRegisterBeforeAnyBoot(t *testing.T) { var log []string hello := &tracePlugin{id: "golem15.hello", log: &log} From cc44a1fa49711d14a83fcfd6d9ce9f398f4fc253 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 16 Sep 2026 13:17:13 +0200 Subject: [PATCH 2/7] feat(01-02): implement layered compass config - Merge plugin defaults, sorted YAML, SUMMER_ env, overrides and Set - Persist atomically to env overrides; Reload clears runtime Set - Discover HasConfig before Register and print layered hello values --- compass/config.go | 326 ++++++++++++++++-- compass/env.go | 124 +++++++ compass/persist.go | 107 ++++++ examples/hello/config/app.yaml | 1 + .../hello/config/env/development/app.yaml | 2 + examples/hello/go.mod | 5 + examples/hello/go.sum | 10 + .../hello/plugins/base/config/config.yaml | 1 + examples/hello/plugins/base/go.mod | 8 + examples/hello/plugins/base/go.sum | 18 + examples/hello/plugins/base/plugin.go | 8 + examples/hello/plugins/greeter/go.mod | 5 + examples/hello/plugins/greeter/go.sum | 10 + examples/hello/plugins/greeter/plugin.go | 6 +- go.mod | 5 + go.sum | 10 + party/registry.go | 8 + 17 files changed, 633 insertions(+), 21 deletions(-) create mode 100644 compass/env.go create mode 100644 compass/persist.go create mode 100644 examples/hello/config/env/development/app.yaml create mode 100644 examples/hello/plugins/base/config/config.yaml diff --git a/compass/config.go b/compass/config.go index 31c5cf8..ed180dc 100644 --- a/compass/config.go +++ b/compass/config.go @@ -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/
.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 .. +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) { diff --git a/compass/env.go b/compass/env.go new file mode 100644 index 0000000..68afeda --- /dev/null +++ b/compass/env.go @@ -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 +} diff --git a/compass/persist.go b/compass/persist.go new file mode 100644 index 0000000..a942976 --- /dev/null +++ b/compass/persist.go @@ -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//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))) +} diff --git a/examples/hello/config/app.yaml b/examples/hello/config/app.yaml index a0784e0..f8a6b87 100644 --- a/examples/hello/config/app.yaml +++ b/examples/hello/config/app.yaml @@ -1 +1,2 @@ name: hello-app +debug: false diff --git a/examples/hello/config/env/development/app.yaml b/examples/hello/config/env/development/app.yaml new file mode 100644 index 0000000..8c851ef --- /dev/null +++ b/examples/hello/config/env/development/app.yaml @@ -0,0 +1,2 @@ +name: hello-dev +debug: true diff --git a/examples/hello/go.mod b/examples/hello/go.mod index 738782a..460742b 100644 --- a/examples/hello/go.mod +++ b/examples/hello/go.mod @@ -11,16 +11,21 @@ require ( ) 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 ( diff --git a/examples/hello/go.sum b/examples/hello/go.sum index af22983..ffb529b 100644 --- a/examples/hello/go.sum +++ b/examples/hello/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= diff --git a/examples/hello/plugins/base/config/config.yaml b/examples/hello/plugins/base/config/config.yaml new file mode 100644 index 0000000..14bdebf --- /dev/null +++ b/examples/hello/plugins/base/config/config.yaml @@ -0,0 +1 @@ +posts_per_page: 10 diff --git a/examples/hello/plugins/base/go.mod b/examples/hello/plugins/base/go.mod index bfca136..1089b90 100644 --- a/examples/hello/plugins/base/go.mod +++ b/examples/hello/plugins/base/go.mod @@ -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 => ../../../.. diff --git a/examples/hello/plugins/base/go.sum b/examples/hello/plugins/base/go.sum index 06a36ce..ffb529b 100644 --- a/examples/hello/plugins/base/go.sum +++ b/examples/hello/plugins/base/go.sum @@ -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= diff --git a/examples/hello/plugins/base/plugin.go b/examples/hello/plugins/base/plugin.go index b0a84e7..c39e819 100644 --- a/examples/hello/plugins/base/plugin.go +++ b/examples/hello/plugins/base/plugin.go @@ -1,10 +1,16 @@ package base import ( + "embed" + "io/fs" + "git.golem15.com/golem15/summercms/backpack" "git.golem15.com/golem15/summercms/party" ) +//go:embed config +var configFS embed.FS + // Plugin is the golem15.hello plugin. type Plugin struct{} @@ -14,6 +20,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{}) } diff --git a/examples/hello/plugins/greeter/go.mod b/examples/hello/plugins/greeter/go.mod index dc86573..d009409 100644 --- a/examples/hello/plugins/greeter/go.mod +++ b/examples/hello/plugins/greeter/go.mod @@ -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 => ../../../.. diff --git a/examples/hello/plugins/greeter/go.sum b/examples/hello/plugins/greeter/go.sum index af22983..ffb529b 100644 --- a/examples/hello/plugins/greeter/go.sum +++ b/examples/hello/plugins/greeter/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= diff --git a/examples/hello/plugins/greeter/plugin.go b/examples/hello/plugins/greeter/plugin.go index 683da00..ab28d3c 100644 --- a/examples/hello/plugins/greeter/plugin.go +++ b/examples/hello/plugins/greeter/plugin.go @@ -29,10 +29,14 @@ 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) + out.Printf("name=%s posts_per_page=%d debug=%t\n", name, posts, debug) return nil }, }} diff --git a/go.mod b/go.mod index cd470eb..8824788 100644 --- a/go.mod +++ b/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 ) diff --git a/go.sum b/go.sum index af22983..ffb529b 100644 --- a/go.sum +++ b/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= diff --git a/party/registry.go b/party/registry.go index e035989..8f5abe4 100644 --- a/party/registry.go +++ b/party/registry.go @@ -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,13 @@ func activate(registered []Plugin, app *backpack.App, ids []string) ([]Plugin, e return nil, err } + 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) From 5f2291849d41786661d874578c0ba3e162912d47 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 16 Sep 2026 13:20:53 +0200 Subject: [PATCH 3/7] test(01-02): add failing test for optional plugin services - Cover typed Publish/Lookup, duplicate providers and HasPlugin - Require missing/cycle errors to name plugin IDs before Boot - Assert greeter works with and without an optional extra --- backpack/services_test.go | 72 +++++++++++ examples/hello/hello_test.go | 53 +++++++- examples/hello/plugins/greeter/plugin_test.go | 75 +++++++++++ party/registry_test.go | 122 ++++++++++++++++++ 4 files changed, 317 insertions(+), 5 deletions(-) create mode 100644 backpack/services_test.go create mode 100644 examples/hello/plugins/greeter/plugin_test.go diff --git a/backpack/services_test.go b/backpack/services_test.go new file mode 100644 index 0000000..9daa5be --- /dev/null +++ b/backpack/services_test.go @@ -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") + } +} diff --git a/examples/hello/hello_test.go b/examples/hello/hello_test.go index 5bbfcdb..8672f2b 100644 --- a/examples/hello/hello_test.go +++ b/examples/hello/hello_test.go @@ -7,11 +7,17 @@ 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 TestGreeterHelloPrintsLayeredConfig(t *testing.T) { got := runHello(t, nil) - want := "name=hello-app posts_per_page=10 debug=false" + want := "name=hello-app posts_per_page=10 debug=false extra=hello-from-optional" if got != want { t.Fatalf("output = %q, want %q", got, want) } @@ -19,7 +25,7 @@ func TestGreeterHelloPrintsLayeredConfig(t *testing.T) { func TestGreeterHelloSeesDevelopmentOverlay(t *testing.T) { got := runHello(t, map[string]string{"SUMMER_ENV": "development"}) - want := "name=hello-dev posts_per_page=10 debug=true" + want := "name=hello-dev posts_per_page=10 debug=true extra=hello-from-optional" if got != want { t.Fatalf("output = %q, want %q", got, want) } @@ -29,7 +35,7 @@ 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" + want := "name=hello-app posts_per_page=25 debug=false extra=hello-from-optional" if got != want { t.Fatalf("output = %q, want %q", got, want) } @@ -47,6 +53,43 @@ func runHello(t *testing.T, env map[string]string) string { 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=" + 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) @@ -61,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 != "name=hello-app posts_per_page=10 debug=false" { - t.Fatalf("output = %q, want layered production config", got) + if got := strings.TrimSpace(string(out)); got != "name=hello-app posts_per_page=10 debug=false extra=hello-from-optional" { + t.Fatalf("output = %q, want layered production config with optional extra", got) } } diff --git a/examples/hello/plugins/greeter/plugin_test.go b/examples/hello/plugins/greeter/plugin_test.go new file mode 100644 index 0000000..f2b6280 --- /dev/null +++ b/examples/hello/plugins/greeter/plugin_test.go @@ -0,0 +1,75 @@ +package greeter + +import ( + "bytes" + "context" + "fmt" + "io" + "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 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()) +} diff --git a/party/registry_test.go b/party/registry_test.go index f914571..21ef950 100644 --- a/party/registry_test.go +++ b/party/registry_test.go @@ -76,6 +76,128 @@ func TestActivateMergesHasConfigBeforeRegister(t *testing.T) { } } +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} From 86969739ae35d8a6c38788e55cef2183d85344c0 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 16 Sep 2026 13:24:30 +0200 Subject: [PATCH 4/7] feat(01-02): add optional plugin services and HasPlugin - App-scoped typed Publish/Lookup with duplicate-provider errors - Set plugin IDs before Register so HasPlugin sees the full set - Greeter uses pact.OptionalMessage without importing optional --- backpack/app.go | 64 ++++++++++++++++++- backpack/services.go | 60 +++++++++++++++++ examples/hello/go.mod | 2 + examples/hello/plugins.gen.go | 2 + examples/hello/plugins/base/plugin.go | 3 + examples/hello/plugins/greeter/plugin.go | 14 +++- examples/hello/plugins/greeter/plugin_test.go | 11 ++++ .../hello/plugins/optional/config/config.yaml | 1 + examples/hello/plugins/optional/go.mod | 27 ++++++++ examples/hello/plugins/optional/go.sum | 47 ++++++++++++++ examples/hello/plugins/optional/plugin.go | 43 +++++++++++++ examples/hello/summer.yaml | 2 + go.work | 1 + internal/build/build_test.go | 2 +- pact/capabilities.go | 14 +++- party/registry.go | 6 ++ party/registry_test.go | 10 +-- 17 files changed, 297 insertions(+), 12 deletions(-) create mode 100644 backpack/services.go create mode 100644 examples/hello/plugins/optional/config/config.yaml create mode 100644 examples/hello/plugins/optional/go.mod create mode 100644 examples/hello/plugins/optional/go.sum create mode 100644 examples/hello/plugins/optional/plugin.go diff --git a/backpack/app.go b/backpack/app.go index dbf96aa..fd1511d 100644 --- a/backpack/app.go +++ b/backpack/app.go @@ -1,13 +1,71 @@ package backpack -import "git.golem15.com/golem15/summercms/compass" +import ( + "fmt" + "sync" + + "git.golem15.com/golem15/summercms/compass" +) // App is the per-instance application container. It must not import party. type App struct { - Config *compass.Config + Config *compass.Config + Services *Registry + + mu sync.RWMutex + plugins map[string]struct{} } // New returns an App that owns cfg. func New(cfg *compass.Config) *App { - return &App{Config: cfg} + return &App{ + Config: cfg, + Services: NewRegistry(), + plugins: make(map[string]struct{}), + } +} + +// SetPlugins records the complete activated plugin set. HasPlugin uses this +// list, including plugins that have not yet Booted. +func (a *App) SetPlugins(ids []string) { + if a == nil { + return + } + next := make(map[string]struct{}, len(ids)) + for _, id := range ids { + if id != "" { + next[id] = struct{}{} + } + } + a.mu.Lock() + a.plugins = next + a.mu.Unlock() +} + +// HasPlugin reports whether id is in the complete registered set. +func (a *App) HasPlugin(id string) bool { + if a == nil { + return false + } + a.mu.RLock() + defer a.mu.RUnlock() + _, ok := a.plugins[id] + return ok +} + +// Publish stores an app-scoped service under T. +func (a *App) Publish[T any](value T) error { + if a == nil || a.Services == nil { + return fmt.Errorf("backpack: app has no service registry") + } + return a.Services.Publish(value) +} + +// Lookup returns the app-scoped service published under T. +func (a *App) Lookup[T any]() (T, bool) { + var zero T + if a == nil || a.Services == nil { + return zero, false + } + return a.Services.Lookup[T]() } diff --git a/backpack/services.go b/backpack/services.go new file mode 100644 index 0000000..b358685 --- /dev/null +++ b/backpack/services.go @@ -0,0 +1,60 @@ +package backpack + +import ( + "fmt" + "reflect" + "sync" +) + +// Registry is an app-scoped typed service catalog. It has no process-global state. +type Registry struct { + mu sync.RWMutex + m map[reflect.Type]any +} + +// NewRegistry returns an empty service registry. +func NewRegistry() *Registry { + return &Registry{m: make(map[reflect.Type]any)} +} + +// Publish stores value under the concrete type argument T. Publishing the +// same T twice is an error. +func (r *Registry) Publish[T any](value T) error { + if r == nil { + return fmt.Errorf("backpack: registry is nil") + } + if any(value) == nil { + return fmt.Errorf("backpack: publish nil service") + } + key := reflect.TypeFor[T]() + r.mu.Lock() + defer r.mu.Unlock() + if r.m == nil { + r.m = make(map[reflect.Type]any) + } + if _, exists := r.m[key]; exists { + return fmt.Errorf("backpack: duplicate provider for %s", key) + } + r.m[key] = value + return nil +} + +// Lookup returns the service published under T, or (zero, false). +func (r *Registry) Lookup[T any]() (T, bool) { + var zero T + if r == nil { + return zero, false + } + key := reflect.TypeFor[T]() + r.mu.RLock() + defer r.mu.RUnlock() + v, ok := r.m[key] + if !ok { + return zero, false + } + got, ok := v.(T) + if !ok { + return zero, false + } + return got, true +} diff --git a/examples/hello/go.mod b/examples/hello/go.mod index 460742b..1e88209 100644 --- a/examples/hello/go.mod +++ b/examples/hello/go.mod @@ -8,6 +8,7 @@ require ( git.golem15.com/golem15/summercms v0.0.0 git.golem15.com/golem15/summercms/examples/hello/plugins/base v0.0.0 git.golem15.com/golem15/summercms/examples/hello/plugins/greeter v0.0.0 + git.golem15.com/golem15/summercms/examples/hello/plugins/optional v0.0.0 ) require ( @@ -32,4 +33,5 @@ replace ( git.golem15.com/golem15/summercms => ../.. git.golem15.com/golem15/summercms/examples/hello/plugins/base => ./plugins/base git.golem15.com/golem15/summercms/examples/hello/plugins/greeter => ./plugins/greeter + git.golem15.com/golem15/summercms/examples/hello/plugins/optional => ./plugins/optional ) diff --git a/examples/hello/plugins.gen.go b/examples/hello/plugins.gen.go index f2a78bc..f90d6ae 100644 --- a/examples/hello/plugins.gen.go +++ b/examples/hello/plugins.gen.go @@ -5,10 +5,12 @@ package main import ( _ "git.golem15.com/golem15/summercms/examples/hello/plugins/base" _ "git.golem15.com/golem15/summercms/examples/hello/plugins/greeter" + _ "git.golem15.com/golem15/summercms/examples/hello/plugins/optional" ) // PluginIDs is the ordered plugin list from summer.yaml. var PluginIDs = []string{ "golem15.hello", "golem15.greeter", + "golem15.optional", } diff --git a/examples/hello/plugins/base/plugin.go b/examples/hello/plugins/base/plugin.go index c39e819..167d225 100644 --- a/examples/hello/plugins/base/plugin.go +++ b/examples/hello/plugins/base/plugin.go @@ -5,9 +5,12 @@ import ( "io/fs" "git.golem15.com/golem15/summercms/backpack" + "git.golem15.com/golem15/summercms/pact" "git.golem15.com/golem15/summercms/party" ) +var _ pact.HasConfig = (*Plugin)(nil) + //go:embed config var configFS embed.FS diff --git a/examples/hello/plugins/greeter/plugin.go b/examples/hello/plugins/greeter/plugin.go index ab28d3c..184a665 100644 --- a/examples/hello/plugins/greeter/plugin.go +++ b/examples/hello/plugins/greeter/plugin.go @@ -5,12 +5,14 @@ import ( "git.golem15.com/golem15/summercms/backpack" "git.golem15.com/golem15/summercms/bonfire" + "git.golem15.com/golem15/summercms/pact" "git.golem15.com/golem15/summercms/party" ) // Plugin is the golem15.greeter plugin. It requires golem15.hello. type Plugin struct { - app *backpack.App + app *backpack.App + extra string } func (p *Plugin) ID() string { return "golem15.greeter" } @@ -20,6 +22,12 @@ func (p *Plugin) Register(*backpack.App) error { return nil } func (p *Plugin) Boot(app *backpack.App) error { p.app = app + p.extra = "" + if app != nil && app.HasPlugin("golem15.optional") { + if msg, ok := app.Lookup[pact.OptionalMessage](); ok { + p.extra = msg.Message() + } + } return nil } @@ -36,12 +44,14 @@ func (p *Plugin) Commands() []bonfire.Command { posts = p.app.Config.Int("golem15.hello.posts_per_page") debug = p.app.Config.Bool("app.debug") } - out.Printf("name=%s posts_per_page=%d debug=%t\n", name, posts, debug) + out.Printf("name=%s posts_per_page=%d debug=%t extra=%s\n", name, posts, debug, p.extra) return nil }, }} } +var _ pact.HasCommands = (*Plugin)(nil) + func init() { party.Register(&Plugin{}) } diff --git a/examples/hello/plugins/greeter/plugin_test.go b/examples/hello/plugins/greeter/plugin_test.go index f2b6280..7bc5e59 100644 --- a/examples/hello/plugins/greeter/plugin_test.go +++ b/examples/hello/plugins/greeter/plugin_test.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "io" + "os" "strings" "testing" @@ -61,6 +62,16 @@ func TestBootUsesPublishedOptionalService(t *testing.T) { } } +func TestGreeterSourceDoesNotImportOptional(t *testing.T) { + body, err := os.ReadFile("plugin.go") + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(body), "plugins/optional") { + t.Fatal("greeter must not import the optional plugin module") + } +} + func runHelloCmd(t *testing.T, p *Plugin) string { t.Helper() cmds := p.Commands() diff --git a/examples/hello/plugins/optional/config/config.yaml b/examples/hello/plugins/optional/config/config.yaml new file mode 100644 index 0000000..d4ca941 --- /dev/null +++ b/examples/hello/plugins/optional/config/config.yaml @@ -0,0 +1 @@ +enabled: true diff --git a/examples/hello/plugins/optional/go.mod b/examples/hello/plugins/optional/go.mod new file mode 100644 index 0000000..2be5e7b --- /dev/null +++ b/examples/hello/plugins/optional/go.mod @@ -0,0 +1,27 @@ +module git.golem15.com/golem15/summercms/examples/hello/plugins/optional + +go 1.27.0 + +toolchain go1.27.0 + +require git.golem15.com/golem15/summercms v0.0.0 + +require ( + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/knadh/koanf/maps v0.1.2 // indirect + github.com/knadh/koanf/parsers/yaml v1.1.1 // indirect + github.com/knadh/koanf/providers/confmap v1.0.1 // indirect + github.com/knadh/koanf/providers/env/v2 v2.0.1 // indirect + github.com/knadh/koanf/providers/file v1.2.1 // indirect + github.com/knadh/koanf/v2 v2.3.6 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.9 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/sys v0.32.0 // indirect +) + +replace git.golem15.com/golem15/summercms => ../../../.. diff --git a/examples/hello/plugins/optional/go.sum b/examples/hello/plugins/optional/go.sum new file mode 100644 index 0000000..ffb529b --- /dev/null +++ b/examples/hello/plugins/optional/go.sum @@ -0,0 +1,47 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= +github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= +github.com/knadh/koanf/parsers/yaml v1.1.1 h1:u70vV5IyaM0HvONh8HoqBC97oTgO33KcpZbTLiKVinU= +github.com/knadh/koanf/parsers/yaml v1.1.1/go.mod h1:HHmcHXUrp9cOPcuC+2wrr44GTUB0EC+PyfN3HZD9tFg= +github.com/knadh/koanf/providers/confmap v1.0.1 h1:L15hbvMqlvhwUuCtL9BkL+rqiMAjk6cZc8O9XoDtE3A= +github.com/knadh/koanf/providers/confmap v1.0.1/go.mod h1:txHYHiI2hAtF0/0sCmcuol4IDcuQbKTybiB1nOcUo1A= +github.com/knadh/koanf/providers/env/v2 v2.0.1 h1:a3KagndPqhcWHQv6Pz4OZmwkI/yMeTjkiZye6ZCkyW0= +github.com/knadh/koanf/providers/env/v2 v2.0.1/go.mod h1:1g01PE+Ve1gBfWNNw2wmULRP0tc8RJrjn5p2N/jNCIc= +github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP3AESOJYp9wM= +github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA= +github.com/knadh/koanf/v2 v2.3.6 h1:JoQPSJmvS4aP0xNc8xMDr5tcrkSEInL23/Il7pITAKo= +github.com/knadh/koanf/v2 v2.3.6/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= +github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/examples/hello/plugins/optional/plugin.go b/examples/hello/plugins/optional/plugin.go new file mode 100644 index 0000000..160dd72 --- /dev/null +++ b/examples/hello/plugins/optional/plugin.go @@ -0,0 +1,43 @@ +package optional + +import ( + "embed" + "io/fs" + + "git.golem15.com/golem15/summercms/backpack" + "git.golem15.com/golem15/summercms/pact" + "git.golem15.com/golem15/summercms/party" +) + +//go:embed config +var configFS embed.FS + +// Plugin is the golem15.optional plugin. Greeter may use it without importing this package. +type Plugin struct{} + +func (p *Plugin) ID() string { return "golem15.optional" } +func (p *Plugin) Requires() []string { return nil } + +func (p *Plugin) Register(app *backpack.App) error { + if app == nil { + return nil + } + return app.Publish[pact.OptionalMessage](extra{}) +} + +func (p *Plugin) Boot(*backpack.App) error { return nil } + +func (p *Plugin) ConfigFS() fs.FS { return configFS } + +type extra struct{} + +func (extra) Message() string { return "hello-from-optional" } + +var ( + _ pact.HasConfig = (*Plugin)(nil) + _ pact.OptionalMessage = extra{} +) + +func init() { + party.Register(&Plugin{}) +} diff --git a/examples/hello/summer.yaml b/examples/hello/summer.yaml index 6405c98..c795e9c 100644 --- a/examples/hello/summer.yaml +++ b/examples/hello/summer.yaml @@ -5,3 +5,5 @@ plugins: module: git.golem15.com/golem15/summercms/examples/hello/plugins/base - id: golem15.greeter module: git.golem15.com/golem15/summercms/examples/hello/plugins/greeter + - id: golem15.optional + module: git.golem15.com/golem15/summercms/examples/hello/plugins/optional diff --git a/go.work b/go.work index 31d9812..fb7ffed 100644 --- a/go.work +++ b/go.work @@ -7,4 +7,5 @@ use ( ./examples/hello ./examples/hello/plugins/base ./examples/hello/plugins/greeter + ./examples/hello/plugins/optional ) diff --git a/internal/build/build_test.go b/internal/build/build_test.go index 28af067..db07913 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -16,7 +16,7 @@ func TestLoadHelloManifest(t *testing.T) { if m.Binary != "hello" { t.Fatalf("binary = %q", m.Binary) } - if len(m.Plugins) != 2 || m.Plugins[0].ID != "golem15.hello" || m.Plugins[1].ID != "golem15.greeter" { + if len(m.Plugins) != 3 || m.Plugins[0].ID != "golem15.hello" || m.Plugins[1].ID != "golem15.greeter" || m.Plugins[2].ID != "golem15.optional" { t.Fatalf("plugins = %+v", m.Plugins) } } diff --git a/pact/capabilities.go b/pact/capabilities.go index 45a454e..19ef4c5 100644 --- a/pact/capabilities.go +++ b/pact/capabilities.go @@ -18,7 +18,16 @@ type HasConfig interface { ConfigFS() fs.FS } -// Reserved KERN-03 capability names, declared when their first consumers exist: +// OptionalMessage is a service an optional plugin may publish so other +// plugins can integrate without importing that plugin's package. +type OptionalMessage interface { + Message() string +} + +// Future KERN-03 capability families are type-asserted when their first +// consumer packages exist. Method payloads are not declared here so pact +// does not import surf, lagoon, festival, or other packages that Phase 1 +// does not yet ship: // // HasModels // HasMigrations @@ -32,3 +41,6 @@ type HasConfig interface { // HasSchedule // HasMailTemplates // HasLang +// +// Phase 1 type-asserts only HasConfig (party, before Register) and +// HasCommands (generated app main, after Boot). diff --git a/party/registry.go b/party/registry.go index 8f5abe4..8a00787 100644 --- a/party/registry.go +++ b/party/registry.go @@ -98,6 +98,12 @@ func activate(registered []Plugin, app *backpack.App, ids []string) ([]Plugin, e return nil, err } + orderedIDs := make([]string, len(ordered)) + for i, p := range ordered { + orderedIDs[i] = p.ID() + } + app.SetPlugins(orderedIDs) + for _, p := range ordered { if hc, ok := p.(pact.HasConfig); ok && app.Config != nil { if err := app.Config.MergePlugin(p.ID(), hc.ConfigFS()); err != nil { diff --git a/party/registry_test.go b/party/registry_test.go index 21ef950..f9a915e 100644 --- a/party/registry_test.go +++ b/party/registry_test.go @@ -126,11 +126,11 @@ func TestActivateDuplicateIDRejected(t *testing.T) { } type bootProbe struct { - id string - req []string - log *[]string - sawOpt *bool - booted *[]string + id string + req []string + log *[]string + sawOpt *bool + booted *[]string } func (p *bootProbe) ID() string { return p.id } From 36c152f4bbbffecddcbcd0068fc356335fac37c7 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 16 Sep 2026 13:27:53 +0200 Subject: [PATCH 5/7] test(01-02): add failing test for typed events and context - Cover Fire/Collect/UntilHandled, priority, panics and bus isolation - Require towel actor/org/collection/locale accessors on context - Assert hello command demonstrates all three dispatch modes --- backpack/app_test.go | 13 +++ examples/hello/hello_test.go | 12 +-- festival/bus_test.go | 166 +++++++++++++++++++++++++++++++++++ towel/context_test.go | 75 ++++++++++++++++ 4 files changed, 260 insertions(+), 6 deletions(-) create mode 100644 festival/bus_test.go create mode 100644 towel/context_test.go diff --git a/backpack/app_test.go b/backpack/app_test.go index 2f8097a..3876621 100644 --- a/backpack/app_test.go +++ b/backpack/app_test.go @@ -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 { diff --git a/examples/hello/hello_test.go b/examples/hello/hello_test.go index 8672f2b..9a877e3 100644 --- a/examples/hello/hello_test.go +++ b/examples/hello/hello_test.go @@ -17,7 +17,7 @@ import ( func TestGreeterHelloPrintsLayeredConfig(t *testing.T) { got := runHello(t, nil) - want := "name=hello-app posts_per_page=10 debug=false extra=hello-from-optional" + 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) } @@ -25,7 +25,7 @@ func TestGreeterHelloPrintsLayeredConfig(t *testing.T) { 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" + 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) } @@ -35,7 +35,7 @@ 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" + 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) } @@ -55,7 +55,7 @@ func runHello(t *testing.T, env map[string]string) 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=" + 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) } @@ -104,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 != "name=hello-app posts_per_page=10 debug=false extra=hello-from-optional" { - t.Fatalf("output = %q, want layered production config with optional extra", got) + 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) } } diff --git a/festival/bus_test.go b/festival/bus_test.go new file mode 100644 index 0000000..81cb61c --- /dev/null +++ b/festival/bus_test.go @@ -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") + } +} diff --git a/towel/context_test.go b/towel/context_test.go new file mode 100644 index 0000000..f90b62b --- /dev/null +++ b/towel/context_test.go @@ -0,0 +1,75 @@ +package towel + +import ( + "context" + "go/parser" + "go/token" + "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) + } + fset := token.NewFileSet() + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + file, err := parser.ParseFile(fset, name, nil, parser.SkipObjectResolution) + if err != nil { + t.Fatal(err) + } + for _, spec := range file.Decls { + gen, ok := spec.(interface{ TokString() string }) + _ = gen + _ = ok + } + 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) + } + } + } + } +} From a654e96d664e067cf83618f523f21c8faf1e15ef Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 16 Sep 2026 13:30:46 +0200 Subject: [PATCH 6/7] feat(01-02): dispatch typed events with request context - App-owned festival bus with Fire, Collect and UntilHandled - Recover listener panics with owner plugin IDs; isolate buses per app - towel context accessors and hello command demo of all three modes --- backpack/app.go | 3 + examples/hello/plugins/greeter/plugin.go | 58 ++++++++- festival/bus.go | 149 +++++++++++++++++++++++ towel/context.go | 63 ++++++++++ towel/context_test.go | 12 -- 5 files changed, 272 insertions(+), 13 deletions(-) create mode 100644 festival/bus.go create mode 100644 towel/context.go diff --git a/backpack/app.go b/backpack/app.go index fd1511d..4e178c0 100644 --- a/backpack/app.go +++ b/backpack/app.go @@ -5,12 +5,14 @@ import ( "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 Services *Registry + Events *festival.Bus mu sync.RWMutex plugins map[string]struct{} @@ -21,6 +23,7 @@ func New(cfg *compass.Config) *App { return &App{ Config: cfg, Services: NewRegistry(), + Events: festival.New(), plugins: make(map[string]struct{}), } } diff --git a/examples/hello/plugins/greeter/plugin.go b/examples/hello/plugins/greeter/plugin.go index 184a665..3a7f16a 100644 --- a/examples/hello/plugins/greeter/plugin.go +++ b/examples/hello/plugins/greeter/plugin.go @@ -5,10 +5,15 @@ 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 @@ -28,6 +33,19 @@ func (p *Plugin) Boot(app *backpack.App) error { 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 } @@ -44,12 +62,50 @@ func (p *Plugin) Commands() []bonfire.Command { posts = p.app.Config.Int("golem15.hello.posts_per_page") debug = p.app.Config.Bool("app.debug") } - out.Printf("name=%s posts_per_page=%d debug=%t extra=%s\n", name, posts, debug, p.extra) + 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() { diff --git a/festival/bus.go b/festival/bus.go new file mode 100644 index 0000000..3d17076 --- /dev/null +++ b/festival/bus.go @@ -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 + } +} diff --git a/towel/context.go b/towel/context.go new file mode 100644 index 0000000..89d2bfb --- /dev/null +++ b/towel/context.go @@ -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{}) +} diff --git a/towel/context_test.go b/towel/context_test.go index f90b62b..42976bf 100644 --- a/towel/context_test.go +++ b/towel/context_test.go @@ -2,8 +2,6 @@ package towel import ( "context" - "go/parser" - "go/token" "os" "strings" "testing" @@ -40,21 +38,11 @@ func TestNoPackageGlobalRequestState(t *testing.T) { if err != nil { t.Fatal(err) } - fset := token.NewFileSet() for _, entry := range entries { name := entry.Name() if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { continue } - file, err := parser.ParseFile(fset, name, nil, parser.SkipObjectResolution) - if err != nil { - t.Fatal(err) - } - for _, spec := range file.Decls { - gen, ok := spec.(interface{ TokString() string }) - _ = gen - _ = ok - } src, err := os.ReadFile(name) if err != nil { t.Fatal(err) From 6d1b1ec689c62b1ea4d13862d8927cac68417d90 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 16 Sep 2026 13:32:33 +0200 Subject: [PATCH 7/7] docs(01-02): complete layered config services and events plan - Record six-level compass, optional services and typed festival bus - Document TDD commits and auto-fixed toolchain/singleton issues --- .../01-02-SUMMARY.md | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 .planning/phases/01-framework-kernel-foundation/01-02-SUMMARY.md diff --git a/.planning/phases/01-framework-kernel-foundation/01-02-SUMMARY.md b/.planning/phases/01-framework-kernel-foundation/01-02-SUMMARY.md new file mode 100644 index 0000000..ef3db77 --- /dev/null +++ b/.planning/phases/01-framework-kernel-foundation/01-02-SUMMARY.md @@ -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 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*