Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Validate Elasticsearch resource names #1647

Merged
merged 7 commits into from
Sep 4, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions config/crds/elasticsearch_v1alpha1_elasticsearch.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,9 @@ spec:
description: Config represents Elasticsearch configuration.
type: object
name:
description: 'Name is a logical name for this set of nodes. Used
as a part of the managed Elasticsearch node.name setting. TODO:
refactor and explain name length conventions'
maxLength: 19
description: Name is a logical name for this set of nodes. Used
as a part of the managed Elasticsearch node.name setting.
maxLength: 23
pattern: '[a-zA-Z0-9-]+'
type: string
nodeCount:
Expand Down
3 changes: 1 addition & 2 deletions pkg/apis/elasticsearch/v1alpha1/elasticsearch_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,7 @@ func (es ElasticsearchSpec) NodeCount() int32 {
type NodeSpec struct {
// Name is a logical name for this set of nodes. Used as a part of the managed Elasticsearch node.name setting.
// +kubebuilder:validation:Pattern=[a-zA-Z0-9-]+
// +kubebuilder:validation:MaxLength=19
// TODO: refactor and explain name length conventions
// +kubebuilder:validation:MaxLength=23
Name string `json:"name"`

// Config represents Elasticsearch configuration.
Expand Down
10 changes: 1 addition & 9 deletions pkg/controller/apmserver/name/name.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,14 @@ import (
)

const (
// APM name, used as prefix, is limited to 36 characters,
MaxAPMNameLength = 36
// this leaves common_name.MaxNameLength - 36 characters for a suffix.
MaxSuffixLength = common_name.MaxNameLength - MaxAPMNameLength

secretTokenSuffix = "token"
httpServiceSuffix = "http"
configSuffix = "config"
deploymentSuffix = "server"
)

// APMNamer is a Namer that is configured with the defaults for resources related to an APM resource.
var APMNamer = common_name.Namer{
MaxSuffixLength: MaxSuffixLength,
DefaultSuffixes: []string{"apm"},
}
var APMNamer = common_name.NewNamer("apm")

func SecretToken(apmName string) string {
return APMNamer.Suffix(apmName, secretTokenSuffix)
Expand Down
2 changes: 2 additions & 0 deletions pkg/controller/common/certificates/ca_reconcile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@ import (
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/validation"
"k8s.io/client-go/kubernetes/scheme"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
)

var testNamer = name.Namer{
MaxNameLength: validation.LabelValueMaxLength,
MaxSuffixLength: 27, // from a prefix length of 36
DefaultSuffixes: []string{"test"},
}
Expand Down
2 changes: 1 addition & 1 deletion pkg/controller/common/keystore/resources_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ func TestResources(t *testing.T) {
{
name: "no secure settings specified: no resources",
client: k8s.WrapClient(fake.NewFakeClient()),
kb: v1alpha1.Kibana{},
kb: testKibana,
wantContainers: nil,
wantVersion: "",
wantNil: true,
Expand Down
2 changes: 1 addition & 1 deletion pkg/controller/common/keystore/user_secret_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ func Test_secureSettingsVolume(t *testing.T) {
name: "no secure settings specified in Kibana spec: should return nothing",
c: k8s.WrapClient(fake.NewFakeClient()),
w: createWatches(""),
kb: v1alpha1.Kibana{},
kb: testKibana,
wantVolume: nil,
wantVersion: "",
wantWatches: []string{},
Expand Down
102 changes: 77 additions & 25 deletions pkg/controller/common/name/name.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,66 +7,118 @@ package name
import (
"fmt"
"strings"
"unicode/utf8"

"github.com/elastic/cloud-on-k8s/pkg/utils/stringsutil"
"github.com/hashicorp/go-multierror"
"k8s.io/apimachinery/pkg/util/validation"
logf "sigs.k8s.io/controller-runtime/pkg/runtime/log"
)

var (
log = logf.Log.WithName("name")
)

const (
// MaxNameLength is the maximum length of a resource name.
MaxNameLength = 63
// MaxResourceNameLength is the max length of a name that will keep all its child resources under the K8S max label
// length after accounting for the longest suffix that could be added by the operator. See https://git.io/fjpFl.
MaxResourceNameLength = 36
charith-elastic marked this conversation as resolved.
Show resolved Hide resolved
// MaxSuffixLength is the max allowed suffix length that will keep a name within K8S label length restrictions.
MaxSuffixLength = validation.LabelValueMaxLength - MaxResourceNameLength
)

var log = logf.Log.WithName("name")

// nameLengthError is an error type for names exceeding the allowed length.
type nameLengthError struct {
reason string
maxLen int
actualLen int
value string
}

func newNameLengthError(reason string, maxLen int, value string) nameLengthError {
return nameLengthError{
reason: reason,
maxLen: maxLen,
actualLen: len(value),
value: value,
}
}

func (nle nameLengthError) Error() string {
return fmt.Sprintf("%s: '%s' has length %d which is more than %d", nle.reason, nle.value, nle.actualLen, nle.maxLen)
}

// Namer assists with constructing names for K8s resources and avoiding collisions by ensuring that certain suffixes
// are always used, and prevents the use of too long suffixes.
type Namer struct {
// MaxSuffixLength is the maximum allowable length of a suffix.
MaxSuffixLength int

// DefaultSuffixes are suffixes that should be added by default before the provided suffixes when Suffix is called.
MaxNameLength int
DefaultSuffixes []string
}

// NewNamer creates a new Namer object with the default suffix length restriction.
func NewNamer(defaultSuffixes ...string) Namer {
return Namer{
MaxSuffixLength: MaxSuffixLength,
MaxNameLength: validation.DNS1123SubdomainMaxLength,
DefaultSuffixes: defaultSuffixes,
}
}

// WithDefaultSuffixes returns a new Namer with updated default suffixes.
func (n Namer) WithDefaultSuffixes(defaultSuffixes ...string) Namer {
n.DefaultSuffixes = defaultSuffixes
return n
}

// Suffix a resource name.
//
// Panics if the suffix exceeds the suffix limits.
// Trims the name if the concatenated result would exceed the limits.
// Suffix generates a resource name by appending the specified suffixes.
func (n Namer) Suffix(ownerName string, suffixes ...string) string {
suffixedName, err := n.SafeSuffix(ownerName, suffixes...)
if err != nil {
// we should never encounter an error at this point because the names should have been validated
log.Error(err, "Invalid name. This could prevent the operator from functioning correctly", "name", suffixedName)
}

return suffixedName
}

// SafeSuffix attempts to generate a suffixed name, returning an error if the generated name is unacceptable.
func (n Namer) SafeSuffix(ownerName string, suffixes ...string) (string, error) {
var suffixBuilder strings.Builder
var err error

for _, s := range n.DefaultSuffixes {
suffixBuilder.WriteString("-") // #nosec G104
suffixBuilder.WriteString(s) // #nosec G104
suffixBuilder.WriteString("-")
suffixBuilder.WriteString(s)
}
for _, s := range suffixes {
suffixBuilder.WriteString("-") // #nosec G104
suffixBuilder.WriteString(s) // #nosec G104
suffixBuilder.WriteString("-")
suffixBuilder.WriteString(s)
}

suffix := suffixBuilder.String()

// This should never happen because we control all the suffixes!
if len(suffix) > n.MaxSuffixLength {
panic(fmt.Errorf("suffix should not exceed %d characters: got %s", n.MaxSuffixLength, suffix))
err = multierror.Append(err, newNameLengthError("suffix exceeds max length", n.MaxSuffixLength, suffix))
suffix = truncate(suffix, n.MaxSuffixLength)
}

// This should never happen because the ownerName length should have been validated.
// Trim the ownerName and log an error as fallback.
maxPrefixLength := MaxNameLength - len(suffix)
maxPrefixLength := n.MaxNameLength - len(suffix)
if len(ownerName) > maxPrefixLength {
log.Error(fmt.Errorf("ownerName should not exceed %d characters: got %s", maxPrefixLength, ownerName),
"Failed to suffix resource")
ownerName = ownerName[:maxPrefixLength]
err = multierror.Append(err, newNameLengthError("owner name exceeds max length", maxPrefixLength, ownerName))
ownerName = truncate(ownerName, maxPrefixLength)
}

return stringsutil.Concat(ownerName, suffix), err
}

func truncate(s string, length int) string {
var b strings.Builder
for _, r := range s {
if b.Len()+utf8.RuneLen(r) > length {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we expect anything other than single byte characters? Should we validate that somewhere?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kubernetes' own validation checks will not let multi-byte names to be submitted at the moment but I prefer not to rely on that assumption.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it. Should we check it in the Validate function then? It seems here we truncate based on (potential) multibyte length, but other than that we allow it and rely on k8s own validation.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, to provide some context here, the validation webhook simply transitions the object to the INVALID state when the custom validation checks fail. Trying to delete the object causes a problem because the finalizers start running and then start to trip over the invalid names -- making it impossible to delete. This is why I introduced the truncate function so that the object can be deleted even when it is invalid. It should never be used in the happy path and therefore whether the string is multi-byte or not does not really matter.

return b.String()
}
b.WriteRune(r)
}

return stringsutil.Concat(ownerName, suffix)
return b.String()
}
57 changes: 38 additions & 19 deletions pkg/controller/common/name/name_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ func TestNamer_WithDefaultSuffixes(t *testing.T) {
name: "should replace suffixes",
namer: Namer{
MaxSuffixLength: 27,
MaxNameLength: 36,
DefaultSuffixes: []string{"foo"},
},
args: args{
Expand All @@ -36,6 +37,7 @@ func TestNamer_WithDefaultSuffixes(t *testing.T) {
{
name: "should add suffixes when there is no suffix to begin with",
namer: Namer{
MaxNameLength: 36,
MaxSuffixLength: 27,
},
args: args{
Expand All @@ -55,13 +57,6 @@ func TestNamer_WithDefaultSuffixes(t *testing.T) {
}

func TestNamer_Suffix(t *testing.T) {
t.Run("too long suffix should panic", func(t *testing.T) {
require.Panics(t, func() {
namer := Namer{MaxSuffixLength: 1}
namer.Suffix("foo", "bar")
})
})

type args struct {
ownerName string
suffixes []string
Expand All @@ -75,6 +70,7 @@ func TestNamer_Suffix(t *testing.T) {
{
name: "simple suffix",
namer: Namer{
MaxNameLength: 36,
MaxSuffixLength: 20,
},
args: args{ownerName: "foo", suffixes: []string{"bar"}},
Expand All @@ -83,6 +79,7 @@ func TestNamer_Suffix(t *testing.T) {
{
name: "multiple suffixes",
namer: Namer{
MaxNameLength: 36,
MaxSuffixLength: 20,
},
args: args{ownerName: "foo", suffixes: []string{"bar", "baz"}},
Expand All @@ -91,24 +88,13 @@ func TestNamer_Suffix(t *testing.T) {
{
name: "default suffix",
namer: Namer{
MaxNameLength: 36,
MaxSuffixLength: 20,
DefaultSuffixes: []string{"default"},
},
args: args{ownerName: "foo", suffixes: []string{"bar", "baz"}},
want: "foo-default-bar-baz",
},
{
name: "too long owner name",
namer: Namer{
MaxSuffixLength: 20,
DefaultSuffixes: []string{"default"},
},
args: args{
ownerName: "this-owner-name-is-too-long-and-needs-to-be-trimmed-in-order-to-fit-the-suffix",
suffixes: []string{"bar", "baz"},
},
want: "this-owner-name-is-too-long-and-needs-to-be-tri-default-bar-baz",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand All @@ -118,3 +104,36 @@ func TestNamer_Suffix(t *testing.T) {
})
}
}

func TestNamerSafeSuffixErrors(t *testing.T) {
testCases := []struct {
name string
namer Namer
ownerName string
suffixes []string
wantName string
}{
{
name: "long owner name",
namer: Namer{MaxSuffixLength: 20, MaxNameLength: 36, DefaultSuffixes: []string{"es"}},
ownerName: "extremely-long-and-unwieldy-name-for-owner-that-exceeds-the-limit",
suffixes: []string{"bar", "baz"},
wantName: "extremely-long-and-unwiel-es-bar-baz",
},
{
name: "long suffixes",
namer: Namer{MaxSuffixLength: 20, MaxNameLength: 36, DefaultSuffixes: []string{"es"}},
ownerName: "test",
suffixes: []string{"bar", "baz", "very-long-suffix-exceeding-the-limit"},
wantName: "test-es-bar-baz-very-lon",
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
haveName, err := tc.namer.SafeSuffix(tc.ownerName, tc.suffixes...)
require.Error(t, err)
require.Equal(t, tc.wantName, haveName)
})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ func TestNewInitContainers(t *testing.T) {
tt.args.elasticsearchImage,
tt.args.SetVMMaxMapCount,
volume.SecretVolume{},
"clusterName",
"clustername",
tt.args.keystoreResources,
)
assert.NoError(t, err)
Expand Down
Loading