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 22 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
18 changes: 18 additions & 0 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
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.

)
181 changes: 181 additions & 0 deletions pkg/controllerutil/image_util.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
/*
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"
"fmt"
"strings"

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

"github.com/distribution/reference"

"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
Namespaces []RegistryNamespaceConfig
}

type RegistriesConfig struct {
DefaultRegistry string
cjc7373 marked this conversation as resolved.
Show resolved Hide resolved
DefaultNamespace string
Registries []RegistryConfig
}

var registriesConfig = RegistriesConfig{}

func init() {
ReloadRegistryConfig()
}

func ReloadRegistryConfig() {
// TODO: this is needed in componnet controller test, is there a better way?
registriesConfig = RegistriesConfig{}
if err := viper.UnmarshalKey(constant.CfgRegistries, &registriesConfig); err != nil {
zjx20 marked this conversation as resolved.
Show resolved Hide resolved
panic(err)
}

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

for _, namespace := range registry.Namespaces {
if len(namespace.From) == 0 || len(namespace.To) == 0 {
panic("namespace can't be empty")
}
}
}
}

// 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
}

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

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

var dstRegistry, dstNamespace string
for _, registryMapping := range registriesConfig.Registries {
if registryMapping.From == registry {
if len(registryMapping.To) != 0 {
dstRegistry = registryMapping.To
} else {
dstRegistry = chooseRegistry()
}

for _, namespaceConf := range registryMapping.Namespaces {
if namespace == namespaceConf.From {
dstNamespace = namespaceConf.To
break
}
}

if dstNamespace == "" {
dstNamespace = namespace
Copy link
Contributor

Choose a reason for hiding this comment

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

so if namespace is not found, the original namespace rather than the defaultNamespace is used.

Copy link
Contributor

@zjx20 zjx20 Sep 6, 2024

Choose a reason for hiding this comment

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

Maybe add a DefaultNamespace field to the RegistryConfig struct:

type RegistryConfig struct {
	From       string
	To         string
	DefaultNamespace string
	Namespaces []RegistryNamespaceConfig
}

When the namespace is not found in Namespaces, use the DefaultNamespace if it's not empty, otherwise use the original namespace.

Copy link
Contributor Author

@cjc7373 cjc7373 Sep 9, 2024

Choose a reason for hiding this comment

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

I think simply use the global defaultNamespace is enough.

It does seem better.

}

break
}
}

// no match in registriesConf.Registries
if dstRegistry == "" {
dstRegistry = chooseRegistry()
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