blob: 553027c4f71ee678dda2c33cdd4a29b5b9fa0887 [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"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"testing"
dv1 "github.com/apache/doris-operator/api/disaggregated/v1"
v1 "github.com/apache/doris-operator/api/doris/v1"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
)
func fakeClient(objects ...client.Object) client.Client {
scheme := runtime.NewScheme()
_ = appsv1.AddToScheme(scheme)
_ = corev1.AddToScheme(scheme)
_ = v1.AddToScheme(scheme)
_ = dv1.AddToScheme(scheme)
return fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build()
}
func owner() *v1.DorisCluster {
return &v1.DorisCluster{ObjectMeta: metav1.ObjectMeta{Name: "cluster", Namespace: "test", UID: "cluster-uid"}}
}
func workload(kind string) *appsv1.StatefulSet {
n := int32(2)
return &appsv1.StatefulSet{ObjectMeta: metav1.ObjectMeta{Name: "cluster-" + kind, Namespace: "test", UID: types.UID(kind + "-uid"), Generation: 3, OwnerReferences: []metav1.OwnerReference{*metav1.NewControllerRef(owner(), v1.GroupVersion.WithKind("DorisCluster"))}}, Spec: appsv1.StatefulSetSpec{Replicas: &n, Template: corev1.PodTemplateSpec{Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: kind, Image: "doris:test", Env: []corev1.EnvVar{{Name: "POD_NAME", ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{FieldPath: "metadata.name"}}}}, VolumeMounts: []corev1.VolumeMount{{Name: "log", MountPath: "/opt/apache-doris/" + kind + "/log"}, {Name: "config", MountPath: "/etc/doris", ReadOnly: true}}}}, Volumes: []corev1.Volume{{Name: "log", VolumeSource: corev1.VolumeSource{PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ClaimName: "existing"}}}, {Name: "config", VolumeSource: corev1.VolumeSource{ConfigMap: &corev1.ConfigMapVolumeSource{LocalObjectReference: corev1.LocalObjectReference{Name: "user-config"}}}}}}}}}
}
func TestPolicyDefaultsAndMigration(t *testing.T) {
ctx := context.Background()
for _, legacy := range []bool{false, true} {
t.Run(map[bool]string{false: "new", true: "existing"}[legacy], func(t *testing.T) {
obj := owner()
objects := []client.Object{obj}
if legacy {
st := workload("fe")
st.OwnerReferences[0].Controller = nil
objects = append(objects, st)
}
c := fakeClient(objects...)
if err := EnsurePolicy(ctx, c, obj); err != nil {
t.Fatal(err)
}
want := Auto
if legacy {
want = Legacy
}
if obj.Annotations[PolicyAnnotation] != want {
t.Fatalf("policy=%v", obj.Annotations)
}
// A later resource deletion/recreation must not change the recorded decision.
if legacy {
if err := c.Delete(ctx, workload("fe")); err != nil {
t.Fatal(err)
}
}
if err := EnsurePolicy(ctx, c, obj); err != nil {
t.Fatal(err)
}
if obj.Annotations[PolicyAnnotation] != want {
t.Fatal("policy changed")
}
})
}
obj := owner()
obj.Spec.RuntimeConfigPersistence = &v1.RuntimeConfigPersistenceSpec{Enabled: true}
st := workload("fe")
*st.Spec.Replicas = 0
c := fakeClient(obj, st)
if err := EnsurePolicy(ctx, c, obj); err == nil {
t.Fatal("accepted migration without backup")
}
approval := migration{ClusterUID: string(obj.UID), Workloads: map[string]migrationWorkload{st.Name: {UID: string(st.UID), Generation: st.Generation, Replicas: *st.Spec.Replicas}}}
raw, _ := json.Marshal(approval)
obj.Annotations = map[string]string{MigrationAnnotation: string(raw)}
if err := EnsurePolicy(ctx, c, obj); err != nil {
t.Fatal(err)
}
if obj.Annotations[PolicyAnnotation] != Enabled {
t.Fatal("not enabled")
}
obj.Spec.RuntimeConfigPersistence.Enabled = false
if err := EnsurePolicy(ctx, c, obj); err == nil {
t.Fatal("accepted lossy disable")
}
approval.Workloads[st.Name] = migrationWorkload{UID: "stale", Generation: st.Generation, Replicas: 2}
raw, _ = json.Marshal(approval)
obj.Annotations[MigrationAnnotation] = string(raw)
if err := validateMigration(obj, []appsv1.StatefulSet{*st}); err == nil {
t.Fatal("accepted stale migration")
}
}
func TestDDCPolicyAndExistingStatus(t *testing.T) {
obj := &dv1.DorisDisaggregatedCluster{ObjectMeta: metav1.ObjectMeta{Name: "ddc", Namespace: "test"}}
c := fakeClient(obj)
if err := EnsurePolicy(context.Background(), c, obj); err != nil {
t.Fatal(err)
}
if obj.Annotations[PolicyAnnotation] != Auto {
t.Fatal(obj.Annotations)
}
old := owner()
old.Status.FEStatus = &v1.ComponentStatus{}
c = fakeClient(old)
if err := EnsurePolicy(context.Background(), c, old); err != nil {
t.Fatal(err)
}
if old.Annotations[PolicyAnnotation] != Legacy {
t.Fatal("existing status was ignored")
}
}
func TestPrepareComponentsAndKeepTemplates(t *testing.T) {
for _, kind := range []string{"fe", "be", "compute", "metaservice"} {
t.Run(kind, func(t *testing.T) {
obj := owner()
obj.Annotations = map[string]string{PolicyAnnotation: Auto}
st := workload(kind)
before := st.DeepCopy()
if err := Prepare(context.Background(), fakeClient(), nil, obj, st, nil); err != nil {
t.Fatal(err)
}
pod := st.Spec.Template.Spec
if len(pod.InitContainers) != 1 || pod.InitContainers[0].Image != "doris:test" {
t.Fatalf("init=%+v", pod.InitContainers)
}
if !reflect.DeepEqual(before.Spec.VolumeClaimTemplates, st.Spec.VolumeClaimTemplates) {
t.Fatal("PVC templates changed")
}
main := pod.Containers[0]
if main.VolumeMounts[len(main.VolumeMounts)-1].SubPathExpr != StoreDir+"/$(POD_NAME)" {
t.Fatal(main.VolumeMounts)
}
if main.VolumeMounts[1].Name != configVolume || main.VolumeMounts[1].ReadOnly {
t.Fatal("base config not staged")
}
for _, v := range pod.Volumes {
if v.Name == "config" && v.ConfigMap.Name != "user-config" {
t.Fatal("user ConfigMap changed")
}
}
// Reconcile should produce the same template and selected storage.
again := before.DeepCopy()
if err := Prepare(context.Background(), fakeClient(st), nil, obj, again, nil); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(st.Spec, again.Spec) || !reflect.DeepEqual(st.Annotations, again.Annotations) {
t.Fatal("non-idempotent prepare")
}
})
}
}
func TestStorageSafetyAndCompatibility(t *testing.T) {
ctx := context.Background()
obj := owner()
obj.Annotations = map[string]string{PolicyAnnotation: Auto}
st := workload("compute")
st.Spec.Template.Spec.Containers[0].VolumeMounts[0].MountPath = "/cache"
cfg := map[string]interface{}{"file_cache_path": `[{"path":"/cache"}]`}
original := st.Spec.Template.DeepCopy()
if err := Prepare(ctx, fakeClient(), nil, obj, st, cfg); err != nil {
t.Fatal(err)
}
if st.Annotations[StorageAnnotation] != "unavailable" || !reflect.DeepEqual(original, &st.Spec.Template) {
t.Fatal("cache root used")
}
obj.Annotations[PolicyAnnotation] = Enabled
if err := Prepare(ctx, fakeClient(), nil, obj, st, cfg); err == nil {
t.Fatal("explicit enable accepted cache-only PVC")
}
obj.Annotations[PolicyAnnotation] = Legacy
st = workload("be")
original = st.Spec.Template.DeepCopy()
if err := Prepare(ctx, fakeClient(), nil, obj, st, nil); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(original, &st.Spec.Template) {
t.Fatal("legacy changed")
}
obj.Annotations[PolicyAnnotation] = Auto
if err := Prepare(ctx, fakeClient(st), nil, obj, st, nil); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(original, &st.Spec.Template) {
t.Fatal("existing unmanaged workload changed")
}
// A user-owned durable path is respected.
st = workload("fe")
if err := Prepare(ctx, fakeClient(), nil, obj, st, map[string]interface{}{"custom_config_dir": "/opt/apache-doris/fe/log/custom"}); err != nil {
t.Fatal(err)
}
if len(st.Spec.Template.Spec.InitContainers) != 0 || st.Annotations[StorageAnnotation] != "external" {
t.Fatal("explicit path replaced")
}
if err := Prepare(ctx, fakeClient(), nil, obj, workload("fe"), map[string]interface{}{"custom_config_dir": "/tmp/custom"}); err == nil {
t.Fatal("ephemeral custom path accepted")
}
// VolumeClaimTemplates also represent durable storage before the Pod exists.
st = workload("fe")
st.Spec.Template.Spec.Volumes = st.Spec.Template.Spec.Volumes[1:]
st.Spec.VolumeClaimTemplates = []corev1.PersistentVolumeClaim{{ObjectMeta: metav1.ObjectMeta{Name: "log"}}}
if err := Prepare(ctx, fakeClient(), nil, obj, st, nil); err != nil {
t.Fatal(err)
}
if len(st.Spec.Template.Spec.InitContainers) == 0 {
t.Fatal("claim template not recognized")
}
}
func TestPrepareShellPreservesOverrides(t *testing.T) {
for _, kind := range []string{"fe", "be", "ms"} {
t.Run(kind, func(t *testing.T) {
root := t.TempDir()
store := filepath.Join(root, "store")
src := filepath.Join(root, "source")
out := filepath.Join(root, "out")
home := filepath.Join(root, "home")
for _, dir := range []string{store, src, out, filepath.Join(home, "conf")} {
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatal(err)
}
}
base := kind + ".conf"
if kind == "ms" {
base = "doris_cloud.conf"
}
if err := os.WriteFile(filepath.Join(src, base), []byte("port=1234\n"), 0644); err != nil {
t.Fatal(err)
}
script := strings.NewReplacer("/runtime-store", store, "/runtime-source", src, "/runtime-prepared", out).Replace(prepareScript)
run := func(pod, replicas string) error {
cmd := exec.Command("bash", "-ec", script)
cmd.Env = append(os.Environ(), "POD_NAME="+pod, "RUNTIME_MIGRATED_REPLICAS="+replicas, "RUNTIME_COMPONENT="+kind, "RUNTIME_BASE="+base, "RUNTIME_HOME="+home)
output, err := cmd.CombinedOutput()
if err != nil {
return &shellError{err, string(output)}
}
return nil
}
if err := run("cluster-0", "0"); err != nil {
t.Fatal(err)
}
custom := filepath.Join(store, StoreDir, "cluster-0", kind+"_custom.conf")
if err := os.WriteFile(custom, []byte("dynamic=value\n"), 0644); err != nil {
t.Fatal(err)
}
// A ConfigMap/image restart cannot overwrite runtime modifications.
if err := run("cluster-0", "0"); err != nil {
t.Fatal(err)
}
got, _ := os.ReadFile(custom)
if string(got) != "dynamic=value\n" {
t.Fatal(string(got))
}
got, _ = os.ReadFile(filepath.Join(out, base))
if !strings.Contains(string(got), RuntimeDir) {
t.Fatal(string(got))
}
if err := run("cluster-0", "1"); err == nil {
t.Fatal("missing migration marker accepted")
}
if err := os.WriteFile(filepath.Join(filepath.Dir(custom), ".migration-complete"), nil, 0644); err != nil {
t.Fatal(err)
}
if err := run("cluster-0", "1"); err != nil {
t.Fatal(err)
}
if err := run("cluster-1", "1"); err != nil {
t.Fatal("scale-out blocked:", err)
}
})
}
}
type shellError struct {
err error
output string
}
func (e *shellError) Error() string { return e.err.Error() + ": " + e.output }
func TestMigrationPodIdentityAndStorageIdentity(t *testing.T) {
obj := owner()
st := workload("fe")
*st.Spec.Replicas = 1
pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "cluster-fe-0", Namespace: "test", UID: "pod-new", OwnerReferences: []metav1.OwnerReference{*metav1.NewControllerRef(st, appsv1.SchemeGroupVersion.WithKind("StatefulSet"))}}}
approval := migration{ClusterUID: string(obj.UID), Workloads: map[string]migrationWorkload{st.Name: {UID: string(st.UID), Generation: st.Generation, Replicas: 1}}, PodUIDs: map[string]string{pod.Name: "pod-old"}}
raw, _ := json.Marshal(approval)
obj.Annotations = map[string]string{MigrationAnnotation: string(raw)}
c := fakeClient(obj, st, pod)
if err := validateMigrationPods(context.Background(), c, obj, []appsv1.StatefulSet{*st}); err == nil {
t.Fatal("replacement Pod accepted")
}
approval.PodUIDs[pod.Name] = string(pod.UID)
raw, _ = json.Marshal(approval)
obj.Annotations[MigrationAnnotation] = string(raw)
if err := validateMigrationPods(context.Background(), c, obj, []appsv1.StatefulSet{*st}); err != nil {
t.Fatal(err)
}
obj.Annotations[PolicyAnnotation] = Auto
st = workload("fe")
if err := Prepare(context.Background(), fakeClient(), nil, obj, st, nil); err != nil {
t.Fatal(err)
}
desired := workload("fe")
desired.Spec.Template.Spec.Volumes[0].PersistentVolumeClaim.ClaimName = "different-disk"
if err := Prepare(context.Background(), fakeClient(st), nil, obj, desired, nil); err == nil {
t.Fatal("PVC replacement silently lost configuration")
}
}
func TestShellImageDefaultsAndConfigMapSeed(t *testing.T) {
root := t.TempDir()
store := filepath.Join(root, "store")
src := filepath.Join(root, "source")
out := filepath.Join(root, "out")
home := filepath.Join(root, "home")
for _, dir := range []string{store, out, filepath.Join(home, "conf")} {
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(home, "conf", "be.conf"), []byte("image_default = true\n"), 0644); err != nil {
t.Fatal(err)
}
script := strings.NewReplacer("/runtime-store", store, "/runtime-source", src, "/runtime-prepared", out).Replace(prepareScript)
run := func() {
t.Helper()
cmd := exec.Command("bash", "-ec", script)
cmd.Env = append(os.Environ(), "POD_NAME=cluster-0", "RUNTIME_MIGRATED_REPLICAS=0", "RUNTIME_COMPONENT=be", "RUNTIME_BASE=be.conf", "RUNTIME_HOME="+home)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("%v: %s", err, output)
}
}
run()
base, _ := os.ReadFile(filepath.Join(out, "be.conf"))
if !strings.Contains(string(base), "image_default = true") {
t.Fatal("image defaults lost")
}
if err := os.MkdirAll(src, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(src, "be_custom.conf"), []byte("seed=initial\n"), 0644); err != nil {
t.Fatal(err)
}
custom := filepath.Join(store, StoreDir, "cluster-0", "be_custom.conf")
if err := os.Remove(custom); err != nil {
t.Fatal(err)
}
run()
value, _ := os.ReadFile(custom)
if string(value) != "seed=initial\n" {
t.Fatal("ConfigMap seed lost")
}
if _, err := os.Stat(filepath.Join(out, "be_custom.conf")); !os.IsNotExist(err) {
t.Fatal("mutable file exposed through source ConfigMap")
}
if err := os.WriteFile(custom, []byte("seed=changed\n"), 0644); err != nil {
t.Fatal(err)
}
run()
value, _ = os.ReadFile(custom)
if string(value) != "seed=changed\n" {
t.Fatal("runtime override replaced by seed")
}
}