diff --git a/runtimevar/etcdvar/etcdvar.go b/runtimevar/etcdvar/etcdvar.go index 352116ef26..3b137b8e2a 100644 --- a/runtimevar/etcdvar/etcdvar.go +++ b/runtimevar/etcdvar/etcdvar.go @@ -15,6 +15,18 @@ // Package etcdvar provides a runtimevar implementation with variables // backed by etcd. Use New to construct a *runtimevar.Variable. // +// URLs +// +// For runtimevar.OpenVariable URLs, etcdvar registers for the scheme "etcd". +// The host+path is used as the blob key. etcdvar supports the following URL +// parameters: +// - client: The URL to be passed to etcd's +// go.etcd.io/etcd/clientv3.NewFromURL (required). +// NewFromURL will be called once per unique client URL. +// - decoder: The decoder to use. Defaults to runtimevar.BytesDecoder. +// See runtimevar.DecoderByName for supported values. +// Example URL: "blob://myvar?client=http://my.etcd.server:8080&decoder=string". +// // As // // etcdvar exposes the following types for As: @@ -26,6 +38,9 @@ import ( "context" "errors" "fmt" + "net/url" + "path" + "sync" "time" "go.etcd.io/etcd/clientv3" @@ -36,6 +51,80 @@ import ( "google.golang.org/grpc/codes" ) +func init() { + runtimevar.DefaultURLMux().RegisterVariable(Scheme, &lazyClientOpener{}) +} + +// Scheme is the URL scheme etcdvar registers its URLOpener under on runtimevar.DefaultMux. +const Scheme = "etcd" + +type lazyClientOpener struct { + mu sync.Mutex + clients map[string]*clientv3.Client +} + +func (o *lazyClientOpener) OpenVariableURL(ctx context.Context, u *url.URL) (*runtimevar.Variable, error) { + clientURL := u.Query().Get("client") + if clientURL == "" { + return nil, fmt.Errorf("open variable %q: URL parameter \"client\" is required", u) + } + o.mu.Lock() + defer o.mu.Unlock() + if o.clients == nil { + o.clients = map[string]*clientv3.Client{} + } + cli := o.clients[clientURL] + if cli == nil { + var err error + cli, err = clientv3.NewFromURL(clientURL) + if err != nil { + return nil, fmt.Errorf("open variable %q: NewFromURL failed: %v", u, err) + } + o.clients[clientURL] = cli + } + opener := URLOpener{Client: cli} + return opener.OpenVariableURL(ctx, u) +} + +// URLOpener opens Variable URLs like "etcd://mykey?decoder=string". +// It supports the URL parameters: +// - decoder: The decoder to use. Defaults to runtimevar.BytesDecoder. +// See runtimevar.DecoderByName for supported values. +type URLOpener struct { + // The Client to use; required. + Client *clientv3.Client + + // Decoder and Options can be specified at URLOpener construction time, + // or provided/overridden via URL parameters. + Decoder *runtimevar.Decoder + Options Options +} + +// OpenVariableURL opens the variable at the URL's path. See the package doc +// for more details. +func (o *URLOpener) OpenVariableURL(ctx context.Context, u *url.URL) (*runtimevar.Variable, error) { + if o.Client == nil { + return nil, fmt.Errorf("open variable %q: a client is required", u) + } + q := u.Query() + if decoderName := q.Get("decoder"); decoderName != "" || o.Decoder == nil { + var err error + o.Decoder, err = runtimevar.DecoderByName(decoderName) + if err != nil { + return nil, fmt.Errorf("open variable %q: invalid \"decoder\": %v", u, err) + } + } + for param := range q { + switch param { + case "decoder", "client": + // processed elsewhere + default: + return nil, fmt.Errorf("open variable %q: invalid query parameter %q", u, param) + } + } + return New(o.Client, path.Join(u.Host, u.Path), o.Decoder, &o.Options) +} + // Options sets options. type Options struct { // Timeout controls the timeout on RPCs to etcd; timeouts will result in diff --git a/runtimevar/etcdvar/etcdvar_test.go b/runtimevar/etcdvar/etcdvar_test.go index 6deb84578b..8e7b748272 100644 --- a/runtimevar/etcdvar/etcdvar_test.go +++ b/runtimevar/etcdvar/etcdvar_test.go @@ -20,6 +20,7 @@ import ( "testing" "time" + "github.com/google/go-cmp/cmp" "go.etcd.io/etcd/clientv3" "go.etcd.io/etcd/embed" "go.etcd.io/etcd/etcdserver/api/v3rpc/rpctypes" @@ -156,3 +157,58 @@ func TestNoConnectionError(t *testing.T) { t.Error("got nil want error") } } + +func TestOpenVariable(t *testing.T) { + h, err := newHarness(t) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + if err := h.CreateVariable(ctx, "string-var", []byte("hello world")); err != nil { + t.Fatal(err) + } + if err := h.CreateVariable(ctx, "json-var", []byte(`{"Foo": "Bar"}`)); err != nil { + t.Fatal(err) + } + + tests := []struct { + URL string + WantErr bool + WantWatchErr bool + Want interface{} + }{ + // Variable construction succeeds, but nonexistentvar does not exist + // so we get an error from Watch. + {"etcd://nonexistentvar?client=http://localhost:2379", false, true, nil}, + // Variable construction fails due to missing client arg. + {"etcd://string-var", true, false, nil}, + // Variable construction fails due to invalid decoder arg. + {"etcd://string-var?client=http://localhost:2379&decoder=notadecoder", true, false, nil}, + // Variable construction fails due to invalid arg. + {"etcd://string-var?client=http://localhost:2379¶m=value", true, false, nil}, + // Working example with string decoder. + {"etcd://string-var?client=http://localhost:2379&decoder=string", false, false, "hello world"}, + // Working example with JSON decoder. + {"etcd://json-var?client=http://localhost:2379&decoder=jsonmap", false, false, &map[string]interface{}{"Foo": "Bar"}}, + } + + for _, test := range tests { + v, err := runtimevar.OpenVariable(ctx, test.URL) + if (err != nil) != test.WantErr { + t.Errorf("%s: got error %v, want error %v", test.URL, err, test.WantErr) + } + if err != nil { + continue + } + snapshot, err := v.Watch(ctx) + if (err != nil) != test.WantWatchErr { + t.Errorf("%s: got Watch error %v, want error %v", test.URL, err, test.WantWatchErr) + } + if err != nil { + continue + } + if !cmp.Equal(snapshot.Value, test.Want) { + t.Errorf("%s: got snapshot value\n%v\n want\n%v", test.URL, snapshot.Value, test.Want) + } + } +} diff --git a/runtimevar/etcdvar/example_test.go b/runtimevar/etcdvar/example_test.go index cade2f4dcd..608a78ad79 100644 --- a/runtimevar/etcdvar/example_test.go +++ b/runtimevar/etcdvar/example_test.go @@ -56,3 +56,16 @@ func Example() { cfg := snapshot.Value.(MyConfig) _ = cfg } + +func Example_openVariable() { + // OpenVariable creates a *runtimevar.Variable from a URL. + // This example watches a variable based on a file-based blob.Bucket with JSON. + ctx := context.Background() + v, err := runtimevar.OpenVariable(ctx, "etcd://myvarname?client=my.etcd.server:9999") + if err != nil { + log.Fatal(err) + } + + snapshot, err := v.Watch(ctx) + _, _ = snapshot, err +} diff --git a/runtimevar/filevar/example_test.go b/runtimevar/filevar/example_test.go index 1d0d5e4f3f..617f947447 100644 --- a/runtimevar/filevar/example_test.go +++ b/runtimevar/filevar/example_test.go @@ -61,3 +61,15 @@ func Example() { // Output: // foo.com running on port 80 } + +func Example_openVariable() { + // OpenVariable creates a *runtimevar.Variable from a URL. + ctx := context.Background() + v, err := runtimevar.OpenVariable(ctx, "file:///path/to/config.json?decoder=json") + if err != nil { + log.Fatal(err) + } + + snapshot, err := v.Watch(ctx) + _, _ = snapshot, err +} diff --git a/runtimevar/filevar/filevar.go b/runtimevar/filevar/filevar.go index 67e72c5ff6..6dab411f61 100644 --- a/runtimevar/filevar/filevar.go +++ b/runtimevar/filevar/filevar.go @@ -26,6 +26,12 @@ // * On macOS, if an empty file is copied into a configuration file, // filevar will not detect the change. // +// URLs +// +// For runtimevar.OpenVariable URLs, filevar registers for the scheme +// "file". For details on the format of the URL, see URLOpener. +// Example URL: file:///path/to/config.json?decoder=json +// // As // // filevar does not support any types for As. @@ -35,9 +41,12 @@ import ( "bytes" "context" "errors" + "fmt" "io/ioutil" + "net/url" "os" "path/filepath" + "strings" "time" "github.com/fsnotify/fsnotify" @@ -46,6 +55,68 @@ import ( "gocloud.dev/runtimevar/driver" ) +func init() { + runtimevar.DefaultURLMux().RegisterVariable(Scheme, &URLOpener{}) +} + +// Scheme is the URL scheme filevar registers its URLOpener under on runtimevar.DefaultMux. +const Scheme = "file" + +// URLOpener opens filevar URLs like "file:///path/to/config.json?decoder=json". +type URLOpener struct { + // Decoder and Options can be specified at URLOpener construction time, + // or provided/overridden via URL parameters. + Decoder *runtimevar.Decoder + Options Options +} + +// OpenVariableURL opens the file variable at the URL's path. +// The URL's host+path is used as the path to the file to watch. +// If os.PathSeparator != "/", any leading "/" from the path is dropped +// and remaining '/' characters are converted to os.PathSeparator. +// +// In addition, the following URL parameters are supported: +// - decoder: The decoder to use. Defaults to URLOpener.Decoder, or +// runtimevar.BytesDecoder if URLOpener.Decoder is nil. +// See runtimevar.DecoderByName for supported values. +// - wait: The poll interval; supported values are from time.ParseDuration. +// Defaults to 30s. +func (o *URLOpener) OpenVariableURL(ctx context.Context, u *url.URL) (*runtimevar.Variable, error) { + q := u.Query() + if decoderName := q.Get("decoder"); decoderName != "" || o.Decoder == nil { + var err error + o.Decoder, err = runtimevar.DecoderByName(q.Get("decoder")) + if err != nil { + return nil, fmt.Errorf("open variable %q: invalid \"decoder\": %v", u, err) + } + q.Del("decoder") + } + + if wait := q.Get("wait"); wait != "" { + var err error + o.Options.WaitDuration, err = time.ParseDuration(wait) + if err != nil { + return nil, fmt.Errorf("open variable %q: invalid \"wait\": %v", u, err) + } + q.Del("wait") + } + for param := range q { + return nil, fmt.Errorf("open bucket %q: invalid query parameter %q", u, param) + } + return New(mungeURLPath(u.Path, os.PathSeparator), o.Decoder, &o.Options) +} + +func mungeURLPath(path string, pathSeparator byte) string { + if pathSeparator != '/' { + path = strings.TrimPrefix(path, "/") + // TODO: use filepath.FromSlash instead; and remove the pathSeparator arg + // from this function. Test Windows behavior by opening a bucket on Windows. + // See #1075 for why Windows is disabled. + return strings.Replace(path, "/", string(pathSeparator), -1) + } + return path +} + // Options sets options. type Options struct { // WaitDuration controls the frequency of retries after an error. For example, diff --git a/runtimevar/filevar/filevar_test.go b/runtimevar/filevar/filevar_test.go index 61adfa49a3..6328e806ec 100644 --- a/runtimevar/filevar/filevar_test.go +++ b/runtimevar/filevar/filevar_test.go @@ -17,6 +17,7 @@ package filevar import ( "context" "errors" + "github.com/google/go-cmp/cmp" "io/ioutil" "os" "path/filepath" @@ -170,3 +171,63 @@ func TestNew(t *testing.T) { }) } } + +func TestOpenVariable(t *testing.T) { + dir, err := ioutil.TempDir("", "gcdk-filevar-example") + if err != nil { + t.Fatal(err) + } + jsonPath := filepath.Join(dir, "myvar.json") + if err := ioutil.WriteFile(jsonPath, []byte(`{"Foo": "Bar"}`), 0666); err != nil { + t.Fatal(err) + } + txtPath := filepath.Join(dir, "myvar.txt") + if err := ioutil.WriteFile(txtPath, []byte("hello world!"), 0666); err != nil { + t.Fatal(err) + } + nonexistentPath := filepath.Join(dir, "filenotfound") + defer os.RemoveAll(dir) + + tests := []struct { + URL string + WantErr bool + WantWatchErr bool + Want interface{} + }{ + // Variable construction succeeds, but the file does not exist. + {"file://" + nonexistentPath, false, true, nil}, + // Variable construction fails due to invalid wait arg. + {"file://" + txtPath + "?decoder=string&wait=notaduration", true, false, nil}, + // Variable construction fails due to invalid decoder arg. + {"file://" + txtPath + "?decoder=notadecoder", true, false, nil}, + // Variable construction fails due to invalid arg. + {"file://" + txtPath + "?param=value", true, false, nil}, + // Working example with default decoder. + {"file://" + txtPath, false, false, []byte("hello world!")}, + // Working example with string decoder and wait. + {"file://" + txtPath + "?decoder=string&wait=5s", false, false, "hello world!"}, + // Working example with JSON decoder. + {"file://" + jsonPath + "?decoder=jsonmap", false, false, &map[string]interface{}{"Foo": "Bar"}}, + } + + ctx := context.Background() + for _, test := range tests { + v, err := runtimevar.OpenVariable(ctx, test.URL) + if (err != nil) != test.WantErr { + t.Errorf("%s: got error %v, want error %v", test.URL, err, test.WantErr) + } + if err != nil { + continue + } + snapshot, err := v.Watch(ctx) + if (err != nil) != test.WantWatchErr { + t.Errorf("%s: got Watch error %v, want error %v", test.URL, err, test.WantWatchErr) + } + if err != nil { + continue + } + if !cmp.Equal(snapshot.Value, test.Want) { + t.Errorf("%s: got snapshot value\n%v\n want\n%v", test.URL, snapshot.Value, test.Want) + } + } +} diff --git a/runtimevar/paramstore/example_test.go b/runtimevar/paramstore/example_test.go index 22a2317d08..d1c7cd902c 100644 --- a/runtimevar/paramstore/example_test.go +++ b/runtimevar/paramstore/example_test.go @@ -57,3 +57,15 @@ func Example() { cfg := snapshot.Value.(MyConfig) _ = cfg } + +func Example_openVariable() { + // OpenVariable creates a *runtimevar.Variable from a URL. + ctx := context.Background() + v, err := runtimevar.OpenVariable(ctx, "paramstore://myvar?region=us-west-1") + if err != nil { + log.Fatal(err) + } + + snapshot, err := v.Watch(ctx) + _, _ = snapshot, err +} diff --git a/runtimevar/paramstore/paramstore.go b/runtimevar/paramstore/paramstore.go index 4f49b3dbc6..eb160fcea1 100644 --- a/runtimevar/paramstore/paramstore.go +++ b/runtimevar/paramstore/paramstore.go @@ -17,6 +17,13 @@ // (https://docs.aws.amazon.com/systems-manager/latest/userguide/systems-manager-paramstore.html) // Use NewVariable to construct a *runtimevar.Variable. // +// URLs +// +// For runtimevar.OpenVariable URLs, paramstore registers for the scheme +// "paramstore". runtimevar.OpenVariable will create a new AWS session with the +// default options. If you want to use a different session or +// find details on the format of the URL, see URLOpener. +// // As // // paramstore exposes the following types for As: @@ -27,18 +34,106 @@ package paramstore // import "gocloud.dev/runtimevar/paramstore" import ( "context" "fmt" + "net/url" + "path" + "sync" "time" - "gocloud.dev/gcerrors" - "gocloud.dev/runtimevar" - "gocloud.dev/runtimevar/driver" - "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/awserr" "github.com/aws/aws-sdk-go/aws/client" + "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/ssm" + gcaws "gocloud.dev/aws" + "gocloud.dev/gcerrors" + "gocloud.dev/runtimevar" + "gocloud.dev/runtimevar/driver" ) +func init() { + runtimevar.DefaultURLMux().RegisterVariable(Scheme, new(lazySessionOpener)) +} + +// URLOpener opens AWS Paramstore URLs like "paramstore://myvar". +// See gocloud.dev/aws/ConfigFromURLParams for supported query parameters +// that affect the default AWS session. +// +// In addition, the following URL parameters are supported: +// - decoder: The decoder to use. Defaults to URLOpener.Decoder, or +// runtimevar.BytesDecoder if URLOpener.Decoder is nil. +// See runtimevar.DecoderByName for supported values. +// - wait: The poll interval; supported values are from time.ParseDuration. +// Defaults to 30s. +type URLOpener struct { + // ConfigProvider must be set to a non-nil value. + ConfigProvider client.ConfigProvider + + // Decoder and Options can be specified at URLOpener construction time, + // or provided/overridden via URL parameters. + Decoder *runtimevar.Decoder + Options Options +} + +// lazySessionOpener obtains the AWS session from the environment on the first +// call to OpenVariableURL. +type lazySessionOpener struct { + init sync.Once + opener *URLOpener + err error +} + +func (o *lazySessionOpener) OpenVariableURL(ctx context.Context, u *url.URL) (*runtimevar.Variable, error) { + o.init.Do(func() { + sess, err := session.NewSessionWithOptions(session.Options{SharedConfigState: session.SharedConfigEnable}) + if err != nil { + o.err = err + return + } + o.opener = &URLOpener{ + ConfigProvider: sess, + } + }) + if o.err != nil { + return nil, fmt.Errorf("open variable %q: %v", u, o.err) + } + return o.opener.OpenVariableURL(ctx, u) +} + +// Scheme is the URL scheme paramstore registers its URLOpener under on runtimevar.DefaultMux. +const Scheme = "paramstore" + +// OpenVariableURL opens the paramstore variable with the name as the host+path from the URL. +func (o *URLOpener) OpenVariableURL(ctx context.Context, u *url.URL) (*runtimevar.Variable, error) { + q := u.Query() + if decoderName := q.Get("decoder"); decoderName != "" || o.Decoder == nil { + var err error + o.Decoder, err = runtimevar.DecoderByName(q.Get("decoder")) + if err != nil { + return nil, fmt.Errorf("open variable %q: invalid \"decoder\": %v", u, err) + } + q.Del("decoder") + } + + if wait := q.Get("wait"); wait != "" { + var err error + o.Options.WaitDuration, err = time.ParseDuration(wait) + if err != nil { + return nil, fmt.Errorf("open variable %q: invalid \"wait\": %v", u, err) + } + q.Del("wait") + } + + configProvider := &gcaws.ConfigOverrider{ + Base: o.ConfigProvider, + } + overrideCfg, err := gcaws.ConfigFromURLParams(q) + if err != nil { + return nil, fmt.Errorf("open variable %v: %v", u, err) + } + configProvider.Configs = append(configProvider.Configs, overrideCfg) + return NewVariable(configProvider, path.Join(u.Host, u.Path), o.Decoder, &o.Options) +} + // Options sets options. type Options struct { // WaitDuration controls the rate at which Parameter Store is polled. diff --git a/runtimevar/paramstore/paramstore_test.go b/runtimevar/paramstore/paramstore_test.go index 582795ddee..5e82979cd4 100644 --- a/runtimevar/paramstore/paramstore_test.go +++ b/runtimevar/paramstore/paramstore_test.go @@ -157,3 +157,26 @@ func TestNoConnectionError(t *testing.T) { t.Error("got nil want error") } } + +func TestOpenVariable(t *testing.T) { + tests := []struct { + URL string + WantErr bool + }{ + {"paramstore://myvar", false}, + {"paramstore://myvar?region=us-west-1", false}, + {"paramstore://myvar?decoder=string", false}, + {"paramstore://myvar?decoder=notadecoder", true}, + {"paramstore://myvar?wait=30s", false}, + {"paramstore://myvar?wait=notaduration", true}, + {"paramstore://myvar?param=value", true}, + } + + ctx := context.Background() + for _, test := range tests { + _, err := runtimevar.OpenVariable(ctx, test.URL) + if (err != nil) != test.WantErr { + t.Errorf("%s: got error %v, want error %v", test.URL, err, test.WantErr) + } + } +} diff --git a/runtimevar/runtimeconfigurator/example_test.go b/runtimevar/runtimeconfigurator/example_test.go index 90b50e3fb4..a7dc671164 100644 --- a/runtimevar/runtimeconfigurator/example_test.go +++ b/runtimevar/runtimeconfigurator/example_test.go @@ -73,3 +73,15 @@ func Example() { cfg := snapshot.Value.(MyConfig) _ = cfg } + +func Example_openVariable() { + // OpenVariable creates a *runtimevar.Variable from a URL. + ctx := context.Background() + v, err := runtimevar.OpenVariable(ctx, "runtimeconfigurator://myproject/myconfigid/myvar?decoder=string") + if err != nil { + log.Fatal(err) + } + + snapshot, err := v.Watch(ctx) + _, _ = snapshot, err +} diff --git a/runtimevar/runtimeconfigurator/runtimeconfigurator.go b/runtimevar/runtimeconfigurator/runtimeconfigurator.go index 0e4a395b31..0794d30f76 100644 --- a/runtimevar/runtimeconfigurator/runtimeconfigurator.go +++ b/runtimevar/runtimeconfigurator/runtimeconfigurator.go @@ -17,6 +17,15 @@ // (https://cloud.google.com/deployment-manager/runtime-configurator). // Use NewVariable to construct a *runtimevar.Variable. // +// URLs +// +// For runtimevar.OpenVariable URLs, runtimeconfigurator registers for the +// scheme "runtimeconfigurator". runtimevar.OpenVariable will use Application +// Default Credentials, as described in https://cloud.google.com/docs/authentication/production. +// If you want to use different credentials or find details on the format of the +// URL, see URLOpener. +// Example URL: runtimeconfigurator://myproject/myconfig/myvar?decoder=string +// // As // // runtimeconfigurator exposes the following types for As: @@ -28,6 +37,9 @@ import ( "bytes" "context" "fmt" + "net/url" + "strings" + "sync" "time" "github.com/golang/protobuf/ptypes" @@ -68,6 +80,104 @@ func Dial(ctx context.Context, ts gcp.TokenSource) (pb.RuntimeConfigManagerClien return pb.NewRuntimeConfigManagerClient(conn), func() { conn.Close() }, nil } +func init() { + runtimevar.DefaultURLMux().RegisterVariable(Scheme, new(lazyCredsOpener)) +} + +// lazyCredsOpener obtains Application Default Credentials on the first call +// to OpenVariableURL. +type lazyCredsOpener struct { + init sync.Once + opener *URLOpener + err error +} + +func (o *lazyCredsOpener) OpenVariableURL(ctx context.Context, u *url.URL) (*runtimevar.Variable, error) { + o.init.Do(func() { + creds, err := gcp.DefaultCredentials(ctx) + if err != nil { + o.err = err + return + } + client, _, err := Dial(ctx, creds.TokenSource) + if err != nil { + o.err = err + return + } + o.opener = &URLOpener{Client: client} + }) + if o.err != nil { + return nil, fmt.Errorf("open variable %q: %v", u, o.err) + } + return o.opener.OpenVariableURL(ctx, u) +} + +// Scheme is the URL scheme runtimeconfigurator registers its URLOpener under on runtimevar.DefaultMux. +const Scheme = "runtimeconfigurator" + +// URLOpener opens runtimeconfigurator URLs like "runtimeconfigurator://myproject/mycfg/myvar", +// where: +// +// - The URL's host holds the GCP projectID. +// - The first element of the URL's path holds the GCP RuntimeConfigurator ConfigID. +// - The second element of the URL's path holds the GCP RuntimeConfigurator Variable Name. +// See https://cloud.google.com/deployment-manager/runtime-configurator/ +// for more details. +// +// This opener supports the following query parameters: +// +// - decoder: The decoder to use. Defaults to URLOpener.Decoder, or +// runtimevar.BytesDecoder if URLOpener.Decoder is nil. +// See runtimevar.DecoderByName for supported values. +// - wait: The poll interval; supported values are from time.ParseDuration. +// Defaults to 30s. +type URLOpener struct { + // Client must be set to a non-nil client authenticated with + // Cloud RuntimeConfigurator scope or equivalent. + Client pb.RuntimeConfigManagerClient + + // Decoder and Options can be specified at URLOpener construction time, + // or provided/overridden via URL parameters. + Decoder *runtimevar.Decoder + Options Options +} + +// OpenVariableURL opens a runtimeconfigurator Variable for u. +func (o *URLOpener) OpenVariableURL(ctx context.Context, u *url.URL) (*runtimevar.Variable, error) { + q := u.Query() + if decoderName := q.Get("decoder"); decoderName != "" || o.Decoder == nil { + var err error + o.Decoder, err = runtimevar.DecoderByName(q.Get("decoder")) + if err != nil { + return nil, fmt.Errorf("open variable %q: invalid \"decoder\": %v", u, err) + } + q.Del("decoder") + } + + if wait := q.Get("wait"); wait != "" { + var err error + o.Options.WaitDuration, err = time.ParseDuration(wait) + if err != nil { + return nil, fmt.Errorf("open variable %q: invalid \"wait\": %v", u, err) + } + q.Del("wait") + } + + for param := range q { + return nil, fmt.Errorf("open variable %q: invalid query parameter %q", u, param) + } + var rn ResourceName + rn.ProjectID = u.Host + if pathParts := strings.SplitN(strings.TrimPrefix(u.Path, "/"), "/", 2); len(pathParts) == 2 { + rn.Config = pathParts[0] + rn.Variable = pathParts[1] + } + if rn.ProjectID == "" || rn.Config == "" || rn.Variable == "" { + return nil, fmt.Errorf("open keeper %q: URL is expected to have a non-empty Host (the project ID), and a Path with 2 non-empty elements (the key config and key name)", u) + } + return NewVariable(o.Client, rn, o.Decoder, &o.Options) +} + // Options sets options. type Options struct { // WaitDuration controls the rate at which Parameter Store is polled. diff --git a/runtimevar/runtimeconfigurator/runtimeconfigurator_test.go b/runtimevar/runtimeconfigurator/runtimeconfigurator_test.go index b87dfc7ae5..02514d68b1 100644 --- a/runtimevar/runtimeconfigurator/runtimeconfigurator_test.go +++ b/runtimevar/runtimeconfigurator/runtimeconfigurator_test.go @@ -192,3 +192,44 @@ func TestNoConnectionError(t *testing.T) { t.Error("got nil want error") } } + +func TestOpenVariable(t *testing.T) { + cleanup := setup.FakeGCPDefaultCredentials(t) + defer cleanup() + + tests := []struct { + URL string + WantErr bool + }{ + // OK. + {"runtimeconfigurator://myproject/mycfg/myvar", false}, + // OK, hierarchical key name. + {"runtimeconfigurator://myproject/mycfg/myvar1/myvar2", false}, + // OK, setting decoder. + {"runtimeconfigurator://myproject/mycfg/myvar?decoder=string", false}, + // Missing project ID. + {"runtimeconfigurator:///mycfg/myvar", true}, + // Empty config. + {"runtimeconfigurator://myproject//myvar", true}, + // Empty key name. + {"runtimeconfigurator://myproject/mycfg/", true}, + // Missing key name. + {"runtimeconfigurator://myproject/mycfg", true}, + // Invalid decoder. + {"runtimeconfigurator://myproject/mycfg/myvar?decoder=notadecoder", true}, + // OK, setting wait. + {"runtimeconfigurator://myproject/mycfg/myvar?wait=30s", false}, + // Invalid wait. + {"runtimeconfigurator://myproject/mycfg/myvar?wait=notaduration", true}, + // Unknown param. + {"runtimeconfigurator://myproject/mycfg/myvar?param=value", true}, + } + + ctx := context.Background() + for _, test := range tests { + _, err := runtimevar.OpenVariable(ctx, test.URL) + if (err != nil) != test.WantErr { + t.Errorf("%s: got error %v, want error %v", test.URL, err, test.WantErr) + } + } +} diff --git a/runtimevar/runtimevar.go b/runtimevar/runtimevar.go index e05954c5dd..b14d14ff2b 100644 --- a/runtimevar/runtimevar.go +++ b/runtimevar/runtimevar.go @@ -341,3 +341,24 @@ func bytesDecode(b []byte, obj interface{}) error { *v = b[:] return nil } + +// DecoderByName returns a *Decoder based on decoderName. +// It is intended to be used by VariableURLOpeners in driver packages. +// Supported values include: +// - (empty string), "bytes": Returns the default, BytesDecoder; +// Snapshot.Valuewill be of type []byte. +// - "jsonmap": Returns a JSON decoder for a map[string]interface{}; +// Snapshot.Value will be of type *map[string]interface{}. +// - "string": Returns StringDecoder; Snapshot.Value will be of type string. +func DecoderByName(decoderName string) (*Decoder, error) { + switch decoderName { + case "", "bytes": + return BytesDecoder, nil + case "jsonmap": + var m map[string]interface{} + return NewDecoder(&m, JSONDecode), nil + case "string": + return StringDecoder, nil + } + return nil, fmt.Errorf("unsupported decoder %q", decoderName) +} diff --git a/secrets/azurekeyvault/akv.go b/secrets/azurekeyvault/akv.go index e316605b72..b84530b419 100644 --- a/secrets/azurekeyvault/akv.go +++ b/secrets/azurekeyvault/akv.go @@ -128,13 +128,8 @@ func (o *URLOpener) OpenKeeperURL(ctx context.Context, u *url.URL) (*secrets.Kee if u.Host == "" { return nil, fmt.Errorf("open keeper %q: URL is expected to have a non-empty Host (the key vault name)", u) } - path := u.Path - if len(path) > 0 && path[0] == '/' { - path = path[1:] - } - pathParts := strings.Split(path, "/") var keyName, keyVersion string - if len(pathParts) == 1 { + if pathParts := strings.Split(strings.TrimPrefix(u.Path, "/"), "/"); len(pathParts) == 1 { keyName = pathParts[0] } else if len(pathParts) == 2 { keyName = pathParts[0]