Skip to content

Commit

Permalink
Custom secret template for ServiceBinding (#398)
Browse files Browse the repository at this point in the history
  • Loading branch information
I065450 authored Mar 14, 2024
1 parent e5bafe1 commit 8c0a3d7
Show file tree
Hide file tree
Showing 16 changed files with 871 additions and 66 deletions.
4 changes: 4 additions & 0 deletions api/common/consts.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,8 @@ const (
// Cred Rotation
CredPreparing = "Preparing"
CredRotating = "Rotating"

// Constance for seceret template
InstanceKey = "instance"
CredentialsKey = "credentials"
)
60 changes: 60 additions & 0 deletions api/common/utils/limit_writer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package utils

import (
"errors"
"io"
)

var (
// ErrLimitExceeded is the error returned when a limit was exceeded.
// Callers will test for ErrLimitExceeded using ==.
ErrLimitExceeded = errors.New("limit exceeded")
)

// A LimitedWriter writes to W but limits the amount of data that can be written
// to just N bytes. Each call to Write immediately returns ErrLimitExceeded if N
// <= 0. Otherwise Write writes max N bytes to W and updates N to reflect the
// new amount remaining. If the number of byte to be written is greater than N,
// ErrLimitExceeded is returned. Any error from W is returned to the caller of
// Write, i.e. they have precedence over ErrLimitExceeded.
type LimitedWriter struct {
W io.Writer // underlying writer
N int64 // max bytes remaining
Converter func(err error) error
}

// Write implements io.Writer
func (l *LimitedWriter) Write(p []byte) (int, error) {
if l.N <= 0 {
return l.returnAfterConverter(0, ErrLimitExceeded)
}

writeable := int64(len(p))
if l.N < writeable {
writeable = l.N
}

written, err := l.W.Write(p[:writeable])
if written < 0 {
written = 0
}
l.N -= int64(written)
if err != nil {
return l.returnAfterConverter(written, err)
}
if written < int(writeable) {
return l.returnAfterConverter(written, io.ErrShortWrite)
}

if writeable < int64(len(p)) {
err = ErrLimitExceeded
}
return l.returnAfterConverter(written, err)
}

func (l *LimitedWriter) returnAfterConverter(written int, err error) (int, error) {
if l.Converter != nil {
err = l.Converter(err)
}
return written, err
}
65 changes: 65 additions & 0 deletions api/common/utils/limit_writer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package utils

import (
"errors"
"fmt"

. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)

var _ = Describe("limit writer", func() {
/* tests for limit_writer.go */
Describe("limited writer", func() {
It("should write to the underlying writer", func() {
// Given
limitedWriter := LimitedWriter{
W: nil,
N: 0,
Converter: nil,
}

// When
i, err := limitedWriter.Write(nil)
// Then
Expect(i).To(Equal(0))
Expect(err).To(Equal(ErrLimitExceeded))
})
It("should return error when N <= 0", func() {
// Given
limitedWriter := LimitedWriter{
W: nil,
N: 0,
Converter: func(err error) error {
if errors.Is(err, ErrLimitExceeded) {
return fmt.Errorf("the size of the generated secret manifest exceeds the limit of %d bytes", 0)
}
return err
},
}

// When
i, err := limitedWriter.Write(nil)

// Then
Expect(i).To(Equal(0))
Expect(err.Error()).To(ContainSubstring("the size of the generated secret manifest exceeds the limit of 0 bytes"))

})
It("should return error when N < writeable", func() {
// Given
limitedWriter := LimitedWriter{
W: nil,
N: 0,
Converter: nil,
}

// When
limitedWriter.Write(nil)

// Then
})

})

})
115 changes: 115 additions & 0 deletions api/common/utils/secret_template.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package utils

import (
"bytes"
"encoding/json"
"fmt"
"io"
"text/template"

"github.com/SAP/sap-btp-service-operator/api/common"

"k8s.io/apimachinery/pkg/runtime/schema"

"sigs.k8s.io/yaml"

"github.com/pkg/errors"
corev1 "k8s.io/api/core/v1"
)

const templateOutputMaxBytes int64 = 1 * 1024 * 1024

var allowedMetadataFields = map[string]string{"labels": "any", "annotations": "any", "creationTimestamp": "any"}
var validGroupVersionKind = schema.GroupVersionKind{
Group: "",
Kind: "Secret",
Version: "v1",
}

// CreateSecretFromTemplate executes the template to create a secret objects, validates and returns it
// The template needs to be a v1 Secret and in metadata labels and annotations are allowed only
// Set templateOptions of the "text/template" package to specify the template behavior
func CreateSecretFromTemplate(templateName, secretTemplate string, option string, data map[string]interface{}) (*corev1.Secret, error) {

secretManifest, err := executeTemplate(templateName, secretTemplate, option, data)
if err != nil {
return nil, errors.Wrap(err, "the Secret template is invalid")
}

secret := &corev1.Secret{}
if err := yaml.Unmarshal(secretManifest, secret); err != nil {
return nil, errors.Wrap(err, "the Secret template is invalid: It does not result in a valid Secret YAML")
}

if err := validateSecret(secret); err != nil {
return nil, err
}
return secret, nil
}

func validateSecret(secret *corev1.Secret) error {
// validate GroupVersionKind
gvk := secret.GetObjectKind().GroupVersionKind()
if (gvk.Kind != "" || gvk.Version != "") && gvk != validGroupVersionKind {
return fmt.Errorf("the Secret template is invalid: It is of kind '%s' but needs to be of kind 'Secret'", gvk.String())
}

metadataKeyValues := map[string]interface{}{}
secretMetadataBytes, err := json.Marshal(secret.ObjectMeta)
if err != nil {
return errors.Wrap(err, "the Secret template is invalid: It does not result in a valid Secret YAML")
}
if err := json.Unmarshal(secretMetadataBytes, &metadataKeyValues); err != nil {
return errors.Wrap(err, "the Secret template is invalid: It does not result in a valid Secret YAML")
}

for metadataKey := range metadataKeyValues {
if _, ok := allowedMetadataFields[metadataKey]; !ok {
return fmt.Errorf("the Secret template is invalid: Secret's metadata field '%s' cannot be edited", metadataKey)
}
}

return nil
}

// ParseTemplate create a new template with given name, add allowed sprig functions and parse the template
func ParseTemplate(templateName, text string) (*template.Template, error) {
return template.New(templateName).Funcs(filteredFuncMap()).Parse(text)
}

func filteredFuncMap() template.FuncMap {

return template.FuncMap{}
}

func executeTemplate(templateName, text, option string, parameters map[string]interface{}) ([]byte, error) {
t, err := ParseTemplate(templateName, text)
if err != nil {
return nil, err
}

var buf bytes.Buffer
var writer io.Writer = &LimitedWriter{
W: &buf,
N: templateOutputMaxBytes,
Converter: func(err error) error {
if errors.Is(err, ErrLimitExceeded) {
return fmt.Errorf("the size of the generated Secret exceeds the limit of %d bytes", templateOutputMaxBytes)
}
return err
},
}
err = t.Option(option).Execute(writer, parameters)
if err != nil {
return nil, err
}

return buf.Bytes(), nil
}

func GetSecretDataForTemplate(Credential map[string]interface{}, instance map[string]string) map[string]interface{} {
return map[string]interface{}{
common.CredentialsKey: Credential,
common.InstanceKey: instance,
}
}
123 changes: 123 additions & 0 deletions api/common/utils/secret_template_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package utils

import (
"fmt"
"strings"

"github.com/lithammer/dedent"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
corev1 "k8s.io/api/core/v1"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

var _ = Describe("Secret template", func() {

Context("With valid secretTemplate, but missing keys (credentials are nil)", func() {

It("should fail", func() {
nonexistingKey := "nonexistingKey"
secretTemplate := fmt.Sprintf(
dedent.Dedent(`
apiVersion: v1
kind: Secret
stringData:
foo: {{ .%s }}
`),
nonexistingKey,
)

secret, err := CreateSecretFromTemplate("", secretTemplate, "missingkey=error", nil)

Expect(err).Should(MatchError(ContainSubstring("map has no entry for key \"%s\"", nonexistingKey)))
Expect(secret).Should(BeNil())
})
})

Context("With unknown field", func() {

It("should succeed and invalid key provided in the secret is ignored", func() {
expectedSecret := &corev1.Secret{
TypeMeta: v1.TypeMeta{
Kind: "Secret",
APIVersion: "v1",
},
}
secretTemplate := dedent.Dedent(`
apiVersion: v1
kind: Secret
unknownField: foo
`)

secret, err := CreateSecretFromTemplate("", secretTemplate, "missingkey=error", nil)

Expect(err).ShouldNot(HaveOccurred())
Expect(secret).Should(Equal(expectedSecret))
})
})

Context("With wrong kind", func() {

It("should fail", func() {
secretTemplate := dedent.Dedent(`
apiVersion: v1
kind: Pod
`)

secret, err := CreateSecretFromTemplate("", secretTemplate, "missingkey=error", nil)

Expect(err).Should(MatchError(
SatisfyAll(
ContainSubstring("generated secret manifest has unexpected type"),
ContainSubstring("Pod"),
),
))
Expect(secret).Should(BeNil())
})
})

Context("With sprig functions", func() {

It("should fail if forbidden sprig func is used in the template", func() {
secretTemplate := dedent.Dedent(`
apiVersion: v1
kind: Secret
stringData:
foo: {{ .param1 | env }}
`)

secret, err := CreateSecretFromTemplate("", secretTemplate, "missingkey=error", nil)

Expect(err).Should(MatchError(ContainSubstring("function \"env\" not defined")))
Expect(secret).To(BeNil())
})

Describe("limited template output size", func() {

It("should succeed if template output is too big", func() {
secretTemplate := dedent.Dedent(`
apiVersion: v1
kind: Secret
stringData:
foo: x
`)
secretTemplate += strings.Repeat("#", int(templateOutputMaxBytes)-len(secretTemplate))
Expect(len(secretTemplate)).To(Equal(int(templateOutputMaxBytes)))

secret, err := CreateSecretFromTemplate("", secretTemplate, "missingkey=error", nil)

Expect(err).ShouldNot(HaveOccurred())
Expect(secret).NotTo(BeNil())
})

It("should fail if template output is too big", func() {
secretTemplate := strings.Repeat("a", int(templateOutputMaxBytes)+1)

secret, err := CreateSecretFromTemplate("", secretTemplate, "missingkey=error", nil)

Expect(err).Should(MatchError(ContainSubstring("the size of the generated secret manifest exceeds the limit")))
Expect(secret).To(BeNil())
})
})
})
})
11 changes: 11 additions & 0 deletions api/v1/servicebinding_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,17 @@ type ServiceBindingSpec struct {
// CredentialsRotationPolicy holds automatic credentials rotation configuration.
// +optional
CredRotationPolicy *CredentialsRotationPolicy `json:"credentialsRotationPolicy,omitempty"`

// SecretTemplate is a Go template that generates a custom Kubernetes
// v1/Secret based on data from the service binding returned by Service Manager and the instance information.
// The generated secret is used instead of the default secret.
// This is useful if the consumer of service binding data expects them in
// a specific format.
// For Go templates see https://pkg.go.dev/text/template.
// For supported funcs: https://pkg.go.dev/text/template#hdr-Functions
// +optional
// +kubebuilder:pruning:PreserveUnknownFields
SecretTemplate string `json:"secretTemplate,omitempty"`
}

// ServiceBindingStatus defines the observed state of ServiceBinding
Expand Down
Loading

0 comments on commit 8c0a3d7

Please sign in to comment.