Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: replace image registry dynamically #8018

Open
wants to merge 17 commits into
base: main
Choose a base branch
from
2 changes: 2 additions & 0 deletions cmd/manager/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -286,9 +286,11 @@ func main() {
if err := viper.ReadInConfig(); err != nil { // Handle errors reading the config file
setupLog.Info("unable to read in config, errors ignored")
}
intctrlutil.ReloadRegistryConfig()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apply these changes to the main function of dataprotection (it runs in a separate process).

setupLog.Info(fmt.Sprintf("config file: %s", viper.GetViper().ConfigFileUsed()))
viper.OnConfigChange(func(e fsnotify.Event) {
setupLog.Info(fmt.Sprintf("config file changed: %s", e.Name))
intctrlutil.ReloadRegistryConfig()
})
viper.WatchConfig()

Expand Down
88 changes: 88 additions & 0 deletions controllers/apps/component_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2099,4 +2099,92 @@ var _ = Describe("Component Controller", func() {
testImageUnchangedAfterNewReleasePublished(release)
})
})

Context("with registry replace enabled", func() {
registry := "foo.bar"
setRegistryConfig := func() {
viper.Set(constant.CfgRegistries, intctrlutil.RegistriesConfig{
DefaultRegistry: registry,
})
intctrlutil.ReloadRegistryConfig()
}

BeforeEach(func() {
createAllDefinitionObjects()
})

AfterEach(func() {
viper.Set(constant.CfgRegistries, nil)
intctrlutil.ReloadRegistryConfig()
})

It("replaces image registry", func() {
setRegistryConfig()

createClusterObj(defaultCompName, compDefName, nil)

itsKey := compKey
Eventually(testapps.CheckObj(&testCtx, itsKey, func(g Gomega, its *workloads.InstanceSet) {
// check the image
c := its.Spec.Template.Spec.Containers[0]
g.Expect(c.Image).To(HavePrefix(registry))
})).Should(Succeed())
})

It("handles running its and upgrade", func() {
createClusterObj(defaultCompName, compDefName, nil)
itsKey := compKey
Eventually(testapps.CheckObj(&testCtx, itsKey, func(g Gomega, its *workloads.InstanceSet) {
// check the image
c := its.Spec.Template.Spec.Containers[0]
g.Expect(c.Image).To(Equal(compVerObj.Spec.Releases[0].Images[c.Name]))
})).Should(Succeed())

setRegistryConfig()
By("trigger component reconcile")
now := time.Now().Format(time.RFC3339)
Expect(testapps.GetAndChangeObj(&testCtx, compKey, func(comp *kbappsv1.Component) {
comp.Annotations["now"] = now
})()).Should(Succeed())

Consistently(testapps.CheckObj(&testCtx, itsKey, func(g Gomega, its *workloads.InstanceSet) {
// check the image
c := its.Spec.Template.Spec.Containers[0]
g.Expect(c.Image).NotTo(HavePrefix(registry))
})).Should(Succeed())

By("replaces registry when upgrading")
release := kbappsv1.ComponentVersionRelease{
Name: "8.0.31",
ServiceVersion: "8.0.31",
Images: map[string]string{
testapps.DefaultMySQLContainerName: "docker.io/apecloud/mysql:8.0.31",
},
}

By("publish a new release")
compVerKey := client.ObjectKeyFromObject(compVerObj)
Expect(testapps.GetAndChangeObj(&testCtx, compVerKey, func(compVer *kbappsv1.ComponentVersion) {
compVer.Spec.Releases = append(compVer.Spec.Releases, release)
compVer.Spec.CompatibilityRules[0].Releases = append(compVer.Spec.CompatibilityRules[0].Releases, release.Name)
})()).Should(Succeed())

By("update serviceversion in cluster")
Expect(testapps.GetAndChangeObj(&testCtx, clusterKey, func(cluster *kbappsv1.Cluster) {
cluster.Spec.ComponentSpecs[0].ServiceVersion = "8.0.31"
})()).Should(Succeed())

By("trigger component reconcile")
now = time.Now().Format(time.RFC3339)
Expect(testapps.GetAndChangeObj(&testCtx, compKey, func(comp *kbappsv1.Component) {
comp.Annotations["now"] = now
})()).Should(Succeed())

Eventually(testapps.CheckObj(&testCtx, itsKey, func(g Gomega, its *workloads.InstanceSet) {
// check the image
c := its.Spec.Template.Spec.Containers[0]
g.Expect(c.Image).To(HavePrefix(registry))
})).Should(Succeed())
})
})
})
2 changes: 1 addition & 1 deletion controllers/apps/suite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ var _ = BeforeSuite(func() {
Expect(err).ToNot(HaveOccurred())

viper.SetDefault("CERT_DIR", "/tmp/k8s-webhook-server/serving-certs")
viper.SetDefault(constant.KBToolsImage, "apecloud/kubeblocks-tools:latest")
viper.SetDefault(constant.KBToolsImage, "docker.io/apecloud/kubeblocks-tools:latest")
viper.SetDefault("PROBE_SERVICE_PORT", 3501)
viper.SetDefault("PROBE_SERVICE_LOG_LEVEL", "info")
viper.SetDefault("CM_NAMESPACE", "default")
Expand Down
10 changes: 10 additions & 0 deletions controllers/apps/transformer_component_workload.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,16 @@ func (t *componentWorkloadTransformer) reconcileWorkload(synthesizedComp *compon
protoITS.Spec.Template.Labels = intctrlutil.MergeMetadataMaps(runningITS.Spec.Template.Labels, synthesizedComp.DynamicLabels)
}

// if runningITS already exists, the image changes in protoITS will be
// rollback to the original image in `checkNRollbackProtoImages`.
// So changing registry configs won't affect existing clusters.
for i, container := range protoITS.Spec.Template.Spec.Containers {
protoITS.Spec.Template.Spec.Containers[i].Image = intctrlutil.ReplaceImageRegistry(container.Image)
}
for i, container := range protoITS.Spec.Template.Spec.InitContainers {
protoITS.Spec.Template.Spec.InitContainers[i].Image = intctrlutil.ReplaceImageRegistry(container.Image)
}

buildInstanceSetPlacementAnnotation(comp, protoITS)

// build configuration template annotations to workload
Expand Down
2 changes: 1 addition & 1 deletion controllers/extensions/addon_controller_stages.go
Original file line number Diff line number Diff line change
Expand Up @@ -514,7 +514,7 @@ func setInitContainer(addon *extensionsv1alpha1.Addon, helmJobPodSpec *corev1.Po
}
copyChartsContainer := corev1.Container{
Name: "copy-charts",
Image: addon.Spec.Helm.ChartsImage,
Image: intctrlutil.ReplaceImageRegistry(addon.Spec.Helm.ChartsImage),
Command: []string{"sh", "-c", fmt.Sprintf("cp %s/* /mnt/charts", fromPath)},
VolumeMounts: []corev1.VolumeMount{
{
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ require (
github.com/charmbracelet/keygen v0.5.1
github.com/clbanning/mxj/v2 v2.5.7
github.com/containers/common v0.55.4
github.com/distribution/reference v0.6.0
github.com/docker/docker v25.0.6+incompatible
github.com/evanphx/json-patch v5.6.0+incompatible
github.com/fasthttp/router v1.4.20
Expand Down Expand Up @@ -104,7 +105,6 @@ require (
github.com/containerd/log v0.1.0 // indirect
github.com/cyphar/filepath-securejoin v0.2.4 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/distribution/reference v0.5.0 // indirect
github.com/docker/cli v25.0.1+incompatible // indirect
github.com/docker/distribution v2.8.3+incompatible // indirect
github.com/docker/docker-credential-helpers v0.8.0 // indirect
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -192,8 +192,8 @@ github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZm
github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
github.com/distribution/distribution/v3 v3.0.0-20221208165359-362910506bc2 h1:aBfCb7iqHmDEIp6fBvC/hQUddQfg+3qdYjwzaiP9Hnc=
github.com/distribution/distribution/v3 v3.0.0-20221208165359-362910506bc2/go.mod h1:WHNsWjnIn2V1LYOrME7e8KxSeKunYHsxEm4am0BUtcI=
github.com/distribution/reference v0.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK2OFGvA0=
github.com/distribution/reference v0.5.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/cli v25.0.1+incompatible h1:mFpqnrS6Hsm3v1k7Wa/BO23oz0k121MTbTO1lpcGSkU=
github.com/docker/cli v25.0.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk=
Expand Down
2 changes: 2 additions & 0 deletions pkg/constant/viper_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,6 @@ const (
CfgKBReconcileWorkers = "KUBEBLOCKS_RECONCILE_WORKERS"
CfgClientQPS = "CLIENT_QPS"
CfgClientBurst = "CLIENT_BURST"

CfgRegistries = "registries"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A global flag is hard to config and maintain for the user, why not using the kb manager config CM?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does uses the manager config CM. This flag is the first level key in the config.

)
202 changes: 202 additions & 0 deletions pkg/controllerutil/image_util.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
/*
Copyright (C) 2022-2024 ApeCloud Co., Ltd

This file is part of KubeBlocks project

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.

You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

package controllerutil

import (
cjc7373 marked this conversation as resolved.
Show resolved Hide resolved
// Import these crypto algorithm so that the image parser can work with digest
_ "crypto/sha256"
_ "crypto/sha512"
"fmt"
"strings"
"sync"

"github.com/distribution/reference"
"sigs.k8s.io/controller-runtime/pkg/log"

"github.com/apecloud/kubeblocks/pkg/constant"
viper "github.com/apecloud/kubeblocks/pkg/viperx"
)

var imageLogger = log.Log.WithName("ImageUtil")

type RegistryConfig struct {
From string `mapstructure:"from"`
To string `mapstructure:"to"`
RegistryDefaultNamespace string `mapstructure:"registryDefaultNamespace"`

// Quote from https://docs.docker.com/reference/cli/docker/image/tag/
// > While the OCI Distribution Specification supports more than two slash-separated components,
// > most registries only support two slash-separated components.
// > For Docker's public registry, the path format is as follows:
// > [NAMESPACE/]REPOSITORY: The first, optional component is typically a user's or an organization's
// > namespace. The second, mandatory component is the repository name. When the namespace is
// > not present, Docker uses `library` as the default namespace.
//
// So if there are more than two components (like `a/b` as a namespace), specify them both,
// or they won't be matched. Note empty namespace is legal too.
//
// key is the orignal namespace, value is the new namespace
NamespaceMapping map[string]string `mapstructure:"namespaceMapping"`
}

type RegistriesConfig struct {
DefaultRegistry string `mapstructure:"defaultRegistry"`
DefaultNamespace string `mapstructure:"defaultNamespace"`
RegistryConfig []RegistryConfig `mapstructure:"registryConfig"`
}

// this lock protects r/w to this variable itself,
// not the data it points to
var registriesConfigMutex sync.RWMutex
var registriesConfig = &RegistriesConfig{}

func GetRegistriesConfig() *RegistriesConfig {
registriesConfigMutex.RLock()
defer registriesConfigMutex.RUnlock()

// this will return a copy of the pointer
return registriesConfig
}

func ReloadRegistryConfig() {
registriesConfigMutex.Lock()
defer registriesConfigMutex.Unlock()

registriesConfig = &RegistriesConfig{}
if err := viper.UnmarshalKey(constant.CfgRegistries, &registriesConfig); err != nil {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it return an error if this key is not found?

panic(err)
}

for _, registry := range registriesConfig.RegistryConfig {
if len(registry.From) == 0 {
panic("from can't be empty")
}

if len(registry.To) == 0 {
panic("to can't be empty")
}
}

// since the use of kb tools image is widespread, set viper value here so that we don't need
// to replace it every time
viper.Set(constant.KBToolsImage, ReplaceImageRegistry(viper.GetString(constant.KBToolsImage)))

imageLogger.Info("registriesConfig reloaded", "registriesConfig", registriesConfig)
}

// For a detailed explanation of an image's format, see:
// https://pkg.go.dev/github.com/distribution/reference

// if registry is omitted, the default (docker hub) will be added.
// if namespace is omiited when using docker hub, the default namespace (library) will be added.
func parseImageName(image string) (
host string, namespace string, repository string, remainder string, err error,
) {
named, err := reference.ParseNormalizedNamed(image)
if err != nil {
imageLogger.Error(err, "parse image failed, the image remains unchanged", "image", image)
return
}

tagged, ok := named.(reference.Tagged)
if ok {
remainder += ":" + tagged.Tag()
}

digested, ok := named.(reference.Digested)
if ok {
remainder += "@" + digested.Digest().String()
}

host = reference.Domain(named)

pathSplit := strings.Split(reference.Path(named), "/")
if len(pathSplit) > 1 {
namespace = strings.Join(pathSplit[:len(pathSplit)-1], "/")
}
repository = pathSplit[len(pathSplit)-1]

return
}

func ReplaceImageRegistry(image string) string {
registry, namespace, repository, remainder, err := parseImageName(image)
// if parse has failed, return the original image. Since k8s will always error an invalid image, we
// don't need to deal with the error here
if err != nil {
return image
}
registriesConfigCopy := GetRegistriesConfig()

chooseRegistry := func() string {
if registriesConfigCopy.DefaultRegistry != "" {
return registriesConfigCopy.DefaultRegistry
} else {
return registry
}
}

chooseNamespace := func() *string {
if registriesConfigCopy.DefaultNamespace != "" {
return &registriesConfigCopy.DefaultNamespace
} else {
return &namespace
}
}

var dstRegistry string
var dstNamespace *string
for _, registryMapping := range registriesConfigCopy.RegistryConfig {
if registryMapping.From == registry {
dstRegistry = registryMapping.To

for orig, new := range registryMapping.NamespaceMapping {
if namespace == orig {
dstNamespace = &new
break
}
}

if dstNamespace == nil {
if registryMapping.RegistryDefaultNamespace != "" {
dstNamespace = &registryMapping.RegistryDefaultNamespace
} else {
dstNamespace = &namespace
}
}

break
}
}

// no match in registriesConf.Registries
if dstRegistry == "" {
dstRegistry = chooseRegistry()
}

if dstNamespace == nil {
dstNamespace = chooseNamespace()
}

if *dstNamespace == "" {
return fmt.Sprintf("%v/%v%v", dstRegistry, repository, remainder)
}
return fmt.Sprintf("%v/%v/%v%v", dstRegistry, *dstNamespace, repository, remainder)
}
Loading
Loading