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 @@ -281,9 +281,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 @@ -2371,6 +2371,94 @@ 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 *appsv1alpha1.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 := appsv1alpha1.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 *appsv1alpha1.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 *appsv1alpha1.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 *appsv1alpha1.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())
})
})
})

func mockRestoreCompleted(ml client.MatchingLabels) {
Expand Down
2 changes: 1 addition & 1 deletion controllers/apps/suite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,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
22 changes: 18 additions & 4 deletions controllers/apps/transformer_component_workload.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,24 @@ func (t *componentWorkloadTransformer) reconcileWorkload(synthesizedComp *compon
protoITS.Spec.Template.Labels = intctrlutil.MergeMetadataMaps(runningITS.Spec.Template.Labels, synthesizedComp.UserDefinedLabels)
}

// 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 {
newImage, err := intctrlutil.ReplaceImageRegistry(container.Image)
if err != nil {
return err
}
protoITS.Spec.Template.Spec.Containers[i].Image = newImage
}
for i, container := range protoITS.Spec.Template.Spec.InitContainers {
newImage, err := intctrlutil.ReplaceImageRegistry(container.Image)
if err != nil {
return err
}
protoITS.Spec.Template.Spec.InitContainers[i].Image = newImage
}

buildInstanceSetPlacementAnnotation(comp, protoITS)

// build configuration template annotations to workload
Expand Down Expand Up @@ -388,10 +406,6 @@ func checkNRollbackProtoImages(itsObj, itsProto *workloads.InstanceSet) {
for i, cc := range [][]corev1.Container{itsObj.Spec.Template.Spec.InitContainers, itsObj.Spec.Template.Spec.Containers} {
images[i] = make(map[string]string)
for _, c := range cc {
// skip the kb-agent container
cjc7373 marked this conversation as resolved.
Show resolved Hide resolved
if component.IsKBAgentContainer(&c) {
continue
}
images[i][c.Name] = c.Image
}
}
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ require (
github.com/bhmj/jsonslice v1.1.2
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 @@ -102,7 +103,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 @@ -190,8 +190,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.

)
199 changes: 199 additions & 0 deletions pkg/controllerutil/image_util.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
/*
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 the crypto sha256 algorithm for the docker image parser to work
_ "crypto/sha256"
"sync"

// Import the crypto/sha512 algorithm for the docker image parser to work with 384 and 512 sha hashes
_ "crypto/sha512"
"fmt"
"strings"

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

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

// RegistryNamespaceConfig maps registry namespaces.
//
// 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, specify them both, or they won't be matched.
type RegistryNamespaceConfig struct {
From string
To string
}

type RegistryConfig struct {
From string
To string
RegistryDefaultNamespace string
Namespaces []RegistryNamespaceConfig
cjc7373 marked this conversation as resolved.
Show resolved Hide resolved
}

type RegistriesConfig struct {
DefaultRegistry string
cjc7373 marked this conversation as resolved.
Show resolved Hide resolved
DefaultNamespace string
RegistryConfig []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")
}
}

logger := log.Log
logger.Info("registriesConfig reloaded", "registriesConfig", registriesConfig)
cjc7373 marked this conversation as resolved.
Show resolved Hide resolved
}

// 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 {
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, error) {
registry, namespace, repository, remainder, err := parseImageName(image)
if err != nil {
return "", err
}
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 _, namespaceConf := range registryMapping.Namespaces {
if namespace == namespaceConf.From {
dstNamespace = &namespaceConf.To
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), nil
}
return fmt.Sprintf("%v/%v/%v%v", dstRegistry, *dstNamespace, repository, remainder), nil
}
Loading
Loading