From 13393ecf25f48d41e6eebeed26e26966d432f12c Mon Sep 17 00:00:00 2001 From: Pavel Busko Date: Thu, 2 May 2024 16:21:43 +0200 Subject: [PATCH] Support reading service bindings from VCAP_SERVICES env var (#566) Co-authored-by: Johannes Dillmann --- servicebindings/entry.go | 51 ++++++++++++--- servicebindings/entry_test.go | 26 +++++++- servicebindings/resolver.go | 60 +++++++++++++++++- servicebindings/resolver_test.go | 37 +++++++++++ servicebindings/testdata/vcap_services.json | 69 +++++++++++++++++++++ 5 files changed, 228 insertions(+), 15 deletions(-) create mode 100644 servicebindings/testdata/vcap_services.json diff --git a/servicebindings/entry.go b/servicebindings/entry.go index 6df41045..4613b860 100644 --- a/servicebindings/entry.go +++ b/servicebindings/entry.go @@ -1,13 +1,16 @@ package servicebindings import ( + "bytes" + "io" "os" ) // Entry represents the read-only content of a binding entry. type Entry struct { - path string - file *os.File + path string + file *os.File + value *bytes.Reader } // NewEntry returns a new Entry whose content is given by the file at the provided path. @@ -17,18 +20,39 @@ func NewEntry(path string) *Entry { } } +// NewWithValue returns a new Entry with predefined value. +func NewWithValue(value []byte) *Entry { + return &Entry{ + value: bytes.NewReader(value), + } +} + // ReadBytes reads the entire raw content of the entry. There is no need to call Close after calling ReadBytes. func (e *Entry) ReadBytes() ([]byte, error) { + if e.value != nil { + return io.ReadAll(e.value) + } return os.ReadFile(e.path) } // ReadString reads the entire content of the entry as a string. There is no need to call Close after calling // ReadString. func (e *Entry) ReadString() (string, error) { - bytes, err := e.ReadBytes() - if err != nil { - return "", err + var bytes []byte + var err error + + if e.value != nil { + bytes, err = io.ReadAll(e.value) + if err != nil { + return "", err + } + } else { + bytes, err = e.ReadBytes() + if err != nil { + return "", err + } } + return string(bytes), nil } @@ -36,6 +60,10 @@ func (e *Entry) ReadString() (string, error) { // of entry data, Read returns 0, io.EOF. // Close must be called when all read operations are complete. func (e *Entry) Read(b []byte) (int, error) { + if e.value != nil { + return e.value.Read(b) + } + if e.file == nil { file, err := os.Open(e.path) if err != nil { @@ -49,11 +77,16 @@ func (e *Entry) Read(b []byte) (int, error) { // Close closes the entry and resets it for reading. After calling Close, any subsequent calls to Read will read entry // data from the beginning. Close may be called on a closed entry without error. func (e *Entry) Close() error { - if e.file == nil { - return nil - } defer func() { e.file = nil }() - return e.file.Close() + + if e.value != nil { + _, err := e.value.Seek(0, io.SeekStart) + return err + } else if e.file == nil { + return nil + } else { + return e.file.Close() + } } diff --git a/servicebindings/entry_test.go b/servicebindings/entry_test.go index 95c5824e..7178b7ce 100644 --- a/servicebindings/entry_test.go +++ b/servicebindings/entry_test.go @@ -14,9 +14,10 @@ import ( func testEntry(t *testing.T, context spec.G, it spec.S) { var ( - Expect = NewWithT(t).Expect - entry *servicebindings.Entry - tmpDir string + Expect = NewWithT(t).Expect + entry *servicebindings.Entry + entryWithValue *servicebindings.Entry + tmpDir string ) it.Before(func() { @@ -26,6 +27,7 @@ func testEntry(t *testing.T, context spec.G, it spec.S) { entryPath := filepath.Join(tmpDir, "entry") Expect(os.WriteFile(entryPath, []byte("some data"), os.ModePerm)).To(Succeed()) entry = servicebindings.NewEntry(entryPath) + entryWithValue = servicebindings.NewWithValue([]byte("value from env")) }) it.After(func() { @@ -35,12 +37,14 @@ func testEntry(t *testing.T, context spec.G, it spec.S) { context("ReadBytes", func() { it("returns the raw bytes of the entry", func() { Expect(entry.ReadBytes()).To(Equal([]byte("some data"))) + Expect(entryWithValue.ReadBytes()).To(Equal([]byte("value from env"))) }) }) context("ReadString", func() { it("returns the string value of the entry", func() { Expect(entry.ReadString()).To(Equal("some data")) + Expect(entryWithValue.ReadString()).To(Equal("value from env")) }) }) @@ -59,6 +63,16 @@ func testEntry(t *testing.T, context spec.G, it spec.S) { Expect(err).NotTo(HaveOccurred()) Expect(entry.Close()).To(Succeed()) Expect(data).To(Equal([]byte("some data"))) + + data, err = io.ReadAll(entryWithValue) + Expect(err).NotTo(HaveOccurred()) + Expect(entryWithValue.Close()).To(Succeed()) + Expect(data).To(Equal([]byte("value from env"))) + + data, err = io.ReadAll(entryWithValue) + Expect(err).NotTo(HaveOccurred()) + Expect(entryWithValue.Close()).To(Succeed()) + Expect(data).To(Equal([]byte("value from env"))) }) it("can be closed multiple times in a row", func() { @@ -66,10 +80,16 @@ func testEntry(t *testing.T, context spec.G, it spec.S) { Expect(err).NotTo(HaveOccurred()) Expect(entry.Close()).To(Succeed()) Expect(entry.Close()).To(Succeed()) + + _, err = io.ReadAll(entryWithValue) + Expect(err).NotTo(HaveOccurred()) + Expect(entryWithValue.Close()).To(Succeed()) + Expect(entryWithValue.Close()).To(Succeed()) }) it("can be closed if never read from", func() { Expect(entry.Close()).To(Succeed()) + Expect(entryWithValue.Close()).To(Succeed()) }) }) } diff --git a/servicebindings/resolver.go b/servicebindings/resolver.go index 8d71552f..2b46f763 100644 --- a/servicebindings/resolver.go +++ b/servicebindings/resolver.go @@ -1,6 +1,7 @@ package servicebindings import ( + "encoding/json" "errors" "fmt" "os" @@ -47,9 +48,9 @@ func NewResolver() *Resolver { // // The location of bindings is given by one of the following, in order of precedence: // -// 1. SERVICE_BINDING_ROOT environment variable -// 2. CNB_BINDINGS environment variable, if above is not set -// 3. `/bindings`, if both above are not set +// 1. SERVICE_BINDING_ROOT environment variable +// 2. CNB_BINDINGS environment variable, if above is not set +// 3. `/bindings`, if both above are not set func (r *Resolver) Resolve(typ, provider, platformDir string) ([]Binding, error) { if newRoot := bindingRoot(platformDir); r.bindingRoot != newRoot { r.bindingRoot = newRoot @@ -92,6 +93,10 @@ func (r *Resolver) ResolveOne(typ, provider, platformDir string) (Binding, error } func loadBindings(bindingRoot string) ([]Binding, error) { + if vcapEnv, ok := os.LookupEnv("VCAP_SERVICES"); ok { + return loadvcapservicesbinding(vcapEnv) + } + files, err := os.ReadDir(bindingRoot) if os.IsNotExist(err) { return nil, nil @@ -233,6 +238,55 @@ func loadLegacyBinding(bindingRoot, name string) (Binding, error) { return binding, nil } +func loadvcapservicesbinding(content string) ([]Binding, error) { + var contentTyped map[string][]vcapServicesBinding + + err := json.Unmarshal([]byte(content), &contentTyped) + if err != nil { + return []Binding{}, err + } + + bindings := []Binding{} + for p, bArray := range contentTyped { + for _, b := range bArray { + entries := map[string]*Entry{} + for k, v := range b.Credentials { + entries[k], err = toJSONString(v) + if err != nil { + return nil, err + } + } + bindings = append(bindings, Binding{ + Name: b.Name, + Type: b.Label, + Provider: p, + Entries: entries, + }) + } + } + + return bindings, nil +} + +type vcapServicesBinding struct { + Name string `json:"name"` + Label string `json:"label"` + Credentials map[string]interface{} `json:"credentials"` +} + +func toJSONString(input interface{}) (*Entry, error) { + switch in := input.(type) { + case string: + return NewWithValue([]byte(in)), nil + default: + jsonProperty, err := json.Marshal(in) + if err != nil { + return nil, err + } + return NewWithValue(jsonProperty), nil + } +} + func loadEntries(path string) (map[string]*Entry, error) { entries := map[string]*Entry{} files, err := os.ReadDir(path) diff --git a/servicebindings/resolver_test.go b/servicebindings/resolver_test.go index a4513b3a..a56b94aa 100644 --- a/servicebindings/resolver_test.go +++ b/servicebindings/resolver_test.go @@ -147,6 +147,43 @@ func testResolver(t *testing.T, context spec.G, it spec.S) { }) }) }) + + context("VCAP_SERVICES env var is set", func() { + it.Before(func() { + content, err := os.ReadFile("testdata/vcap_services.json") + Expect(err).NotTo(HaveOccurred()) + Expect(os.Setenv("VCAP_SERVICES", string(content))).To(Succeed()) + }) + + it.After(func() { + Expect(os.Unsetenv("VCAP_SERVICES")).To(Succeed()) + }) + + context("SERVICE_BINDING_ROOT env var is set", func() { + it.Before(func() { + Expect(os.Setenv("SERVICE_BINDING_ROOT", bindingRootK8s)).To(Succeed()) + }) + + it("resolves bindings from VCAP_SERVICES", func() { + resolver := servicebindings.NewResolver() + bindings, err := resolver.Resolve("postgres", "", platformDir) + Expect(err).NotTo(HaveOccurred()) + Expect(bindings).To(ConsistOf( + servicebindings.Binding{ + Name: "postgres", + Path: "", + Type: "postgres", + Provider: "postgres", + Entries: map[string]*servicebindings.Entry{ + "username": servicebindings.NewWithValue([]byte("foo")), + "password": servicebindings.NewWithValue([]byte("bar")), + "urls": servicebindings.NewWithValue([]byte("{\"example\":\"http://example.com\"}")), + }, + }, + )) + }) + }) + }) }) context("resolving bindings", func() { diff --git a/servicebindings/testdata/vcap_services.json b/servicebindings/testdata/vcap_services.json new file mode 100644 index 00000000..48d96b52 --- /dev/null +++ b/servicebindings/testdata/vcap_services.json @@ -0,0 +1,69 @@ +{ + "elephantsql-provider": [ + { + "name": "elephantsql-binding-c6c60", + "binding_guid": "44ceb72f-100b-4f50-87a2-7809c8b42b8d", + "binding_name": "elephantsql-binding-c6c60", + "instance_guid": "391308e8-8586-4c42-b464-c7831aa2ad22", + "instance_name": "elephantsql-c6c60", + "label": "elephantsql-type", + "tags": [ + "postgres", + "postgresql", + "relational" + ], + "plan": "turtle", + "credentials": { + "uri": "postgres://exampleuser:examplepass@postgres.example.com:5432/exampleuser", + "int": 1, + "bool": true + }, + "syslog_drain_url": null, + "volume_mounts": [] + } + ], + "sendgrid-provider": [ + { + "name": "mysendgrid", + "binding_guid": "6533b1b6-7916-488d-b286-ca33d3fa0081", + "binding_name": null, + "instance_guid": "8c907d0f-ec0f-44e4-87cf-e23c9ba3925d", + "instance_name": "mysendgrid", + "label": "sendgrid-type", + "tags": [ + "smtp" + ], + "plan": "free", + "credentials": { + "hostname": "smtp.example.com", + "username": "QvsXMbJ3rK", + "password": "HCHMOYluTv" + }, + "syslog_drain_url": null, + "volume_mounts": [] + } + ], + "postgres": [ + { + "name": "postgres", + "label": "postgres", + "plan": "default", + "tags": [ + "postgres" + ], + "binding_guid": "6533b1b6-7916-488d-b286-ca33d3fa0081", + "binding_name": null, + "instance_guid": "8c907d0f-ec0f-44e4-87cf-e23c9ba3925d", + "credentials": { + "username": "foo", + "password": "bar", + "urls": { + "example": "http://example.com" + } + }, + "syslog_drain_url": null, + "volume_mounts": [] + } + ] + } + \ No newline at end of file