Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 40 additions & 7 deletions azureappconfiguration.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ package azureappconfiguration
import (
"context"
"encoding/json"
"fmt"
"log"
"regexp"
"strings"
"sync"

"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"golang.org/x/sync/errgroup"
Expand All @@ -21,6 +23,7 @@ type AzureAppConfiguration struct {
trimPrefixes []string

clientManager *configurationClientManager
resolver *keyVaultReferenceResolver
}

func Load(ctx context.Context, authentication AuthenticationOptions, options *Options) (*AzureAppConfiguration, error) {
Expand All @@ -43,6 +46,11 @@ func Load(ctx context.Context, authentication AuthenticationOptions, options *Op
azappcfg.kvSelectors = deduplicateSelectors(options.Selectors)
azappcfg.trimPrefixes = options.TrimKeyPrefixes
azappcfg.clientManager = clientManager
azappcfg.resolver = &keyVaultReferenceResolver{
clients: sync.Map{},
secretResolver: options.KeyVaultOptions.SecretResolver,
credential: options.KeyVaultOptions.Credential,
}

if err := azappcfg.load(ctx); err != nil {
return nil, err
Expand All @@ -57,12 +65,7 @@ func (azappcfg *AzureAppConfiguration) load(ctx context.Context) error {
client: azappcfg.clientManager.staticClient.client,
}

eg, egCtx := errgroup.WithContext(ctx)
eg.Go(func() error {
return azappcfg.loadKeyValues(egCtx, keyValuesClient)
})

return eg.Wait()
return azappcfg.loadKeyValues(ctx, keyValuesClient)
}

func (azappcfg *AzureAppConfiguration) loadKeyValues(ctx context.Context, settingsClient settingsClient) error {
Expand All @@ -72,6 +75,7 @@ func (azappcfg *AzureAppConfiguration) loadKeyValues(ctx context.Context, settin
}

kvSettings := make(map[string]any, len(settingsResponse.settings))
keyVaultRefs := make(map[string]string)
for _, setting := range settingsResponse.settings {
if setting.Key == nil {
continue
Expand All @@ -91,7 +95,7 @@ func (azappcfg *AzureAppConfiguration) loadKeyValues(ctx context.Context, settin
case featureFlagContentType:
continue // ignore feature flag while getting key value settings
case secretReferenceContentType:
continue // Todo - implement secret reference
keyVaultRefs[trimmedKey] = *setting.Value
default:
if isJsonContentType(setting.ContentType) {
var v any
Expand All @@ -106,6 +110,35 @@ func (azappcfg *AzureAppConfiguration) loadKeyValues(ctx context.Context, settin
}
}

var eg errgroup.Group
resolvedSecrets := sync.Map{}
if len(keyVaultRefs) > 0 {
if azappcfg.resolver.credential == nil && azappcfg.resolver.secretResolver == nil {
return fmt.Errorf("no Key Vault credential or SecretResolver configured")
}

for key, kvRef := range keyVaultRefs {
key, kvRef := key, kvRef
eg.Go(func() error {
resolvedSecret, err := azappcfg.resolver.resolveSecret(ctx, kvRef)
if err != nil {
return fmt.Errorf("fail to resolve the Key Vault reference '%s': %s", key, err.Error())
}
resolvedSecrets.Store(key, resolvedSecret)
return nil
})
}

if err := eg.Wait(); err != nil {
return err
}
}

resolvedSecrets.Range(func(key, value interface{}) bool {
kvSettings[key.(string)] = value.(string)
return true
})

azappcfg.keyValueETags = settingsResponse.eTags
azappcfg.keyValues = kvSettings

Expand Down
152 changes: 152 additions & 0 deletions azureappconfiguration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ package azureappconfiguration

import (
"context"
"net/url"
"sync"
"testing"
"time"

"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig"
Expand Down Expand Up @@ -51,6 +54,45 @@ func TestLoadKeyValues_Success(t *testing.T) {
assert.Equal(t, map[string]interface{}{"jsonKey": "jsonValue"}, azappcfg.keyValues["key2"])
}

func TestLoadKeyValues_WithKeyVaultReferences(t *testing.T) {
ctx := context.Background()
mockSettingsClient := new(mockSettingsClient)
mockSecretResolver := new(mockSecretResolver)

kvReference := `{"uri":"https://myvault.vault.azure.net/secrets/mysecret"}`
mockResponse := &settingsResponse{
settings: []azappconfig.Setting{
{Key: toPtr("key1"), Value: toPtr("value1"), ContentType: toPtr("")},
{Key: toPtr("secret1"), Value: toPtr(kvReference), ContentType: toPtr(secretReferenceContentType)},
},
eTags: map[Selector][]*azcore.ETag{},
}

mockSettingsClient.On("getSettings", ctx).Return(mockResponse, nil)
expectedURL, _ := url.Parse("https://myvault.vault.azure.net/secrets/mysecret")
mockSecretResolver.On("ResolveSecret", ctx, *expectedURL).Return("resolved-secret", nil)

azappcfg := &AzureAppConfiguration{
clientManager: &configurationClientManager{
staticClient: &configurationClientWrapper{client: nil},
},
kvSelectors: deduplicateSelectors([]Selector{}),
keyValues: make(map[string]any),
resolver: &keyVaultReferenceResolver{
clients: sync.Map{},
secretResolver: mockSecretResolver,
},
}

err := azappcfg.loadKeyValues(ctx, mockSettingsClient)

assert.NoError(t, err)
assert.Equal(t, "value1", *azappcfg.keyValues["key1"].(*string))
assert.Equal(t, "resolved-secret", azappcfg.keyValues["secret1"])
mockSettingsClient.AssertExpectations(t)
mockSecretResolver.AssertExpectations(t)
}

func TestLoadKeyValues_WithTrimPrefix(t *testing.T) {
ctx := context.Background()
mockClient := new(mockSettingsClient)
Expand Down Expand Up @@ -356,3 +398,113 @@ func TestIsJsonContentType(t *testing.T) {
func toPtr(s string) *string {
return &s
}

// mockDelayedSecretResolver simulates a resolver with variable response times
type mockDelayedSecretResolver struct {
mock.Mock
delays map[string]time.Duration
mu sync.Mutex
calls []time.Time
}

func (m *mockDelayedSecretResolver) ResolveSecret(ctx context.Context, keyVaultReference url.URL) (string, error) {
m.mu.Lock()
m.calls = append(m.calls, time.Now())
m.mu.Unlock()

if delay, ok := m.delays[keyVaultReference.String()]; ok {
time.Sleep(delay)
}
args := m.Called(ctx, keyVaultReference)
return args.String(0), args.Error(1)
}

func TestLoadKeyValues_WithConcurrentKeyVaultReferences(t *testing.T) {
ctx := context.Background()
mockSettingsClient := new(mockSettingsClient)

// Create a resolver with intentional delays to verify concurrent execution
mockResolver := &mockDelayedSecretResolver{
delays: map[string]time.Duration{
"https://vault1.vault.azure.net/secrets/secret1": 50 * time.Millisecond,
"https://vault1.vault.azure.net/secrets/secret2": 30 * time.Millisecond,
"https://vault2.vault.azure.net/secrets/secret3": 40 * time.Millisecond,
},
calls: make([]time.Time, 0, 3),
}

// Create key vault references
kvReference1 := `{"uri":"https://vault1.vault.azure.net/secrets/secret1"}`
kvReference2 := `{"uri":"https://vault1.vault.azure.net/secrets/secret2"}`
kvReference3 := `{"uri":"https://vault2.vault.azure.net/secrets/secret3"}`

// Set up mock response with multiple key vault references
mockResponse := &settingsResponse{
settings: []azappconfig.Setting{
{Key: toPtr("standard"), Value: toPtr("value1"), ContentType: toPtr("")},
{Key: toPtr("secret1"), Value: toPtr(kvReference1), ContentType: toPtr(secretReferenceContentType)},
{Key: toPtr("secret2"), Value: toPtr(kvReference2), ContentType: toPtr(secretReferenceContentType)},
{Key: toPtr("secret3"), Value: toPtr(kvReference3), ContentType: toPtr(secretReferenceContentType)},
},
eTags: map[Selector][]*azcore.ETag{},
}

mockSettingsClient.On("getSettings", ctx).Return(mockResponse, nil)

// Set up expectations for mock resolver
secret1URL, _ := url.Parse("https://vault1.vault.azure.net/secrets/secret1")
secret2URL, _ := url.Parse("https://vault1.vault.azure.net/secrets/secret2")
secret3URL, _ := url.Parse("https://vault2.vault.azure.net/secrets/secret3")

mockResolver.On("ResolveSecret", ctx, *secret1URL).Return("resolved-secret1", nil)
mockResolver.On("ResolveSecret", ctx, *secret2URL).Return("resolved-secret2", nil)
mockResolver.On("ResolveSecret", ctx, *secret3URL).Return("resolved-secret3", nil)

// Create app configuration
azappcfg := &AzureAppConfiguration{
clientManager: &configurationClientManager{
staticClient: &configurationClientWrapper{client: nil},
},
kvSelectors: deduplicateSelectors([]Selector{}),
keyValues: make(map[string]any),
resolver: &keyVaultReferenceResolver{
clients: sync.Map{},
secretResolver: mockResolver,
},
}

// Record start time
startTime := time.Now()

// Load key values
err := azappcfg.loadKeyValues(ctx, mockSettingsClient)

// Record elapsed time
elapsed := time.Since(startTime)

// Verify results
assert.NoError(t, err)
assert.Equal(t, "value1", *azappcfg.keyValues["standard"].(*string))
assert.Equal(t, "resolved-secret1", azappcfg.keyValues["secret1"])
assert.Equal(t, "resolved-secret2", azappcfg.keyValues["secret2"])
assert.Equal(t, "resolved-secret3", azappcfg.keyValues["secret3"])

// Verify all resolver calls were made
mockResolver.AssertNumberOfCalls(t, "ResolveSecret", 3)
mockSettingsClient.AssertExpectations(t)

// Verify concurrent execution by checking elapsed time
// If executed sequentially, it would take at least 50+30+40=120ms
// With concurrency, it should take closer to the longest delay (50ms) plus some overhead
assert.Less(t, elapsed, 110*time.Millisecond, "Expected concurrent execution to complete faster than sequential execution")

// Verify that calls started close to each other (within 10ms)
// This indicates that the goroutines were started concurrently
if len(mockResolver.calls) == 3 {
firstCallTime := mockResolver.calls[0]
for i := 1; i < len(mockResolver.calls); i++ {
timeDiff := mockResolver.calls[i].Sub(firstCallTime)
assert.Less(t, timeDiff, 10*time.Millisecond, "Expected resolver calls to start concurrently")
}
}
}
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ go 1.23.2
require github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig v1.2.0-beta.1

require (
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.2 // indirect
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
Expand All @@ -15,6 +15,7 @@ require (
require (
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0
github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.3.1
github.com/stretchr/testify v1.10.0
golang.org/x/net v0.35.0 // indirect
golang.org/x/sync v0.11.0
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig v1.2.0-beta.1 h1:wSwUNd/T
github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig v1.2.0-beta.1/go.mod h1:0uyyPvSFLlPiPzoTTLXN6wR9sFFqL6iPVd4FAugCooo=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 h1:ywEEhmNahHBihViHepv3xPBn1663uRv2t2q/ESv9seY=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.3.1 h1:mrkDCdkMsD4l9wjFGhofFHFrV43Y3c53RSLKOCJ5+Ow=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.3.1/go.mod h1:hPv41DbqMmnxcGralanA/kVlfdH5jv3T4LxGku2E1BY=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuoEKg+gImo7pvkiQEFAc8ocibADgXeiLAxWhWmkI=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww=
github.com/AzureAD/microsoft-authentication-library-for-go v1.3.3 h1:H5xDQaE3XowWfhZRUpnfC+rGZMEVoSiji+b+/HFAPU4=
github.com/AzureAD/microsoft-authentication-library-for-go v1.3.3/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
Expand Down
Loading