Skip to content

Commit b50b2ed

Browse files
committed
feat: Add tenant validation for multi-tenant app and enhance TenantAffixedEnvFeeder functionality
1 parent 8630bfe commit b50b2ed

4 files changed

Lines changed: 330 additions & 5 deletions

File tree

.github/workflows/examples-ci.yml

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,56 @@ jobs:
100100
101101
kill $PID 2>/dev/null || true
102102
103-
elif [ "${{ matrix.example }}" = "reverse-proxy" ] || [ "${{ matrix.example }}" = "http-client" ] || [ "${{ matrix.example }}" = "advanced-logging" ] || [ "${{ matrix.example }}" = "multi-tenant-app" ] || [ "${{ matrix.example }}" = "verbose-debug" ] || [ "${{ matrix.example }}" = "instance-aware-db" ]; then
103+
elif [ "${{ matrix.example }}" = "multi-tenant-app" ]; then
104+
# Multi-tenant app needs special validation to ensure tenants are loaded
105+
echo "🏢 Testing multi-tenant app with tenant validation..."
106+
107+
# Run the app and capture logs
108+
timeout 10s ./example > app.log 2>&1 &
109+
PID=$!
110+
sleep 5
111+
112+
# Check if process is still running
113+
if ! kill -0 $PID 2>/dev/null; then
114+
echo "❌ multi-tenant-app crashed during startup"
115+
cat app.log
116+
exit 1
117+
fi
118+
119+
# Validate that tenants were loaded successfully
120+
if grep -q "Successfully loaded tenant configurations" app.log; then
121+
echo "✅ multi-tenant-app successfully loaded tenant configurations"
122+
else
123+
echo "❌ multi-tenant-app failed to load tenant configurations"
124+
echo "📋 Application logs:"
125+
cat app.log
126+
kill $PID 2>/dev/null || true
127+
exit 1
128+
fi
129+
130+
# Check for any tenant loading errors
131+
if grep -q "Failed to load tenant config" app.log; then
132+
echo "❌ multi-tenant-app encountered tenant loading errors"
133+
echo "📋 Application logs:"
134+
cat app.log
135+
kill $PID 2>/dev/null || true
136+
exit 1
137+
fi
138+
139+
# Validate that expected tenants were registered
140+
if grep -q "tenantCount=2" app.log; then
141+
echo "✅ multi-tenant-app loaded expected number of tenants"
142+
else
143+
echo "❌ multi-tenant-app did not load expected number of tenants"
144+
echo "📋 Application logs:"
145+
cat app.log
146+
kill $PID 2>/dev/null || true
147+
exit 1
148+
fi
149+
150+
kill $PID 2>/dev/null || true
151+
152+
elif [ "${{ matrix.example }}" = "reverse-proxy" ] || [ "${{ matrix.example }}" = "http-client" ] || [ "${{ matrix.example }}" = "advanced-logging" ] || [ "${{ matrix.example }}" = "verbose-debug" ] || [ "${{ matrix.example }}" = "instance-aware-db" ]; then
104153
# These apps just need to start without immediate errors
105154
timeout 5s ./example &
106155
PID=$!

feeders/tenant_affixed_env.go

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package feeders
22

