Skip to content

Commit 3eb2876

Browse files
Copilotintel352
andauthored
Add config.provider module for centralized application configuration (#209)
* Initial plan * feat: add config.provider module with schema validation, defaults, env sources, and {{config "key"}} template function Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> * fix: expand config refs in platform section too Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> * fix: address review comments - fix regex quotes, sort missing keys, fix doc comments Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: intel352 <77607+intel352@users.noreply.github.com>
1 parent 463cd2f commit 3eb2876

9 files changed

Lines changed: 1252 additions & 0 deletions

File tree

cmd/wfctl/type_registry.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,14 @@ func KnownModuleTypes() map[string]ModuleTypeInfo {
6868
ConfigKeys: []string{"address", "password", "db", "prefix", "defaultTTL"},
6969
},
7070

71+
// configprovider plugin
72+
"config.provider": {
73+
Type: "config.provider",
74+
Plugin: "configprovider",
75+
Stateful: false,
76+
ConfigKeys: []string{"sources", "schema"},
77+
},
78+
7179
// http plugin
7280
"http.server": {
7381
Type: "http.server",

module/config_provider.go

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
// This file implements the config.provider module type and config registry.
2+
package module
3+
4+
import (
5+
"fmt"
6+
"os"
7+
"regexp"
8+
"sort"
9+
"strings"
10+
"sync"
11+
12+
"github.com/CrisisTextLine/modular"
13+
)
14+
15+
// configKeyRegexp matches {{config "key"}}, {{ config "key" }}, {{config 'key'}},
16+
// or {{ config 'key' }} patterns. It handles single or double quotes and optional whitespace.
17+
var configKeyRegexp = regexp.MustCompile(`\{\{\s*config\s+["']([^"']+)["']\s*\}\}`)
18+
19+
// SchemaEntry defines a single configuration key's metadata.
20+
type SchemaEntry struct {
21+
Env string `json:"env"`
22+
Required bool `json:"required"`
23+
Default string `json:"default"`
24+
Sensitive bool `json:"sensitive"`
25+
Desc string `json:"desc"`
26+
}
27+
28+
// ConfigRegistry is a thread-safe, immutable store of resolved configuration values.
29+
type ConfigRegistry struct {
30+
mu sync.RWMutex
31+
values map[string]string
32+
sensitive map[string]bool
33+
frozen bool
34+
}
35+
36+
// globalConfigRegistry is the singleton config registry used by the engine.
37+
var globalConfigRegistry = &ConfigRegistry{
38+
values: make(map[string]string),
39+
sensitive: make(map[string]bool),
40+
}
41+
42+
// GetConfigRegistry returns the global config registry singleton.
43+
func GetConfigRegistry() *ConfigRegistry {
44+
return globalConfigRegistry
45+
}
46+
47+
// NewConfigRegistry creates a fresh ConfigRegistry. Primarily used for testing.
48+
func NewConfigRegistry() *ConfigRegistry {
49+
return &ConfigRegistry{
50+
values: make(map[string]string),
51+
sensitive: make(map[string]bool),
52+
}
53+
}
54+
55+
// Set stores a value in the registry. Returns an error if the registry is frozen.
56+
func (r *ConfigRegistry) Set(key, value string, sensitive bool) error {
57+
r.mu.Lock()
58+
defer r.mu.Unlock()
59+
if r.frozen {
60+
return fmt.Errorf("config registry is frozen; cannot set key %q", key)
61+
}
62+
r.values[key] = value
63+
r.sensitive[key] = sensitive
64+
return nil
65+
}
66+
67+
// Get retrieves a value from the registry.
68+
func (r *ConfigRegistry) Get(key string) (string, bool) {
69+
r.mu.RLock()
70+
defer r.mu.RUnlock()
71+
v, ok := r.values[key]
72+
return v, ok
73+
}
74+
75+
// IsSensitive returns whether a key is marked as sensitive.
76+
func (r *ConfigRegistry) IsSensitive(key string) bool {
77+
r.mu.RLock()
78+
defer r.mu.RUnlock()
79+
return r.sensitive[key]
80+
}
81+
82+
// Freeze makes the registry immutable. After calling Freeze, Set will return an error.
83+
func (r *ConfigRegistry) Freeze() {
84+
r.mu.Lock()
85+
defer r.mu.Unlock()
86+
r.frozen = true
87+
}
88+
89+
// Reset clears all values and unfreezes the registry. Intended for testing.
90+
func (r *ConfigRegistry) Reset() {
91+
r.mu.Lock()
92+
defer r.mu.Unlock()
93+
r.values = make(map[string]string)
94+
r.sensitive = make(map[string]bool)
95+
r.frozen = false
96+
}
97+
98+
// Keys returns all registered configuration key names.
99+
func (r *ConfigRegistry) Keys() []string {
100+
r.mu.RLock()
101+
defer r.mu.RUnlock()
102+
keys := make([]string, 0, len(r.values))
103+
for k := range r.values {
104+
keys = append(keys, k)
105+
}
106+
return keys
107+
}
108+
109+
// RedactedValue returns the value for display purposes. Sensitive values are
110+
// replaced with "********".
111+
func (r *ConfigRegistry) RedactedValue(key string) string {
112+
r.mu.RLock()
113+
defer r.mu.RUnlock()
114+
if r.sensitive[key] {
115+
return "********"
116+
}
117+
return r.values[key]
118+
}
119+
120+
// ExpandConfigTemplate replaces all {{config "key"}} references in a string
121+
// with their resolved values from the registry. Unresolved keys are left as-is.
122+
func (r *ConfigRegistry) ExpandConfigTemplate(s string) string {
123+
return configKeyRegexp.ReplaceAllStringFunc(s, func(match string) string {
124+
sub := configKeyRegexp.FindStringSubmatch(match)
125+
if len(sub) < 2 {
126+
return match
127+
}
128+
if v, ok := r.Get(sub[1]); ok {
129+
return v
130+
}
131+
return match
132+
})
133+
}
134+
135+
// ParseSchema parses a schema definition from a config map.
136+
func ParseSchema(raw map[string]any) (map[string]SchemaEntry, error) {
137+
schema := make(map[string]SchemaEntry)
138+
for key, val := range raw {
139+
entryMap, ok := val.(map[string]any)
140+
if !ok {
141+
return nil, fmt.Errorf("schema entry %q must be a map", key)
142+
}
143+
entry := SchemaEntry{}
144+
if v, ok := entryMap["env"].(string); ok {
145+
entry.Env = v
146+
}
147+
if v, ok := entryMap["required"].(bool); ok {
148+
entry.Required = v
149+
}
150+
if v, ok := entryMap["default"].(string); ok {
151+
entry.Default = v
152+
}
153+
if v, ok := entryMap["sensitive"].(bool); ok {
154+
entry.Sensitive = v
155+
}
156+
if v, ok := entryMap["desc"].(string); ok {
157+
entry.Desc = v
158+
}
159+
schema[key] = entry
160+
}
161+
return schema, nil
162+
}
163+
164+
// LoadConfigSources loads configuration values into the registry from the
165+
// declared sources in order. Later sources override earlier ones.
166+
// Supported source types: "defaults" (from schema defaults) and "env" (from
167+
// environment variables, with optional prefix).
168+
func LoadConfigSources(registry *ConfigRegistry, sources []map[string]any, schemaEntries map[string]SchemaEntry) error {
169+
for _, src := range sources {
170+
srcType, _ := src["type"].(string)
171+
switch srcType {
172+
case "defaults":
173+
for key, entry := range schemaEntries {
174+
if entry.Default != "" {
175+
if err := registry.Set(key, entry.Default, entry.Sensitive); err != nil {
176+
return err
177+
}
178+
}
179+
}
180+
case "env":
181+
prefix, _ := src["prefix"].(string)
182+
for key, entry := range schemaEntries {
183+
envKey := entry.Env
184+
if envKey == "" {
185+
continue
186+
}
187+
if prefix != "" {
188+
envKey = prefix + envKey
189+
}
190+
if val, ok := os.LookupEnv(envKey); ok {
191+
if err := registry.Set(key, val, entry.Sensitive); err != nil {
192+
return err
193+
}
194+
}
195+
}
196+
default:
197+
return fmt.Errorf("unsupported config source type: %q", srcType)
198+
}
199+
}
200+
return nil
201+
}
202+
203+
// ValidateRequired checks that all required schema keys have values in the
204+
// registry. Returns an error listing all missing keys.
205+
func ValidateRequired(registry *ConfigRegistry, schemaEntries map[string]SchemaEntry) error {
206+
var missing []string
207+
for key, entry := range schemaEntries {
208+
if entry.Required {
209+
if _, ok := registry.Get(key); !ok {
210+
missing = append(missing, key)
211+
}
212+
}
213+
}
214+
if len(missing) > 0 {
215+
sort.Strings(missing)
216+
return fmt.Errorf("missing required config keys: %s", strings.Join(missing, ", "))
217+
}
218+
return nil
219+
}
220+
221+
// ExpandConfigRefsMap recursively walks a config map and expands all
222+
// {{config "key"}} references in string values using the given registry.
223+
func ExpandConfigRefsMap(registry *ConfigRegistry, cfg map[string]any) {
224+
if registry == nil || cfg == nil {
225+
return
226+
}
227+
for k, v := range cfg {
228+
switch val := v.(type) {
229+
case string:
230+
cfg[k] = registry.ExpandConfigTemplate(val)
231+
case map[string]any:
232+
ExpandConfigRefsMap(registry, val)
233+
case []any:
234+
expandConfigRefsSlice(registry, val)
235+
}
236+
}
237+
}
238+
239+
// expandConfigRefsSlice recursively walks a slice and expands all
240+
// {{config "key"}} references in string values.
241+
func expandConfigRefsSlice(registry *ConfigRegistry, items []any) {
242+
for i, item := range items {
243+
switch v := item.(type) {
244+
case string:
245+
items[i] = registry.ExpandConfigTemplate(v)
246+
case map[string]any:
247+
ExpandConfigRefsMap(registry, v)
248+
case []any:
249+
expandConfigRefsSlice(registry, v)
250+
}
251+
}
252+
}
253+
254+
// ConfigProviderModule implements modular.Module for the config.provider type.
255+
// It acts as a no-op module at runtime since all config resolution happens at
256+
// build time via the ConfigTransformHook. The module exists to hold the config
257+
// registry reference for service discovery.
258+
type ConfigProviderModule struct {
259+
name string
260+
config map[string]any
261+
registry *ConfigRegistry
262+
}
263+
264+
// NewConfigProviderModule creates a new ConfigProviderModule.
265+
func NewConfigProviderModule(name string, cfg map[string]any) *ConfigProviderModule {
266+
return &ConfigProviderModule{
267+
name: name,
268+
config: cfg,
269+
registry: globalConfigRegistry,
270+
}
271+
}
272+
273+
// Name returns the module name.
274+
func (m *ConfigProviderModule) Name() string { return m.name }
275+
276+
// Dependencies returns an empty slice — config.provider has no dependencies.
277+
func (m *ConfigProviderModule) Dependencies() []string { return nil }
278+
279+
// Init registers the config registry as a service in the application.
280+
func (m *ConfigProviderModule) Init(app modular.Application) error {
281+
return app.RegisterService("config.registry", m.registry)
282+
}
283+
284+
// Registry returns the underlying ConfigRegistry.
285+
func (m *ConfigProviderModule) Registry() *ConfigRegistry {
286+
return m.registry
287+
}

0 commit comments

Comments
 (0)