Skip to content

Commit

Permalink
feat: added custom grpc resolver (#1424)
Browse files Browse the repository at this point in the history
## This PR
Added custom gRPC resolver to support envoy proxy

- support gRPC custom resolver 

### Related Issues

Fixes open-feature/go-sdk-contrib#585

### Notes

- update `.github/workflow/build.yaml` to install `envoy`
[binary](https://www.envoyproxy.io/docs/envoy/latest/start/install#install-binaries)
part of e2e test.
- this is a pre-requisite for `flagd` go [provider
update](open-feature/go-sdk-contrib#585)

### How to test
Unit test are already added for the custom resolver for integration test
you need a working envoy proxy support or any of the supported core
resolver mentioned
[here](https://grpc.io/docs/guides/custom-name-resolution/#overview)

```shell
bin % ./flagd start -x --uri envoy://localhost:9211/test.service

                 ______   __       ________   _______    ______      
                /_____/\ /_/\     /_______/\ /______/\  /_____/\     
                \::::_\/_\:\ \    \::: _  \ \\::::__\/__\:::_ \ \    
                 \:\/___/\\:\ \    \::(_)  \ \\:\ /____/\\:\ \ \ \   
                  \:::._\/ \:\ \____\:: __  \ \\:\\_  _\/ \:\ \ \ \  
                   \:\ \    \:\/___/\\:.\ \  \ \\:\_\ \ \  \:\/.:| | 
                    \_\/     \_____\/ \__\/\__\/ \_____\/   \____/_/                                                                                                            

2024-10-14T20:19:51.411+0200    info    cmd/start.go:120        flagd version: dev (f716423), built at: 2024-10-14T20:19:34Z    {"component": "start"}
2024-10-14T20:19:51.412+0200    debug   telemetry/builder.go:81 skipping trace provider setup as collector target is not set. Traces will use NoopTracerProvider provider and propagator will use no-Op TextMapPropagator
2024-10-14T20:19:51.412+0200    info    flag-sync/sync_service.go:54    starting flag sync service on port 8015 {"component": "FlagSyncService"}
2024-10-14T20:19:51.412+0200    debug   builder/syncbuilder.go:111      using grpc sync-provider for: envoy://localhost:9211/test.service       {"component": "sync"}
2024-10-14T20:19:51.413+0200    info    flag-evaluation/connect_service.go:247  metrics and probes listening at 8014    {"component": "service"}
2024-10-14T20:19:51.413+0200    info    ofrep/ofrep_service.go:56       ofrep service listening at 8016 {"component": "OFREPService"}
2024-10-14T20:19:51.415+0200    info    flag-evaluation/connect_service.go:227  Flag IResolver listening at [::]:8013   {"component": "service"}
2024-10-14T20:19:51.428+0200    debug   grpc/grpc_sync.go:201   received full configuration payload     {"component": "sync", "sync": "grpc"}
2024-10-14T20:19:55.057+0200    debug   grpc/grpc_sync.go:201   received full configuration payload     {"component": "sync", "sync": "grpc"}
```

---------

Signed-off-by: Pradeep <[email protected]>
Signed-off-by: Matthew Wilson <[email protected]>
Co-authored-by: Matthew Wilson <[email protected]>
  • Loading branch information
pradeepbbl and wilson-matthew authored Oct 28, 2024
1 parent 02d881e commit e5007e2
Show file tree
Hide file tree
Showing 8 changed files with 272 additions and 332 deletions.
8 changes: 8 additions & 0 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,14 @@ jobs:
with:
go-version-file: 'flagd/go.mod'

- name: Install envoy
run: |
wget -O- https://apt.envoyproxy.io/signing.key | sudo gpg --dearmor -o /etc/apt/keyrings/envoy-keyring.gpg
echo "deb [signed-by=/etc/apt/keyrings/envoy-keyring.gpg] https://apt.envoyproxy.io jammy main" | sudo tee /etc/apt/sources.list.d/envoy.list
sudo apt-get update
sudo apt-get install envoy
envoy --version
- name: Workspace init
run: make workspace-init

Expand Down
16 changes: 9 additions & 7 deletions core/pkg/sync/builder/syncbuilder.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,20 +35,22 @@ const (
)

var (
regCrd *regexp.Regexp
regURL *regexp.Regexp
regGRPC *regexp.Regexp
regGRPCSecure *regexp.Regexp
regFile *regexp.Regexp
regGcs *regexp.Regexp
regAzblob *regexp.Regexp
regCrd *regexp.Regexp
regURL *regexp.Regexp
regGRPC *regexp.Regexp
regGRPCSecure *regexp.Regexp
regGRPCCustomResolver *regexp.Regexp
regFile *regexp.Regexp
regGcs *regexp.Regexp
regAzblob *regexp.Regexp
)

func init() {
regCrd = regexp.MustCompile("^core.openfeature.dev/")
regURL = regexp.MustCompile("^https?://")
regGRPC = regexp.MustCompile("^" + grpc.Prefix)
regGRPCSecure = regexp.MustCompile("^" + grpc.PrefixSecure)
regGRPCCustomResolver = regexp.MustCompile("^" + grpc.SupportedScheme)
regFile = regexp.MustCompile("^file:")
regGcs = regexp.MustCompile("^gs://.+?/")
regAzblob = regexp.MustCompile("^azblob://.+?/")
Expand Down
5 changes: 5 additions & 0 deletions core/pkg/sync/builder/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ func ParseSyncProviderURIs(uris []string) ([]sync.SourceConfig, error) {
Provider: syncProviderGrpc,
TLS: true,
})
case regGRPCCustomResolver.Match(uriB):
syncProvidersParsed = append(syncProvidersParsed, sync.SourceConfig{
URI: uri,
Provider: syncProviderGrpc,
})
case regGcs.Match(uriB):
syncProvidersParsed = append(syncProvidersParsed, sync.SourceConfig{
URI: uri,
Expand Down
6 changes: 4 additions & 2 deletions core/pkg/sync/grpc/grpc_sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,16 @@ import (
"github.com/open-feature/flagd/core/pkg/logger"
"github.com/open-feature/flagd/core/pkg/sync"
grpccredential "github.com/open-feature/flagd/core/pkg/sync/grpc/credentials"
_ "github.com/open-feature/flagd/core/pkg/sync/grpc/nameresolvers" // initialize custom resolvers e.g. envoy.Init()
"google.golang.org/grpc"
)

const (
// Prefix for GRPC URL inputs. GRPC does not define a standard prefix. This prefix helps to differentiate remote
// URLs for REST APIs (i.e - HTTP) from GRPC endpoints.
Prefix = "grpc://"
PrefixSecure = "grpcs://"
Prefix = "grpc://"
PrefixSecure = "grpcs://"
SupportedScheme = "(envoy|dns|uds|xds)"

// Connection retry constants
// Back off period is calculated with backOffBase ^ #retry-iteration. However, when #retry-iteration count reach
Expand Down
84 changes: 84 additions & 0 deletions core/pkg/sync/grpc/nameresolvers/envoy_resolver.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package nameresolvers

import (
"fmt"
"strings"

"google.golang.org/grpc/resolver"
)

const scheme = "envoy"

type envoyBuilder struct{}

// Build A custom NameResolver to resolve gRPC target uri for envoy in the
// format of.
//
// Custom URI Scheme:
//
// envoy://[proxy-agent-host]:[proxy-agent-port]/[service-name]
func (*envoyBuilder) Build(target resolver.Target,
cc resolver.ClientConn, _ resolver.BuildOptions,
) (resolver.Resolver, error) {
_, err := isValidTarget(target)
if err != nil {
return nil, err
}

r := &envoyResolver{
target: target,
cc: cc,
}
r.start()
return r, nil
}

func (*envoyBuilder) Scheme() string {
return scheme
}

type envoyResolver struct {
target resolver.Target
cc resolver.ClientConn
}

// Envoy NameResolver, will always override the authority with the specified authority i.e. URL.path and
// use the socketAddress i.e. Host:Port to connect.
func (r *envoyResolver) start() {
addr := fmt.Sprintf("%s:%s", r.target.URL.Hostname(), r.target.URL.Port())
err := r.cc.UpdateState(resolver.State{Addresses: []resolver.Address{{Addr: addr}}})
if err != nil {
return
}
}

func (*envoyResolver) ResolveNow(resolver.ResolveNowOptions) {}

func (*envoyResolver) Close() {}

// Validate user specified target
//
// Sample target string: envoy://localhost:9211/test.service
//
// return `true` if the target string used match the scheme and format
func isValidTarget(target resolver.Target) (bool, error) {
// make sure and host and port not empty
// used as resolver.Address
if target.URL.Scheme != "envoy" || target.URL.Hostname() == "" || target.URL.Port() == "" {
return false, fmt.Errorf("envoy-resolver: invalid scheme or missing host/port, target: %s",
target)
}

// make sure the path is valid
// used as :authority e.g. test.service
path := target.Endpoint()
if path == "" || strings.Contains(path, "/") {
return false, fmt.Errorf("envoy-resolver: invalid path %s", path)
}

return true, nil
}

func init() {
resolver.Register(&envoyBuilder{})
}
103 changes: 103 additions & 0 deletions core/pkg/sync/grpc/nameresolvers/envoy_resolver_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package nameresolvers

import (
"net/url"
"testing"

"github.com/stretchr/testify/require"
"google.golang.org/grpc/resolver"
)

func Test_EnvoyTargetString(t *testing.T) {
tests := []struct {
name string
mockURL url.URL
mockError string
shouldError bool
}{
{
name: "Should be valid string",
mockURL: url.URL{
Scheme: "envoy",
Host: "localhost:8080",
Path: "/test.service",
},
mockError: "",
shouldError: false,
},
{
name: "Should be valid scheme",
mockURL: url.URL{
Scheme: "invalid",
Host: "localhost:8080",
Path: "/test.service",
},
mockError: "envoy-resolver: invalid scheme or missing host/port, target: invalid://localhost:8080/test.service",
shouldError: true,
},
{
name: "Should be valid path",
mockURL: url.URL{
Scheme: "envoy",
Host: "localhost:8080",
Path: "/test.service/test",
},
mockError: "envoy-resolver: invalid path test.service/test",
shouldError: true,
},
{
name: "Should be valid path",
mockURL: url.URL{
Scheme: "envoy",
Host: "localhost:8080",
Path: "/test.service/",
},
mockError: "envoy-resolver: invalid path test.service/",
shouldError: true,
},
{
name: "Hostname should not be empty",
mockURL: url.URL{
Scheme: "envoy",
Host: ":8080",
Path: "/test.service",
},
mockError: "envoy-resolver: invalid scheme or missing host/port, target: envoy://:8080/test.service",
shouldError: true,
},
{
name: "Port should not be empty",
mockURL: url.URL{
Scheme: "envoy",
Host: "localhost",
Path: "/test.service",
},
mockError: "envoy-resolver: invalid scheme or missing host/port, target: envoy://localhost/test.service",
shouldError: true,
},
{
name: "Hostname and Port should not be empty",
mockURL: url.URL{
Scheme: "envoy",
Path: "/test.service",
},
mockError: "envoy-resolver: invalid scheme or missing host/port, target: envoy:///test.service",
shouldError: true,
},
}

for _, test := range tests {
target := resolver.Target{URL: test.mockURL}

isValid, err := isValidTarget(target)

if test.shouldError {
require.False(t, isValid, "Should not be valid")
require.NotNilf(t, err, "Error should not be nil")
require.Containsf(t, err.Error(), test.mockError, "Error should contains %s", test.mockError)
} else {
require.True(t, isValid, "Should be valid")
require.NoErrorf(t, err, "Error should be nil")
}
}
}
15 changes: 15 additions & 0 deletions docs/reference/sync-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,20 @@ it is passed to the correct implementation:
| `file` | `file:` | `file:etc/flagd/my-flags.json` |
| `http` | `http(s)://` | `https://my-flags.com/flags` |
| `grpc` | `grpc(s)://` | `grpc://my-flags-server` |
| &nbsp;[grpc](#custom-grpc-target-uri) | `[ envoy \| dns \| uds\| xds ]://` | `envoy://localhost:9211/test.service` |
| `gcs` | `gs://` | `gs://my-bucket/my-flags.json` |
| `azblob` | `azblob://` | `azblob://my-container/my-flags.json` |

### Custom gRPC Target URI

Apart from default `dns` resolution, Flagd also support different resolution method e.g. `xds`. Currently, we are supporting all [core resolver](https://grpc.io/docs/guides/custom-name-resolution/)
and one custom resolver for `envoy` proxy resolution. For more details, please refer the
[RFC](https://github.com/open-feature/flagd/blob/main/docs/reference/specifications/proposal/rfc-grpc-custom-name-resolver.md) document.

```shell
./bin/flagd start -x --uri envoy://localhost:9211/test.service
```

## Source Configuration

While a URI may be passed to flagd via the `--uri` (`-f`) flag, some implementations may require further configurations.
Expand Down Expand Up @@ -65,6 +76,7 @@ Sync providers:
- `kubernetes` - default/my-flag-config
- `grpc`(insecure) - grpc-source:8080
- `grpcs`(secure) - my-flag-source:8080
- `grpc`(envoy) - envoy://localhost:9211/test.service
- `gcs` - gs://my-bucket/my-flags.json
- `azblob` - azblob://my-container/my-flags.json

Expand All @@ -81,6 +93,7 @@ Startup command:
{"uri":"default/my-flag-config","provider":"kubernetes"},
{"uri":"grpc-source:8080","provider":"grpc"},
{"uri":"my-flag-source:8080","provider":"grpc", "maxMsgSize": 5242880},
{"uri":"envoy://localhost:9211/test.service", "provider":"grpc"},
{"uri":"my-flag-source:8080","provider":"grpc", "certPath": "/certs/ca.cert", "tls": true, "providerID": "flagd-weatherapp-sidecar", "selector": "source=database,app=weatherapp"},
{"uri":"gs://my-bucket/my-flag.json","provider":"gcs"},
{"uri":"azblob://my-container/my-flag.json","provider":"azblob"}]'
Expand All @@ -106,6 +119,8 @@ sources:
- uri: my-flag-source:8080
provider: grpc
maxMsgSize: 5242880
- uri: envoy://localhost:9211/test.service
provider: grpc
- uri: my-flag-source:8080
provider: grpc
certPath: /certs/ca.cert
Expand Down
Loading

0 comments on commit e5007e2

Please sign in to comment.