diff --git a/apis/apps/v1/componentversion_types.go b/apis/apps/v1/componentversion_types.go index f9641def044..0fa780c7e01 100644 --- a/apis/apps/v1/componentversion_types.go +++ b/apis/apps/v1/componentversion_types.go @@ -141,12 +141,15 @@ type ComponentVersionRelease struct { // +kubebuilder:validation:MaxLength=32 ServiceVersion string `json:"serviceVersion"` - // Images define the new images for different containers within the release. + // Images define the new images for containers, actions or external applications within the release. + // + // If an image is specified for a lifecycle action, the key should be the field name (case-insensitive) of + // the action in the LifecycleActions struct. // // +kubebuilder:validation:Required // +kubebuilder:validation:MinProperties=1 // +kubebuilder:validation:MaxProperties=128 - // +kubebuilder:validation:XValidation:rule="self.all(key, size(key) <= 32)",message="Container name may not exceed maximum length of 32 characters" + // +kubebuilder:validation:XValidation:rule="self.all(key, size(key) <= 32)",message="Container, action or external application name may not exceed maximum length of 32 characters" // +kubebuilder:validation:XValidation:rule="self.all(key, size(self[key]) <= 256)",message="Image name may not exceed maximum length of 256 characters" Images map[string]string `json:"images"` } diff --git a/config/crd/bases/apps.kubeblocks.io_componentversions.yaml b/config/crd/bases/apps.kubeblocks.io_componentversions.yaml index 9e224ef5808..97e945aa7f4 100644 --- a/config/crd/bases/apps.kubeblocks.io_componentversions.yaml +++ b/config/crd/bases/apps.kubeblocks.io_componentversions.yaml @@ -109,14 +109,18 @@ spec: images: additionalProperties: type: string - description: Images define the new images for different containers - within the release. + description: |- + Images define the new images for containers, actions or external applications within the release. + + + If an image is specified for a lifecycle action, the key should be the field name (case-insensitive) of + the action in the LifecycleActions struct. maxProperties: 128 minProperties: 1 type: object x-kubernetes-validations: - - message: Container name may not exceed maximum length of 32 - characters + - message: Container, action or external application name may + not exceed maximum length of 32 characters rule: self.all(key, size(key) <= 32) - message: Image name may not exceed maximum length of 256 characters rule: self.all(key, size(self[key]) <= 256) diff --git a/controllers/apps/componentversion_controller.go b/controllers/apps/componentversion_controller.go index 3cad2300486..3d8f3b1aa48 100644 --- a/controllers/apps/componentversion_controller.go +++ b/controllers/apps/componentversion_controller.go @@ -323,24 +323,12 @@ func (r *ComponentVersionReconciler) validateServiceVersion(release appsv1.Compo } func (r *ComponentVersionReconciler) validateImages(release appsv1.ComponentVersionRelease, cmpds map[string]*appsv1.ComponentDefinition) error { - validateContainer := func(cmpd appsv1.ComponentDefinition, name string) error { - cmp := func(c corev1.Container) bool { - return c.Name == name - } - if slices.IndexFunc(cmpd.Spec.Runtime.InitContainers, cmp) != -1 { - return nil - } - if slices.IndexFunc(cmpd.Spec.Runtime.Containers, cmp) != -1 { - return nil - } - return fmt.Errorf("container %s is not found in ComponentDefinition %s", name, cmpd.Name) - } for name := range release.Images { for _, cmpd := range cmpds { if cmpd == nil { continue } - if err := validateContainer(*cmpd, name); err != nil { + if err := r.validateImageContainer(*cmpd, name); err != nil { return err } } @@ -348,6 +336,45 @@ func (r *ComponentVersionReconciler) validateImages(release appsv1.ComponentVers return nil } +func (r *ComponentVersionReconciler) validateImageContainer(cmpd appsv1.ComponentDefinition, name string) error { + if r.imageDefinedInContainers(cmpd, name) { + return nil + } + if r.imageDefinedInActions(cmpd, name) { + return nil + } + // user-managed images, leave it to the user to handle + return nil +} + +func (r *ComponentVersionReconciler) imageDefinedInContainers(cmpd appsv1.ComponentDefinition, name string) bool { + cmp := func(c corev1.Container) bool { + return c.Name == name + } + if slices.IndexFunc(cmpd.Spec.Runtime.InitContainers, cmp) != -1 { + return true + } + if slices.IndexFunc(cmpd.Spec.Runtime.Containers, cmp) != -1 { + return true + } + return false +} + +func (r *ComponentVersionReconciler) imageDefinedInActions(_ appsv1.ComponentDefinition, name string) bool { + match := func(action string) bool { + // case insensitive + return strings.EqualFold(action, name) + } + + tp := reflect.TypeOf(appsv1.ComponentLifecycleActions{}) + for i := 0; i < tp.NumField(); i++ { + if match(tp.Field(i).Name) { + return true + } + } + return false +} + // validateCompatibilityRulesCompDef validates the reference component definition name pattern defined in compatibility rules. func validateCompatibilityRulesCompDef(compVersion *appsv1.ComponentVersion) error { for _, rule := range compVersion.Spec.CompatibilityRules { diff --git a/controllers/apps/componentversion_controller_test.go b/controllers/apps/componentversion_controller_test.go index d1cc7163484..6cf901680f8 100644 --- a/controllers/apps/componentversion_controller_test.go +++ b/controllers/apps/componentversion_controller_test.go @@ -81,6 +81,8 @@ var _ = Describe("ComponentVersion Controller", func() { // use empty revision as init image tag f = f.SetRuntime(&corev1.Container{Name: app, Image: testapps.AppImage(app, testapps.ReleaseID(""))}) } + f.SetLifecycleAction(testapps.DefaultActionName, + &appsv1.Action{Exec: &appsv1.ExecAction{Image: testapps.AppImage(testapps.DefaultActionName, testapps.ReleaseID(""))}}) objs = append(objs, f.Create(&testCtx).GetObject()) } for _, obj := range objs { @@ -116,6 +118,7 @@ var _ = Describe("ComponentVersion Controller", func() { Images: map[string]string{ testapps.AppName: testapps.AppImage(testapps.AppName, testapps.ReleaseID("r0")), testapps.AppNameSamePrefix: testapps.AppImage(testapps.AppNameSamePrefix, testapps.ReleaseID("r0")), + testapps.DefaultActionName: testapps.AppImage(testapps.DefaultActionName, testapps.ReleaseID("r0")), }, }, { @@ -133,6 +136,7 @@ var _ = Describe("ComponentVersion Controller", func() { Images: map[string]string{ testapps.AppName: testapps.AppImage(testapps.AppName, testapps.ReleaseID("r2")), testapps.AppNameSamePrefix: testapps.AppImage(testapps.AppNameSamePrefix, testapps.ReleaseID("r2")), + testapps.DefaultActionName: testapps.AppImage(testapps.DefaultActionName, testapps.ReleaseID("r2")), }, }, { @@ -150,6 +154,7 @@ var _ = Describe("ComponentVersion Controller", func() { Images: map[string]string{ testapps.AppName: testapps.AppImage(testapps.AppName, testapps.ReleaseID("r4")), testapps.AppNameSamePrefix: testapps.AppImage(testapps.AppNameSamePrefix, testapps.ReleaseID("r4")), + testapps.DefaultActionName: testapps.AppImage(testapps.DefaultActionName, testapps.ReleaseID("r4")), }, }, { @@ -159,6 +164,7 @@ var _ = Describe("ComponentVersion Controller", func() { Images: map[string]string{ testapps.AppName: testapps.AppImage(testapps.AppName, testapps.ReleaseID("r5")), testapps.AppNameSamePrefix: testapps.AppImage(testapps.AppNameSamePrefix, testapps.ReleaseID("r5")), + testapps.DefaultActionName: testapps.AppImage(testapps.DefaultActionName, testapps.ReleaseID("r5")), }, }, }, @@ -180,6 +186,11 @@ var _ = Describe("ComponentVersion Controller", func() { Expect(compDef.Spec.Runtime.Containers).Should(HaveLen(2)) Expect(compDef.Spec.Runtime.Containers[0].Image).Should(Equal(testapps.AppImage(compDef.Spec.Runtime.Containers[0].Name, testapps.ReleaseID(r0)))) Expect(compDef.Spec.Runtime.Containers[1].Image).Should(Equal(testapps.AppImage(compDef.Spec.Runtime.Containers[1].Name, testapps.ReleaseID(r1)))) + + Expect(compDef.Spec.LifecycleActions).ShouldNot(BeNil()) + Expect(compDef.Spec.LifecycleActions.PreTerminate).ShouldNot(BeNil()) + Expect(compDef.Spec.LifecycleActions.PreTerminate.Exec).ShouldNot(BeNil()) + Expect(compDef.Spec.LifecycleActions.PreTerminate.Exec.Image).Should(Equal(testapps.AppImage(testapps.DefaultActionName, testapps.ReleaseID(r1)))) } Context("reconcile component version", func() { @@ -220,18 +231,19 @@ var _ = Describe("ComponentVersion Controller", func() { })).Should(Succeed()) }) - It("w/o container defined", func() { + It("w/o container or action defined", func() { By("update component version to add a non-exist app") compVersionKey := client.ObjectKeyFromObject(compVersionObj) Eventually(testapps.GetAndChangeObj(&testCtx, compVersionKey, func(compVersion *appsv1.ComponentVersion) { compVersion.Spec.Releases[0].Images["app-non-exist"] = "app-image-non-exist" })).Should(Succeed()) - By("checking the object unavailable") + By("checking the object available") Eventually(testapps.CheckObj(&testCtx, client.ObjectKeyFromObject(compVersionObj), func(g Gomega, cmpv *appsv1.ComponentVersion) { g.Expect(cmpv.Status.ObservedGeneration).Should(Equal(cmpv.GetGeneration())) - g.Expect(cmpv.Status.Phase).Should(Equal(appsv1.UnavailablePhase)) + // support to specify user-managed images + g.Expect(cmpv.Status.Phase).Should(Equal(appsv1.AvailablePhase)) })).Should(Succeed()) }) @@ -584,6 +596,8 @@ var _ = Describe("ComponentVersion Controller", func() { SetServiceVersion(testapps.ServiceVersion("v4")). SetRuntime(&corev1.Container{Name: testapps.AppName, Image: testapps.AppImage(testapps.AppName, testapps.ReleaseID(""))}). SetRuntime(&corev1.Container{Name: testapps.AppNameSamePrefix, Image: testapps.AppImage(testapps.AppNameSamePrefix, testapps.ReleaseID(""))}). + SetLifecycleAction(testapps.DefaultActionName, + &appsv1.Action{Exec: &appsv1.ExecAction{Image: testapps.AppImage(testapps.DefaultActionName, testapps.ReleaseID(""))}}). Create(&testCtx). GetObject() Eventually(testapps.CheckObj(&testCtx, client.ObjectKeyFromObject(compDefObj), diff --git a/deploy/helm/crds/apps.kubeblocks.io_componentversions.yaml b/deploy/helm/crds/apps.kubeblocks.io_componentversions.yaml index 9e224ef5808..97e945aa7f4 100644 --- a/deploy/helm/crds/apps.kubeblocks.io_componentversions.yaml +++ b/deploy/helm/crds/apps.kubeblocks.io_componentversions.yaml @@ -109,14 +109,18 @@ spec: images: additionalProperties: type: string - description: Images define the new images for different containers - within the release. + description: |- + Images define the new images for containers, actions or external applications within the release. + + + If an image is specified for a lifecycle action, the key should be the field name (case-insensitive) of + the action in the LifecycleActions struct. maxProperties: 128 minProperties: 1 type: object x-kubernetes-validations: - - message: Container name may not exceed maximum length of 32 - characters + - message: Container, action or external application name may + not exceed maximum length of 32 characters rule: self.all(key, size(key) <= 32) - message: Image name may not exceed maximum length of 256 characters rule: self.all(key, size(self[key]) <= 256) diff --git a/docs/developer_docs/api-reference/cluster.md b/docs/developer_docs/api-reference/cluster.md index 6c46a121c9d..7e555d7ac84 100644 --- a/docs/developer_docs/api-reference/cluster.md +++ b/docs/developer_docs/api-reference/cluster.md @@ -5770,7 +5770,9 @@ map[string]string -

Images define the new images for different containers within the release.

+

Images define the new images for containers, actions or external applications within the release.

+

If an image is specified for a lifecycle action, the key should be the field name (case-insensitive) of +the action in the LifecycleActions struct.

diff --git a/pkg/controller/component/component_version.go b/pkg/controller/component/component_version.go index 8e352d5c3e5..9fe96335325 100644 --- a/pkg/controller/component/component_version.go +++ b/pkg/controller/component/component_version.go @@ -22,10 +22,13 @@ package component import ( "context" "fmt" + "reflect" "slices" + "strings" "golang.org/x/exp/maps" corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/util/sets" "k8s.io/apimachinery/pkg/util/version" "sigs.k8s.io/controller-runtime/pkg/client" @@ -100,48 +103,72 @@ func UpdateCompDefinitionImages4ServiceVersion(ctx context.Context, cli client.R func resolveImagesWithCompVersions(compDef *appsv1.ComponentDefinition, compVersions []*appsv1.ComponentVersion, serviceVersion string) error { appsInDef := covertImagesFromCompDefinition(compDef) - appsByUser, err := findMatchedImagesFromCompVersions(compVersions, serviceVersion) + appsInVer, err := findMatchedImagesFromCompVersions(compVersions, serviceVersion) if err != nil { return err } - apps := checkNMergeImages(serviceVersion, appsInDef, appsByUser) + apps := checkNMergeImages(serviceVersion, appsInDef, appsInVer) - checkNUpdateImage := func(c *corev1.Container) error { - var err error - app, ok := apps[c.Name] - switch { - case ok && app.err == nil: - c.Image = app.image - case ok: - err = app.err - default: - err = fmt.Errorf("no matched image found for container %s", c.Name) + if err = func() error { + checkNUpdateImage := func(c *corev1.Container) error { + var err error + app, ok := apps[c.Name] + switch { + case ok && app.err == nil: + c.Image = app.image + case ok: + err = app.err + default: + err = fmt.Errorf("no matched image found for container %s", c.Name) + } + return err + } + for i := range compDef.Spec.Runtime.InitContainers { + if err := checkNUpdateImage(&compDef.Spec.Runtime.InitContainers[i]); err != nil { + return err + } + } + for i := range compDef.Spec.Runtime.Containers { + if err := checkNUpdateImage(&compDef.Spec.Runtime.Containers[i]); err != nil { + return err + } } + return nil + }(); err != nil { return err } - for i := range compDef.Spec.Runtime.InitContainers { - if err := checkNUpdateImage(&compDef.Spec.Runtime.InitContainers[i]); err != nil { - return err - } - } - for i := range compDef.Spec.Runtime.Containers { - if err := checkNUpdateImage(&compDef.Spec.Runtime.Containers[i]); err != nil { - return err + if err = func() error { + for name, action := range actionsToResolveImage(compDef) { + if action != nil && action.Exec != nil { + if app, ok := apps[name]; ok { + if app.err != nil { + return app.err + } + action.Exec.Image = app.image + } + } } + return nil + }(); err != nil { + return err } + return nil } func covertImagesFromCompDefinition(compDef *appsv1.ComponentDefinition) map[string]appNameVersionImage { apps := make(map[string]appNameVersionImage) + + // containers checkNAdd := func(c *corev1.Container) { if len(c.Image) > 0 { apps[c.Name] = appNameVersionImage{ - name: c.Name, - version: compDef.Spec.ServiceVersion, - image: c.Image, + name: c.Name, + version: compDef.Spec.ServiceVersion, + image: c.Image, + required: true, } } } @@ -151,10 +178,63 @@ func covertImagesFromCompDefinition(compDef *appsv1.ComponentDefinition) map[str for i := range compDef.Spec.Runtime.Containers { checkNAdd(&compDef.Spec.Runtime.Containers[i]) } + + // actions + for name, action := range actionsToResolveImage(compDef) { + if action != nil && action.Exec != nil { + apps[name] = appNameVersionImage{ + name: name, + version: compDef.Spec.ServiceVersion, + image: action.Exec.Image, + required: false, + } + } + } + return apps } +func actionsToResolveImage(compDef *appsv1.ComponentDefinition) map[string]*appsv1.Action { + if compDef.Spec.LifecycleActions == nil { + return nil + } + + normalize := strings.ToLower + actions := map[string]*appsv1.Action{ + normalize("postProvision"): compDef.Spec.LifecycleActions.PostProvision, + normalize("preTerminate"): compDef.Spec.LifecycleActions.PreTerminate, + normalize("switchover"): compDef.Spec.LifecycleActions.Switchover, + normalize("memberJoin"): compDef.Spec.LifecycleActions.MemberJoin, + normalize("memberLeave"): compDef.Spec.LifecycleActions.MemberLeave, + normalize("readonly"): compDef.Spec.LifecycleActions.Readonly, + normalize("readwrite"): compDef.Spec.LifecycleActions.Readwrite, + normalize("dataDump"): compDef.Spec.LifecycleActions.DataDump, + normalize("dataLoad"): compDef.Spec.LifecycleActions.DataLoad, + normalize("reconfigure"): compDef.Spec.LifecycleActions.Reconfigure, + normalize("accountProvision"): compDef.Spec.LifecycleActions.AccountProvision, + } + if compDef.Spec.LifecycleActions.RoleProbe != nil { + actions[normalize("roleProbe")] = &compDef.Spec.LifecycleActions.RoleProbe.Action + } + return actions +} + func findMatchedImagesFromCompVersions(compVersions []*appsv1.ComponentVersion, serviceVersion string) (map[string]appNameVersionImage, error) { + normalize := func() func(string) (bool, string) { + names := sets.New[string]() + tp := reflect.TypeOf(appsv1.ComponentLifecycleActions{}) + for i := 0; i < tp.NumField(); i++ { + names.Insert(strings.ToLower(tp.Field(i).Name)) + } + return func(name string) (bool, string) { + l := strings.ToLower(name) + if names.Has(l) { + return true, l + } + return false, name + } + }() + appsWithReleases := make(map[string]map[string]appNameVersionImage) for _, compVersion := range compVersions { for _, release := range compVersion.Spec.Releases { @@ -164,52 +244,56 @@ func findMatchedImagesFromCompVersions(compVersions []*appsv1.ComponentVersion, } if match { for name, image := range release.Images { - if _, ok := appsWithReleases[name]; !ok { - appsWithReleases[name] = make(map[string]appNameVersionImage) + isAction, appName := normalize(name) + if _, ok := appsWithReleases[appName]; !ok { + appsWithReleases[appName] = make(map[string]appNameVersionImage) } - appsWithReleases[name][release.Name] = appNameVersionImage{ - name: name, - version: release.ServiceVersion, - image: image, + appsWithReleases[appName][release.Name] = appNameVersionImage{ + name: appName, + version: release.ServiceVersion, + image: image, + required: !isAction, } } } } } + apps := make(map[string]appNameVersionImage) - for name, releases := range appsWithReleases { - names := maps.Keys(releases) - slices.Sort(names) + for appName, releases := range appsWithReleases { + releaseNames := maps.Keys(releases) + slices.Sort(releaseNames) // use the latest release - apps[name] = releases[names[len(names)-1]] + apps[appName] = releases[releaseNames[len(releaseNames)-1]] } return apps, nil } -func checkNMergeImages(serviceVersion string, appsInDef, appsByUser map[string]appNameVersionImage) map[string]appNameVersionImage { +func checkNMergeImages(serviceVersion string, appsInDef, appsInVer map[string]appNameVersionImage) map[string]appNameVersionImage { apps := make(map[string]appNameVersionImage) - merge := func(name string, def, user appNameVersionImage) appNameVersionImage { - if len(user.name) == 0 { + merge := func(name string, def, ver appNameVersionImage) appNameVersionImage { + if len(ver.name) == 0 { match, err := CompareServiceVersion(serviceVersion, def.version) if err != nil { def.err = err } - if !match { + if !match && def.required { def.err = fmt.Errorf("no matched image found for container %s with required version %s", name, serviceVersion) } return def } - return user + return ver } - for _, name := range append(maps.Keys(appsInDef), maps.Keys(appsByUser)...) { - apps[name] = merge(name, appsInDef[name], appsByUser[name]) + for _, name := range append(maps.Keys(appsInDef), maps.Keys(appsInVer)...) { + apps[name] = merge(name, appsInDef[name], appsInVer[name]) } return apps } type appNameVersionImage struct { - name string - version string - image string - err error + name string + version string + image string + err error + required bool } diff --git a/pkg/testutil/apps/component_version_util.go b/pkg/testutil/apps/component_version_util.go index 8da499656c6..8106dab34b3 100644 --- a/pkg/testutil/apps/component_version_util.go +++ b/pkg/testutil/apps/component_version_util.go @@ -29,6 +29,7 @@ const ( AppName = "app" AppNameSamePrefix = "app-same-prefix" + DefaultActionName = "preTerminate" ReleasePrefix = "v0.0.1" ServiceVersionPrefix = "8.0.30"