diff --git a/docs/Manual/Deployment/Kubernetes/DeploymentResource.md b/docs/Manual/Deployment/Kubernetes/DeploymentResource.md index 7ccf56d91..66ec4ab3f 100644 --- a/docs/Manual/Deployment/Kubernetes/DeploymentResource.md +++ b/docs/Manual/Deployment/Kubernetes/DeploymentResource.md @@ -342,6 +342,18 @@ This setting specifies the name of a kubernetes `Secret` that contains the license key token used for enterprise images. This value is not used for the community edition. +### `spec.bootstrap.passwordSecretNames.root: string` + +This setting specifies a secret name for the credentials of the root user. + +When a deployment is created the operator will setup the root user account +according to the credentials given by the secret. If the secret doesn't exist +the operator creates a secret with a random password. + +There are two magic values for the secret name: +- `None` specifies no action. This disables root password randomization. This is the default value. (Thus the root password is empty - not recommended) +- `Auto` specifies automatic name generation, which is `-root-password`. + ### `spec..count: number` This setting specifies the number of servers to start for the given group. diff --git a/pkg/apis/deployment/v1alpha/bootstrap.go b/pkg/apis/deployment/v1alpha/bootstrap.go new file mode 100644 index 000000000..e5e0406a1 --- /dev/null +++ b/pkg/apis/deployment/v1alpha/bootstrap.go @@ -0,0 +1,136 @@ +// +// DISCLAIMER +// +// Copyright 2018 ArangoDB GmbH, Cologne, Germany +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Copyright holder is ArangoDB GmbH, Cologne, Germany +// + +package v1alpha + +import ( + "fmt" + + "github.com/arangodb/kube-arangodb/pkg/util/k8sutil" +) + +const ( + // UserNameRoot root user name + UserNameRoot = "root" +) + +// PasswordSecretName contains user password secret name +type PasswordSecretName string + +const ( + // PasswordSecretNameNone is magic value for no action + PasswordSecretNameNone PasswordSecretName = "None" + // PasswordSecretNameAuto is magic value for autogenerate name + PasswordSecretNameAuto PasswordSecretName = "Auto" +) + +// PasswordSecretNameList is a map from username to secretnames +type PasswordSecretNameList map[string]PasswordSecretName + +// BootstrapSpec contains information for cluster bootstrapping +type BootstrapSpec struct { + // PasswordSecretNames contains a map of username to password-secret-name + PasswordSecretNames PasswordSecretNameList `json:"passwordSecretNames,omitempty"` +} + +// IsNone returns true if p is None or p is empty +func (p PasswordSecretName) IsNone() bool { + return p == PasswordSecretNameNone || p == "" +} + +// IsAuto returns true if p is Auto +func (p PasswordSecretName) IsAuto() bool { + return p == PasswordSecretNameAuto +} + +// GetSecretName returns the secret name given by the specs. Or None if not set. +func (s PasswordSecretNameList) GetSecretName(user string) PasswordSecretName { + if s != nil { + if secretname, ok := s[user]; ok { + return secretname + } + } + return PasswordSecretNameNone +} + +// getSecretNameForUserPassword returns the default secret name for the given user +func getSecretNameForUserPassword(deploymentname, username string) PasswordSecretName { + return PasswordSecretName(k8sutil.FixupResourceName(deploymentname + "-" + username + "-password")) +} + +// Validate the specification. +func (b *BootstrapSpec) Validate() error { + for username, secretname := range b.PasswordSecretNames { + // Remove this restriction as soon as we can bootstrap databases + if username != UserNameRoot { + return fmt.Errorf("only username `root` allowed in passwordSecretNames") + } + + if secretname.IsNone() { + if username != UserNameRoot { + return fmt.Errorf("magic value None not allowed for %s", username) + } + } else { + if err := k8sutil.ValidateResourceName(string(secretname)); err != nil { + return maskAny(err) + } + } + } + + return nil +} + +// SetDefaults fills in default values when a field is not specified. +func (b *BootstrapSpec) SetDefaults(deploymentname string) { + if b.PasswordSecretNames == nil { + b.PasswordSecretNames = make(map[string]PasswordSecretName) + } + + // If root is not set init with Auto + if _, ok := b.PasswordSecretNames[UserNameRoot]; !ok { + b.PasswordSecretNames[UserNameRoot] = PasswordSecretNameAuto + } + + // Replace Auto with generated secret name + for user, secretname := range b.PasswordSecretNames { + if secretname.IsAuto() { + b.PasswordSecretNames[user] = getSecretNameForUserPassword(deploymentname, user) + } + } +} + +// NewPasswordSecretNameListOrNil returns nil if input is nil, otherwise returns a clone of the given value. +func NewPasswordSecretNameListOrNil(list PasswordSecretNameList) PasswordSecretNameList { + if list == nil { + return nil + } + var newList = make(PasswordSecretNameList) + for k, v := range list { + newList[k] = v + } + return newList +} + +// SetDefaultsFrom fills unspecified fields with a value from given source spec. +func (b *BootstrapSpec) SetDefaultsFrom(source BootstrapSpec) { + if b.PasswordSecretNames == nil { + b.PasswordSecretNames = NewPasswordSecretNameListOrNil(source.PasswordSecretNames) + } +} diff --git a/pkg/apis/deployment/v1alpha/conditions.go b/pkg/apis/deployment/v1alpha/conditions.go index 165f23972..855f73050 100644 --- a/pkg/apis/deployment/v1alpha/conditions.go +++ b/pkg/apis/deployment/v1alpha/conditions.go @@ -53,6 +53,10 @@ const ( ConditionTypeSecretsChanged ConditionType = "SecretsChanged" // ConditionTypeMemberOfCluster indicates that the member is a known member of the ArangoDB cluster. ConditionTypeMemberOfCluster ConditionType = "MemberOfCluster" + // ConditionTypeBootstrapCompleted indicates that the initial cluster bootstrap has been completed. + ConditionTypeBootstrapCompleted ConditionType = "BootstrapCompleted" + // ConditionTypeBootstrapSucceded indicates that the initial cluster bootstrap completed successfully. + ConditionTypeBootstrapSucceded ConditionType = "BootstrapSucceded" // ConditionTypeTerminating indicates that the member is terminating but not yet terminated. ConditionTypeTerminating ConditionType = "Terminating" ) diff --git a/pkg/apis/deployment/v1alpha/deployment_spec.go b/pkg/apis/deployment/v1alpha/deployment_spec.go index c7846277f..b37cd36c1 100644 --- a/pkg/apis/deployment/v1alpha/deployment_spec.go +++ b/pkg/apis/deployment/v1alpha/deployment_spec.go @@ -70,6 +70,8 @@ type DeploymentSpec struct { SyncWorkers ServerGroupSpec `json:"syncworkers"` Chaos ChaosSpec `json:"chaos"` + + Bootstrap BootstrapSpec `json:"bootstrap",omitempty` } // Equal compares two DeploymentSpec @@ -188,6 +190,7 @@ func (s *DeploymentSpec) SetDefaults(deploymentName string) { s.SyncMasters.SetDefaults(ServerGroupSyncMasters, s.Sync.IsEnabled(), s.GetMode()) s.SyncWorkers.SetDefaults(ServerGroupSyncWorkers, s.Sync.IsEnabled(), s.GetMode()) s.Chaos.SetDefaults() + s.Bootstrap.SetDefaults(deploymentName) } // SetDefaultsFrom fills unspecified fields with a value from given source spec. @@ -226,6 +229,7 @@ func (s *DeploymentSpec) SetDefaultsFrom(source DeploymentSpec) { s.SyncMasters.SetDefaultsFrom(source.SyncMasters) s.SyncWorkers.SetDefaultsFrom(source.SyncWorkers) s.Chaos.SetDefaultsFrom(source.Chaos) + s.Bootstrap.SetDefaultsFrom(source.Bootstrap) } // Validate the specification. @@ -285,6 +289,9 @@ func (s *DeploymentSpec) Validate() error { if err := s.License.Validate(); err != nil { return maskAny(errors.Wrap(err, "spec.licenseKey")) } + if err := s.Bootstrap.Validate(); err != nil { + return maskAny(err) + } return nil } diff --git a/pkg/apis/deployment/v1alpha/zz_generated.deepcopy.go b/pkg/apis/deployment/v1alpha/zz_generated.deepcopy.go index cdd4d9662..01ca07905 100644 --- a/pkg/apis/deployment/v1alpha/zz_generated.deepcopy.go +++ b/pkg/apis/deployment/v1alpha/zz_generated.deepcopy.go @@ -135,6 +135,29 @@ func (in *AuthenticationSpec) DeepCopy() *AuthenticationSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BootstrapSpec) DeepCopyInto(out *BootstrapSpec) { + *out = *in + if in.PasswordSecretNames != nil { + in, out := &in.PasswordSecretNames, &out.PasswordSecretNames + *out = make(PasswordSecretNameList, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BootstrapSpec. +func (in *BootstrapSpec) DeepCopy() *BootstrapSpec { + if in == nil { + return nil + } + out := new(BootstrapSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ChaosSpec) DeepCopyInto(out *ChaosSpec) { *out = *in @@ -257,6 +280,7 @@ func (in *DeploymentSpec) DeepCopyInto(out *DeploymentSpec) { in.SyncMasters.DeepCopyInto(&out.SyncMasters) in.SyncWorkers.DeepCopyInto(&out.SyncWorkers) in.Chaos.DeepCopyInto(&out.Chaos) + in.Bootstrap.DeepCopyInto(&out.Bootstrap) return } @@ -551,6 +575,28 @@ func (in *MonitoringSpec) DeepCopy() *MonitoringSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in PasswordSecretNameList) DeepCopyInto(out *PasswordSecretNameList) { + { + in := &in + *out = make(PasswordSecretNameList, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + return + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PasswordSecretNameList. +func (in PasswordSecretNameList) DeepCopy() PasswordSecretNameList { + if in == nil { + return nil + } + out := new(PasswordSecretNameList) + in.DeepCopyInto(out) + return *out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in Plan) DeepCopyInto(out *Plan) { { diff --git a/pkg/deployment/bootstrap.go b/pkg/deployment/bootstrap.go new file mode 100644 index 000000000..c54615603 --- /dev/null +++ b/pkg/deployment/bootstrap.go @@ -0,0 +1,152 @@ +// +// DISCLAIMER +// +// Copyright 2018 ArangoDB GmbH, Cologne, Germany +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Copyright holder is ArangoDB GmbH, Cologne, Germany +// +// Author Ewout Prangsma +// + +package deployment + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + + api "github.com/arangodb/kube-arangodb/pkg/apis/deployment/v1alpha" + "github.com/arangodb/kube-arangodb/pkg/util/k8sutil" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + driver "github.com/arangodb/go-driver" + + "github.com/arangodb/kube-arangodb/pkg/util/constants" +) + +const ( + rootUserName = "root" +) + +// EnsureBootstrap executes the bootstrap once as soon as the deployment becomes ready +func (d *Deployment) EnsureBootstrap() error { + + status, version := d.GetStatus() + + if status.Conditions.IsTrue(api.ConditionTypeReady) { + if _, hasBootstrap := status.Conditions.Get(api.ConditionTypeBootstrapCompleted); !hasBootstrap { + return nil // The cluster was not initialised with ConditionTypeBoostrapCompleted == false + } + + if status.Conditions.IsTrue(api.ConditionTypeBootstrapCompleted) { + return nil // Nothing to do, already bootstrapped + } + + d.deps.Log.Info().Msgf("Bootstrap deployment %s", d.Name()) + err := d.runBootstrap() + if err != nil { + status.Conditions.Update(api.ConditionTypeBootstrapCompleted, true, "Bootstrap failed", err.Error()) + status.Conditions.Update(api.ConditionTypeBootstrapSucceded, false, "Bootstrap failed", err.Error()) + } else { + status.Conditions.Update(api.ConditionTypeBootstrapCompleted, true, "Bootstrap successful", "The bootstrap process has been completed successfully") + status.Conditions.Update(api.ConditionTypeBootstrapSucceded, true, "Bootstrap successful", "The bootstrap process has been completed successfully") + } + + if err = d.UpdateStatus(status, version); err != nil { + return maskAny(err) + } + + d.deps.Log.Info().Msgf("Bootstrap completed for %s", d.Name()) + } + + return nil +} + +// ensureRootUserPassword ensures the root user secret and returns the password specified or generated +func (d *Deployment) ensureUserPasswordSecret(secrets k8sutil.SecretInterface, username, secretName string) (string, error) { + + if auth, err := secrets.Get(secretName, metav1.GetOptions{}); k8sutil.IsNotFound(err) { + // Create new one + tokenData := make([]byte, 32) + rand.Read(tokenData) + token := hex.EncodeToString(tokenData) + owner := d.GetAPIObject().AsOwner() + + if err := k8sutil.CreateBasicAuthSecret(secrets, secretName, username, token, &owner); err != nil { + return "", err + } + + return token, nil + } else if err == nil { + user, ok := auth.Data[constants.SecretUsername] + if ok && string(user) == username { + pass, ok := auth.Data[constants.SecretPassword] + if ok { + return string(pass), nil + } + } + return "", fmt.Errorf("invalid secret format in secret %s", secretName) + } else { + return "", err + } +} + +// bootstrapUserPassword loads the password for the given user and updates the password stored in the database +func (d *Deployment) bootstrapUserPassword(client driver.Client, secrets k8sutil.SecretInterface, username, secretname string) error { + + d.deps.Log.Debug().Msgf("Bootstrapping user %s, secret %s", username, secretname) + + password, err := d.ensureUserPasswordSecret(secrets, username, secretname) + if err != nil { + return maskAny(err) + } + + // Obtain the user + if user, err := client.User(nil, username); driver.IsNotFound(err) { + _, err := client.CreateUser(nil, username, &driver.UserOptions{Password: password}) + return maskAny(err) + } else if err == nil { + return maskAny(user.Update(nil, driver.UserOptions{ + Password: password, + })) + } else { + return err + } +} + +// runBootstrap is run for a deployment once +func (d *Deployment) runBootstrap() error { + + // execute the bootstrap code + // make sure that the bootstrap code is idempotent + client, err := d.clientCache.GetDatabase(nil) + if err != nil { + return maskAny(err) + } + + spec := d.GetSpec() + secrets := d.GetKubeCli().CoreV1().Secrets(d.Namespace()) + + for user, secret := range spec.Bootstrap.PasswordSecretNames { + if secret.IsNone() { + continue + } + if err := d.bootstrapUserPassword(client, secrets, user, string(secret)); err != nil { + return maskAny(err) + } + } + + return nil +} diff --git a/pkg/deployment/deployment_inspector.go b/pkg/deployment/deployment_inspector.go index bac433f7d..4516063f2 100644 --- a/pkg/deployment/deployment_inspector.go +++ b/pkg/deployment/deployment_inspector.go @@ -165,6 +165,12 @@ func (d *Deployment) inspectDeployment(lastInterval util.Interval) util.Interval d.CreateEvent(k8sutil.NewErrorEvent("AccessPackage creation failed", err, d.apiObject)) } + // Ensure deployment bootstrap + if err := d.EnsureBootstrap(); err != nil { + hasError = true + d.CreateEvent(k8sutil.NewErrorEvent("Bootstrap failed", err, d.apiObject)) + } + // Inspect deployment for obsolete members if err := d.resources.CleanupRemovedMembers(); err != nil { hasError = true diff --git a/pkg/deployment/resources/pod_inspector.go b/pkg/deployment/resources/pod_inspector.go index 207c37e62..28f1dbeaa 100644 --- a/pkg/deployment/resources/pod_inspector.go +++ b/pkg/deployment/resources/pod_inspector.go @@ -239,6 +239,10 @@ func (r *Resources) InspectPods(ctx context.Context) (util.Interval, error) { }) // Update overall conditions + if _, hasReady := status.Conditions.Get(api.ConditionTypeReady); !hasReady { + // Ready was never set, set BootstrapComplete to false + status.Conditions.Update(api.ConditionTypeBootstrapCompleted, false, "Bootstrap waiting", "Waiting for deployment") + } spec := r.context.GetSpec() allMembersReady := status.Members.AllMembersReady(spec.GetMode(), spec.Sync.IsEnabled()) status.Conditions.Update(api.ConditionTypeReady, allMembersReady, "", "") diff --git a/pkg/util/k8sutil/names.go b/pkg/util/k8sutil/names.go index 4a242fedc..b026667b9 100644 --- a/pkg/util/k8sutil/names.go +++ b/pkg/util/k8sutil/names.go @@ -23,9 +23,11 @@ package k8sutil import ( + "crypto/sha1" "fmt" "regexp" "strings" + "unicode" ) var ( @@ -67,3 +69,36 @@ func stripArangodPrefix(id string) string { } return id } + +// FixupResourceName ensures that the given name +// complies with kubernetes name requirements. +// If the name is to long or contains invalid characters, +// if will be adjusted and a hash with be added. +func FixupResourceName(name string, maxLength ...int) string { + maxLen := 63 + if len(maxLength) > 0 { + maxLen = maxLength[0] + } + sb := strings.Builder{} + needHash := len(name) > maxLen + for _, ch := range name { + if unicode.IsDigit(ch) || unicode.IsLower(ch) || ch == '-' { + sb.WriteRune(ch) + } else if unicode.IsUpper(ch) { + sb.WriteRune(unicode.ToLower(ch)) + needHash = true + } else { + needHash = true + } + } + result := sb.String() + if needHash { + hash := sha1.Sum([]byte(name)) + h := fmt.Sprintf("-%0x", hash[:3]) + if len(result)+len(h) > maxLen { + result = result[:maxLen-(len(h))] + } + result = result + h + } + return result +} diff --git a/pkg/util/k8sutil/secrets.go b/pkg/util/k8sutil/secrets.go index 6c32f837a..2c8b94769 100644 --- a/pkg/util/k8sutil/secrets.go +++ b/pkg/util/k8sutil/secrets.go @@ -248,6 +248,28 @@ func CreateTokenSecret(secrets SecretInterface, secretName, token string, ownerR return nil } +// CreateBasicAuthSecret creates a secret with given name in given namespace +// with a given username and password as value. +func CreateBasicAuthSecret(secrets SecretInterface, secretName, username, password string, ownerRef *metav1.OwnerReference) error { + // Create secret + secret := &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + }, + Data: map[string][]byte{ + constants.SecretUsername: []byte(username), + constants.SecretPassword: []byte(password), + }, + } + // Attach secret to owner + addOwnerRefToObject(secret, ownerRef) + if _, err := secrets.Create(secret); err != nil { + // Failed to create secret + return maskAny(err) + } + return nil +} + // GetBasicAuthSecret loads a secret with given name in the given namespace // and extracts the `username` & `password` field. // If the secret does not exists or one of the fields is missing,