Skip to content

Commit 5290a51

Browse files
chore(core): decompose config loading (#2639)
### Proposed Changes * Goal: Support custom loaders of core configuration, to allow extensibility in downstream integrators. ### Checklist - [ ] I have added or updated unit tests - [ ] I have added or updated integration tests (if appropriate) - [ ] I have added or updated documentation ### Testing Instructions --------- Co-authored-by: opentdf-automation[bot] <149537512+opentdf-automation[bot]@users.noreply.github.com>
1 parent c781996 commit 5290a51

File tree

15 files changed

+590
-188
lines changed

15 files changed

+590
-188
lines changed

service/cmd/migrate.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,24 @@ func migrateService(cmd *cobra.Command, args []string, migrationFunc func(*db.Cl
121121
func migrateDBClient(cmd *cobra.Command, opts ...db.OptsFunc) (*db.Client, error) {
122122
configFile, _ := cmd.Flags().GetString(configFileFlag)
123123
configKey, _ := cmd.Flags().GetString(configKeyFlag)
124-
conf, err := config.LoadConfig(cmd.Context(), configKey, configFile)
124+
envLoader, err := config.NewEnvironmentValueLoader(configKey, nil)
125+
if err != nil {
126+
panic(fmt.Errorf("could not load config: %w", err))
127+
}
128+
configFileLoader, err := config.NewConfigFileLoader(configKey, configFile)
129+
if err != nil {
130+
panic(fmt.Errorf("could not load config: %w", err))
131+
}
132+
defaultSettingsLoader, err := config.NewDefaultSettingsLoader()
133+
if err != nil {
134+
panic(fmt.Errorf("could not load config: %w", err))
135+
}
136+
conf, err := config.Load(
137+
cmd.Context(),
138+
envLoader,
139+
configFileLoader,
140+
defaultSettingsLoader,
141+
)
125142
if err != nil {
126143
panic(fmt.Errorf("could not load config: %w", err))
127144
}

service/cmd/policy.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,24 @@ var (
3535
Run: func(cmd *cobra.Command, _ []string) {
3636
configFile, _ := cmd.Flags().GetString(configFileFlag)
3737
configKey, _ := cmd.Flags().GetString(configKeyFlag)
38-
cfg, err := config.LoadConfig(cmd.Context(), configKey, configFile)
38+
envLoader, err := config.NewEnvironmentValueLoader(configKey, nil)
39+
if err != nil {
40+
panic(fmt.Errorf("could not load config: %w", err))
41+
}
42+
configFileLoader, err := config.NewConfigFileLoader(configKey, configFile)
43+
if err != nil {
44+
panic(fmt.Errorf("could not load config: %w", err))
45+
}
46+
defaultSettingsLoader, err := config.NewDefaultSettingsLoader()
47+
if err != nil {
48+
panic(fmt.Errorf("could not load config: %w", err))
49+
}
50+
cfg, err := config.Load(
51+
cmd.Context(),
52+
envLoader,
53+
configFileLoader,
54+
defaultSettingsLoader,
55+
)
3956
if err != nil {
4057
panic(fmt.Errorf("could not load config: %w", err))
4158
}

service/cmd/provisionFixtures.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,24 @@ You can clear/recycle your database with 'docker compose down' and 'docker compo
3838
Run: func(cmd *cobra.Command, _ []string) {
3939
configFile, _ := cmd.Flags().GetString(configFileFlag)
4040
configKey, _ := cmd.Flags().GetString(configKeyFlag)
41-
cfg, err := config.LoadConfig(cmd.Context(), configKey, configFile)
41+
envLoader, err := config.NewEnvironmentValueLoader(configKey, nil)
42+
if err != nil {
43+
panic(fmt.Errorf("could not load config: %w", err))
44+
}
45+
configFileLoader, err := config.NewConfigFileLoader(configKey, configFile)
46+
if err != nil {
47+
panic(fmt.Errorf("could not load config: %w", err))
48+
}
49+
defaultSettingsLoader, err := config.NewDefaultSettingsLoader()
50+
if err != nil {
51+
panic(fmt.Errorf("could not load config: %w", err))
52+
}
53+
cfg, err := config.Load(
54+
cmd.Context(),
55+
envLoader,
56+
configFileLoader,
57+
defaultSettingsLoader,
58+
)
4259
if err != nil {
4360
panic(fmt.Errorf("could not load config: %w", err))
4461
}

service/internal/auth/config.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ import (
1010

1111
// AuthConfig pulls AuthN and AuthZ together
1212
type Config struct {
13-
Enabled bool `mapstructure:"enabled" json:"enabled" default:"true" `
14-
PublicRoutes []string `mapstructure:"-"`
13+
Enabled bool `mapstructure:"enabled" json:"enabled" default:"true"`
14+
PublicRoutes []string `mapstructure:"-" json:"-"`
1515
// Used for re-authentication of IPC connections
16-
IPCReauthRoutes []string `mapstructure:"-"`
16+
IPCReauthRoutes []string `mapstructure:"-" json:"-"`
1717
AuthNConfig `mapstructure:",squash"`
1818
}
1919

@@ -23,17 +23,17 @@ type AuthNConfig struct { //nolint:revive // AuthNConfig is a valid name
2323
Issuer string `mapstructure:"issuer" json:"issuer"`
2424
Audience string `mapstructure:"audience" json:"audience"`
2525
Policy PolicyConfig `mapstructure:"policy" json:"policy"`
26-
CacheRefresh string `mapstructure:"cache_refresh_interval"`
27-
DPoPSkew time.Duration `mapstructure:"dpopskew" default:"1h"`
28-
TokenSkew time.Duration `mapstructure:"skew" default:"1m"`
26+
CacheRefresh string `mapstructure:"cache_refresh_interval" json:"cache_refresh_interval"`
27+
DPoPSkew time.Duration `mapstructure:"dpopskew" json:"dpopskew" default:"1h"`
28+
TokenSkew time.Duration `mapstructure:"skew" json:"skew" default:"1m"`
2929
}
3030

3131
type PolicyConfig struct {
3232
Builtin string `mapstructure:"-" json:"-"`
3333
// Username claim to use for user information
3434
UserNameClaim string `mapstructure:"username_claim" json:"username_claim" default:"preferred_username"`
3535
// Claim to use for group/role information
36-
GroupsClaim string `mapstructure:"groups_claim" json:"group_claim" default:"realm_access.roles"`
36+
GroupsClaim string `mapstructure:"groups_claim" json:"groups_claim" default:"realm_access.roles"`
3737
// Deprecated: Use GroupClain instead
3838
RoleClaim string `mapstructure:"claim" json:"claim" default:"realm_access.roles"`
3939
// Deprecated: Use Casbin grouping statements g, <user/group>, <role>

service/internal/server/server.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ type Config struct {
6969
// Enable pprof
7070
EnablePprof bool `mapstructure:"enable_pprof" json:"enable_pprof" default:"false"`
7171
// Trace is for configuring open telemetry based tracing.
72-
Trace tracing.Config `mapstructure:"trace"`
72+
Trace tracing.Config `mapstructure:"trace" json:"trace"`
7373
}
7474

7575
func (c Config) LogValue() slog.Value {
@@ -126,7 +126,7 @@ type CORSConfig struct {
126126
AllowedMethods []string `mapstructure:"allowedmethods" json:"allowedmethods" default:"[\"GET\",\"POST\",\"PATCH\",\"DELETE\",\"OPTIONS\"]"`
127127
AllowedHeaders []string `mapstructure:"allowedheaders" json:"allowedheaders" default:"[\"Accept\",\"Content-Type\",\"Content-Length\",\"Accept-Encoding\",\"X-CSRF-Token\",\"Authorization\",\"X-Requested-With\",\"Dpop\",\"Connect-Protocol-Version\"]"`
128128
ExposedHeaders []string `mapstructure:"exposedheaders" json:"exposedheaders"`
129-
AllowCredentials bool `mapstructure:"allowcredentials" json:"allowedcredentials" default:"true"`
129+
AllowCredentials bool `mapstructure:"allowcredentials" json:"allowcredentials" default:"true"`
130130
MaxAge int `mapstructure:"maxage" json:"maxage" default:"3600"`
131131
Debug bool `mapstructure:"debug" json:"debug"`
132132
}

service/pkg/config/config.go

Lines changed: 135 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,16 @@ package config
33
import (
44
"context"
55
"errors"
6+
"fmt"
67
"log/slog"
8+
"sync"
79

10+
"github.com/go-playground/validator/v10"
811
"github.com/opentdf/platform/service/internal/server"
912
"github.com/opentdf/platform/service/logger"
1013
"github.com/opentdf/platform/service/pkg/db"
1114
"github.com/opentdf/platform/service/tracing"
15+
"github.com/spf13/viper"
1216
)
1317

1418
// ChangeHook is a function invoked when the configuration changes.
@@ -42,15 +46,17 @@ type Config struct {
4246
SDKConfig SDKConfig `mapstructure:"sdk_config" json:"sdk_config"`
4347

4448
// Services represents the configuration settings for the services.
45-
Services ServicesMap `mapstructure:"services"`
49+
Services ServicesMap `mapstructure:"services" json:"services"`
4650

4751
// Trace is for configuring open telemetry based tracing.
48-
Trace tracing.Config `mapstructure:"trace"`
52+
Trace tracing.Config `mapstructure:"trace" json:"trace"`
4953

5054
// onConfigChangeHooks is a list of functions to call when the configuration changes.
5155
onConfigChangeHooks []ChangeHook
5256
// loaders is a list of configuration loaders.
5357
loaders []Loader
58+
// reloadMux ensures that the Reload function is thread-safe.
59+
reloadMux *sync.Mutex
5460
}
5561

5662
// SDKConfig represents the configuration for the SDK.
@@ -115,8 +121,23 @@ func (c *Config) Watch(ctx context.Context) error {
115121
if len(c.loaders) == 0 {
116122
return nil
117123
}
124+
118125
for _, loader := range c.loaders {
119-
if err := loader.Watch(ctx, c, c.OnChange); err != nil {
126+
// onChangeCallback is the function that will be called by loaders when a change is detected.
127+
// It orchestrates the reloading of the entire configuration and then triggers the registered hooks.
128+
loaderName := loader.Name()
129+
onChangeCallback := func(ctx context.Context) error {
130+
slog.InfoContext(ctx, "configuration change detected, reloading...", slog.String("loader", loaderName))
131+
if err := c.Reload(ctx); err != nil {
132+
slog.ErrorContext(ctx, "failed to reload configuration", slog.Any("error", err))
133+
return err
134+
}
135+
slog.InfoContext(ctx, "configuration reloaded successfully")
136+
137+
// Now call the user-provided hooks with the new configuration.
138+
return c.OnChange(ctx)
139+
}
140+
if err := loader.Watch(ctx, c, onChangeCallback); err != nil {
120141
return err
121142
}
122143
}
@@ -138,10 +159,11 @@ func (c *Config) Close(ctx context.Context) error {
138159
}
139160

140161
// OnChange invokes all registered onConfigChangeHooks after a configuration change.
141-
func (c *Config) OnChange(_ context.Context) error {
142-
if len(c.loaders) == 0 {
162+
func (c *Config) OnChange(ctx context.Context) error {
163+
if len(c.onConfigChangeHooks) == 0 {
143164
return nil
144165
}
166+
slog.DebugContext(ctx, "executing configuration change hooks")
145167
for _, hook := range c.onConfigChangeHooks {
146168
if err := hook(c.Services); err != nil {
147169
return err
@@ -150,6 +172,87 @@ func (c *Config) OnChange(_ context.Context) error {
150172
return nil
151173
}
152174

175+
// Reload re-reads and merges configuration from all registered loaders.
176+
// It is thread-safe and handles dependencies between loaders by iterating
177+
// until the configuration stabilizes.
178+
func (c *Config) Reload(ctx context.Context) error {
179+
// Lock to ensure only one reload operation happens at a time.
180+
c.reloadMux.Lock()
181+
defer c.reloadMux.Unlock()
182+
183+
// This loop handles dependencies between loaders. It continues to iterate
184+
// until a full pass over all loaders adds no new configuration values.
185+
// This ensures that a loader can use configuration provided by another
186+
// loader that appears later in the priority list.
187+
var assigned map[string]struct{}
188+
for {
189+
lastAssignedCount := len(assigned)
190+
assigned = make(map[string]struct{})
191+
orderedViper := viper.NewWithOptions(viper.WithLogger(slog.Default()))
192+
193+
// Loop through loaders in their order of priority.
194+
for _, loader := range c.loaders {
195+
// The Load call allows the loader to refresh its internal state (e.g., re-read file, re-query DB).
196+
// It uses the config `c` from the *previous* iteration to configure itself if needed.
197+
if err := loader.Load(*c); err != nil {
198+
return fmt.Errorf("loader %s failed to load: %w", loader.Name(), err)
199+
}
200+
201+
// Get all keys this loader knows about.
202+
keys, err := loader.GetConfigKeys()
203+
if err != nil {
204+
slog.WarnContext(
205+
ctx,
206+
"loader failed to get config keys",
207+
slog.String("loader", loader.Name()),
208+
slog.Any("error", err),
209+
)
210+
continue
211+
}
212+
213+
// Merge values from the current loader into Viper.
214+
for _, key := range keys {
215+
// If a higher-priority loader already set this key, skip.
216+
if _, assignedAlready := assigned[key]; assignedAlready {
217+
continue
218+
}
219+
loaderValue, err := loader.Get(key)
220+
if err != nil {
221+
slog.WarnContext(
222+
ctx,
223+
"loader.Get failed for a reported key",
224+
slog.String("loader", loader.Name()),
225+
slog.String("key", key),
226+
slog.Any("error", err),
227+
)
228+
continue
229+
}
230+
if loaderValue != nil {
231+
orderedViper.Set(key, loaderValue)
232+
assigned[key] = struct{}{}
233+
}
234+
}
235+
}
236+
237+
// Unmarshal the merged configuration into the main config struct `c`
238+
// so it's available for the next iteration of the dependency loop.
239+
if err := orderedViper.Unmarshal(c); err != nil {
240+
return errors.Join(err, ErrUnmarshallingConfig)
241+
}
242+
243+
// If no new keys were assigned in this pass, the configuration has stabilized.
244+
if len(assigned) == lastAssignedCount {
245+
break
246+
}
247+
}
248+
249+
// Final validation after the configuration has converged.
250+
if err := validator.New().Struct(c); err != nil {
251+
return errors.Join(err, ErrUnmarshallingConfig)
252+
}
253+
return nil
254+
}
255+
153256
func (c SDKConfig) LogValue() slog.Value {
154257
return slog.GroupValue(
155258
slog.Group("core",
@@ -165,19 +268,36 @@ func (c SDKConfig) LogValue() slog.Value {
165268
)
166269
}
167270

168-
// LoadConfig loads configuration using the provided loader or creates a default Viper loader
169-
func LoadConfig(_ context.Context, key, file string) (*Config, error) {
170-
config := &Config{}
171-
172-
// Create default loader if none provided
173-
loader, err := NewEnvironmentLoader(key, file)
271+
// Deprecated: Use the `Load` method with your preferred loaders
272+
func LoadConfig(ctx context.Context, key, file string) (*Config, error) {
273+
envLoader, err := NewEnvironmentValueLoader(key, nil)
174274
if err != nil {
175-
return nil, err
275+
return nil, fmt.Errorf("could not load config: %w", err)
276+
}
277+
configFileLoader, err := NewConfigFileLoader(key, file)
278+
if err != nil {
279+
return nil, fmt.Errorf("could not load config: %w", err)
280+
}
281+
defaultSettingsLoader, err := NewDefaultSettingsLoader()
282+
if err != nil {
283+
return nil, fmt.Errorf("could not load config: %w", err)
284+
}
285+
return Load(
286+
ctx,
287+
envLoader,
288+
configFileLoader,
289+
defaultSettingsLoader,
290+
)
291+
}
292+
293+
// Load loads configuration using the provided loaders
294+
func Load(ctx context.Context, loaders ...Loader) (*Config, error) {
295+
config := &Config{
296+
loaders: loaders,
297+
reloadMux: &sync.Mutex{},
176298
}
177299

178-
// Load initial configuration
179-
config.AddLoader(loader)
180-
if err := loader.Load(config); err != nil {
300+
if err := config.Reload(ctx); err != nil {
181301
return nil, err
182302
}
183303

0 commit comments

Comments
 (0)