Repository: apache/camel-k
Pull request #3325 (2022-06-01 15:29:20)

feat(cli): environment promotion
With this PR the user can run:
```
kamel promote sample -n development --to production
```
in order to copy the Integration spec and the related container image from one environment (namespace) to another. In order to work the Operators have to share the same Image registry.

Features:

* Check compatibility version between source and dest operators
* Verify namespaced resources (ie, mount traits) are present in dest namespace
* Verify the operators registries are the same
* Verify Kamelets are available in dest namespace
* Copy the Integration spec from namespace source to ns dest
* Set `container.image` trait on destination to reuse image (tested) from the source Integration

<!-- Description -->




<!--
Enter your extended release note in the below block. If the PR requires
additional action from users switching to the new release, include the string
"action required". If no release note is required, write "NONE". 

You can (optionally) mark this PR with labels "kind/bug" or "kind/feature" to make sure
the text is added to the right section of the release notes. 
-->

**Release Note**
```release-note
feat(cli): environment promotion
```


tadayosi: Looks great!  Here is my early feedback on the feature:
- What if the dev namespace uses a local operator but the production depends on a cluster operator?  Is such promotion not allowed?
- Do we need to check user's privileges for the target namespace?  Or is it ok to let it fail if they are not permitted?
- Users might want to transform some labels (or annotations) depending on the types of envs (namespaces). Should we provide such feature as well?
squakez: Thanks for the feedback, much appreciated!!

>     * What if the dev namespace uses a local operator but the production depends on a cluster operator (or vice versa)?  Is such promotion not allowed?

I think it still can be done. The idea is that we *copy* the Integration spec plus the container which was tested and validated in a previous stage. I think the destination operator should be still able to cope with that.

>     * Do we need to check user's privileges for the target namespace?  Or is it ok to let it fail if they are not permitted?

We may understand which is the best ux there. If I am not wrong, the client we use to interact with the cluster is inheriting privileges from the user who has performed the login. I guess in a very first iteration we can let it fail, and add a check for valid permission in a later stage if we want.

>     * Users might want to transform some labels (or annotations) depending on the types of envs (namespaces). Should we provide such feature as well?

