pkg/controller/enterprisesearch/config.go (265 lines of code) (raw):
// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
// or more contributor license agreements. Licensed under the Elastic License 2.0;
// you may not use this file except in compliance with the Elastic License 2.0.
package enterprisesearch
import (
"context"
"fmt"
"net"
"path/filepath"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
commonv1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/common/v1"
entv1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/enterprisesearch/v1"
"github.com/elastic/cloud-on-k8s/v3/pkg/controller/association"
"github.com/elastic/cloud-on-k8s/v3/pkg/controller/common"
"github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/certificates"
"github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/driver"
"github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/labels"
"github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/reconciler"
"github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/settings"
"github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/version"
"github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/volume"
kibana_network "github.com/elastic/cloud-on-k8s/v3/pkg/controller/kibana/network"
"github.com/elastic/cloud-on-k8s/v3/pkg/utils/k8s"
ulog "github.com/elastic/cloud-on-k8s/v3/pkg/utils/log"
netutil "github.com/elastic/cloud-on-k8s/v3/pkg/utils/net"
)
const (
ESCertsPath = "/mnt/elastic-internal/es-certs"
ConfigMountPath = "/usr/share/enterprise-search/config/enterprise-search.yml"
ConfigFilename = "enterprise-search.yml"
ReadinessProbeMountPath = "/mnt/elastic-internal/scripts/readiness-probe.sh"
ReadinessProbeFilename = "readiness-probe.sh"
ReadinessProbeTimeoutSec = 5
SecretSessionSetting = "secret_session_key"
EncryptionKeysSetting = "secret_management.encryption_keys"
)
func ConfigSecretVolume(ent entv1.EnterpriseSearch) volume.SecretVolume {
return volume.NewSecretVolume(ConfigName(ent.Name), "config", ConfigMountPath, ConfigFilename, 0444)
}
func ReadinessProbeSecretVolume(ent entv1.EnterpriseSearch) volume.SecretVolume {
// reuse the config secret
return volume.NewSecretVolume(ConfigName(ent.Name), "readiness-probe", ReadinessProbeMountPath, ReadinessProbeFilename, 0444)
}
// ReconcileConfig reconciles the configuration of Enterprise Search: it generates the right configuration and
// stores it in a secret that is kept up to date.
// The secret contains 2 entries:
// - the Enterprise Search configuration file
// - a bash script used as readiness probe
func ReconcileConfig(ctx context.Context, driver driver.Interface, ent entv1.EnterpriseSearch, ipFamily corev1.IPFamily) (corev1.Secret, error) {
cfg, err := newConfig(ctx, driver, ent, ipFamily)
if err != nil {
return corev1.Secret{}, err
}
cfgBytes, err := cfg.Render()
if err != nil {
return corev1.Secret{}, err
}
readinessProbeBytes, err := readinessProbeScript(ent, cfg, ipFamily)
if err != nil {
return corev1.Secret{}, err
}
// Reconcile the configuration in a secret
expectedConfigSecret := corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Namespace: ent.Namespace,
Name: ConfigName(ent.Name),
Labels: labels.AddCredentialsLabel(ent.GetIdentityLabels()),
},
Data: map[string][]byte{
ConfigFilename: cfgBytes,
ReadinessProbeFilename: readinessProbeBytes,
},
}
return reconciler.ReconcileSecret(ctx, driver.K8sClient(), expectedConfigSecret, &ent)
}
// partialConfigWithESAuth helps parsing the configuration file to retrieve ES credentials.
type partialConfigWithESAuth struct {
Elasticsearch struct {
Username string `yaml:"username"`
Password string `yaml:"password"`
} `yaml:"elasticsearch"`
}
// readinessProbeScript returns a bash script that requests the health endpoint.
func readinessProbeScript(ent entv1.EnterpriseSearch, config *settings.CanonicalConfig, ipFamily corev1.IPFamily) ([]byte, error) {
url := fmt.Sprintf("%s://%s/api/ent/v1/internal/health", ent.Spec.HTTP.Protocol(), netutil.LoopbackHostPort(ipFamily, HTTPPort))
// retrieve Elasticsearch user credentials from the aggregated config since it could be user-provided
var esAuth partialConfigWithESAuth
if err := config.Unpack(&esAuth); err != nil {
return nil, err
}
basicAuthArgs := "" // no credentials: no basic auth
if esAuth.Elasticsearch.Username != "" {
basicAuthArgs = fmt.Sprintf("-u %s:%s", esAuth.Elasticsearch.Username, esAuth.Elasticsearch.Password)
}
return []byte(`#!/usr/bin/env bash
# fail should be called as a last resort to help the user to understand why the probe failed
function fail {
timestamp=$(date --iso-8601=seconds)
echo "{\"timestamp\": \"${timestamp}\", \"message\": \"readiness probe failed\", "$1"}" | tee /proc/1/fd/2 2> /dev/null
exit 1
}
# request timeout can be overridden from an environment variable
READINESS_PROBE_TIMEOUT=${READINESS_PROBE_TIMEOUT:=` + fmt.Sprintf("%d", ReadinessProbeTimeoutSec) + `}
# request the health endpoint and expect http status code 200. Turning globbing off for unescaped IPv6 addresses
status=$(curl -g -o /dev/null -w "%{http_code}" ` + url + ` ` + basicAuthArgs + ` -k -s --max-time ${READINESS_PROBE_TIMEOUT})
curl_rc=$?
if [[ ${curl_rc} -ne 0 ]]; then
fail "\"curl_rc\": \"${curl_rc}\""
fi
if [[ ${status} == "200" ]]; then
exit 0
else
fail " \"status\": \"${status}\", \"version\":\"${version}\" "
fi
`), nil
}
// newConfig builds a single merged config from:
// - ECK-managed default configuration
// - association configuration (eg. ES credentials)
// - TLS settings configuration
// - user-provided plaintext configuration
// - user-provided secret configuration
// In case of duplicate settings, the last one takes precedence.
func newConfig(ctx context.Context, driver driver.Interface, ent entv1.EnterpriseSearch, ipFamily corev1.IPFamily) (*settings.CanonicalConfig, error) {
reusedCfg, err := getOrCreateReusableSettings(ctx, driver.K8sClient(), ent)
if err != nil {
return nil, err
}
tlsCfg := tlsConfig(ent)
specConfig := ent.Spec.Config
if specConfig == nil {
specConfig = &commonv1.Config{}
}
userProvidedCfg, err := settings.NewCanonicalConfigFrom(specConfig.Data)
if err != nil {
return nil, err
}
userProvidedSecretCfg, err := parseConfigRef(driver, ent)
if err != nil {
return nil, err
}
cfg, err := defaultConfig(ent, ipFamily)
if err != nil {
return nil, err
}
userCfgHasAuth := userConfigHasAuth(userProvidedCfg, userProvidedSecretCfg)
associationCfg, err := associationConfig(ctx, driver.K8sClient(), ent, userCfgHasAuth)
if err != nil {
return nil, err
}
// merge with user settings last so they take precedence
err = cfg.MergeWith(reusedCfg, tlsCfg, associationCfg, userProvidedCfg, userProvidedSecretCfg)
return cfg, err
}
func userConfigHasAuth(userProvidedCfg, userProvidedSecretCfg *settings.CanonicalConfig) bool {
authSettings := "ent_search.auth"
return userProvidedCfg.HasChildConfig(authSettings) || userProvidedSecretCfg.HasChildConfig(authSettings)
}
// reusableSettings captures secrets settings in the Enterprise Search configuration that we want to reuse.
type reusableSettings struct {
SecretSession string `config:"secret_session_key"`
EncryptionKeys []string `config:"secret_management.encryption_keys"`
}
// getOrCreateReusableSettings reads the current configuration and reuse existing secrets it they exist.
func getOrCreateReusableSettings(ctx context.Context, c k8s.Client, ent entv1.EnterpriseSearch) (*settings.CanonicalConfig, error) {
cfg, err := getExistingConfig(ctx, c, ent)
if err != nil {
return nil, err
}
var e reusableSettings
if cfg == nil {
e = reusableSettings{}
} else if err := cfg.Unpack(&e); err != nil {
return nil, err
}
// generate a random secret session key, or reuse the existing one
if len(e.SecretSession) == 0 {
e.SecretSession = string(common.RandomBytes(32))
}
// generate a random encryption key, or reuse the existing one
// Encryption keys are stored in an array, so they can be rotated.
// When Enterprise Search decrypts a secret, it tries all encryption keys in the array, in order.
// When Enterprise Search rewrites a secret, it uses the latest encryption key in the array.
// We manage the first item of that array: it is randomly generated once, then reused.
// Users are free to provide their own encryption keys through the configuration:
// in that case we still keep the first item we manage, user-provided keys will be appended to the array.
// This allows users to go from no custom key provided (use operator's generated one), to providing their own.
if len(e.EncryptionKeys) == 0 {
// no encryption key, generate a new one
e.EncryptionKeys = []string{string(common.RandomBytes(32))}
} else {
// encryption keys already exist, reuse the first ECK-managed one
// other user-provided keys from user-provided config will be merged in later
e.EncryptionKeys = []string{e.EncryptionKeys[0]}
}
return settings.MustCanonicalConfig(e), nil
}
// getExistingConfig retrieves the canonical config, if one exists
func getExistingConfig(ctx context.Context, client k8s.Client, ent entv1.EnterpriseSearch) (*settings.CanonicalConfig, error) {
var secret corev1.Secret
key := types.NamespacedName{
Namespace: ent.Namespace,
Name: ConfigName(ent.Name),
}
err := client.Get(context.Background(), key, &secret)
if err != nil && apierrors.IsNotFound(err) {
ulog.FromContext(ctx).V(1).Info("Enterprise Search config secret does not exist", "namespace", ent.Namespace, "ent_name", ent.Name)
return nil, nil
} else if err != nil {
return nil, err
}
rawCfg, exists := secret.Data[ConfigFilename]
if !exists {
return nil, nil
}
cfg, err := settings.ParseConfig(rawCfg)
if err != nil {
return nil, err
}
return cfg, nil
}
func parseConfigRef(driver driver.Interface, ent entv1.EnterpriseSearch) (*settings.CanonicalConfig, error) {
return common.ParseConfigRef(driver, &ent, ent.Spec.ConfigRef, ConfigFilename)
}
func inAddrAnyFor(ipFamily corev1.IPFamily) string {
if ipFamily == corev1.IPv4Protocol {
return net.IPv4zero.String()
}
// Enterprise Search even in its most recent version 7.9.0 cannot properly handle contracted IPv6 addresses like "::"
return "0:0:0:0:0:0:0:0"
}
func defaultConfig(ent entv1.EnterpriseSearch, ipFamily corev1.IPFamily) (*settings.CanonicalConfig, error) {
settingsMap := map[string]interface{}{
"ent_search.external_url": fmt.Sprintf("%s://localhost:%d", ent.Spec.HTTP.Protocol(), HTTPPort),
"ent_search.listen_host": inAddrAnyFor(ipFamily),
"filebeat_log_directory": LogVolumeMountPath,
"log_directory": LogVolumeMountPath,
"allow_es_settings_modification": true,
}
ver, err := version.Parse(ent.Spec.Version)
if err != nil {
return nil, err
}
// kibana.host is available starting with Enterprise Search 7.15
if ver.GTE(version.From(7, 15, 0)) {
settingsMap["kibana.host"] = fmt.Sprintf("%s://localhost:%d", ent.Spec.HTTP.Protocol(), kibana_network.HTTPPort)
}
return settings.MustCanonicalConfig(settingsMap), nil
}
func associationConfig(ctx context.Context, c k8s.Client, ent entv1.EnterpriseSearch, userCfgHasAuth bool) (*settings.CanonicalConfig, error) {
entAssocConf, err := ent.AssociationConf()
if err != nil {
return nil, err
}
if !entAssocConf.IsConfigured() {
return settings.NewCanonicalConfig(), nil
}
ver, err := version.Parse(ent.Spec.Version)
if err != nil {
return nil, err
}
cfg := settings.NewCanonicalConfig()
if !userCfgHasAuth && ver.LT(version.MinFor(7, 14, 0)) {
cfg = settings.MustCanonicalConfig(map[string]string{
"ent_search.auth.source": "elasticsearch-native",
})
}
credentials, err := association.ElasticsearchAuthSettings(ctx, c, &ent)
if err != nil {
return nil, err
}
if err := cfg.MergeWith(settings.MustCanonicalConfig(map[string]string{
"elasticsearch.host": entAssocConf.URL,
"elasticsearch.username": credentials.Username,
"elasticsearch.password": credentials.Password,
})); err != nil {
return nil, err
}
if entAssocConf.GetCACertProvided() {
if err := cfg.MergeWith(settings.MustCanonicalConfig(map[string]interface{}{
"elasticsearch.ssl.enabled": true,
"elasticsearch.ssl.certificate_authority": filepath.Join(ESCertsPath, certificates.CAFileName),
})); err != nil {
return nil, err
}
}
return cfg, nil
}
func tlsConfig(ent entv1.EnterpriseSearch) *settings.CanonicalConfig {
if !ent.Spec.HTTP.TLS.Enabled() {
return settings.NewCanonicalConfig()
}
certsDir := certificates.HTTPCertSecretVolume(entv1.Namer, ent.Name).VolumeMount().MountPath
return settings.MustCanonicalConfig(map[string]interface{}{
"ent_search.ssl.enabled": true,
"ent_search.ssl.certificate": filepath.Join(certsDir, certificates.CertFileName),
"ent_search.ssl.key": filepath.Join(certsDir, certificates.KeyFileName),
"ent_search.ssl.certificate_authorities": []string{filepath.Join(certsDir, certificates.CAFileName)},
})
}