diff --git a/bindings/init_test.go b/bindings/init_test.go new file mode 100644 index 00000000..ae148ca4 --- /dev/null +++ b/bindings/init_test.go @@ -0,0 +1,14 @@ +package bindings_test + +import ( + "testing" + + "github.com/sclevine/spec" + "github.com/sclevine/spec/report" +) + +func TestUnitBindings(t *testing.T) { + suite := spec.New("packit/bindings", spec.Report(report.Terminal{})) + suite("Resolver", testResolver) + suite.Run(t) +} diff --git a/bindings/resolver.go b/bindings/resolver.go new file mode 100644 index 00000000..10e98b42 --- /dev/null +++ b/bindings/resolver.go @@ -0,0 +1,133 @@ +package bindings + +import ( + "fmt" + "github.com/pkg/errors" + "io/ioutil" + "os" + "path/filepath" + "strings" +) + +// Binding represents metadata related to an external service. +type Binding struct { + + // Name is the name of the binding, given by the binding directory name. + Name string + + // Path is the path to the binding directory. + Path string + + // Type is the type of the binding, given by the content of the 'type' file within the binding directory. + Type string + + // Provider is the provider of the binding, given by the content of the 'provider' file within the binding + // directory. + Provider string + + // Secret is the primary content of the binding. Keys are given by each file name within the binding directory + // (other than 'type' or 'provider'), and corresponding values are given by the content of each file. + Secret map[string][]byte // TODO: place in custom buffer class +} + +// Resolver resolves service bindings. +type Resolver struct { + bindingRoot string + bindings []Binding +} + +// NewResolver returns a new service binding resolver. If the SERVICE_BINDING_ROOT environment variable is not set, uses +// the provided platform directory to resolve bindings at `/bindings`. +func NewResolver(platformDir string) *Resolver { + root := os.Getenv("SERVICE_BINDING_ROOT") + if root == "" { + root = filepath.Join(platformDir, "bindings") + } + + return &Resolver{ + bindingRoot: root, + } +} + +// Resolve returns all bindings matching the given type and optional provider (case-insensitive). To match on type only, +// provider may be an empty string. Returns an error if there are problems loading bindings from the file system. +func (r *Resolver) Resolve(typ string, provider string) ([]Binding, error) { + if r.bindings == nil { + bindings, err := loadBindings(r.bindingRoot) + if err != nil { + return nil, errors.Wrapf(err, "loading bindings from '%s'", r.bindingRoot) + } + r.bindings = bindings + } + + var resolved []Binding + for _, bind := range r.bindings { + if (strings.ToLower(bind.Type) == strings.ToLower(typ)) && + (provider == "" || strings.ToLower(bind.Provider) == strings.ToLower(provider)) { + resolved = append(resolved, bind) + } + } + return resolved, nil +} + +// ResolveOne returns a single binding matching the given type and optional provider (case-insensitive). To match on +// type only, provider may be an empty string. Returns an error if the number of matched bindings is not exactly one, or +// if there are problems loading bindings from the file system. +func (r *Resolver) ResolveOne(typ string, provider string) (Binding, error) { + binds, err := r.Resolve(typ, provider) + if err != nil { + return Binding{}, err + } + if len(binds) != 1 { + return Binding{}, fmt.Errorf("found %d bindings for type '%s' and provider '%s' but expected exactly 1", len(binds), typ, provider) + } + return binds[0], nil +} + +func loadBindings(bindingRoot string) ([]Binding, error) { + files, err := ioutil.ReadDir(bindingRoot) + if err != nil { + return nil, err + } + + var bindings []Binding + for _, file := range files { + binding, err := loadBinding(bindingRoot, file.Name()) + if err != nil { + return nil, err + } + bindings = append(bindings, binding) + } + return bindings, nil +} + +func loadBinding(bindingRoot, name string) (Binding, error) { + binding := Binding{ + Name: name, + Path: filepath.Join(bindingRoot, name), + Secret: map[string][]byte{}, + } + + files, err := ioutil.ReadDir(binding.Path) + if err != nil { + return Binding{}, nil + } + + for _, file := range files { + content, err := os.ReadFile(filepath.Join(binding.Path, file.Name())) + if err != nil { + return Binding{}, err + } + + switch file.Name() { + case "type": + binding.Type = string(content) + case "provider": + binding.Provider = string(content) + default: + binding.Secret[file.Name()] = content + } + } + + return binding, nil +} diff --git a/bindings/resolver_test.go b/bindings/resolver_test.go new file mode 100644 index 00000000..f5390730 --- /dev/null +++ b/bindings/resolver_test.go @@ -0,0 +1,249 @@ +package bindings_test + +import ( + "github.com/paketo-buildpacks/packit/bindings" + "github.com/sclevine/spec" + "os" + "path/filepath" + "testing" + + . "github.com/onsi/gomega" +) + +func testResolver(t *testing.T, context spec.G, it spec.S) { + var Expect = NewWithT(t).Expect + + context("NewResolver", func() { + var ( + bindingRoot string + platformDir string + ) + + it.Before(func() { + var err error + + bindingRoot, err = os.MkdirTemp("", "bindings") + Expect(err).NotTo(HaveOccurred()) + + err = os.MkdirAll(filepath.Join(bindingRoot, "some-binding"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + + err = os.WriteFile(filepath.Join(bindingRoot, "some-binding", "type"), []byte("some-type"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + + platformDir, err = os.MkdirTemp("", "bindings") + Expect(err).NotTo(HaveOccurred()) + + err = os.MkdirAll(filepath.Join(platformDir, "bindings", "some-binding"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + + err = os.WriteFile(filepath.Join(platformDir, "bindings", "some-binding", "type"), []byte("some-type"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + }) + + context("SERVICE_BINDING_ROOT is set", func() { + it.Before(func() { + Expect(os.Setenv("SERVICE_BINDING_ROOT", bindingRoot)).To(Succeed()) + }) + + it("uses env var value for binding root", func() { + resolver := bindings.NewResolver(platformDir) + + binds, err := resolver.Resolve("some-type", "") + Expect(err).NotTo(HaveOccurred()) + Expect(binds).To(ConsistOf( + bindings.Binding{ + Name: "some-binding", + Path: filepath.Join(bindingRoot, "some-binding"), + Type: "some-type", + Secret: map[string][]byte{}, + }, + )) + }) + + }) + + context("SERVICE_BINDING_ROOT is unset", func() { + it.Before(func() { + Expect(os.Unsetenv("SERVICE_BINDING_ROOT")).To(Succeed()) + }) + + it("uses '/bindings' for binding root", func() { + resolver := bindings.NewResolver(platformDir) + + binds, err := resolver.Resolve("some-type", "") + Expect(err).NotTo(HaveOccurred()) + Expect(binds).To(ConsistOf( + bindings.Binding{ + Name: "some-binding", + Path: filepath.Join(platformDir, "bindings", "some-binding"), + Type: "some-type", + Secret: map[string][]byte{}, + }, + )) + }) + + }) + + }) + + context("resolving bindings", func() { + var bindingRoot string + var resolver *bindings.Resolver + + it.Before(func() { + var err error + bindingRoot, err = os.MkdirTemp("", "bindings") + Expect(err).NotTo(HaveOccurred()) + Expect(os.Setenv("SERVICE_BINDING_ROOT", bindingRoot)).To(Succeed()) + + resolver = bindings.NewResolver("") + + err = os.MkdirAll(filepath.Join(bindingRoot, "binding-1A"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + + err = os.WriteFile(filepath.Join(bindingRoot, "binding-1A", "type"), []byte("type-1"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + + err = os.WriteFile(filepath.Join(bindingRoot, "binding-1A", "provider"), []byte("provider-1A"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + + err = os.WriteFile(filepath.Join(bindingRoot, "binding-1A", "username"), []byte("username-1A"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + + err = os.WriteFile(filepath.Join(bindingRoot, "binding-1A", "password"), []byte("password-1A"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + + err = os.MkdirAll(filepath.Join(bindingRoot, "binding-1B"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + + err = os.WriteFile(filepath.Join(bindingRoot, "binding-1B", "type"), []byte("type-1"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + + err = os.WriteFile(filepath.Join(bindingRoot, "binding-1B", "provider"), []byte("provider-1B"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + + err = os.WriteFile(filepath.Join(bindingRoot, "binding-1B", "username"), []byte("username-1B"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + + err = os.WriteFile(filepath.Join(bindingRoot, "binding-1B", "password"), []byte("password-1B"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + + err = os.MkdirAll(filepath.Join(bindingRoot, "binding-2"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + + err = os.WriteFile(filepath.Join(bindingRoot, "binding-2", "type"), []byte("type-2"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + + err = os.WriteFile(filepath.Join(bindingRoot, "binding-2", "provider"), []byte("provider-2"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + + err = os.WriteFile(filepath.Join(bindingRoot, "binding-2", "username"), []byte("username-2"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + + err = os.WriteFile(filepath.Join(bindingRoot, "binding-2", "password"), []byte("password-2"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + }) + + it.After(func() { + Expect(os.RemoveAll(bindingRoot)).To(Succeed()) + Expect(os.Unsetenv("SERVICE_BINDING_ROOT")).To(Succeed()) + }) + + context("Resolve", func() { + it("resolves by type only (case-insensitive)", func() { + binds, err := resolver.Resolve("TyPe-1", "") + Expect(err).NotTo(HaveOccurred()) + + Expect(binds).To(ConsistOf( + bindings.Binding{ + Name: "binding-1A", + Path: filepath.Join(bindingRoot, "binding-1A"), + Type: "type-1", + Provider: "provider-1A", + Secret: map[string][]byte{ + "username": []byte("username-1A"), + "password": []byte("password-1A"), + }, + }, + bindings.Binding{ + Name: "binding-1B", + Path: filepath.Join(bindingRoot, "binding-1B"), + Type: "type-1", + Provider: "provider-1B", + Secret: map[string][]byte{ + "username": []byte("username-1B"), + "password": []byte("password-1B"), + }, + }, + )) + }) + + it("resolves by type and provider (case-insensitive)", func() { + binds, err := resolver.Resolve("TyPe-1", "PrOvIdEr-1B") + Expect(err).NotTo(HaveOccurred()) + + Expect(binds).To(ConsistOf( + bindings.Binding{ + Name: "binding-1B", + Path: filepath.Join(bindingRoot, "binding-1B"), + Type: "type-1", + Provider: "provider-1B", + Secret: map[string][]byte{ + "username": []byte("username-1B"), + "password": []byte("password-1B"), + }, + }, + )) + }) + + it("returns errors encountered while reading binding", func() { + err := os.MkdirAll(filepath.Join(bindingRoot, "bad-binding"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + + err = os.WriteFile(filepath.Join(bindingRoot, "bad-binding", "type"), []byte("bad-type"), 000) + + _, err = resolver.Resolve("bad-type", "") + Expect(err).To(MatchError(HavePrefix("loading bindings from '%s': open %s: permission denied", bindingRoot, filepath.Join(bindingRoot, "bad-binding", "type")))) + }) + }) + + context("ResolveOne", func() { + it("resolves one binding (case-insensitive)", func() { + bind, err := resolver.ResolveOne("TyPe-2", "") + Expect(err).NotTo(HaveOccurred()) + Expect(bind).To(Equal(bindings.Binding{ + Name: "binding-2", + Path: filepath.Join(bindingRoot, "binding-2"), + Type: "type-2", + Provider: "provider-2", + Secret: map[string][]byte{ + "username": []byte("username-2"), + "password": []byte("password-2"), + }, + })) + }) + + it("returns an error if no matches", func() { + _, err := resolver.ResolveOne("non-existent-type", "non-existent-provider") + Expect(err).To(MatchError("found 0 bindings for type 'non-existent-type' and provider 'non-existent-provider' but expected exactly 1")) + }) + + it("returns an error if more than one match", func() { + _, err := resolver.ResolveOne("TyPe-1", "") + Expect(err).To(MatchError("found 2 bindings for type 'TyPe-1' and provider '' but expected exactly 1")) + }) + + it("returns errors encountered while reading binding", func() { + err := os.MkdirAll(filepath.Join(bindingRoot, "bad-binding"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + + err = os.WriteFile(filepath.Join(bindingRoot, "bad-binding", "type"), []byte("bad-type"), 000) + + _, err = resolver.Resolve("bad-type", "") + Expect(err).To(MatchError(HavePrefix("loading bindings from '%s': open %s: permission denied", bindingRoot, filepath.Join(bindingRoot, "bad-binding", "type")))) + }) + + }) + }) +} diff --git a/go.mod b/go.mod index 3cf87679..6aae1ec7 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect github.com/onsi/gomega v1.16.0 github.com/pelletier/go-toml v1.9.4 + github.com/pkg/errors v0.9.1 github.com/sclevine/spec v1.4.0 github.com/ulikunitz/xz v0.5.10 golang.org/x/net v0.0.0-20210525063256-abc453219eb5 // indirect diff --git a/go.sum b/go.sum index cc0de12b..96f3a282 100644 --- a/go.sum +++ b/go.sum @@ -61,6 +61,8 @@ github.com/onsi/gomega v1.16.0 h1:6gjqkI8iiRHMvdccRJM8rVKjCWk6ZIm6FTm3ddIe4/c= github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= github.com/pelletier/go-toml v1.9.4 h1:tjENF6MfZAg8e4ZmZTeWaWiT2vXtsoO6+iuOjFhECwM= github.com/pelletier/go-toml v1.9.4/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= diff --git a/postal/internal/dependency_mappings.go b/postal/internal/dependency_mappings.go index 6a09c825..39f97544 100644 --- a/postal/internal/dependency_mappings.go +++ b/postal/internal/dependency_mappings.go @@ -2,9 +2,8 @@ package internal import ( "fmt" - "os" - "path/filepath" - "strings" + + "github.com/paketo-buildpacks/packit/bindings" ) type DependencyMappingResolver struct{} @@ -17,36 +16,24 @@ func NewDependencyMappingResolver() DependencyMappingResolver { // - bindings // - some-binding // - type -> dependency-mapping -// - some-sha -> some-uri +// - some-sha -> some-uri // - other-sha -> other-uri // Given a target dependency, look up if there is a matching dependency mapping at the given binding path func (d DependencyMappingResolver) FindDependencyMapping(sha256, bindingPath string) (string, error) { - allBindings, err := filepath.Glob(filepath.Join(bindingPath, "*")) + bindings, err := bindings.ListServiceBindings(bindingPath) if err != nil { - return "", err + return "", fmt.Errorf("failed to list service bindings: %w", err) } - for _, binding := range allBindings { - bindType, err := os.ReadFile(filepath.Join(binding, "type")) - if err != nil { - return "", fmt.Errorf("couldn't read binding type: %w", err) - } - - if strings.TrimSpace(string(bindType)) == "dependency-mapping" { - if _, err := os.Stat(filepath.Join(binding, sha256)); err != nil { - if !os.IsNotExist(err) { - return "", err - } - continue + for _, binding := range bindings { + if binding.Type == "dependency-mapping" { + uri, ok := binding.Secrets[sha256] + if ok { + return string(uri), nil } - - uri, err := os.ReadFile(filepath.Join(binding, sha256)) - if err != nil { - return "", err - } - return strings.TrimSpace(string(uri)), nil } } + return "", nil } diff --git a/postal/internal/dependency_mappings_test.go b/postal/internal/dependency_mappings_test.go index ed0cea6e..a6028a3d 100644 --- a/postal/internal/dependency_mappings_test.go +++ b/postal/internal/dependency_mappings_test.go @@ -66,45 +66,7 @@ func testDependencyMappings(t *testing.T, context spec.G, it spec.S) { context("when the binding path is a bad pattern", func() { it("errors", func() { _, err := resolver.FindDependencyMapping("some-sha", "///") - Expect(err).To(HaveOccurred()) - }) - }) - - context("when type file cannot be opened", func() { - it.Before(func() { - Expect(os.MkdirAll(filepath.Join(bindingPath, "some-binding"), 0700)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(bindingPath, "some-binding", "type"), []byte("dependency-mapping"), 0000)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(bindingPath, "some-binding", "some-sha"), []byte("dependency-mapping-entry.tgz"), 0600)).To(Succeed()) - }) - it("errors", func() { - _, err := resolver.FindDependencyMapping("some-sha", bindingPath) - Expect(err).To(HaveOccurred()) - Expect(err).To(MatchError(ContainSubstring("couldn't read binding type"))) - }) - }) - - context("when SHA256 file cannot be stat", func() { - it.Before(func() { - Expect(os.MkdirAll(filepath.Join(bindingPath, "new-binding"), 0700)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(bindingPath, "new-binding", "type"), []byte("dependency-mapping"), 0644)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(bindingPath, "new-binding", "some-sha"), []byte("dependency-mapping-entry.tgz"), 0644)).To(Succeed()) - Expect(os.Chmod(filepath.Join(bindingPath, "new-binding", "some-sha"), 0000)).To(Succeed()) - }) - it("errors", func() { - _, err := resolver.FindDependencyMapping("some-sha", bindingPath) - Expect(err).To(HaveOccurred()) - }) - }) - - context("when SHA256 contents cannot be opened", func() { - it.Before(func() { - Expect(os.MkdirAll(filepath.Join(bindingPath, "some-binding"), 0700)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(bindingPath, "some-binding", "type"), []byte("dependency-mapping"), 0600)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(bindingPath, "some-binding", "some-sha"), []byte("dependency-mapping-entry.tgz"), 0000)).To(Succeed()) - }) - it("errors", func() { - _, err := resolver.FindDependencyMapping("some-sha", bindingPath) - Expect(err).To(HaveOccurred()) + Expect(err).To(MatchError(ContainSubstring("failed to list service bindings"))) }) }) }) diff --git a/postal/service.go b/postal/service.go index 37859a13..6f26e7fb 100644 --- a/postal/service.go +++ b/postal/service.go @@ -175,7 +175,8 @@ func (s Service) Deliver(dependency Dependency, cnbPath, layerPath, platformPath return nil } -// Install will invoke Deliver with a hardcoded value of /platform for the platform path. +// Install will invoke Deliver with a hardcoded value of /platform for the +// platform path. // // Deprecated: Use Deliver instead. func (s Service) Install(dependency Dependency, cnbPath, layerPath string) error {