blob: c24e5cf33c14aa797eea95ee538e1e410ae04c99 [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you 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.
package runtimeconfig
import (
"context"
_ "embed"
"encoding/json"
"fmt"
"path"
"sort"
"strings"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
)
const (
RuntimeDir = "/var/lib/doris/runtime-config"
StoreDir = ".doris-runtime-config"
configVolume = "runtime-config-prepared"
initName = "prepare-runtime-config"
)
//go:embed prepare.sh
var prepareScript string
type storage struct {
Volume string `json:"volume"`
Claim string `json:"claim"`
MountPath string `json:"mountPath"`
SubPath string `json:"subPath,omitempty"`
}
func component(name string) string {
switch strings.ToLower(name) {
case "fe":
return "fe"
case "be", "compute":
return "be"
case "metaservice":
return "ms"
}
return ""
}
func supportedContainer(containers []corev1.Container) bool {
for _, c := range containers {
if component(c.Name) != "" {
return true
}
}
return false
}
// Prepare only changes the Pod template, never the user's ConfigMap or PVC
// templates. Auto mode leaves workloads without safe persistent storage alone.
func Prepare(ctx context.Context, c client.Client, recorder record.EventRecorder, owner client.Object, st *appsv1.StatefulSet, config map[string]interface{}) error {
policy := owner.GetAnnotations()[PolicyAnnotation]
if policy != Auto && policy != Enabled {
return nil
}
var existing appsv1.StatefulSet
err := c.Get(ctx, types.NamespacedName{Namespace: st.Namespace, Name: st.Name}, &existing)
if err != nil && !apierrors.IsNotFound(err) {
return err
}
recorded := existing.Annotations[StorageAnnotation]
// An automatically skipped workload must not be silently migrated later.
if policy == Auto && err == nil && (recorded == "" || recorded == "unavailable") {
if recorded != "" {
setStorage(st, recorded)
}
return nil
}
for i := range st.Spec.Template.Spec.Containers {
main := &st.Spec.Template.Spec.Containers[i]
kind := component(main.Name)
if kind == "" {
continue
}
key := "custom_config_dir"
if kind == "ms" {
key = "custom_conf_path"
}
configured, _ := config[key].(string)
if strings.TrimSpace(configured) != "" {
// Explicit paths belong to the user. Verify durable backing rather than
// replacing paths, creating directories, or changing their loading rules.
target := strings.TrimSpace(configured)
for _, m := range main.VolumeMounts {
if persistent(st, m.Name) && !m.ReadOnly && within(target, m.MountPath) {
setStorage(st, "external")
return nil
}
}
home := "/opt/apache-doris/" + kind
defaultPath := home + "/conf"
if kind == "ms" {
defaultPath += "/doris_cloud.conf"
}
expanded := strings.ReplaceAll(target, "${DORIS_HOME}", home)
if path.Clean(expanded) != defaultPath {
return fmt.Errorf("%s: explicit %s=%s is not backed by a writable PVC", st.Name, key, target)
}
}
if recorded == "external" {
return fmt.Errorf("%s: removing a user-managed runtime configuration path requires migration", st.Name)
}
chosen, ok := selectStorage(st, main, kind, config, recorded)
if !ok {
if recorded != "" && recorded != "unavailable" {
return fmt.Errorf("%s: previously selected runtime configuration PVC is missing or changed; migrate before changing storage", st.Name)
}
msg := fmt.Sprintf("%s: runtime configuration requires a writable log PVC, FE metadata PVC, or DCR BE storage PVC; cache roots and emptyDir are not safe", st.Name)
if policy == Enabled {
return fmt.Errorf("%s", msg)
}
setStorage(st, "unavailable")
if recorder != nil {
recorder.Event(owner, corev1.EventTypeWarning, "RuntimeConfigStorageUnavailable", msg)
}
return nil
}
if recorded != "" && recorded != "unavailable" && recorded != "external" {
var old storage
if err := json.Unmarshal([]byte(recorded), &old); err != nil || old != chosen {
return fmt.Errorf("%s: runtime configuration storage changed; migrate it before changing volume mounts", st.Name)
}
}
migratedReplicas := int32(0)
var approval migration
if policy == Enabled && json.Unmarshal([]byte(owner.GetAnnotations()[MigrationAnnotation]), &approval) == nil {
migratedReplicas = approval.Workloads[st.Name].Replicas
}
if err := preparePod(st, main, kind, chosen, migratedReplicas); err != nil {
return err
}
raw, _ := json.Marshal(chosen)
setStorage(st, string(raw))
return nil
}
return nil
}
func setStorage(st *appsv1.StatefulSet, value string) {
if st.Annotations == nil {
st.Annotations = map[string]string{}
}
st.Annotations[StorageAnnotation] = value
}
// RetainPVCOnScaleDown preserves the selected configuration store when an
// ordinal is removed. Other claims, including file cache claims, remain subject
// to normal cleanup. Explicit user-managed paths keep their existing policy.
func RetainPVCOnScaleDown(st *appsv1.StatefulSet, pvcName string) (bool, error) {
recorded := st.Annotations[StorageAnnotation]
if recorded == "" || recorded == "unavailable" || recorded == "external" {
return false, nil
}
var selected storage
if err := json.Unmarshal([]byte(recorded), &selected); err != nil || selected.Volume == "" || selected.Claim == "" {
return false, fmt.Errorf("%s: invalid runtime configuration storage; refusing PVC cleanup", st.Name)
}
if claimIdentity(st, selected.Volume) != selected.Claim {
return false, fmt.Errorf("%s: runtime configuration storage changed; refusing PVC cleanup", st.Name)
}
if selected.Claim == "template:"+selected.Volume {
prefix := selected.Volume + "-" + st.Name + "-"
return strings.HasPrefix(pvcName, prefix), nil
}
return selected.Claim == "claim:"+pvcName, nil
}
func within(target, root string) bool {
return path.IsAbs(target) && (path.Clean(target) == path.Clean(root) || strings.HasPrefix(path.Clean(target), path.Clean(root)+"/"))
}
func persistent(st *appsv1.StatefulSet, name string) bool {
for _, v := range st.Spec.Template.Spec.Volumes {
if v.Name == name && v.PersistentVolumeClaim != nil && !v.PersistentVolumeClaim.ReadOnly {
return true
}
}
for _, v := range st.Spec.VolumeClaimTemplates {
if v.Name == name {
return true
}
}
return false
}
func selectStorage(st *appsv1.StatefulSet, main *corev1.Container, kind string, config map[string]interface{}, recorded string) (storage, bool) {
type candidate struct {
storage
rank int
}
var candidates []candidate
var old storage
_ = json.Unmarshal([]byte(recorded), &old)
for _, m := range main.VolumeMounts {
if !persistent(st, m.Name) || m.ReadOnly || m.SubPathExpr != "" {
continue
}
s := storage{Volume: m.Name, Claim: claimIdentity(st, m.Name), MountPath: m.MountPath, SubPath: m.SubPath}
if old.Volume != "" {
if old == s && !cacheRoot(config, m.MountPath) {
return s, true
}
continue
}
rank := 0
if path.Base(m.MountPath) == "log" || equalsConfigPath(config, "log_dir", m.MountPath) || equalsConfigPath(config, "LOG_DIR", m.MountPath) {
rank = 1
}
if kind == "fe" && (path.Base(m.MountPath) == "doris-meta" || equalsConfigPath(config, "meta_dir", m.MountPath)) {
rank = 2
}
if strings.EqualFold(main.Name, "be") && (path.Base(m.MountPath) == "storage" || storageRoot(config, m.MountPath)) {
rank = 3
}
// file_cache_path roots may be recursively removed, including custom directories.
if cacheRoot(config, m.MountPath) {
rank = 0
}
if rank != 0 {
candidates = append(candidates, candidate{s, rank})
}
}
sort.Slice(candidates, func(i, j int) bool {
if candidates[i].rank != candidates[j].rank {
return candidates[i].rank < candidates[j].rank
}
return candidates[i].MountPath < candidates[j].MountPath
})
if len(candidates) == 0 {
return storage{}, false
}
return candidates[0].storage, true
}
func equalsConfigPath(config map[string]interface{}, key, p string) bool {
v, _ := config[key].(string)
return v != "" && path.Clean(v) == path.Clean(p)
}
func storageRoot(config map[string]interface{}, p string) bool {
v, _ := config["storage_root_path"].(string)
for _, root := range strings.Split(v, ";") {
if path.Clean(strings.Split(strings.TrimSpace(root), ",")[0]) == path.Clean(p) {
return true
}
}
return false
}
func cacheRoot(config map[string]interface{}, p string) bool {
v, _ := config["file_cache_path"].(string)
var roots []struct {
Path string `json:"path"`
}
if json.Unmarshal([]byte(v), &roots) == nil {
for _, r := range roots {
r.Path = strings.ReplaceAll(r.Path, "${DORIS_HOME}", "/opt/apache-doris/be")
r.Path = strings.ReplaceAll(r.Path, "$DORIS_HOME", "/opt/apache-doris/be")
if within(p, r.Path) || within(r.Path, p) {
return true
}
}
}
return false
}
func preparePod(st *appsv1.StatefulSet, main *corev1.Container, kind string, s storage, migratedReplicas int32) error {
pod := &st.Spec.Template.Spec
for _, c := range pod.InitContainers {
if c.Name == initName {
return fmt.Errorf("%s: init container name %s is reserved", st.Name, initName)
}
}
for _, m := range main.VolumeMounts {
if path.Clean(m.MountPath) == RuntimeDir {
return fmt.Errorf("%s: mount path %s is reserved; configure an explicit custom path to manage it yourself", st.Name, RuntimeDir)
}
}
home := "/opt/apache-doris/" + kind
base := kind + ".conf"
if kind == "ms" {
base = "doris_cloud.conf"
}
source := "/etc/doris"
envKey := "CONFIGMAP_MOUNT_PATH"
if kind == "ms" {
envKey = "CONFIGMAP_PATH"
}
for _, e := range main.Env {
if e.Name == envKey && e.Value != "" {
source = e.Value
}
}
for _, v := range pod.Volumes {
if v.Name == configVolume {
return fmt.Errorf("%s: volume name %s is reserved", st.Name, configVolume)
}
}
init := corev1.Container{
Name: initName, Image: main.Image, ImagePullPolicy: main.ImagePullPolicy,
Command: []string{"/bin/bash", "-ec", prepareScript},
Env: []corev1.EnvVar{
{Name: "POD_NAME", ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{FieldPath: "metadata.name"}}},
{Name: "RUNTIME_COMPONENT", Value: kind}, {Name: "RUNTIME_BASE", Value: base}, {Name: "RUNTIME_HOME", Value: home},
{Name: "RUNTIME_MIGRATED_REPLICAS", Value: fmt.Sprint(migratedReplicas)},
},
SecurityContext: main.SecurityContext, Resources: main.Resources,
VolumeMounts: []corev1.VolumeMount{
{Name: s.Volume, MountPath: "/runtime-store", SubPath: s.SubPath},
{Name: configVolume, MountPath: "/runtime-prepared"},
},
}
foundSource := false
for i := range main.VolumeMounts {
m := &main.VolumeMounts[i]
if path.Clean(m.MountPath) == path.Clean(source) {
src := *m
src.MountPath = "/runtime-source"
src.ReadOnly = true
init.VolumeMounts = append(init.VolumeMounts, src)
m.Name = configVolume
m.ReadOnly = false
m.SubPath = ""
m.SubPathExpr = ""
foundSource = true
} else if path.Clean(m.MountPath) == home+"/conf" {
return fmt.Errorf("%s: direct conf mounts require an explicit PVC-backed custom configuration path", st.Name)
}
}
if !foundSource {
main.VolumeMounts = append(main.VolumeMounts, corev1.VolumeMount{Name: configVolume, MountPath: source})
}
pod.Volumes = append(pod.Volumes, corev1.Volume{Name: configVolume, VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}})
main.VolumeMounts = append(main.VolumeMounts, corev1.VolumeMount{Name: s.Volume, MountPath: RuntimeDir, SubPathExpr: path.Join(s.SubPath, StoreDir, "$(POD_NAME)")})
// The initializer must not run the image's normal entrypoint.
pod.InitContainers = append(pod.InitContainers, init)
return nil
}
func claimIdentity(st *appsv1.StatefulSet, name string) string {
for _, v := range st.Spec.VolumeClaimTemplates {
if v.Name == name {
return "template:" + name
}
}
for _, v := range st.Spec.Template.Spec.Volumes {
if v.Name == name && v.PersistentVolumeClaim != nil {
return "claim:" + v.PersistentVolumeClaim.ClaimName
}
}
return ""
}