Skip to content

Commit

Permalink
feat: Azure Blob Storage Retriever (#2672)
Browse files Browse the repository at this point in the history
  • Loading branch information
Abeeujah authored Nov 21, 2024
1 parent 41cccf7 commit 158e04a
Show file tree
Hide file tree
Showing 11 changed files with 539 additions and 2 deletions.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,7 @@ The available retrievers are:
- **MongoDB**
- **Redis**
- **BitBucket**
- **AzBlobStorage**
- ...

_[See the full list and more information.](https://gofeatureflag.org/docs/configure_flag/store_your_flags)_
Expand Down Expand Up @@ -620,4 +621,4 @@ These are our really cool sponsors!

If you are using `go-feature-flag`, we encourage you to include your company's name in this list. This simple act significantly boosts the project's visibility and credibility, making a substantial contribution to its advancement. To do so, kindly add yourself to [adopters](./ADOPTERS.md).

Here is the list of [adopters](./ADOPTERS.md).
Here is the list of [adopters](./ADOPTERS.md).
23 changes: 22 additions & 1 deletion cmd/relayproxy/config/retriever.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ type RetrieverConf struct {
Collection string `mapstructure:"collection" koanf:"collection"`
RedisOptions *redis.Options `mapstructure:"redisOptions" koanf:"redisOptions"`
RedisPrefix string `mapstructure:"redisPrefix" koanf:"redisPrefix"`
AccountName string `mapstructure:"accountName" koanf:"accountname"`
AccountKey string `mapstructure:"accountKey" koanf:"accountkey"`
Container string `mapstructure:"container" koanf:"container"`
}

// IsValid validate the configuration of the retriever
Expand Down Expand Up @@ -67,6 +70,9 @@ func (c *RetrieverConf) IsValid() error {
if c.Kind == RedisRetriever {
return c.validateRedisRetriever()
}
if c.Kind == AzBlobStorageRetriever {
return c.validateAzBlobStorageRetriever()
}
return nil
}

Expand Down Expand Up @@ -113,6 +119,19 @@ func (c *RetrieverConf) validateRedisRetriever() error {
return nil
}

func (c *RetrieverConf) validateAzBlobStorageRetriever() error {
if c.AccountName == "" {
return fmt.Errorf("invalid retriever: no \"accountName\" property found for kind \"%s\"", c.Kind)
}
if c.Container == "" {
return fmt.Errorf("invalid retriever: no \"container\" property found for kind \"%s\"", c.Kind)
}
if c.Object == "" {
return fmt.Errorf("invalid retriever: no \"object\" property found for kind \"%s\"", c.Kind)
}
return nil
}

// RetrieverKind is an enum containing all accepted Retriever kind
type RetrieverKind string

Expand All @@ -127,13 +146,15 @@ const (
MongoDBRetriever RetrieverKind = "mongodb"
RedisRetriever RetrieverKind = "redis"
BitbucketRetriever RetrieverKind = "bitbucket"
AzBlobStorageRetriever RetrieverKind = "azureBlobStorage"
)

// IsValid is checking if the value is part of the enum
func (r RetrieverKind) IsValid() error {
switch r {
case HTTPRetriever, GitHubRetriever, GitlabRetriever, S3Retriever, RedisRetriever,
FileRetriever, GoogleStorageRetriever, KubernetesRetriever, MongoDBRetriever, BitbucketRetriever:
FileRetriever, GoogleStorageRetriever, KubernetesRetriever, MongoDBRetriever,
BitbucketRetriever, AzBlobStorageRetriever:
return nil
}
return fmt.Errorf("invalid retriever: kind \"%s\" is not supported", r)
Expand Down
33 changes: 33 additions & 0 deletions cmd/relayproxy/config/retriever_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,39 @@ func TestRetrieverConf_IsValid(t *testing.T) {
wantErr: true,
errValue: "invalid retriever: no \"bucket\" property found for kind \"googleStorage\"",
},
{
name: "kind azureBlobStorage without object",
fields: config.RetrieverConf{
Kind: "azureBlobStorage",
Container: "testcontainer",
AccountName: "devstoreaccount1",
AccountKey: "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq",
},
wantErr: true,
errValue: "invalid retriever: no \"object\" property found for kind \"azureBlobStorage\"",
},
{
name: "kind azureBlobStorage without accountName",
fields: config.RetrieverConf{
Kind: "azureBlobStorage",
Container: "testcontainer",
Object: "flag-config.yaml",
AccountKey: "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq",
},
wantErr: true,
errValue: "invalid retriever: no \"accountName\" property found for kind \"azureBlobStorage\"",
},
{
name: "kind azureBlobStorage without container",
fields: config.RetrieverConf{
Kind: "azureBlobStorage",
Object: "flag-config.yaml",
AccountName: "devstoreaccount1",
AccountKey: "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq",
},
wantErr: true,
errValue: "invalid retriever: no \"container\" property found for kind \"azureBlobStorage\"",
},
{
name: "valid s3",
fields: config.RetrieverConf{
Expand Down
8 changes: 8 additions & 0 deletions cmd/relayproxy/service/gofeatureflag.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"github.com/thomaspoignant/go-feature-flag/notifier/slacknotifier"
"github.com/thomaspoignant/go-feature-flag/notifier/webhooknotifier"
"github.com/thomaspoignant/go-feature-flag/retriever"
"github.com/thomaspoignant/go-feature-flag/retriever/azblobstorageretriever"
"github.com/thomaspoignant/go-feature-flag/retriever/bitbucketretriever"
"github.com/thomaspoignant/go-feature-flag/retriever/fileretriever"
"github.com/thomaspoignant/go-feature-flag/retriever/gcstorageretriever"
Expand Down Expand Up @@ -186,6 +187,13 @@ func initRetriever(c *config.RetrieverConf) (retriever.Retriever, error) {
return &mongodbretriever.Retriever{Database: c.Database, URI: c.URI, Collection: c.Collection}, nil
case config.RedisRetriever:
return &redisretriever.Retriever{Options: c.RedisOptions, Prefix: c.RedisPrefix}, nil
case config.AzBlobStorageRetriever:
return &azblobretriever.Retriever{
Container: c.Container,
Object: c.Object,
AccountName: c.AccountName,
AccountKey: c.AccountKey,
}, nil
default:
return nil, fmt.Errorf("invalid retriever: kind \"%s\" "+
"is not supported", c.Kind)
Expand Down
105 changes: 105 additions & 0 deletions retriever/azblobstorageretriever/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# Azure Blob Storage Feature Flag Retriever

This retriever is used to retrieve data from a Container on Azure Blob Storage.

## Installation

```bash
go get github.com/thomaspoignant/go-feature-flag/retriever/azblobstorageretriever
```

## Usage

### Configuration

Create a `Retriever` struct with the following fields:

- `Container`: Name of the Azure Blob Storage container
- `AccountName`: Azure Storage Account Name
- `AccountKey`: (Optional) Storage Account Key
- `ServiceURL`: (Optional) Custom service URL
- `Object`: Name of the feature flag file in the container

### Authentication Methods

#### 1. Shared Key Authentication

```go
retriever := &azblobretriever.Retriever{
Container: "your-container",
AccountName: "your-account-name",
AccountKey: "your-account-key",
Object: "feature-flags.json",
}
```

#### 2. Microsoft Entra ID (Recommended)

```go
retriever := &azblobretriever.Retriever{
Container: "your-container",
AccountName: "your-account-name",
Object: "feature-flags.json",
}
```

### Retrieving Feature Flags

```go
package main

import (
"context"
"fmt"
"log"
"time"

"github.com/thomaspoignant/go-feature-flag/retriever/azblobstorageretriever"
)

func main() {
retriever := &azblobretriever.Retriever{
Container: "feature-flags",
AccountName: "mystorageaccount",
AccountKey: "your-account-key",
Object: "flags.json",
}

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

err := retriever.Init(ctx, nil)
defer func() { _ = r.Shutdown(ctx) }()
if err != nil {
log.Fatalf("Failed to initialize retriever:", err)
}

data, err := retriever.Retrieve(ctx)
if err != nil {
log.Fatalf("Failed to retrieve feature flags: %v", err)
}

fmt.Println("Retrieved feature flags:")
fmt.Println(string(data))
}
```

## Key Features

- Supports both shared key and default Azure credential authentication
- Automatic retry mechanism for blob downloads
- Flexible configuration with optional custom service URL

## Error Handling

The `Retrieve` method returns an error if:
- `AccountName` is empty
- `Container` or `Object` is not specified
- There's an issue initializing the Azure client.
- There's a problem downloading or reading the file from Azure Blob Storage.

## Best Practices

- **Security** Never hard-code your `AccountKey` in your source code. Use environment variables or a secure secret management system.
- **Error Handling**: Always check for errors returned by the `Retrieve` method.
- **Context**: Use a context with timeout for better control over the retrieval process.
113 changes: 113 additions & 0 deletions retriever/azblobstorageretriever/retriever.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package azblobretriever

import (
"context"
"fmt"
"io"

"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob"
"github.com/thomaspoignant/go-feature-flag/retriever"
"github.com/thomaspoignant/go-feature-flag/utils/fflog"
)

type Retriever struct {
// Container is the name of your Azure Blob Storage Container.
Container string

// Storage Account Name and Key
AccountName string
AccountKey string

// ServiceURL is the URL of the storage account e.g. https://<account>.blob.core.windows.net/
// It can be overridden by the user to use a custom URL.
// Default: https://<account>.blob.core.windows.net/
ServiceURL string

// Object is the name of your file in your container.
Object string

// client is a pointer to an Azure Blob Storage client.
// It provides access to Azure Blob Storage services for operations like
// creating, reading, updating, and deleting blobs.
client *azblob.Client
status retriever.Status
}

func (r *Retriever) Init(_ context.Context, _ *fflog.FFLogger) error {
if r.AccountName == "" {
return fmt.Errorf("unable to connect to Azure Blob Storage, \"AccountName\" cannot be empty")
}

url := r.ServiceURL
if url == "" {
url = fmt.Sprintf("https://%s.blob.core.windows.net/", r.AccountName)
}

var client *azblob.Client
var err error

if r.AccountKey == "" {
var cred *azidentity.DefaultAzureCredential
cred, err = azidentity.NewDefaultAzureCredential(nil)
if err != nil {
r.status = retriever.RetrieverError
return err
}
client, err = azblob.NewClient(url, cred, nil)
} else {
var cred *azblob.SharedKeyCredential
cred, err = azblob.NewSharedKeyCredential(r.AccountName, r.AccountKey)
if err != nil {
r.status = retriever.RetrieverError
return err
}
client, err = azblob.NewClientWithSharedKeyCredential(url, cred, nil)
}

if err != nil {
r.status = retriever.RetrieverError
return err
}

r.client = client
r.status = retriever.RetrieverReady
return nil
}

func (r *Retriever) Shutdown(_ context.Context) error {
r.client = nil
r.status = retriever.RetrieverNotReady
return nil
}

func (r *Retriever) Status() retriever.Status {
return r.status
}

func (r *Retriever) Retrieve(ctx context.Context) ([]byte, error) {
if r.client == nil {
r.status = retriever.RetrieverError
return nil, fmt.Errorf("client is not initialized")
}

if r.Object == "" || r.Container == "" {
return nil, fmt.Errorf("missing mandatory information object=%s, repositorySlug=%s", r.Object, r.Container)
}

fileStream, err := r.client.DownloadStream(ctx, r.Container, r.Object, nil)
if err != nil {
return nil, err
}

retryReader := fileStream.NewRetryReader(ctx, nil)
defer func() { _ = retryReader.Close() }()

body, err := io.ReadAll(retryReader)
if err != nil {
return nil,
fmt.Errorf("unable to read from Azure Blob Storage Object %s in Container %s, error: %s", r.Object, r.Container, err)
}

return body, nil
}
Loading

0 comments on commit 158e04a

Please sign in to comment.