Yeah, that is something we should analyze better. I've already thought we should check for various resources in the destination namespace. We should include also some parameter in order to transform the "typical" resources bound to an environment (annotations, envs, affininity/tolerations, etc...).



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 17a7b74aa..538bfb9c6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,7 +7,6 @@
 **Closed issues:**
 
 - kamel local run support --build-property ? [\#3356](https://github.com/apache/camel-k/issues/3356)
-- Kamel install error [\#3348](https://github.com/apache/camel-k/issues/3348)
 - E2E repeated failure [\#3315](https://github.com/apache/camel-k/issues/3315)
 - Integrations, IntegrationKits, IntegrationPlatforms are not owned by Namespaces [\#3311](https://github.com/apache/camel-k/issues/3311)
 - Helm Chart for camel-k 1.9.2 is invalid [\#3300](https://github.com/apache/camel-k/issues/3300)
diff --git a/go.mod b/go.mod
index cd900be95..7e3cb400d 100644
--- a/go.mod
+++ b/go.mod
@@ -7,7 +7,7 @@ require (
 	github.com/apache/camel-k/pkg/apis/camel v0.0.0
 	github.com/apache/camel-k/pkg/client/camel v0.0.0
 	github.com/apache/camel-k/pkg/kamelet/repository v0.0.0
-	github.com/container-tools/spectrum v0.3.9
+	github.com/container-tools/spectrum v0.3.10
 	github.com/evanphx/json-patch v4.12.0+incompatible
 	github.com/fatih/camelcase v1.0.0
 	github.com/fatih/structs v1.1.0
diff --git a/go.sum b/go.sum
index e566ea018..711cdf2a0 100644
--- a/go.sum
+++ b/go.sum
@@ -276,8 +276,8 @@ github.com/cockroachdb/errors v1.2.4/go.mod h1:rQD95gz6FARkaKkQXUksEje/d9a6wBJoC
 github.com/cockroachdb/logtags v0.0.0-20190617123548-eb05cc24525f/go.mod h1:i/u985jwjWRlyHXQbwatDASoW0RMlZ/3i9yJHE2xLkI=
 github.com/container-tools/go-containerregistry v0.7.1-0.20211124090132-40ccc94a466b h1:PnXGj/WjUnNI7O3AAjXIYs+8QXVRzgoxurEgVgsItrU=
 github.com/container-tools/go-containerregistry v0.7.1-0.20211124090132-40ccc94a466b/go.mod h1:6cMIl1RfryEiPzBE67OgtZdEiLWz4myqCQIiBMy3CsM=
-github.com/container-tools/spectrum v0.3.9 h1:AslAkt0c6HKWIZUHlweiU1gbe7CVvOiKMl6iqE4rPS0=
-github.com/container-tools/spectrum v0.3.9/go.mod h1:nxoXaVMRNF1dS3Gp3dwE2CRYrt+nUM1p/1joIEheYJE=
+github.com/container-tools/spectrum v0.3.10 h1:iDf6SENLglf5drpth0OLhhwmcFofGrB3Z6+uig+Q7sk=
+github.com/container-tools/spectrum v0.3.10/go.mod h1:nxoXaVMRNF1dS3Gp3dwE2CRYrt+nUM1p/1joIEheYJE=
 github.com/containerd/aufs v0.0.0-20200908144142-dab0cbea06f4/go.mod h1:nukgQABAEopAHvB6j7cnP5zJ+/3aVcE7hCYqvIwAHyE=
 github.com/containerd/aufs v0.0.0-20201003224125-76a6863f2989/go.mod h1:AkGGQs9NM2vtYHaUen+NljV0/baGCAPELGm2q9ZXpWU=
 github.com/containerd/aufs v0.0.0-20210316121734-20793ff83c97/go.mod h1:kL5kd6KM5TzQjR79jljyi4olc1Vrx6XBlcyj3gNv2PU=
diff --git a/pkg/cmd/promote.go b/pkg/cmd/promote.go
new file mode 100644
index 000000000..6e15c6cc3
--- /dev/null
+++ b/pkg/cmd/promote.go
@@ -0,0 +1,426 @@
+/*
+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 cmd
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"strings"
+
+	"github.com/pkg/errors"
+
+	v1 "github.com/apache/camel-k/pkg/apis/camel/v1"
+	"github.com/apache/camel-k/pkg/apis/camel/v1alpha1"
+	"github.com/apache/camel-k/pkg/client"
+	"github.com/apache/camel-k/pkg/util/camel"
+	"github.com/apache/camel-k/pkg/util/kamelets"
+	"github.com/apache/camel-k/pkg/util/kubernetes"
+	"github.com/apache/camel-k/pkg/util/resource"
+	"github.com/spf13/cobra"
+	corev1 "k8s.io/api/core/v1"
+	k8serrors "k8s.io/apimachinery/pkg/api/errors"
+	k8sclient "sigs.k8s.io/controller-runtime/pkg/client"
+)
+
+// newCmdPromote --.
+func newCmdPromote(rootCmdOptions *RootCmdOptions) (*cobra.Command, *promoteCmdOptions) {
+	options := promoteCmdOptions{
+		RootCmdOptions: rootCmdOptions,
+	}
+	cmd := cobra.Command{
+		Use:     "promote integration --to [namespace] ...",
+		Short:   "Promote an Integration/KameletBinding from an environment to another",
+		Long:    "Promote an Integration/KameletBinding from an environment to another, for example from a Development environment to a Production environment",
+		PreRunE: decode(&options),
+		RunE:    options.run,
+	}
+
+	cmd.Flags().String("to", "", "The namespace where to promote the Integration")
+
+	return &cmd, &options
+}
+
+type promoteCmdOptions struct {
+	*RootCmdOptions
+	To string `mapstructure:"to" yaml:",omitempty"`
+}
+
+func (o *promoteCmdOptions) validate(_ *cobra.Command, args []string) error {
+	if len(args) != 1 {
+		return errors.New("promote expects an Integration/KameletBinding name argument")
+	}
+	if o.To == "" {
+		return errors.New("promote expects a destination namespace as --to argument")
+	}
+	return nil
+}
+
+func (o *promoteCmdOptions) run(cmd *cobra.Command, args []string) error {
+	if err := o.validate(cmd, args); err != nil {
+		return err
+	}
+
+	name := args[0]
+	c, err := o.GetCmdClient()
+	if err != nil {
+		return errors.Wrap(err, "could not retrieve cluster client")
+	}
+
+	opSource, err := operatorInfo(o.Context, c, o.Namespace)
+	if err != nil {
+		return errors.Wrap(err, "could not retrieve info for Camel K operator source")
+	}
+	opDest, err := operatorInfo(o.Context, c, o.To)
+	if err != nil {
+		return errors.Wrap(err, "could not retrieve info for Camel K operator destination")
+	}
+
+	err = checkOpsCompatibility(cmd, opSource, opDest)
+	if err != nil {
+		return errors.Wrap(err, "could not verify operators compatibility")
+	}
+	promoteKameletBinding := false
+	var sourceIntegration *v1.Integration
+	// We first look if a KameletBinding with the name exists
+	sourceKameletBinding, err := o.getKameletBinding(c, name)
+	if err != nil && !k8serrors.IsNotFound(err) {
+		return errors.Wrap(err, "problems looking for KameletBinding "+name)
+	}
+	if sourceKameletBinding != nil {
+		promoteKameletBinding = true
+	}
+	sourceIntegration, err = o.getIntegration(c, name)
+	if err != nil {
+		return errors.Wrap(err, "could not get Integration "+name)
+	}
+	if sourceIntegration.Status.Phase != v1.IntegrationPhaseRunning {
+		return fmt.Errorf("could not promote an Integration in %s status", sourceIntegration.Status.Phase)
+	}
+	err = o.validateDestResources(c, sourceIntegration)
+	if err != nil {
+		return errors.Wrap(err, "could not validate destination resources")
+	}
+	if promoteKameletBinding {
+		// KameletBinding promotion
+		destKameletBinding, err := o.editKameletBinding(sourceKameletBinding, sourceIntegration)
+		if err != nil {
+			return errors.Wrap(err, "could not edit KameletBinding "+name)
+		}
+
+		return c.Create(o.Context, destKameletBinding)
+	}
+	// Plain Integration promotion
+	destIntegration, err := o.editIntegration(sourceIntegration)
+	if err != nil {
+		if err != nil {
+			return errors.Wrap(err, "could not edit Integration "+name)
+		}
+	}
+
+	return c.Create(o.Context, destIntegration)
+}
+
+func checkOpsCompatibility(cmd *cobra.Command, source, dest map[string]string) error {
+	if !compatibleVersions(source["Version"], dest["Version"], cmd) {
+		return fmt.Errorf("source (%s) and destination (%s) Camel K operator versions are not compatible", source["Version"], dest["Version"])
+	}
+	if !compatibleVersions(source["Runtime Version"], dest["Runtime Version"], cmd) {
+		return fmt.Errorf("source (%s) and destination (%s) Camel K runtime versions are not compatible", source["Runtime Version"], dest["Runtime Version"])
+	}
+	if source["Registry Address"] != dest["Registry Address"] {
+		return fmt.Errorf("source (%s) and destination (%s) Camel K container images registries are not the same", source["Registry Address"], dest["Registry Address"])
+	}
+
+	return nil
+}
+
+func (o *promoteCmdOptions) getKameletBinding(c client.Client, name string) (*v1alpha1.KameletBinding, error) {
+	it := v1alpha1.NewKameletBinding(o.Namespace, name)
+	key := k8sclient.ObjectKey{
+		Name:      name,
+		Namespace: o.Namespace,
+	}
+	if err := c.Get(o.Context, key, &it); err != nil {
+		return nil, err
+	}
+
+	return &it, nil
+}
+
+func (o *promoteCmdOptions) getIntegration(c client.Client, name string) (*v1.Integration, error) {
+	it := v1.NewIntegration(o.Namespace, name)
+	key := k8sclient.ObjectKey{
+		Name:      name,
+		Namespace: o.Namespace,
+	}
+	if err := c.Get(o.Context, key, &it); err != nil {
+		return nil, err
+	}
+
+	return &it, nil
+}
+
+func (o *promoteCmdOptions) validateDestResources(c client.Client, it *v1.Integration) error {
+	var traits map[string][]string
+	var configmaps []string
+	var secrets []string
+	var pvcs []string
+	var kamelets []string
+	if it.Spec.Traits == nil {
+		return nil
+	}
+	// Mount trait
+	mounts := it.Spec.Traits["mount"]
+	if err := json.Unmarshal(mounts.Configuration.RawMessage, &traits); err != nil {
+		return err
+	}
+	for t, v := range traits {
+		switch t {
+		case "configs":
+			for _, c := range v {
+				if conf, parseErr := resource.ParseConfig(c); parseErr == nil {
+					if conf.StorageType() == resource.StorageTypeConfigmap {
+						configmaps = append(configmaps, conf.Name())
+					} else if conf.StorageType() == resource.StorageTypeSecret {
+						secrets = append(secrets, conf.Name())
+					}
+				} else {
+					return parseErr
+				}
+			}
+		case "resources":
+			for _, c := range v {
+				if conf, parseErr := resource.ParseResource(c); parseErr == nil {
+					if conf.StorageType() == resource.StorageTypeConfigmap {
+						configmaps = append(configmaps, conf.Name())
+					} else if conf.StorageType() == resource.StorageTypeSecret {
+						secrets = append(secrets, conf.Name())
+					}
+				} else {
+					return parseErr
+				}
+			}
+		case "volumes":
+			for _, c := range v {
+				if conf, parseErr := resource.ParseVolume(c); parseErr == nil {
+					if conf.StorageType() == resource.StorageTypePVC {
+						pvcs = append(pvcs, conf.Name())
+					}
+				} else {
+					return parseErr
+				}
+			}
+		}
+	}
+	// Openapi trait
+	openapis := it.Spec.Traits["openapi"]
+	if err := json.Unmarshal(openapis.Configuration.RawMessage, &traits); err != nil {
+		return err
+	}
+	for k, v := range traits {
+		for _, c := range v {
+			if k == "configmaps" {
+				configmaps = append(configmaps, c)
+			}
+		}
+	}
+	// Kamelet trait
+	kameletTrait := it.Spec.Traits["kamelets"]
+	var kameletListTrait map[string]string
+	if err := json.Unmarshal(kameletTrait.Configuration.RawMessage, &kameletListTrait); err != nil {
+		return err
+	}
+	kamelets = strings.Split(kameletListTrait["list"], ",")
+	sourceKamelets, err := o.listKamelets(c, it)
+	if err != nil {
+		return err
+	}
+	kamelets = append(kamelets, sourceKamelets...)
+
+	anyError := false
+	var errorTrace string
+	for _, name := range configmaps {
+		if !existsCm(o.Context, c, name, o.To) {
+			anyError = true
+			errorTrace += fmt.Sprintf("Configmap %s is missing from %s namespace\n", name, o.To)
+		}
+	}
+	for _, name := range secrets {
+		if !existsSecret(o.Context, c, name, o.To) {
+			anyError = true
+			errorTrace += fmt.Sprintf("Secret %s is missing from %s namespace\n", name, o.To)
+		}
+	}
+	for _, name := range pvcs {
+		if !existsPv(o.Context, c, name, o.To) {
+			anyError = true
+			errorTrace += fmt.Sprintf("PersistentVolume %s is missing from %s namespace\n", name, o.To)
+		}
+	}
+	for _, name := range kamelets {
+		if !existsKamelet(o.Context, c, name, o.To) {
+			anyError = true
+			errorTrace += fmt.Sprintf("Kamelet %s is missing from %s namespace\n", name, o.To)
+		}
+	}
+
+	if anyError {
+		return fmt.Errorf(errorTrace)
+	}
+
+	return nil
+}
+
+func (o *promoteCmdOptions) listKamelets(c client.Client, it *v1.Integration) ([]string, error) {
+	catalog, err := camel.DefaultCatalog()
+	if err != nil {
+		return nil, err
+	}
+	kamelets, err := kamelets.ExtractKameletFromSources(o.Context, c, catalog, &kubernetes.Collection{}, it)
+	if err != nil {
+		return nil, err
+	}
+
+	// We must remove any default source/sink
+	var filtered []string
+	for _, k := range kamelets {
+		if k != "source" && k != "sink" {
+			filtered = append(filtered, k)
+		}
+	}
+
+	return filtered, nil
+}
+
+func existsCm(ctx context.Context, c client.Client, name string, namespace string) bool {
+	var obj corev1.ConfigMap
+	key := k8sclient.ObjectKey{
+		Name:      name,
+		Namespace: namespace,
+	}
+	if err := c.Get(ctx, key, &obj); err != nil {
+		return false
+	}
+
+	return true
+}
+
+func existsSecret(ctx context.Context, c client.Client, name string, namespace string) bool {
+	var obj corev1.Secret
+	key := k8sclient.ObjectKey{
+		Name:      name,
+		Namespace: namespace,
+	}
+	if err := c.Get(ctx, key, &obj); err != nil {
+		return false
+	}
+
+	return true
+}
+
+func existsPv(ctx context.Context, c client.Client, name string, namespace string) bool {
+	var obj corev1.PersistentVolume
+	key := k8sclient.ObjectKey{
+		Name:      name,
+		Namespace: namespace,
+	}
+	if err := c.Get(ctx, key, &obj); err != nil {
+		return false
+	}
+
+	return true
+}
+
+func existsKamelet(ctx context.Context, c client.Client, name string, namespace string) bool {
+	var obj v1alpha1.Kamelet
+	key := k8sclient.ObjectKey{
+		Name:      name,
+		Namespace: namespace,
+	}
+	if err := c.Get(ctx, key, &obj); err != nil {
+		return false
+	}
+
+	return true
+}
+
+func (o *promoteCmdOptions) editIntegration(it *v1.Integration) (*v1.Integration, error) {
+	dst := v1.NewIntegration(o.To, it.Name)
+	contImage := it.Status.Image
+	dst.Spec = *it.Spec.DeepCopy()
+	if dst.Spec.Traits == nil {
+		dst.Spec.Traits = map[string]v1.TraitSpec{}
+	}
+	editedContTrait, err := editContainerImage(dst.Spec.Traits["container"], contImage)
+	dst.Spec.Traits["container"] = editedContTrait
+	return &dst, err
+}
+
+func (o *promoteCmdOptions) editKameletBinding(kb *v1alpha1.KameletBinding, it *v1.Integration) (*v1alpha1.KameletBinding, error) {
+	dst := v1alpha1.NewKameletBinding(o.To, kb.Name)
+	dst.Spec = *kb.Spec.DeepCopy()
+	contImage := it.Status.Image
+	if dst.Spec.Integration == nil {
+		dst.Spec.Integration = &v1.IntegrationSpec{}
+	}
+	if dst.Spec.Integration.Traits == nil {
+		dst.Spec.Integration.Traits = map[string]v1.TraitSpec{}
+	}
+	editedContTrait, err := editContainerImage(dst.Spec.Integration.Traits["container"], contImage)
+	dst.Spec.Integration.Traits["container"] = editedContTrait
+	if dst.Spec.Source.Ref != nil {
+		dst.Spec.Source.Ref.Namespace = o.To
+	}
+	if dst.Spec.Sink.Ref != nil {
+		dst.Spec.Sink.Ref.Namespace = o.To
+	}
+	if dst.Spec.Steps != nil {
+		for _, step := range dst.Spec.Steps {
+			if step.Ref != nil {
+				step.Ref.Namespace = o.To
+			}
+		}
+	}
+	return &dst, err
+}
+
+func editContainerImage(contTrait v1.TraitSpec, image string) (v1.TraitSpec, error) {
+	var editedTrait v1.TraitSpec
+	m := make(map[string]map[string]interface{})
+	data, err := json.Marshal(contTrait)
+	if err != nil {
+		return editedTrait, err
+	}
+	err = json.Unmarshal(data, &m)
+	if err != nil {
+		return editedTrait, err
+	}
+	// We must initialize, if it was not initialized so far
+	if m["configuration"] == nil {
+		m["configuration"] = make(map[string]interface{})
+	}
+	m["configuration"]["image"] = image
+	newData, err := json.Marshal(m)
+	if err != nil {
+		return editedTrait, err
+	}
+	err = json.Unmarshal(newData, &editedTrait)
+
+	return editedTrait, err
+}
diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go
index f148ab7e5..abdde0747 100644
--- a/pkg/cmd/root.go
+++ b/pkg/cmd/root.go
@@ -150,6 +150,7 @@ func addKamelSubcommands(cmd *cobra.Command, options *RootCmdOptions) {
 	cmd.AddCommand(cmdOnly(newCmdDump(options)))
 	cmd.AddCommand(newCmdLocal(options))
 	cmd.AddCommand(cmdOnly(newCmdBind(options)))
+	cmd.AddCommand(cmdOnly(newCmdPromote(options)))
 	cmd.AddCommand(newCmdKamelet(options))
 }
 
diff --git a/pkg/cmd/version.go b/pkg/cmd/version.go
index 011d6eddb..ec0e493bd 100644
--- a/pkg/cmd/version.go
+++ b/pkg/cmd/version.go
@@ -143,6 +143,7 @@ func operatorInfo(ctx context.Context, c client.Client, namespace string) (map[s
 	infos["version"] = platform.Status.Version
 	infos["publishStrategy"] = string(platform.Status.Build.PublishStrategy)
 	infos["runtimeVersion"] = platform.Status.Build.RuntimeVersion
+	infos["registryAddress"] = platform.Status.Build.Registry.Address
 
 	if platform.Status.Info != nil {
 		for k, v := range platform.Status.Info {
diff --git a/pkg/trait/kamelets.go b/pkg/trait/kamelets.go
index a5c1c936b..f8873cd7d 100644
--- a/pkg/trait/kamelets.go
+++ b/pkg/trait/kamelets.go
@@ -31,13 +31,11 @@ import (
 	"github.com/apache/camel-k/pkg/apis/camel/v1alpha1"
 	kameletutils "github.com/apache/camel-k/pkg/kamelet"
 	"github.com/apache/camel-k/pkg/kamelet/repository"
-	"github.com/apache/camel-k/pkg/metadata"
 	"github.com/apache/camel-k/pkg/platform"
 	"github.com/apache/camel-k/pkg/util"
 	"github.com/apache/camel-k/pkg/util/digest"
 	"github.com/apache/camel-k/pkg/util/dsl"
-	"github.com/apache/camel-k/pkg/util/kubernetes"
-	"github.com/apache/camel-k/pkg/util/source"
+	"github.com/apache/camel-k/pkg/util/kamelets"
 )
 
 // The kamelets trait is a platform trait used to inject Kamelets into the integration runtime.
@@ -91,23 +89,9 @@ func (t *kameletsTrait) Configure(e *Environment) (bool, error) {
 	}
 
 	if IsNilOrTrue(t.Auto) {
-		var kamelets []string
-		if t.List == "" {
-			sources, err := kubernetes.ResolveIntegrationSources(e.Ctx, e.Client, e.Integration, e.Resources)
-			if err != nil {
-				return false, err
-			}
-			metadata.Each(e.CamelCatalog, sources, func(_ int, meta metadata.IntegrationMetadata) bool {
-				util.StringSliceUniqueConcat(&kamelets, meta.Kamelets)
-				return true
-			})
-		}
-		// Check if a Kamelet is configured as default error handler URI
-		defaultErrorHandlerURI := e.Integration.Spec.GetConfigurationProperty(v1alpha1.ErrorHandlerAppPropertiesPrefix + ".deadLetterUri")
-		if defaultErrorHandlerURI != "" {
-			if strings.HasPrefix(defaultErrorHandlerURI, "kamelet:") {
-				kamelets = append(kamelets, source.ExtractKamelet(defaultErrorHandlerURI))
-			}
+		kamelets, err := kamelets.ExtractKameletFromSources(e.Ctx, e.Client, e.CamelCatalog, e.Resources, e.Integration)
+		if err != nil {
+			return false, err
 		}
 
 		if len(kamelets) > 0 {
diff --git a/pkg/trait/mount.go b/pkg/trait/mount.go
index 6c8976a85..81d58c50f 100644
--- a/pkg/trait/mount.go
+++ b/pkg/trait/mount.go
@@ -43,7 +43,7 @@ type mountTrait struct {
 	// A list of configuration pointing to configmap/secret.
 	// The configuration are expected to be UTF-8 resources as they are processed by runtime Camel Context and tried to be parsed as property files.
 	// They are also made available on the classpath in order to ease their usage directly from the Route.
-	// Syntax: [configmap|secret]:name[key], where name represents the resource name and key optionally represents the resource key to be filtered
+	// Syntax: [configmap|secret]:name[/key], where name represents the resource name and key optionally represents the resource key to be filtered
 	Configs []string `property:"configs" json:"configs,omitempty"`
 	// A list of resources (text or binary content) pointing to configmap/secret.
 	// The resources are expected to be any resource type (text or binary content).
diff --git a/pkg/util/kamelets/util.go b/pkg/util/kamelets/util.go
new file mode 100644
index 000000000..2db03c7fc
--- /dev/null
+++ b/pkg/util/kamelets/util.go
@@ -0,0 +1,56 @@
+/*
+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 kamelets
+
+import (
+	"context"
+	"strings"
+
+	v1 "github.com/apache/camel-k/pkg/apis/camel/v1"
+	"github.com/apache/camel-k/pkg/apis/camel/v1alpha1"
+	"github.com/apache/camel-k/pkg/client"
+	"github.com/apache/camel-k/pkg/metadata"
+	"github.com/apache/camel-k/pkg/util"
+	"github.com/apache/camel-k/pkg/util/camel"
+	"github.com/apache/camel-k/pkg/util/kubernetes"
+	"github.com/apache/camel-k/pkg/util/source"
+)
+
+// ExtractKameletFromSources provide a list of Kamelets referred into the Integration sources.
+func ExtractKameletFromSources(context context.Context, c client.Client, catalog *camel.RuntimeCatalog, resources *kubernetes.Collection, it *v1.Integration) ([]string, error) {
+	var kamelets []string
+
+	sources, err := kubernetes.ResolveIntegrationSources(context, c, it, resources)
+	if err != nil {
+		return nil, err
+	}
+	metadata.Each(catalog, sources, func(_ int, meta metadata.IntegrationMetadata) bool {
+		util.StringSliceUniqueConcat(&kamelets, meta.Kamelets)
+		return true
+	})
+
+	// Check if a Kamelet is configured as default error handler URI
+	defaultErrorHandlerURI := it.Spec.GetConfigurationProperty(v1alpha1.ErrorHandlerAppPropertiesPrefix + ".deadLetterUri")
+	if defaultErrorHandlerURI != "" {
+		if strings.HasPrefix(defaultErrorHandlerURI, "kamelet:") {
+			kamelets = append(kamelets, source.ExtractKamelet(defaultErrorHandlerURI))
+		}
+	}
+
+	return kamelets, nil
+}


Test patch:
diff --git a/e2e/common/cli/files/promote-route.groovy b/e2e/common/cli/files/promote-route.groovy
new file mode 100644
index 000000000..943a4ab91
--- /dev/null
+++ b/e2e/common/cli/files/promote-route.groovy
@@ -0,0 +1,25 @@
+// camel-k: language=groovy
+/*
+ * 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.
+ */
+
+from('timer:configmap')
+    .setBody()
+        .simple("resource:classpath:my-configmap-key")
+    .log('configmap: ${body}')
+    .setBody()
+        .simple("resource:classpath:my-secret-key")
+    .log('secret: ${body}')
\ No newline at end of file
diff --git a/e2e/common/cli/promote_test.go b/e2e/common/cli/promote_test.go
new file mode 100644
index 000000000..c2b7182c7
--- /dev/null
+++ b/e2e/common/cli/promote_test.go
@@ -0,0 +1,132 @@
+//go:build integration
+// +build integration
+
+// To enable compilation of this file in Goland, go to "Settings -> Go -> Vendoring & Build Tags -> Custom Tags" and add "integration"
+
+/*
+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 common
+
+import (
+	"testing"
+
+	corev1 "k8s.io/api/core/v1"
+
+	. "github.com/onsi/gomega"
+
+	. "github.com/apache/camel-k/e2e/support"
+	v1 "github.com/apache/camel-k/pkg/apis/camel/v1"
+)
+
+func TestKamelCLIPromote(t *testing.T) {
+	// Dev environment namespace
+	WithNewTestNamespace(t, func(nsDev string) {
+		Expect(Kamel("install", "-n", nsDev).Execute()).To(Succeed())
+		// Dev content configmap
+		var cmData = make(map[string]string)
+		cmData["my-configmap-key"] = "I am development configmap!"
+		NewPlainTextConfigmap(nsDev, "my-cm", cmData)
+		// Dev secret
+		var secData = make(map[string]string)
+		secData["my-secret-key"] = "very top secret development"
+		NewPlainTextSecret(nsDev, "my-sec", secData)
+
+		t.Run("plain integration dev", func(t *testing.T) {
+			Expect(Kamel("run", "-n", nsDev, "./files/promote-route.groovy",
+				"--config", "configmap:my-cm",
+				"--config", "secret:my-sec",
+			).Execute()).To(Succeed())
+			Eventually(IntegrationPodPhase(nsDev, "promote-route"), TestTimeoutMedium).Should(Equal(corev1.PodRunning))
+			Eventually(IntegrationConditionStatus(nsDev, "promote-route", v1.IntegrationConditionReady), TestTimeoutShort).Should(Equal(corev1.ConditionTrue))
+			Eventually(IntegrationLogs(nsDev, "promote-route"), TestTimeoutShort).Should(ContainSubstring("I am development configmap!"))
+			Eventually(IntegrationLogs(nsDev, "promote-route"), TestTimeoutShort).Should(ContainSubstring("very top secret development"))
+		})
+
+		t.Run("kamelet integration dev", func(t *testing.T) {
+			Expect(CreateTimerKamelet(nsDev, "my-own-timer-source")()).To(Succeed())
+			Expect(Kamel("run", "-n", nsDev, "files/timer-kamelet-usage.groovy").Execute()).To(Succeed())
+			Eventually(IntegrationPodPhase(nsDev, "timer-kamelet-usage"), TestTimeoutMedium).Should(Equal(corev1.PodRunning))
+			Eventually(IntegrationLogs(nsDev, "timer-kamelet-usage"), TestTimeoutShort).Should(ContainSubstring("Hello world"))
+		})
+
+		t.Run("kamelet binding dev", func(t *testing.T) {
+			Expect(CreateTimerKamelet(nsDev, "kb-timer-source")()).To(Succeed())
+			Expect(Kamel("bind", "kb-timer-source", "log:info", "-p", "message=my-kamelet-binding-rocks", "-n", nsDev).Execute()).To(Succeed())
+			Eventually(IntegrationPodPhase(nsDev, "kb-timer-source-to-log"), TestTimeoutMedium).Should(Equal(corev1.PodRunning))
+			Eventually(IntegrationLogs(nsDev, "kb-timer-source-to-log"), TestTimeoutShort).Should(ContainSubstring("my-kamelet-binding-rocks"))
+		})
+
+		// Prod environment namespace
+		WithNewTestNamespace(t, func(nsProd string) {
+			Expect(Kamel("install", "-n", nsProd).Execute()).To(Succeed())
+
+			t.Run("no configmap in destination", func(t *testing.T) {
+				Expect(Kamel("promote", "-n", nsDev, "promote-route", "--to", nsProd).Execute()).NotTo(Succeed())
+			})
+			// Prod content configmap
+			var cmData = make(map[string]string)
+			cmData["my-configmap-key"] = "I am production!"
+			NewPlainTextConfigmap(nsProd, "my-cm", cmData)
+
+			t.Run("no secret in destination", func(t *testing.T) {
+				Expect(Kamel("promote", "-n", nsDev, "promote-route", "--to", nsProd).Execute()).NotTo(Succeed())
+			})
+
+			// Prod secret
+			var secData = make(map[string]string)
+			secData["my-secret-key"] = "very top secret production"
+			NewPlainTextSecret(nsProd, "my-sec", secData)
+
+			t.Run("plain integration promotion", func(t *testing.T) {
+				Expect(Kamel("promote", "-n", nsDev, "promote-route", "--to", nsProd).Execute()).To(Succeed())
+				Eventually(IntegrationPodPhase(nsProd, "promote-route"), TestTimeoutMedium).Should(Equal(corev1.PodRunning))
+				Eventually(IntegrationConditionStatus(nsProd, "promote-route", v1.IntegrationConditionReady), TestTimeoutShort).Should(Equal(corev1.ConditionTrue))
+				Eventually(IntegrationLogs(nsProd, "promote-route"), TestTimeoutShort).Should(ContainSubstring("I am production!"))
+				Eventually(IntegrationLogs(nsProd, "promote-route"), TestTimeoutShort).Should(ContainSubstring("very top secret production"))
+				// They must use the same image
+				Expect(IntegrationPodImage(nsProd, "promote-route")()).Should(Equal(IntegrationPodImage(nsDev, "promote-route")()))
+			})
+
+			t.Run("no kamelet in destination", func(t *testing.T) {
+				Expect(Kamel("promote", "-n", nsDev, "timer-kamelet-usage", "--to", nsProd).Execute()).NotTo(Succeed())
+			})
+
+			t.Run("kamelet integration promotion", func(t *testing.T) {
+				Expect(CreateTimerKamelet(nsProd, "my-own-timer-source")()).To(Succeed())
+				Expect(Kamel("promote", "-n", nsDev, "timer-kamelet-usage", "--to", nsProd).Execute()).To(Succeed())
+				Eventually(IntegrationPodPhase(nsProd, "timer-kamelet-usage"), TestTimeoutMedium).Should(Equal(corev1.PodRunning))
+				Eventually(IntegrationLogs(nsProd, "timer-kamelet-usage"), TestTimeoutShort).Should(ContainSubstring("Hello world"))
+				// They must use the same image
+				Expect(IntegrationPodImage(nsProd, "timer-kamelet-usage")()).Should(Equal(IntegrationPodImage(nsDev, "timer-kamelet-usage")()))
+			})
+
+			t.Run("no kamelet for kameletbinding in destination", func(t *testing.T) {
+				Expect(Kamel("promote", "-n", nsDev, "kb-timer-source", "--to", nsProd).Execute()).NotTo(Succeed())
+			})
+
+			t.Run("kamelet binding promotion", func(t *testing.T) {
+				Expect(CreateTimerKamelet(nsProd, "kb-timer-source")()).To(Succeed())
+				Expect(Kamel("promote", "-n", nsDev, "kb-timer-source-to-log", "--to", nsProd).Execute()).To(Succeed())
+				Eventually(IntegrationPodPhase(nsProd, "kb-timer-source-to-log"), TestTimeoutMedium).Should(Equal(corev1.PodRunning))
+				Eventually(IntegrationLogs(nsProd, "kb-timer-source-to-log"), TestTimeoutShort).Should(ContainSubstring("my-kamelet-binding-rocks"))
+				// They must use the same image
+				Expect(IntegrationPodImage(nsProd, "kb-timer-source-to-log")()).Should(Equal(IntegrationPodImage(nsDev, "kb-timer-source-to-log")()))
+			})
+		})
+	})
+}
diff --git a/pkg/cmd/promote_test.go b/pkg/cmd/promote_test.go
new file mode 100644
index 000000000..3fe2331b5
--- /dev/null
+++ b/pkg/cmd/promote_test.go
@@ -0,0 +1,60 @@
+/*
+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 cmd
+
+import (
+	"encoding/json"
+	"testing"
+
+	v1 "github.com/apache/camel-k/pkg/apis/camel/v1"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestEditContainerTrait(t *testing.T) {
+	var containerTrait v1.TraitSpec
+	m := make(map[string]interface{})
+	m["configuration"] = map[string]interface{}{
+		"name":  "myName",
+		"image": "myImage",
+	}
+	data, _ := json.Marshal(m)
+	_ = json.Unmarshal(data, &containerTrait)
+
+	editedContainerTrait, err := editContainerImage(containerTrait, "editedImage")
+	assert.Nil(t, err)
+
+	mappedTrait := make(map[string]map[string]interface{})
+	newData, _ := json.Marshal(editedContainerTrait)
+	_ = json.Unmarshal(newData, &mappedTrait)
+
+	assert.Equal(t, "myName", mappedTrait["configuration"]["name"])
+	assert.Equal(t, "editedImage", mappedTrait["configuration"]["image"])
+}
+
+func TestEditMissingContainerTrait(t *testing.T) {
+	var containerTrait v1.TraitSpec
+
+	editedContainerTrait, err := editContainerImage(containerTrait, "editedImage")
+	assert.Nil(t, err)
+
+	mappedTrait := make(map[string]map[string]interface{})
+	newData, _ := json.Marshal(editedContainerTrait)
+	_ = json.Unmarshal(newData, &mappedTrait)
+
+	assert.Equal(t, "editedImage", mappedTrait["configuration"]["image"])
+}