Skip to content

Commit 680aebe

Browse files
committed
refactor: update interpolation to use only one pass
1 parent 3390660 commit 680aebe

File tree

2 files changed

+103
-56
lines changed

2 files changed

+103
-56
lines changed

internal/prober/interpolation/interpolation.go

Lines changed: 62 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import (
1111
)
1212

1313
// VariableRegex matches ${variable_name} patterns
14-
var VariableRegex = regexp.MustCompile(`\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}`)
14+
var VariableRegex = regexp.MustCompile(`\$\{([a-zA-Z_][a-zA-Z0-9_-]*)\}`)
1515

1616
// SecretRegex matches ${secrets.secret_name} patterns
1717
var SecretRegex = regexp.MustCompile(`\$\{secrets\.([^}]*)\}`)
@@ -33,6 +33,7 @@ type Resolver struct {
3333
tenantID model.GlobalID
3434
logger zerolog.Logger
3535
secretEnabled bool
36+
variableEnabled bool
3637
}
3738

3839
// NewResolver creates a new interpolation resolver
@@ -43,85 +44,94 @@ func NewResolver(variableProvider VariableProvider, secretProvider SecretProvide
4344
tenantID: tenantID,
4445
logger: logger,
4546
secretEnabled: secretEnabled,
47+
variableEnabled: variableProvider != nil,
4648
}
4749
}
4850