3+
import "reflect"
4+
35
// TenantAffixedEnvFeeder is a feeder that reads environment variables with tenant-specific prefixes and suffixes
46
type TenantAffixedEnvFeeder struct {
57
*AffixedEnvFeeder
@@ -16,7 +18,7 @@ type TenantAffixedEnvFeeder struct {
1618
// before they are used to set the struct fields
1719
// The prefix function is used to modify the prefix of the environment variables
1820
// The suffix function is used to modify the suffix of the environment variables
19-
func NewTenantAffixedEnvFeeder(prefix, suffix func(string) string) TenantAffixedEnvFeeder {
21+
func NewTenantAffixedEnvFeeder(prefix, suffix func(string) string) *TenantAffixedEnvFeeder {
2022
affixedFeeder := NewAffixedEnvFeeder("", "") // Initialize with empty prefix and suffix
2123
result := TenantAffixedEnvFeeder{
2224
AffixedEnvFeeder: &affixedFeeder, // Take address of the struct
@@ -32,7 +34,29 @@ func NewTenantAffixedEnvFeeder(prefix, suffix func(string) string) TenantAffixed
3234
result.Suffix = suffix(s)
3335
}
3436

35-
return result
37+
return &result
38+
}
39+
40+
// Feed implements the basic Feeder interface but requires tenant context
41+
// For TenantAffixedEnvFeeder, use FeedKey instead to provide tenant context
42+
func (f *TenantAffixedEnvFeeder) Feed(structure interface{}) error {
43+
if f.verboseDebug && f.logger != nil {
44+
f.logger.Debug("TenantAffixedEnvFeeder: Feed called without tenant context, checking if prefix/suffix are set")
45+
}
46+
47+
// If prefix and suffix have been set (via SetPrefixFunc/SetSuffixFunc), use them
48+
if f.AffixedEnvFeeder != nil && (f.Prefix != "" || f.Suffix != "") {
49+
if f.verboseDebug && f.logger != nil {
50+
f.logger.Debug("TenantAffixedEnvFeeder: Using pre-configured prefix/suffix", "prefix", f.Prefix, "suffix", f.Suffix)
51+
}
52+
return f.AffixedEnvFeeder.Feed(structure)
53+
}
54+
55+
// Otherwise, fall back to empty tenant ID for backward compatibility
56+
if f.verboseDebug && f.logger != nil {
57+
f.logger.Debug("TenantAffixedEnvFeeder: No prefix/suffix set, using FeedKey with empty tenant ID")
58+
}
59+
return f.FeedKey("", structure)
3660
}
3761

3862
// SetVerboseDebug enables or disables verbose debug logging
@@ -55,3 +79,38 @@ func (f *TenantAffixedEnvFeeder) SetFieldTracker(tracker FieldTracker) {
5579
f.AffixedEnvFeeder.SetFieldTracker(tracker)
5680
}
5781
}
82+
83+
// FeedKey implements the ComplexFeeder interface for tenant-specific feeding
84+
func (f *TenantAffixedEnvFeeder) FeedKey(tenantID string, structure interface{}) error {
85+
if f.verboseDebug && f.logger != nil {
86+
f.logger.Debug("TenantAffixedEnvFeeder: Starting FeedKey process", "tenantID", tenantID, "structureType", reflect.TypeOf(structure))
87+
}
88+
89+
// Set tenant-specific prefix and suffix using the provided functions
90+
if f.SetPrefixFunc != nil {
91+
f.SetPrefixFunc(tenantID)
92+
if f.verboseDebug && f.logger != nil {
93+
f.logger.Debug("TenantAffixedEnvFeeder: Set prefix for tenant", "tenantID", tenantID, "prefix", f.Prefix)
94+
}
95+
}
96+
97+
if f.SetSuffixFunc != nil {
98+
f.SetSuffixFunc(tenantID)
99+
if f.verboseDebug && f.logger != nil {
100+
f.logger.Debug("TenantAffixedEnvFeeder: Set suffix for tenant", "tenantID", tenantID, "suffix", f.Suffix)
101+
}
102+
}
103+
104+
// Now call the underlying Feed method with the configured prefix/suffix
105+
err := f.AffixedEnvFeeder.Feed(structure)
106+
107+
if f.verboseDebug && f.logger != nil {
108+
if err != nil {
109+
f.logger.Debug("TenantAffixedEnvFeeder: FeedKey completed with error", "tenantID", tenantID, "error", err)
110+
} else {
111+
f.logger.Debug("TenantAffixedEnvFeeder: FeedKey completed successfully", "tenantID", tenantID)
112+
}
113+
}
114+
115+
return err
116+
}
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
package modular
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
"regexp"
8+
"testing"
9+
10+
"github.com/GoCodeAlone/modular/feeders"
11+
)
12+
13+
// TestTenantConfigAffixedEnvBug tests the specific bug where tenant config loading
14+
// fails with "env: prefix or suffix cannot be empty" when using TenantAffixedEnvFeeder
15+
func TestTenantConfigAffixedEnvBug(t *testing.T) {
16+
// Create temp directory for tenant configs
17+
tempDir, err := os.MkdirTemp("", "tenant-affixed-env-bug-test")
18+
if err != nil {
19+
t.Fatalf("Failed to create temp directory: %v", err)
20+
}
21+
defer func() {
22+
if err := os.RemoveAll(tempDir); err != nil {
23+
t.Logf("Failed to remove temp directory: %v", err)
24+
}
25+
}()
26+
27+
// Create tenant config files
28+
createTenantConfigFiles(t, tempDir)
29+
30+
// Create application with required services
31+
app, tenantService := setupAppWithTenantServices(t)
32+
33+
// Create loader with TenantAffixedEnvFeeder (reproduces the bug)
34+
loader := NewFileBasedTenantConfigLoader(TenantConfigParams{
35+
ConfigNameRegex: regexp.MustCompile(`^\w+\.yaml$`),
36+
ConfigDir: tempDir,
37+
ConfigFeeders: []Feeder{
38+
feeders.NewTenantAffixedEnvFeeder(func(tenantId string) string {
39+
return fmt.Sprintf("%s_", tenantId)
40+
}, func(s string) string { return "" }),
41+
},
42+
})
43+
44+
// This should NOT fail with "env: prefix or suffix cannot be empty"
45+
err = loader.LoadTenantConfigurations(app, tenantService)
46+
if err != nil {
47+
t.Fatalf("Expected tenant config loading to succeed, but got error: %v", err)
48+
}
49+
50+
// Verify tenants were loaded
51+
tenants := tenantService.GetTenants()
52+
if len(tenants) == 0 {
53+
t.Error("Expected at least one tenant to be loaded")
54+
}
55+
}
56+
57+
// TestTenantConfigAffixedEnvBugReproduction confirms the bug has been fixed
58+
func TestTenantConfigAffixedEnvBugReproduction(t *testing.T) {
59+
// Create temp directory for tenant configs
60+
tempDir, err := os.MkdirTemp("", "tenant-affixed-env-reproduction-test")
61+
if err != nil {
62+
t.Fatalf("Failed to create temp directory: %v", err)
63+
}
64+
defer func() {
65+
if err := os.RemoveAll(tempDir); err != nil {
66+
t.Logf("Failed to remove temp directory: %v", err)
67+
}
68+
}()
69+
70+
// Create tenant config files
71+
createTenantConfigFiles(t, tempDir)
72+
73+
// Create application with required services
74+
app, tenantService := setupAppWithTenantServices(t)
75+
76+
// Create loader with TenantAffixedEnvFeeder
77+
loader := NewFileBasedTenantConfigLoader(TenantConfigParams{
78+
ConfigNameRegex: regexp.MustCompile(`^\w+\.yaml$`),
79+
ConfigDir: tempDir,
80+
ConfigFeeders: []Feeder{
81+
feeders.NewTenantAffixedEnvFeeder(func(tenantId string) string {
82+
return fmt.Sprintf("%s_", tenantId)
83+
}, func(s string) string { return "" }),
84+
},
85+
})
86+
87+
// Try to load tenant configurations - this should now succeed
88+
err = loader.LoadTenantConfigurations(app, tenantService)
89+
90+
if err != nil {
91+
t.Errorf("Expected tenant config loading to succeed after fix, but got error: %v", err)
92+
} else {
93+
t.Log("Bug has been fixed - tenant config loading succeeded")
94+
95+
// Verify tenants were loaded
96+
tenants := tenantService.GetTenants()
97+
if len(tenants) == 0 {
98+
t.Error("Expected at least one tenant to be loaded")
99+
} else {
100+
t.Logf("Successfully loaded %d tenants", len(tenants))
101+
}
102+
}
103+
}
104+
105+
// createTenantConfigFiles creates sample tenant config files
106+
func createTenantConfigFiles(t *testing.T, tempDir string) {
107+
ctlConfig := `
108+
content:
109+
defaultTemplate: ctl-branded
110+
cacheTTL: 600
111+
112+
notifications:
113+
provider: email
114+
fromAddress: support@ctl.example.com
115+
maxRetries: 5
116+
`
117+
118+
sampleAff1Config := `
119+
content:
120+
defaultTemplate: sampleaff1-branded
121+
cacheTTL: 300
122+
123+
notifications:
124+
provider: sms
125+
fromAddress: support@sampleaff1.example.com
126+
maxRetries: 3
127+
`
128+
129+
// Write config files
130+
err := os.WriteFile(filepath.Join(tempDir, "ctl.yaml"), []byte(ctlConfig), 0600)
131+
if err != nil {
132+
t.Fatalf("Failed to write ctl.yaml: %v", err)
133+
}
134+
135+
err = os.WriteFile(filepath.Join(tempDir, "sampleaff1.yaml"), []byte(sampleAff1Config), 0600)
136+
if err != nil {
137+
t.Fatalf("Failed to write sampleaff1.yaml: %v", err)
138+
}
139+
}
140+
141+
// setupAppWithTenantServices creates app with required config sections and services
142+
func setupAppWithTenantServices(t *testing.T) (Application, TenantService) {
143+
// Content config
144+
type ContentConfig struct {
145+
DefaultTemplate string `yaml:"defaultTemplate"`
146+
CacheTTL int `yaml:"cacheTTL"`
147+
}
148+
149+
// Notifications config
150+
type NotificationsConfig struct {
151+
Provider string `yaml:"provider"`
152+
FromAddress string `yaml:"fromAddress"`
153+
MaxRetries int `yaml:"maxRetries"`
154+
}
155+
156+
log := &logger{t}
157+
app := NewStdApplication(NewStdConfigProvider(nil), log)
158+
159+
// Register config sections that match the tenant YAML files
160+
app.RegisterConfigSection("content", NewStdConfigProvider(&ContentConfig{}))
161+
app.RegisterConfigSection("notifications", NewStdConfigProvider(&NotificationsConfig{}))
162+
163+
// Create tenant service
164+
tenantService := NewStandardTenantService(log)
165+
166+
return app, tenantService
167+
}
168+
169+
// TestTenantAffixedEnvFeederDirectUsage tests the TenantAffixedEnvFeeder directly
170+
func TestTenantAffixedEnvFeederDirectUsage(t *testing.T) {
171+
type TestConfig struct {
172+
Name string `env:"NAME"`
173+
Port int `env:"PORT"`
174+
}
175+
176+
config := &TestConfig{}
177+
178+
// Create a TenantAffixedEnvFeeder
179+
feeder := feeders.NewTenantAffixedEnvFeeder(func(tenantId string) string {
180+
return fmt.Sprintf("%s_", tenantId)
181+
}, func(s string) string { return "" })
182+
183+
// With the fix, Feed should now work (it will look for unprefixed env vars when no tenant is set)
184+
err := feeder.Feed(config)
185+
if err != nil {
186+
t.Errorf("Expected no error when calling Feed directly, but got: %v", err)
187+
} else {
188+
t.Log("Direct Feed call succeeded - fix is working")
189+
}
190+
191+
// Test FeedKey with a tenant ID
192+
config2 := &TestConfig{}
193+
err = feeder.FeedKey("ctl", config2)
194+
if err != nil {
195+
t.Errorf("Expected no error when calling FeedKey with tenant ID, but got: %v", err)
196+
} else {
197+
t.Log("FeedKey call with tenant ID succeeded")
198+
}
199+
}

tenant_config_file_loader.go

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ func loadAndRegisterTenant(
116116
return err
117117
}
118118

119-
tenantCfgs, err := loadTenantConfig(app, feederSlice)
119+
tenantCfgs, err := loadTenantConfig(app, feederSlice, string(tenantID))
120120
if err != nil {
121121
app.Logger().Error("Failed to load tenant config", "tenantID", tenantID, "error", err)
122122
return fmt.Errorf("failed to load tenant config for %s: %w", tenantID, err)
@@ -149,7 +149,7 @@ func createFeederSlice(fileName, configPath string, additionalFeeders []Feeder)
149149
return feederSlice, nil
150150
}
151151

152-
func loadTenantConfig(app Application, configFeeders []Feeder) (map[string]ConfigProvider, error) {
152+
func loadTenantConfig(app Application, configFeeders []Feeder, tenantID string) (map[string]ConfigProvider, error) {
153153
// Guard against nil application
154154
if app == nil {
155155
return nil, ErrApplicationNil
@@ -163,6 +163,24 @@ func loadTenantConfig(app Application, configFeeders []Feeder) (map[string]Confi
163163

164164
app.Logger().Debug("Loading tenant config", "configFeedersCount", len(configFeeders))
165165

166+
// Configure tenant-aware feeders before building the configuration
167+
for _, feeder := range configFeeders {
168+
if tenantFeeder, ok := feeder.(*feeders.TenantAffixedEnvFeeder); ok {
169+
if tenantFeeder.SetPrefixFunc != nil {
170+
tenantFeeder.SetPrefixFunc(tenantID)
171+
if app.Logger() != nil {
172+
app.Logger().Debug("Configured TenantAffixedEnvFeeder prefix", "tenantID", tenantID, "prefix", tenantFeeder.Prefix)
173+
}
174+
}
175+
if tenantFeeder.SetSuffixFunc != nil {
176+
tenantFeeder.SetSuffixFunc(tenantID)
177+
if app.Logger() != nil {
178+
app.Logger().Debug("Configured TenantAffixedEnvFeeder suffix", "tenantID", tenantID, "suffix", tenantFeeder.Suffix)
179+
}
180+
}
181+
}
182+
}
183+
166184
// Build the configuration
167185
cfgBuilder := NewConfig()
168186
for _, feeder := range configFeeders {

0 commit comments

Comments
 (0)