49-
// Resolve performs string interpolation, replacing both variables and secrets
51+
// Resolve performs string interpolation, replacing both variables and secrets in a single pass
5052
func (r *Resolver) Resolve(ctx context.Context, value string) (string, error) {
5153
if value == "" {
5254
return "", nil
5355
}
5456

55-
// First resolve secrets if enabled
56-
if r.secretEnabled {
57-
resolvedValue, err := r.resolveSecrets(ctx, value)
58-
if err != nil {
59-
return "", err
60-
}
61-
value = resolvedValue
57+
// If secrets are disabled, just process variables in the entire string
58+
if !r.secretEnabled {
59+
return r.processVariables(value), nil
6260
}
6361

64-
// Then resolve variables
65-
if r.variableProvider != nil {
66-
resolvedValue, err := r.resolveVariables(value)
67-
if err != nil {
68-
return "", err
69-
}
70-
value = resolvedValue
62+
// Step 1: Find all secret matches with their positions
63+
type secretMatch struct {
64+
start, end int
65+
name string
66+
placeholder string
7167
}
7268

73-
return value, nil
74-
}
75-
76-
// resolveSecrets resolves ${secrets.secret_name} patterns
77-
func (r *Resolver) resolveSecrets(ctx context.Context, value string) (string, error) {
78-
matches := SecretRegex.FindAllStringSubmatch(value, -1)
79-
if len(matches) == 0 {
80-
return value, nil
81-
}
82-
83-
result := value
69+
var secretMatches []secretMatch
70+
matches := SecretRegex.FindAllStringSubmatchIndex(value, -1)
8471
for _, match := range matches {
85-
if len(match) < 2 {
72+
if len(match) < 4 {
8673
continue
8774
}
8875

89-
secretName := match[1]
90-
placeholder := match[0] // ${secrets.secret_name}
76+
secretName := value[match[2]:match[3]]
77+
placeholder := value[match[0]:match[1]]
9178

92-
// Validate secret name follows Kubernetes DNS subdomain convention
79+
// Validate secret name follows Kubernetes DNS subdomain naming convention
9380
if !isValidSecretName(secretName) {
9481
return "", fmt.Errorf("invalid secret name '%s': must follow Kubernetes DNS subdomain naming convention", secretName)
9582
}
9683

97-
r.logger.Debug().Str("secretName", secretName).Int64("tenantId", int64(r.tenantID)).Msg("resolving secret from GSM")
84+
secretMatches = append(secretMatches, secretMatch{
85+
start: match[0],
86+
end: match[1],
87+
name: secretName,
88+
placeholder: placeholder,
89+
})
90+
}
9891

99-
secretValue, err := r.secretProvider.GetSecretValue(ctx, r.tenantID, secretName)
92+
// Step 2: Split string into parts and process each part
93+
var result strings.Builder
94+
lastPos := 0
95+
96+
for _, secretMatch := range secretMatches {
97+
// Process the part before this secret (non-secret part)
98+
nonSecretPart := value[lastPos:secretMatch.start]
99+
if nonSecretPart != "" {
100+
processedPart := r.processVariables(nonSecretPart)
101+
result.WriteString(processedPart)
102+
}
103+
104+
// Process the secret part
105+
r.logger.Debug().Str("secretName", secretMatch.name).Int64("tenantId", int64(r.tenantID)).Msg("resolving secret from GSM")
106+
107+
secretValue, err := r.secretProvider.GetSecretValue(ctx, r.tenantID, secretMatch.name)
100108
if err != nil {
101-
return "", fmt.Errorf("failed to get secret '%s' from GSM: %w", secretName, err)
109+
return "", fmt.Errorf("failed to get secret '%s' from GSM: %w", secretMatch.name, err)
102110
}
103111

104-
// Replace the placeholder with the actual secret value
105-
result = strings.ReplaceAll(result, placeholder, secretValue)
112+
result.WriteString(secretValue)
113+
lastPos = secretMatch.end
106114
}
107115

108-
return result, nil
109-
}
110-
111-
// resolveVariables resolves ${variable_name} patterns
112-
func (r *Resolver) resolveVariables(value string) (string, error) {
113-
// If no variable provider is set, return the value as-is
114-
if r.variableProvider == nil {
115-
return value, nil
116+
// Process the remaining part after the last secret (non-secret part)
117+
remainingPart := value[lastPos:]
118+
if remainingPart != "" {
119+
processedPart := r.processVariables(remainingPart)
120+
result.WriteString(processedPart)
116121
}
117122

118-
matches := VariableRegex.FindAllStringSubmatch(value, -1)
119-
if len(matches) == 0 {
120-
return value, nil
123+
return result.String(), nil
124+
}
125+
126+
// processVariables resolves ${variable_name} patterns in a string
127+
func (r *Resolver) processVariables(value string) string {
128+
if !r.variableEnabled {
129+
return value
121130
}
122131

123132
result := value
124-
for _, match := range matches {
133+
variableMatches := VariableRegex.FindAllStringSubmatch(result, -1)
134+
for _, match := range variableMatches {
125135
if len(match) < 2 {
126136
continue
127137
}
@@ -131,14 +141,16 @@ func (r *Resolver) resolveVariables(value string) (string, error) {
131141

132142
varValue, err := r.variableProvider.GetVariable(varName)
133143
if err != nil {
134-
return "", fmt.Errorf("failed to get variable '%s': %w", varName, err)
144+
// If variable is not found, leave the placeholder as-is
145+
// This allows for backward compatibility and flexible configuration
146+
continue
135147
}
136148

137149
// Replace the placeholder with the actual variable value
138150
result = strings.ReplaceAll(result, placeholder, varValue)
139151
}
140152

141-
return result, nil
153+
return result
142154
}
143155

144156
// isValidSecretName validates that a secret name follows Kubernetes DNS subdomain naming convention.

internal/prober/interpolation/interpolation_test.go

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,16 +42,20 @@ func TestResolver_Resolve(t *testing.T) {
4242
// Mock providers
4343
variableProvider := &mockVariableProvider{
4444
variables: map[string]string{
45-
"username": "admin",
46-
"domain": "example.com",
45+
"username": "admin",
46+
"domain": "example.com",
47+
"random": "123",
48+
"variable-with-secret": "${secrets.api-token}",
4749
},
4850
}
4951

5052
secretProvider := &mockSecretProvider{
5153
secrets: map[string]string{
52-
"api-token": "secret-token-123",
53-
"db-password": "secret-password",
54-
"empty-secret": "",
54+
"api-token": "secret-token-123",
55+
"db-password": "secret-password",
56+
"empty-secret": "",
57+
"auth-config": "username=${username}&token=${api-token}",
58+
"random-password": "my-password-${random}",
5559
},
5660
}
5761

@@ -145,11 +149,42 @@ func TestResolver_Resolve(t *testing.T) {
145149
expectedOutput: "${some-variable}",
146150
expectError: false,
147151
},
152+
"secret containing variables": {
153+
input: "https://api.example.com/auth?${secrets.auth-config}",
154+
secretEnabled: true,
155+
expectedOutput: "https://api.example.com/auth?username=${username}&token=${api-token}",
156+
expectError: false,
157+
},
158+
"secret with variable-like pattern": {
159+
input: "Password: ${secrets.random-password}",
160+
secretEnabled: true,
161+
expectedOutput: "Password: my-password-${random}",
162+
expectError: false,
163+
},
164+
"variable with secret": {
165+
input: "${variable-with-secret}",
166+
secretEnabled: true,
167+
expectedOutput: "${secrets.api-token}",
168+
expectError: false,
169+
},
170+
"variables disabled": {
171+
input: "Hello ${username} with token ${secrets.api-token}",
172+
secretEnabled: true,
173+
expectedOutput: "Hello ${username} with token secret-token-123",
174+
expectError: false,
175+
},
148176
}
149177

150178
for name, tc := range testcases {
151179
t.Run(name, func(t *testing.T) {
152-
resolver := NewResolver(variableProvider, secretProvider, tenantID, logger, tc.secretEnabled)
180+
var resolver *Resolver
181+
if name == "variables disabled" {
182+
// Create resolver with no variable provider to test variables disabled
183+
resolver = NewResolver(nil, secretProvider, tenantID, logger, tc.secretEnabled)
184+
} else {
185+
resolver = NewResolver(variableProvider, secretProvider, tenantID, logger, tc.secretEnabled)
186+
}
187+
153188
actual, err := resolver.Resolve(ctx, tc.input)
154189

155190
if tc.expectError {

0 commit comments

Comments
 (0)