This is an automated email from the ASF dual-hosted git repository.

pcongiusti pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel-k.git


The following commit(s) were added to refs/heads/main by this push:
     new 6146d8fed feat(ctrl): dry build
6146d8fed is described below

commit 6146d8fed4563a3d68bedbf6d542a5d6b5eb219d
Author: Pasquale Congiusti <[email protected]>
AuthorDate: Sat Oct 25 08:52:09 2025 +0200

    feat(ctrl): dry build
    
    * Add an annotation `dont-run-after-build` which tell the operator not to 
run the application right after the build
    * Add the deploy/undeploy CLI command to simplify the usage of the new 
feature
    
    Closes #5588
---
 docs/modules/ROOT/nav.adoc                     |   1 +
 docs/modules/ROOT/pages/running/dry-build.adoc |  40 ++++++++++
 e2e/common/cli/deploy_test.go                  |  70 +++++++++++++++++
 pkg/apis/camel/v1/common_types.go              |   4 +
 pkg/apis/camel/v1/integration_types.go         |   6 +-
 pkg/cmd/bind_test.go                           |   1 -
 pkg/cmd/builder_test.go                        |   1 -
 pkg/cmd/debug_test.go                          |   1 -
 pkg/cmd/delete.go                              |  18 +----
 pkg/cmd/delete_test.go                         |   1 -
 pkg/cmd/deploy.go                              |  95 ++++++++++++++++++++++
 pkg/cmd/deploy_test.go                         |  89 +++++++++++++++++++++
 pkg/cmd/kit_create_test.go                     |   1 -
 pkg/cmd/operator_test.go                       |   2 -
 pkg/cmd/promote.go                             |  18 +----
 pkg/cmd/promote_test.go                        |   1 -
 pkg/cmd/rebuild.go                             |  18 +----
 pkg/cmd/rebuild_test.go                        |   2 +-
 pkg/cmd/root.go                                |   2 +
 pkg/cmd/run.go                                 |  37 +++++----
 pkg/cmd/run_test.go                            |   2 -
 pkg/cmd/undeploy.go                            | 105 +++++++++++++++++++++++++
 pkg/cmd/undeploy_test.go                       | 103 ++++++++++++++++++++++++
 pkg/cmd/util.go                                |  30 +++++++
 pkg/cmd/version_test.go                        |   1 -
 pkg/controller/integration/build.go            |   6 +-
 pkg/controller/integration/build_kit.go        |  13 ++-
 pkg/controller/integration/initialize.go       |   6 +-
 pkg/controller/integration/monitor.go          |  14 +++-
 pkg/controller/integration/monitor_unknown.go  |   6 +-
 pkg/internal/client.go                         |   4 +
 pkg/internal/fakeStatusWriter.go               |  39 +++++++++
 pkg/trait/gc.go                                |  12 ++-
 pkg/trait/gc_test.go                           |  25 +++++-
 34 files changed, 684 insertions(+), 90 deletions(-)

diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc
index 2a90ba29d..0231efd01 100644
--- a/docs/modules/ROOT/nav.adoc
+++ b/docs/modules/ROOT/nav.adoc
@@ -26,6 +26,7 @@
 ** xref:running/self-managed.adoc[Self managed Integrations]
 ** xref:running/synthetic.adoc[Synthetic Integrations]
 ** xref:running/promoting.adoc[Promote an Integration]
+** xref:running/dry-build.adoc[Dry build]
 * xref:pipes/pipes.adoc[Run an Pipe]
 ** xref:pipes/bind-cli.adoc[kamel bind CLI]
 ** xref:pipes/error-handler.adoc[Error Handler]
diff --git a/docs/modules/ROOT/pages/running/dry-build.adoc 
b/docs/modules/ROOT/pages/running/dry-build.adoc
new file mode 100644
index 000000000..9093bb2ce
--- /dev/null
+++ b/docs/modules/ROOT/pages/running/dry-build.adoc
@@ -0,0 +1,40 @@
+= Dry Build
+
+Camel K have been originally designed to immediately execute an Integration. 
However, since version 2.9 you can split the **build** phase and the 
**deployment** phase performing a **dry build**.
+
+When you build an Integration, you can add a new annotation, 
`camel.apache.org/dont-run-after-build: "true"`. This is telling the operator 
to perform a regular build but not to deploy the application (neither to create 
any resource associated to it). It basically just compile the application and 
push the container image to be eventually run later.
+
+This feature enables you the possibility to separate the build from the 
execution phase giving you a powerful flexibility to accommodate your 
development process (which may require also testing, performance, security 
audits, ...). You may have a cluster dedicated for building images, and another 
cluster (maybe private with no access to the internet) for running the 
applications.
+
+As the `kamel run` command can be helpful for this operation, we've added a 
flag which will take care to add that annotation for you: `kamel run 
my-app.yaml --dont-run-after-build`.
+
+[[deploy]]
+== Deploy an application
+
+The presence of this feature will let you build the application without the 
need to run it. At any point you can decide to **deploy** the application by 
turning its phase to `Deploying`. The operator will be in charge to do the 
deployment as it used to do before.
+
+It would be something like:
+
+```bash
+kubectl patch it my-app --type=merge --subresource=status   -p 
'{"status":{"phase":"Deploying"}}'
+```
+
+As the patching on an Integration custom resource can be boring, we've 
introduced a new CLI `kamel deploy` command. You can provide to it the name(s) 
of the Integration(s) you want to deploy and it will take care to patch their 
status for you. The `kamel deploy` and the patch to "Deploying" are equivalent.
+
+[[undeploy]]
+== Undeploy an application
+
+Specular to that, we have the **undeploy** operation. If at any point in time 
you want to bring the Integration back to its original status, then just patch 
its phase to an empty string (which represent the `Initialization` phase). The 
operator will take care to revert it and to clean all the resources associated.
+
+It would be something like:
+
+```bash
+kubectl patch it my-app --type=merge --subresource=status   -p 
'{"status":{"phase":""}}'
+```
+
+Also here, you will find handy the `kamel undeploy` CLI command. It also 
expects one ore more Integration names you want to undeploy. The `kamel 
undeploy` and the patch to "" are equivalent.
+
+[[references]]
+== Complement to other features
+
+This feature will fit into the rest of build features such as 
xref:running/build-from-git.adoc[Git hosted Integrations], 
xref:running/self-managed.adoc[self managed Integrations] and 
xref:running/promoting.adoc[environment promotions].
diff --git a/e2e/common/cli/deploy_test.go b/e2e/common/cli/deploy_test.go
new file mode 100644
index 000000000..88f407fcf
--- /dev/null
+++ b/e2e/common/cli/deploy_test.go
@@ -0,0 +1,70 @@
+//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 (
+       "context"
+       "testing"
+       "time"
+
+       . "github.com/onsi/gomega"
+       corev1 "k8s.io/api/core/v1"
+       "k8s.io/utils/ptr"
+
+       . "github.com/apache/camel-k/v2/e2e/support"
+       v1 "github.com/apache/camel-k/v2/pkg/apis/camel/v1"
+)
+
+func TestBuildDontRun(t *testing.T) {
+       t.Parallel()
+       WithNewTestNamespace(t, func(ctx context.Context, g *WithT, ns string) {
+               name := RandomizedSuffixName("deploy")
+               t.Run("build and dont run integration", func(t *testing.T) {
+                       g.Expect(KamelRun(t, ctx, ns, "files/yaml.yaml",
+                               "--name", name,
+                               "--dont-run-after-build",
+                       ).Execute()).To(Succeed())
+                       // The integration should not change phase until the 
user request it
+                       g.Eventually(IntegrationPhase(t, ctx, ns, name), 
TestTimeoutMedium).Should(Equal(v1.IntegrationPhaseBuildComplete))
+                       g.Consistently(IntegrationPhase(t, ctx, ns, name), 
10*time.Second).Should(Equal(v1.IntegrationPhaseBuildComplete))
+                       g.Eventually(Deployment(t, ctx, ns, 
name)).Should(BeNil())
+               })
+               t.Run("deploy the integration", func(t *testing.T) {
+                       g.Expect(Kamel(t, ctx, "deploy", name, "-n", 
ns).Execute()).To(Succeed())
+                       // The integration should run immediately
+                       g.Eventually(IntegrationPhase(t, ctx, ns, name), 
TestTimeoutShort).Should(Equal(v1.IntegrationPhaseRunning))
+                       g.Eventually(Deployment(t, ctx, ns, 
name)).ShouldNot(BeNil())
+                       g.Eventually(IntegrationPodPhase(t, ctx, ns, 
name)).Should(Equal(corev1.PodRunning))
+                       g.Eventually(IntegrationConditionStatus(t, ctx, ns, 
name, v1.IntegrationConditionReady)).
+                               Should(Equal(corev1.ConditionTrue))
+                       g.Eventually(IntegrationLogs(t, ctx, ns, 
name)).Should(ContainSubstring("Magicstring!"))
+               })
+               t.Run("undeploy the integration", func(t *testing.T) {
+                       g.Expect(Kamel(t, ctx, "undeploy", name, "-n", 
ns).Execute()).To(Succeed())
+                       // The integration should change phase suddenly and the 
resources associated cleared
+                       g.Eventually(IntegrationPhase(t, ctx, ns, name), 
TestTimeoutShort).Should(Equal(v1.IntegrationPhaseBuildComplete))
+                       g.Eventually(IntegrationPodsNumbers(t, ctx, ns, 
name)).Should(Equal(ptr.To(int32(0))))
+                       g.Eventually(Deployment(t, ctx, ns, 
name)).Should(BeNil())
+               })
+       })
+}
diff --git a/pkg/apis/camel/v1/common_types.go 
b/pkg/apis/camel/v1/common_types.go
index 9da384693..4b41dff88 100644
--- a/pkg/apis/camel/v1/common_types.go
+++ b/pkg/apis/camel/v1/common_types.go
@@ -34,6 +34,10 @@ const (
        IntegrationProfileAnnotation = "camel.apache.org/integration-profile.id"
        // IntegrationProfileNamespaceAnnotation integration profile id 
annotation label.
        IntegrationProfileNamespaceAnnotation = 
"camel.apache.org/integration-profile.namespace"
+       // IntegrationDontRunAfterBuildAnnotation -- .
+       IntegrationDontRunAfterBuildAnnotation = 
"camel.apache.org/dont-run-after-build"
+       // IntegrationDontRunAfterBuildAnnotationTrueValue -- .
+       IntegrationDontRunAfterBuildAnnotationTrueValue = "true"
 )
 
 // BuildConfiguration represent the configuration required to build the 
runtime.
diff --git a/pkg/apis/camel/v1/integration_types.go 
b/pkg/apis/camel/v1/integration_types.go
index 19ec788d5..bdb301e27 100644
--- a/pkg/apis/camel/v1/integration_types.go
+++ b/pkg/apis/camel/v1/integration_types.go
@@ -164,9 +164,11 @@ const (
        // IntegrationPhaseBuildingKit if building from a Camel route.
        IntegrationPhaseBuildingKit IntegrationPhase = "Building Kit"
        // IntegrationPhaseBuildSubmitted if building from a Git repository.
-       IntegrationPhaseBuildSubmitted IntegrationPhase = "BuildSubmitted"
+       IntegrationPhaseBuildSubmitted IntegrationPhase = "Build Submitted"
        // IntegrationPhaseBuildRunning if building from a Git repository.
-       IntegrationPhaseBuildRunning IntegrationPhase = "BuildRunning"
+       IntegrationPhaseBuildRunning IntegrationPhase = "Build Running"
+       // IntegrationPhaseBuildComplete --.
+       IntegrationPhaseBuildComplete IntegrationPhase = "Build Complete"
        // IntegrationPhaseDeploying --.
        IntegrationPhaseDeploying IntegrationPhase = "Deploying"
        // IntegrationPhaseRunning --.
diff --git a/pkg/cmd/bind_test.go b/pkg/cmd/bind_test.go
index 7a0328767..1a90437e9 100644
--- a/pkg/cmd/bind_test.go
+++ b/pkg/cmd/bind_test.go
@@ -31,7 +31,6 @@ import (
 
 const cmdBind = "bind"
 
-// nolint: unparam
 func initializeBindCmdOptions(t *testing.T) (*bindCmdOptions, *cobra.Command, 
RootCmdOptions) {
        t.Helper()
 
diff --git a/pkg/cmd/builder_test.go b/pkg/cmd/builder_test.go
index 3ef4af77e..bbac5ae10 100644
--- a/pkg/cmd/builder_test.go
+++ b/pkg/cmd/builder_test.go
@@ -27,7 +27,6 @@ import (
 
 const cmdBuilder = "builder"
 
-// nolint: unparam
 func initializeBuilderCmdOptions(t *testing.T) (*builderCmdOptions, 
*cobra.Command, RootCmdOptions) {
        t.Helper()
 
diff --git a/pkg/cmd/debug_test.go b/pkg/cmd/debug_test.go
index 15be39672..b710b0097 100644
--- a/pkg/cmd/debug_test.go
+++ b/pkg/cmd/debug_test.go
@@ -32,7 +32,6 @@ import (
 
 const cmdDebug = "debug"
 
-// nolint: unparam
 func initializeDebugCmdOptions(t *testing.T, initObjs ...runtime.Object) 
(*cobra.Command, *debugCmdOptions) {
        t.Helper()
        fakeClient, err := internal.NewFakeClient(initObjs...)
diff --git a/pkg/cmd/delete.go b/pkg/cmd/delete.go
index 5209320cb..7c715fa98 100644
--- a/pkg/cmd/delete.go
+++ b/pkg/cmd/delete.go
@@ -88,7 +88,7 @@ func (command *deleteCmdOptions) run(cmd *cobra.Command, args 
[]string) error {
                                        return err
                                }
                        } else {
-                               err := deleteIntegration(command.Context, cmd, 
c, integration)
+                               err := deletePipeOrIntegration(command.Context, 
cmd, c, integration)
                                if err != nil {
                                        return err
                                }
@@ -109,7 +109,7 @@ func (command *deleteCmdOptions) run(cmd *cobra.Command, 
args []string) error {
                }
                for i := range integrationList.Items {
                        integration := integrationList.Items[i]
-                       err := deleteIntegration(command.Context, cmd, c, 
&integration)
+                       err := deletePipeOrIntegration(command.Context, cmd, c, 
&integration)
                        if err != nil {
                                return err
                        }
@@ -124,19 +124,7 @@ func (command *deleteCmdOptions) run(cmd *cobra.Command, 
args []string) error {
        return nil
 }
 
-func getIntegration(ctx context.Context, c client.Client, name string, 
namespace string) (*v1.Integration, error) {
-       key := k8sclient.ObjectKey{
-               Name:      name,
-               Namespace: namespace,
-       }
-       answer := v1.NewIntegration(namespace, name)
-       if err := c.Get(ctx, key, &answer); err != nil {
-               return nil, err
-       }
-       return &answer, nil
-}
-
-func deleteIntegration(ctx context.Context, cmd *cobra.Command, c 
client.Client, integration *v1.Integration) error {
+func deletePipeOrIntegration(ctx context.Context, cmd *cobra.Command, c 
client.Client, integration *v1.Integration) error {
        deletedPipes, pipe, err := deletePipeIfExists(ctx, c, integration)
        if err != nil {
                return err
diff --git a/pkg/cmd/delete_test.go b/pkg/cmd/delete_test.go
index 806386b4f..0880d5f1f 100644
--- a/pkg/cmd/delete_test.go
+++ b/pkg/cmd/delete_test.go
@@ -27,7 +27,6 @@ import (
 
 const cmdDelete = "delete"
 
-// nolint: unparam
 func initializeDeleteCmdOptions(t *testing.T) (*deleteCmdOptions, 
*cobra.Command, RootCmdOptions) {
        t.Helper()
 
diff --git a/pkg/cmd/deploy.go b/pkg/cmd/deploy.go
new file mode 100644
index 000000000..fb3737720
--- /dev/null
+++ b/pkg/cmd/deploy.go
@@ -0,0 +1,95 @@
+/*
+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 (
+       "errors"
+       "fmt"
+
+       v1 "github.com/apache/camel-k/v2/pkg/apis/camel/v1"
+       "github.com/spf13/cobra"
+       ctrl "sigs.k8s.io/controller-runtime/pkg/client"
+)
+
+// newCmdDeploy --.
+func newCmdDeploy(rootCmdOptions *RootCmdOptions) (*cobra.Command, 
*deployCmdOptions) {
+       options := deployCmdOptions{
+               RootCmdOptions: rootCmdOptions,
+       }
+       cmd := cobra.Command{
+               Use:     "deploy my-it",
+               Short:   "Deploy an Integration",
+               Long:    "Deploy an Integration that was previously built",
+               PreRunE: decode(&options, options.Flags),
+               RunE:    options.run,
+       }
+
+       return &cmd, &options
+}
+
+type deployCmdOptions struct {
+       *RootCmdOptions
+}
+
+func (o *deployCmdOptions) validate(_ *cobra.Command, args []string) error {
+       if len(args) != 1 {
+               return errors.New("deploy requires an Integration name 
argument")
+       }
+       return nil
+}
+
+func (o *deployCmdOptions) 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 fmt.Errorf("could not retrieve cluster client: %w", err)
+       }
+
+       existing, err := getIntegration(o.Context, c, name, o.Namespace)
+       if err != nil {
+               return fmt.Errorf("could not get Integration "+name+": %w", err)
+       }
+       if existing.Status.Phase != v1.IntegrationPhaseBuildComplete {
+               return fmt.Errorf("could not run an Integration in %s status", 
existing.Status.Phase)
+       }
+
+       integration := existing.DeepCopy()
+       integration.Status.Phase = v1.IntegrationPhaseDeploying
+
+       patch := ctrl.MergeFrom(existing)
+       d, err := patch.Data(integration)
+       if err != nil {
+               return err
+       }
+
+       if string(d) == "{}" {
+               fmt.Fprintln(cmd.OutOrStdout(), `Integration "`+name+`" 
unchanged`)
+               return nil
+       }
+       err = c.Status().Patch(o.Context, integration, patch)
+       if err != nil {
+               return err
+       }
+
+       fmt.Fprintln(cmd.OutOrStdout(), `Integration "`+name+`" deployed`)
+       return nil
+}
diff --git a/pkg/cmd/deploy_test.go b/pkg/cmd/deploy_test.go
new file mode 100644
index 000000000..dfe4934df
--- /dev/null
+++ b/pkg/cmd/deploy_test.go
@@ -0,0 +1,89 @@
+/*
+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 (
+       "testing"
+
+       v1 "github.com/apache/camel-k/v2/pkg/apis/camel/v1"
+       "github.com/apache/camel-k/v2/pkg/internal"
+       "github.com/spf13/cobra"
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+       "k8s.io/apimachinery/pkg/runtime"
+)
+
+const cmdDeploy = "deploy"
+
+func initializeDeployCmdOptions(t *testing.T, initObjs ...runtime.Object) 
(*cobra.Command, *deployCmdOptions) {
+       t.Helper()
+       fakeClient, err := internal.NewFakeClient(initObjs...)
+       require.NoError(t, err)
+       options, rootCmd := kamelTestPreAddCommandInitWithClient(fakeClient)
+       options.Namespace = "default"
+       deployCmdOptions := addTestDeployCmd(*options, rootCmd)
+       kamelTestPostAddCommandInit(t, rootCmd, options)
+
+       return rootCmd, deployCmdOptions
+}
+
+func addTestDeployCmd(options RootCmdOptions, rootCmd *cobra.Command) 
*deployCmdOptions {
+       deployCmd, deployOptions := newCmdDeploy(&options)
+       deployCmd.Args = ArbitraryArgs
+       rootCmd.AddCommand(deployCmd)
+       return deployOptions
+}
+
+func TestDeployNonExistingFlag(t *testing.T) {
+       cmd, _ := initializeDeployCmdOptions(t)
+       _, err := ExecuteCommand(cmd, cmdDeploy, "--nonExistingFlag")
+       require.Error(t, err)
+       assert.Equal(t, "unknown flag: --nonExistingFlag", err.Error())
+}
+
+func TestDeployMissingInput(t *testing.T) {
+       cmd, _ := initializeDeployCmdOptions(t)
+       _, err := ExecuteCommand(cmd, cmdDeploy)
+       require.Error(t, err)
+       assert.Equal(t, "deploy requires an Integration name argument", 
err.Error())
+}
+
+func TestDeployMissingIntegration(t *testing.T) {
+       cmd, _ := initializeDeployCmdOptions(t)
+       _, err := ExecuteCommand(cmd, cmdDeploy, "missing-it")
+       require.Error(t, err)
+       assert.Equal(t, "could not get Integration missing-it: 
integrations.camel.apache.org \"missing-it\" not found", err.Error())
+}
+
+func TestDeployCantDeployRunningIntegration(t *testing.T) {
+       it := v1.NewIntegration("default", "my-it")
+       it.Status.Phase = v1.IntegrationPhaseRunning
+       cmd, _ := initializeDeployCmdOptions(t, &it)
+       _, err := ExecuteCommand(cmd, cmdDeploy, "my-it")
+       require.Error(t, err)
+       assert.Equal(t, "could not run an Integration in Running status", 
err.Error())
+}
+
+func TestDeployIntegration(t *testing.T) {
+       it := v1.NewIntegration("default", "my-it")
+       it.Status.Phase = v1.IntegrationPhaseBuildComplete
+       cmd, _ := initializeDeployCmdOptions(t, &it)
+       output, err := ExecuteCommand(cmd, cmdDeploy, "my-it")
+       require.NoError(t, err)
+       assert.Contains(t, output, "Integration \"my-it\" deployed")
+}
diff --git a/pkg/cmd/kit_create_test.go b/pkg/cmd/kit_create_test.go
index 2aa1d43cf..a9921ffae 100644
--- a/pkg/cmd/kit_create_test.go
+++ b/pkg/cmd/kit_create_test.go
@@ -27,7 +27,6 @@ import (
 
 const subCmdKit = "create"
 
-// nolint: unparam
 func initializeKitCreateCmdOptions(t *testing.T) (*kitCreateCommandOptions, 
*cobra.Command, RootCmdOptions) {
        t.Helper()
 
diff --git a/pkg/cmd/operator_test.go b/pkg/cmd/operator_test.go
index c45df0982..3a714be29 100644
--- a/pkg/cmd/operator_test.go
+++ b/pkg/cmd/operator_test.go
@@ -28,7 +28,6 @@ import (
 
 const cmdOperator = "operator"
 
-// nolint: unparam
 func initializeOperatorCmdOptions(t *testing.T) (*operatorCmdOptions, 
*cobra.Command, RootCmdOptions) {
        t.Helper()
 
@@ -39,7 +38,6 @@ func initializeOperatorCmdOptions(t *testing.T) 
(*operatorCmdOptions, *cobra.Com
        return operatorCmdOptions, rootCmd, *options
 }
 
-// nolint: unparam
 func addTestOperatorCmd(options RootCmdOptions, rootCmd *cobra.Command) 
*operatorCmdOptions {
        // add a testing version of operator Command
        operatorCmd, operatorOptions := newCmdOperator(&options)
diff --git a/pkg/cmd/promote.go b/pkg/cmd/promote.go
index 8c68eda35..300ee877c 100644
--- a/pkg/cmd/promote.go
+++ b/pkg/cmd/promote.go
@@ -120,11 +120,12 @@ func (o *promoteCmdOptions) run(cmd *cobra.Command, args 
[]string) error {
        if sourcePipe != nil {
                promotePipe = true
        }
-       sourceIntegration, err = o.getIntegration(c, name)
+       sourceIntegration, err = getIntegration(o.Context, c, name, o.Namespace)
        if err != nil {
                return fmt.Errorf("could not get Integration "+name+": %w", err)
        }
-       if sourceIntegration.Status.Phase != v1.IntegrationPhaseRunning {
+       if sourceIntegration.Status.Phase != v1.IntegrationPhaseRunning &&
+               sourceIntegration.Status.Phase != 
v1.IntegrationPhaseBuildComplete {
                return fmt.Errorf("could not promote an Integration in %s 
status", sourceIntegration.Status.Phase)
        }
        sourceKit, err := o.getIntegrationKit(c, 
sourceIntegration.Status.IntegrationKit)
@@ -219,19 +220,6 @@ func (o *promoteCmdOptions) getPipe(c client.Client, name 
string) (*v1.Pipe, 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) getIntegrationKit(c client.Client, ref 
*corev1.ObjectReference) (*v1.IntegrationKit, error) {
        if ref == nil {
                return nil, nil
diff --git a/pkg/cmd/promote_test.go b/pkg/cmd/promote_test.go
index 54f3a0bc2..fbbb44e7c 100644
--- a/pkg/cmd/promote_test.go
+++ b/pkg/cmd/promote_test.go
@@ -38,7 +38,6 @@ import (
 
 const cmdPromote = "promote"
 
-// nolint: unparam
 func initializePromoteCmdOptions(t *testing.T, initObjs ...runtime.Object) 
(*promoteCmdOptions, *cobra.Command, RootCmdOptions) {
        t.Helper()
        fakeClient, err := internal.NewFakeClient(initObjs...)
diff --git a/pkg/cmd/rebuild.go b/pkg/cmd/rebuild.go
index 9c4a7a089..978596a87 100644
--- a/pkg/cmd/rebuild.go
+++ b/pkg/cmd/rebuild.go
@@ -79,7 +79,7 @@ func (o *rebuildCmdOptions) run(cmd *cobra.Command, args 
[]string) error {
                        return err
                }
        } else if len(args) > 0 {
-               if integrations, err = o.getIntegrations(c, args); err != nil {
+               if integrations, err = getIntegrations(o.Context, c, args, 
o.Namespace); err != nil {
                        return err
                }
        }
@@ -100,22 +100,6 @@ func (o *rebuildCmdOptions) listAllIntegrations(c 
client.Client) ([]v1.Integrati
        return list.Items, nil
 }
 
-func (o *rebuildCmdOptions) getIntegrations(c client.Client, names []string) 
([]v1.Integration, error) {
-       ints := make([]v1.Integration, 0, len(names))
-       for _, n := range names {
-               it := v1.NewIntegration(o.Namespace, n)
-               key := k8sclient.ObjectKey{
-                       Name:      n,
-                       Namespace: o.Namespace,
-               }
-               if err := c.Get(o.Context, key, &it); err != nil {
-                       return nil, fmt.Errorf("could not find integration %s 
in namespace %s: %w", it.Name, o.Namespace, err)
-               }
-               ints = append(ints, it)
-       }
-       return ints, nil
-}
-
 func (o *rebuildCmdOptions) rebuildIntegrations(c k8sclient.StatusClient, 
integrations []v1.Integration) error {
        for _, i := range integrations {
                it := i
diff --git a/pkg/cmd/rebuild_test.go b/pkg/cmd/rebuild_test.go
index 4b6c97cba..3f6e010f5 100644
--- a/pkg/cmd/rebuild_test.go
+++ b/pkg/cmd/rebuild_test.go
@@ -27,7 +27,6 @@ import (
 
 const cmdRebuild = "rebuild"
 
-// nolint: unparam
 func initializeRebuildCmdOptions(t *testing.T) (*rebuildCmdOptions, 
*cobra.Command, RootCmdOptions) {
        t.Helper()
 
@@ -56,6 +55,7 @@ func TestRebuildNonExistingFlag(t *testing.T) {
        _, rootCmd, _ := initializeRebuildCmdOptions(t)
        _, err := ExecuteCommand(rootCmd, cmdRebuild, "--nonExistingFlag")
        require.Error(t, err)
+       assert.Equal(t, "unknown flag: --nonExistingFlag", err.Error())
 }
 
 func TestRebuildAllFlag(t *testing.T) {
diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go
index 53bb32ec4..90267a2c5 100644
--- a/pkg/cmd/root.go
+++ b/pkg/cmd/root.go
@@ -137,6 +137,7 @@ func addKamelSubcommands(cmd *cobra.Command, options 
*RootCmdOptions) {
        cmd.AddCommand(newCmdCompletion(cmd))
        cmd.AddCommand(cmdOnly(newCmdVersion(options)))
        cmd.AddCommand(cmdOnly(newCmdRun(options)))
+       cmd.AddCommand(cmdOnly(newCmdDeploy(options)))
        cmd.AddCommand(cmdOnly(newCmdGet(options)))
        cmd.AddCommand(cmdOnly(newCmdDelete(options)))
        cmd.AddCommand(cmdOnly(newCmdLog(options)))
@@ -149,6 +150,7 @@ func addKamelSubcommands(cmd *cobra.Command, options 
*RootCmdOptions) {
        cmd.AddCommand(cmdOnly(newCmdDump(options)))
        cmd.AddCommand(cmdOnly(newCmdBind(options)))
        cmd.AddCommand(cmdOnly(newCmdPromote(options)))
+       cmd.AddCommand(cmdOnly(newCmdUndeploy(options)))
 }
 
 func addHelpSubCommands(cmd *cobra.Command) error {
diff --git a/pkg/cmd/run.go b/pkg/cmd/run.go
index 26275eefa..06ce39acc 100644
--- a/pkg/cmd/run.go
+++ b/pkg/cmd/run.go
@@ -19,19 +19,13 @@ package cmd
 
 import (
        "context"
-       "errors"
-       "path"
-
-       // this is needed to generate an SHA1 sum for Jars
-       // #nosec G501
-
-       // #nosec G505
-
        "encoding/json"
+       "errors"
        "fmt"
        "net/url"
        "os"
        "os/signal"
+       "path"
        "reflect"
        "strings"
        "syscall"
@@ -71,8 +65,8 @@ func newCmdRun(rootCmdOptions *RootCmdOptions) 
(*cobra.Command, *runCmdOptions)
 
        cmd := cobra.Command{
                Use:               "run [file to run]",
-               Short:             "Run a integration on Kubernetes",
-               Long:              `Deploys and execute a integration pod on 
Kubernetes.`,
+               Short:             "Build and run the Integration on 
Kubernetes.",
+               Long:              `Build and run the Integration on 
Kubernetes.`,
                Args:              options.validateArgs,
                PersistentPreRunE: options.decode,
                PreRunE:           options.preRun,
@@ -83,7 +77,8 @@ func newCmdRun(rootCmdOptions *RootCmdOptions) 
(*cobra.Command, *runCmdOptions)
 
        cmd.Flags().String("name", "", "The integration name")
        cmd.Flags().String("image", "", "An image built externally (ie, via 
CICD). Enabling it will skip the Integration build phase.")
-       cmd.Flags().StringArrayP("dependency", "d", nil, `A dependency that 
should be included, e.g., "camel:mail" for a Camel component, 
"mvn:org.my:app:1.0" for a Maven dependency`)
+       cmd.Flags().StringArrayP("dependency", "d", nil, "A dependency that 
should be included, e.g., \"camel:mail\" for a Camel component, "+
+               "\"mvn:org.my:app:1.0\" for a Maven dependency")
        cmd.Flags().BoolP("wait", "w", false, "Wait for the integration to be 
running")
        cmd.Flags().StringP("kit", "k", "", "The kit used to run the 
integration")
        cmd.Flags().StringArrayP("property", "p", nil, "Add a runtime property 
or a local properties file from a path "+
@@ -112,7 +107,8 @@ func newCmdRun(rootCmdOptions *RootCmdOptions) 
(*cobra.Command, *runCmdOptions)
        cmd.Flags().StringArrayP("env", "e", nil, "Set an environment variable 
in the integration container. E.g \"-e MY_VAR=my-value\"")
        cmd.Flags().StringArray("annotation", nil, "Add an annotation to the 
integration. E.g. \"--annotation my.company=hello\"")
        cmd.Flags().StringArray("label", nil, "Add a label to the integration. 
E.g. \"--label my.company=hello\"")
-       cmd.Flags().StringArray("source", nil, "Add source file to your 
integration, this is added to the list of files listed as arguments of the 
command")
+       cmd.Flags().StringArray("source", nil, "Add source file to your 
integration, "+
+               "this is added to the list of files listed as arguments of the 
command")
        cmd.Flags().String("pod-template", "", "The path of the YAML file 
containing a PodSpec template to be used for the Integration pods")
        cmd.Flags().String("service-account", "", "The SA to use to run this 
Integration")
        cmd.Flags().Bool("force", false, "Force creation of integration 
regardless of potential misconfiguration.")
@@ -121,6 +117,8 @@ func newCmdRun(rootCmdOptions *RootCmdOptions) 
(*cobra.Command, *runCmdOptions)
        cmd.Flags().String("git-tag", "", "Git tag to checkout when using --git 
option")
        cmd.Flags().String("git-commit", "", "Git commit (full SHA) to checkout 
when using --git option")
        cmd.Flags().Bool("save", false, "Save the run parameters into the 
default kamel configuration file (kamel-config.yaml)")
+       cmd.Flags().Bool("dont-run-after-build", false, "Only build, don't run 
the application. "+
+               "You can run \"kamel deploy\" to run a built Integration.")
 
        // completion support
        configureKnownCompletions(&cmd)
@@ -166,8 +164,9 @@ type runCmdOptions struct {
        Annotations     []string `mapstructure:"annotations" yaml:",omitempty"`
        Sources         []string `mapstructure:"sources" yaml:",omitempty"`
        // Deprecated: registry parameter no longer in use.
-       RegistryOptions url.Values
-       Force           bool `mapstructure:"force" yaml:",omitempty"`
+       RegistryOptions   url.Values
+       Force             bool `mapstructure:"force" yaml:",omitempty"`
+       DontRunAfterBuild bool `mapstructure:"dont-run-after-build" 
yaml:",omitempty"`
 }
 
 func (o *runCmdOptions) decode(cmd *cobra.Command, args []string) error {
@@ -345,7 +344,7 @@ func (o *runCmdOptions) run(cmd *cobra.Command, args 
[]string) error {
        }
 
        // We need to make this check at this point, in order to have sources 
filled during decoding
-       if (len(args) < 1 && len(o.Sources) < 1) && o.isSourceLess() {
+       if (len(args) < 1 && len(o.Sources) < 1) && o.isManaged() {
                return errors.New("run command expects either an Integration 
source, a container image " +
                        "(via --image argument) or a git repository (via --git 
argument)")
        }
@@ -564,7 +563,7 @@ func (o *runCmdOptions) createOrUpdateIntegration(cmd 
*cobra.Command, c client.C
        o.applyAnnotations(integration)
 
        //nolint:gocritic
-       if o.isSourceLess() {
+       if o.isManaged() {
                // Resolve resources
                if err := o.resolveSources(cmd, sources, integration); err != 
nil {
                        return nil, err
@@ -649,7 +648,7 @@ func (o *runCmdOptions) createOrUpdateIntegration(cmd 
*cobra.Command, c client.C
        return integration, nil
 }
 
-func (o *runCmdOptions) isSourceLess() bool {
+func (o *runCmdOptions) isManaged() bool {
        return o.ContainerImage == "" && o.GitRepo == ""
 }
 
@@ -726,6 +725,9 @@ func (o *runCmdOptions) applyAnnotations(it 
*v1.Integration) {
                        it.Annotations[parts[0]] = parts[1]
                }
        }
+       if o.DontRunAfterBuild {
+               it.Annotations[v1.IntegrationDontRunAfterBuildAnnotation] = 
"true"
+       }
 }
 
 func (o *runCmdOptions) resolveSources(cmd *cobra.Command, sources []string, 
it *v1.Integration) error {
@@ -974,6 +976,7 @@ func loadPropertyFile(fileName string) 
(*properties.Properties, error) {
        return p, nil
 }
 
+// Deprecated: to be removed in future releases.
 func resolvePodTemplate(ctx context.Context, cmd *cobra.Command, templateSrc 
string, spec *v1.IntegrationSpec) error {
        // check if template is set
        if templateSrc == "" {
diff --git a/pkg/cmd/run_test.go b/pkg/cmd/run_test.go
index 8e2b8a1c9..71172b685 100644
--- a/pkg/cmd/run_test.go
+++ b/pkg/cmd/run_test.go
@@ -54,7 +54,6 @@ const (
 `
 )
 
-// nolint: unparam
 func initializeRunCmdOptions(t *testing.T) (*runCmdOptions, *cobra.Command, 
RootCmdOptions) {
        t.Helper()
 
@@ -65,7 +64,6 @@ func initializeRunCmdOptions(t *testing.T) (*runCmdOptions, 
*cobra.Command, Root
        return runCmdOptions, rootCmd, *options
 }
 
-// nolint: unparam
 func initializeRunCmdOptionsWithOutput(t *testing.T) (*runCmdOptions, 
*cobra.Command, RootCmdOptions) {
        t.Helper()
        defaultIntegrationPlatform := v1.NewIntegrationPlatform("default", 
platform.DefaultPlatformName)
diff --git a/pkg/cmd/undeploy.go b/pkg/cmd/undeploy.go
new file mode 100644
index 000000000..f9f44a13c
--- /dev/null
+++ b/pkg/cmd/undeploy.go
@@ -0,0 +1,105 @@
+/*
+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 (
+       "errors"
+       "fmt"
+
+       "github.com/spf13/cobra"
+
+       k8sclient "sigs.k8s.io/controller-runtime/pkg/client"
+
+       v1 "github.com/apache/camel-k/v2/pkg/apis/camel/v1"
+)
+
+func newCmdUndeploy(rootCmdOptions *RootCmdOptions) (*cobra.Command, 
*undeployCmdOptions) {
+       options := undeployCmdOptions{
+               RootCmdOptions: rootCmdOptions,
+       }
+       cmd := cobra.Command{
+               Use:     "undeploy [integration1] [integration2] ...",
+               Short:   "Undeploy one or more integrations previously 
deployed.",
+               Long:    `Clear the state of one or more integrations causing 
them to move back to a Build Complete status.`,
+               PreRunE: decode(&options, options.Flags),
+               RunE: func(cmd *cobra.Command, args []string) error {
+                       if err := options.validate(args); err != nil {
+                               return err
+                       }
+                       return options.run(cmd, args)
+               },
+       }
+
+       return &cmd, &options
+}
+
+type undeployCmdOptions struct {
+       *RootCmdOptions
+}
+
+func (o *undeployCmdOptions) validate(args []string) error {
+       if len(args) == 0 {
+               return errors.New("undeploy requires an Integration name 
argument")
+       }
+
+       return nil
+}
+
+func (o *undeployCmdOptions) run(cmd *cobra.Command, args []string) error {
+       c, err := o.GetCmdClient()
+       if err != nil {
+               return err
+       }
+       var integrations []v1.Integration
+       if len(args) > 0 {
+               if integrations, err = getIntegrations(o.Context, c, args, 
o.Namespace); err != nil {
+                       return err
+               }
+       }
+
+       undeployed, err := o.undeployIntegrations(cmd, c, integrations)
+       // We print the number of undeployed integrations anyway (they could 
have been correctly processed)
+       fmt.Fprintln(cmd.OutOrStdout(), undeployed, "integrations have been 
undeployed")
+
+       return err
+}
+
+func (o *undeployCmdOptions) undeployIntegrations(cmd *cobra.Command, c 
k8sclient.StatusClient, integrations []v1.Integration) (int, error) {
+       undeployed := 0
+       for _, i := range integrations {
+               if i.Status.Phase != v1.IntegrationPhaseRunning {
+                       fmt.Fprintf(cmd.OutOrStdout(),
+                               "warning: could not undeploy integration %s, it 
is not in status %s\n",
+                               i.Name, v1.IntegrationPhaseRunning)
+                       continue
+               }
+               if i.Annotations[v1.IntegrationDontRunAfterBuildAnnotation] != 
"true" {
+                       fmt.Fprintf(cmd.OutOrStdout(),
+                               "warning: could not undeploy integration %s, it 
is not annotated with %s=true\n",
+                               i.Name, 
v1.IntegrationDontRunAfterBuildAnnotation)
+                       continue
+               }
+               it := i
+               it.Status.Phase = v1.IntegrationPhaseInitialization
+               if err := c.Status().Update(o.Context, &it); err != nil {
+                       return undeployed, fmt.Errorf("could not undeploy %s in 
namespace %s: %w", it.Name, o.Namespace, err)
+               }
+               undeployed++
+       }
+       return undeployed, nil
+}
diff --git a/pkg/cmd/undeploy_test.go b/pkg/cmd/undeploy_test.go
new file mode 100644
index 000000000..2649fb8e7
--- /dev/null
+++ b/pkg/cmd/undeploy_test.go
@@ -0,0 +1,103 @@
+/*
+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 (
+       "testing"
+
+       v1 "github.com/apache/camel-k/v2/pkg/apis/camel/v1"
+       "github.com/apache/camel-k/v2/pkg/internal"
+       "github.com/spf13/cobra"
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+       "k8s.io/apimachinery/pkg/runtime"
+)
+
+const cmdUndeploy = "undeploy"
+
+func initializeUndeployCmdOptions(t *testing.T, initObjs ...runtime.Object) 
(*cobra.Command, *undeployCmdOptions) {
+       t.Helper()
+       fakeClient, err := internal.NewFakeClient(initObjs...)
+       require.NoError(t, err)
+       options, rootCmd := kamelTestPreAddCommandInitWithClient(fakeClient)
+       options.Namespace = "default"
+       undeployCmdOptions := addTestUndeployCmd(*options, rootCmd)
+       kamelTestPostAddCommandInit(t, rootCmd, options)
+
+       return rootCmd, undeployCmdOptions
+}
+
+func addTestUndeployCmd(options RootCmdOptions, rootCmd *cobra.Command) 
*undeployCmdOptions {
+       undeployCmd, undeployOptions := newCmdUndeploy(&options)
+       undeployCmd.Args = ArbitraryArgs
+       rootCmd.AddCommand(undeployCmd)
+       return undeployOptions
+}
+
+func TestUndeployNonExistingFlag(t *testing.T) {
+       cmd, _ := initializeUndeployCmdOptions(t)
+       _, err := ExecuteCommand(cmd, cmdUndeploy, "--nonExistingFlag")
+       require.Error(t, err)
+       assert.Equal(t, "unknown flag: --nonExistingFlag", err.Error())
+}
+
+func TestUndeployNoArgs(t *testing.T) {
+       cmd, _ := initializeUndeployCmdOptions(t)
+       _, err := ExecuteCommand(cmd, cmdUndeploy)
+       require.Error(t, err)
+       assert.Equal(t, "undeploy requires an Integration name argument", 
err.Error())
+}
+
+func TestUndeployMissingIntegrations(t *testing.T) {
+       cmd, _ := initializeUndeployCmdOptions(t)
+       _, err := ExecuteCommand(cmd, cmdUndeploy, "missing")
+       require.Error(t, err)
+       assert.Equal(t,
+               "could not find integration missing in namespace default: 
integrations.camel.apache.org \"missing\" not found",
+               err.Error())
+}
+
+func TestUndeployNotRunningIntegrations(t *testing.T) {
+       it := v1.NewIntegration("default", "my-it")
+       it.Status.Phase = v1.IntegrationPhaseBuildRunning
+       cmd, _ := initializeUndeployCmdOptions(t, &it)
+       output, err := ExecuteCommand(cmd, cmdUndeploy, "my-it")
+       require.NoError(t, err)
+       assert.Contains(t, output, "could not undeploy integration my-it, it is 
not in status Running")
+}
+
+func TestUndeployMissingDontRunAnnotationIntegrations(t *testing.T) {
+       it := v1.NewIntegration("default", "my-it")
+       it.Status.Phase = v1.IntegrationPhaseRunning
+       cmd, _ := initializeUndeployCmdOptions(t, &it)
+       output, err := ExecuteCommand(cmd, cmdUndeploy, "my-it")
+       require.NoError(t, err)
+       assert.Contains(t, output, "could not undeploy integration my-it, it is 
not annotated with camel.apache.org/dont-run-after-build=true")
+}
+
+func TestUndeployIntegrations(t *testing.T) {
+       it := v1.NewIntegration("default", "my-it")
+       it.Status.Phase = v1.IntegrationPhaseRunning
+       it.Annotations = map[string]string{
+               v1.IntegrationDontRunAfterBuildAnnotation: "true",
+       }
+       cmd, _ := initializeUndeployCmdOptions(t, &it)
+       output, err := ExecuteCommand(cmd, cmdUndeploy, "my-it")
+       require.NoError(t, err)
+       assert.Contains(t, output, "1 integrations have been undeployed")
+}
diff --git a/pkg/cmd/util.go b/pkg/cmd/util.go
index 73b78582c..c10f8883a 100644
--- a/pkg/cmd/util.go
+++ b/pkg/cmd/util.go
@@ -27,6 +27,7 @@ import (
        "strings"
 
        "github.com/go-viper/mapstructure/v2"
+       k8sclient "sigs.k8s.io/controller-runtime/pkg/client"
 
        v1 "github.com/apache/camel-k/v2/pkg/apis/camel/v1"
        "github.com/apache/camel-k/v2/pkg/client"
@@ -236,3 +237,32 @@ func fieldByMapstructureTagName(target reflect.Value, 
tagName string) (reflect.S
 
        return reflect.StructField{}, false
 }
+
+func getIntegration(ctx context.Context, c client.Client, name, namespace 
string) (*v1.Integration, error) {
+       it := v1.NewIntegration(namespace, name)
+       key := k8sclient.ObjectKey{
+               Name:      name,
+               Namespace: namespace,
+       }
+       if err := c.Get(ctx, key, &it); err != nil {
+               return nil, err
+       }
+
+       return &it, nil
+}
+
+func getIntegrations(ctx context.Context, c client.Client, names []string, 
namespace string) ([]v1.Integration, error) {
+       ints := make([]v1.Integration, 0, len(names))
+       for _, n := range names {
+               it := v1.NewIntegration(namespace, n)
+               key := k8sclient.ObjectKey{
+                       Name:      n,
+                       Namespace: namespace,
+               }
+               if err := c.Get(ctx, key, &it); err != nil {
+                       return nil, fmt.Errorf("could not find integration %s 
in namespace %s: %w", it.Name, namespace, err)
+               }
+               ints = append(ints, it)
+       }
+       return ints, nil
+}
diff --git a/pkg/cmd/version_test.go b/pkg/cmd/version_test.go
index 7e3cad321..6c7115343 100644
--- a/pkg/cmd/version_test.go
+++ b/pkg/cmd/version_test.go
@@ -34,7 +34,6 @@ import (
 
 const cmdVersion = "version"
 
-// nolint: unparam
 func initializeVersionCmdOptions(t *testing.T, initObjs ...runtime.Object) 
(*versionCmdOptions, *cobra.Command, RootCmdOptions) {
        t.Helper()
 
diff --git a/pkg/controller/integration/build.go 
b/pkg/controller/integration/build.go
index b34b5741a..6b347c2ca 100644
--- a/pkg/controller/integration/build.go
+++ b/pkg/controller/integration/build.go
@@ -226,7 +226,11 @@ func (action *buildAction) handleBuildRunning(ctx 
context.Context, it *v1.Integr
                        }
                        it.Status.Image = fmt.Sprintf("%s@%s", image, 
build.Status.Digest)
                }
-               it.Status.Phase = v1.IntegrationPhaseDeploying
+               if it.Annotations[v1.IntegrationDontRunAfterBuildAnnotation] == 
v1.IntegrationDontRunAfterBuildAnnotationTrueValue {
+                       it.Status.Phase = v1.IntegrationPhaseBuildComplete
+               } else {
+                       it.Status.Phase = v1.IntegrationPhaseDeploying
+               }
        case v1.BuildPhaseError, v1.BuildPhaseInterrupted, v1.BuildPhaseFailed:
                it.Status.Phase = v1.IntegrationPhaseError
                reason := fmt.Sprintf("Build%s", build.Status.Phase)
diff --git a/pkg/controller/integration/build_kit.go 
b/pkg/controller/integration/build_kit.go
index 2ac2d4fd0..79bd22f4d 100644
--- a/pkg/controller/integration/build_kit.go
+++ b/pkg/controller/integration/build_kit.go
@@ -135,13 +135,18 @@ kits:
                }
        }
 
+       //nolint:nestif
        if integrationKit != nil {
                action.L.Debug("Setting integration kit for integration", 
"integration", integration.Name, "namespace", integration.Namespace, 
"integration kit", integrationKit.Name)
                // Set the kit name so the next handle loop, will fall through 
the
                // same path as integration with a user defined kit
                integration.SetIntegrationKit(integrationKit)
                if integrationKit.Status.Phase == v1.IntegrationKitPhaseReady {
-                       integration.Status.Phase = v1.IntegrationPhaseDeploying
+                       if 
integration.Annotations[v1.IntegrationDontRunAfterBuildAnnotation] == 
v1.IntegrationDontRunAfterBuildAnnotationTrueValue {
+                               integration.Status.Phase = 
v1.IntegrationPhaseBuildComplete
+                       } else {
+                               integration.Status.Phase = 
v1.IntegrationPhaseDeploying
+                       }
                }
        } else {
                action.L.Debug("Not yet able to assign an integration kit to 
integration",
@@ -199,7 +204,11 @@ func (action *buildKitAction) checkIntegrationKit(ctx 
context.Context, integrati
        }
 
        if kit.Status.Phase == v1.IntegrationKitPhaseReady {
-               integration.Status.Phase = v1.IntegrationPhaseDeploying
+               if 
integration.Annotations[v1.IntegrationDontRunAfterBuildAnnotation] == 
v1.IntegrationDontRunAfterBuildAnnotationTrueValue {
+                       integration.Status.Phase = 
v1.IntegrationPhaseBuildComplete
+               } else {
+                       integration.Status.Phase = v1.IntegrationPhaseDeploying
+               }
                integration.SetIntegrationKit(kit)
                return integration, nil
        }
diff --git a/pkg/controller/integration/initialize.go 
b/pkg/controller/integration/initialize.go
index cc7e82f14..9b0d0316f 100644
--- a/pkg/controller/integration/initialize.go
+++ b/pkg/controller/integration/initialize.go
@@ -71,7 +71,11 @@ func (action *initializeAction) Handle(ctx context.Context, 
integration *v1.Inte
        }
 
        if integration.Status.Image != "" {
-               integration.Status.Phase = v1.IntegrationPhaseDeploying
+               if 
integration.Annotations[v1.IntegrationDontRunAfterBuildAnnotation] == 
v1.IntegrationDontRunAfterBuildAnnotationTrueValue {
+                       integration.Status.Phase = 
v1.IntegrationPhaseBuildComplete
+               } else {
+                       integration.Status.Phase = v1.IntegrationPhaseDeploying
+               }
                return integration, nil
        }
 
diff --git a/pkg/controller/integration/monitor.go 
b/pkg/controller/integration/monitor.go
index 6d425de86..5a5cb7642 100644
--- a/pkg/controller/integration/monitor.go
+++ b/pkg/controller/integration/monitor.go
@@ -63,7 +63,8 @@ func (action *monitorAction) Name() string {
 func (action *monitorAction) CanHandle(integration *v1.Integration) bool {
        return integration.Status.Phase == v1.IntegrationPhaseDeploying ||
                integration.Status.Phase == v1.IntegrationPhaseRunning ||
-               integration.Status.Phase == v1.IntegrationPhaseError
+               integration.Status.Phase == v1.IntegrationPhaseError ||
+               integration.Status.Phase == v1.IntegrationPhaseBuildComplete
 }
 
 //nolint:nestif
@@ -153,6 +154,14 @@ func (action *monitorAction) Handle(ctx context.Context, 
integration *v1.Integra
        }
        action.checkTraitAnnotationsDeprecatedNotice(integration)
 
+       if integration.Status.Phase == v1.IntegrationPhaseBuildComplete {
+               // The following status fields are only filled during execution.
+               // We must remove them to clear any previous execution status.
+               integration.Status.Replicas = nil
+               integration.Status.RemoveCondition(v1.IntegrationConditionReady)
+               return integration, nil
+       }
+
        return action.monitorPods(ctx, environment, integration)
 }
 
@@ -196,7 +205,8 @@ func (action *monitorAction) monitorPods(ctx 
context.Context, environment *trait
                                Status: corev1.ConditionFalse,
                                Reason: 
v1.IntegrationConditionMonitoringPodsAvailableReason,
                                Message: fmt.Sprintf(
-                                       "Could not find 
`camel.apache.org/integration: %s` label in the %s template. Make sure to 
include this label in the template for Pod monitoring purposes.",
+                                       "Could not find 
`camel.apache.org/integration: %s` label in the %s template. "+
+                                               "Make sure to include this 
label in the template for Pod monitoring purposes.",
                                        integration.GetName(),
                                        controller.getControllerName(),
                                ),
diff --git a/pkg/controller/integration/monitor_unknown.go 
b/pkg/controller/integration/monitor_unknown.go
index 98c966ace..05a2914d7 100644
--- a/pkg/controller/integration/monitor_unknown.go
+++ b/pkg/controller/integration/monitor_unknown.go
@@ -53,7 +53,11 @@ func (action *monitorUnknownAction) Handle(ctx 
context.Context, integration *v1.
        }
        // We're good to monitor this again
        if environment.Platform != nil && environment.Platform.Status.Phase == 
v1.IntegrationPlatformPhaseReady {
-               integration.Status.Phase = v1.IntegrationPhaseRunning
+               if 
integration.Annotations[v1.IntegrationDontRunAfterBuildAnnotation] == 
v1.IntegrationDontRunAfterBuildAnnotationTrueValue {
+                       integration.Status.Phase = 
v1.IntegrationPhaseBuildComplete
+               } else {
+                       integration.Status.Phase = v1.IntegrationPhaseRunning
+               }
                integration.Status.SetCondition(
                        v1.IntegrationConditionPlatformAvailable,
                        corev1.ConditionTrue,
diff --git a/pkg/internal/client.go b/pkg/internal/client.go
index 984f420f2..acc9cd2c9 100644
--- a/pkg/internal/client.go
+++ b/pkg/internal/client.go
@@ -190,6 +190,10 @@ func (c *FakeClient) Patch(ctx context.Context, obj 
controller.Object, patch con
        return nil
 }
 
+func (c *FakeClient) Status() controller.SubResourceWriter {
+       return &FakeStatusWriter{client: c}
+}
+
 func (c *FakeClient) DisableAPIGroupDiscovery(group string) {
        c.disabledGroups = append(c.disabledGroups, group)
 }
diff --git a/pkg/internal/fakeStatusWriter.go b/pkg/internal/fakeStatusWriter.go
new file mode 100644
index 000000000..792efa816
--- /dev/null
+++ b/pkg/internal/fakeStatusWriter.go
@@ -0,0 +1,39 @@
+/*
+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 internal
+
+import (
+       "context"
+
+       controller "sigs.k8s.io/controller-runtime/pkg/client"
+)
+
+type FakeStatusWriter struct {
+       client *FakeClient
+}
+
+func (s *FakeStatusWriter) Patch(ctx context.Context, obj controller.Object, 
patch controller.Patch, opts ...controller.SubResourcePatchOption) error {
+       return s.client.Patch(ctx, obj, patch)
+}
+func (c *FakeStatusWriter) Create(ctx context.Context, obj controller.Object, 
subResource controller.Object, opts ...controller.SubResourceCreateOption) 
error {
+       return nil
+}
+
+func (c *FakeStatusWriter) Update(ctx context.Context, obj controller.Object, 
opts ...controller.SubResourceUpdateOption) error {
+       return nil
+}
diff --git a/pkg/trait/gc.go b/pkg/trait/gc.go
index be1ba1212..38b4c33df 100644
--- a/pkg/trait/gc.go
+++ b/pkg/trait/gc.go
@@ -115,11 +115,11 @@ func (t *gcTrait) Configure(e *Environment) (bool, 
*TraitCondition, error) {
 
        // We need to execute this trait only when all resources have been 
created and
        // deployed with a new generation if there is was any change during the 
Integration drift.
-       return e.IntegrationInRunningPhases(), nil, nil
+       return e.IntegrationInRunningPhases() || 
e.IntegrationInPhase(v1.IntegrationPhaseBuildComplete), nil, nil
 }
 
 func (t *gcTrait) Apply(e *Environment) error {
-       if e.Integration.GetGeneration() > 1 {
+       if e.Integration.GetGeneration() > 1 || 
e.IntegrationInPhase(v1.IntegrationPhaseBuildComplete) {
                // Register a post action that deletes the existing resources 
that are labelled
                // with the previous integration generation(s).
                // We make the assumption generation is a monotonically 
increasing strictly positive integer,
@@ -187,8 +187,12 @@ func (t *gcTrait) garbageCollectResources(e *Environment) 
error {
                return fmt.Errorf("cannot determine generation requirement: 
%w", err)
        }
        selector := labels.NewSelector().
-               Add(*integration).
-               Add(*generation)
+               Add(*integration)
+
+       // Skip the generation checking when we undeploy (which requires 
therefore to remove all dependent resources)
+       if !e.IntegrationInPhase(v1.IntegrationPhaseBuildComplete) {
+               selector = selector.Add(*generation)
+       }
 
        return t.deleteEachOf(e.Ctx, deletableGVKs, e, selector)
 }
diff --git a/pkg/trait/gc_test.go b/pkg/trait/gc_test.go
index a82646c99..cb8bcab18 100644
--- a/pkg/trait/gc_test.go
+++ b/pkg/trait/gc_test.go
@@ -146,7 +146,7 @@ func 
TestGarbageCollectPreserveResourcesWithSameGeneration(t *testing.T) {
        environment.Client = gcTrait.Client
 
        resourceDeleted := false
-       fakeClient := gcTrait.Client.(*internal.FakeClient) //nolint
+       fakeClient := gcTrait.Client.(*internal.FakeClient)
        fakeClient.Intercept(&interceptor.Funcs{
                Delete: func(ctx context.Context, client ctrl.WithWatch, obj 
ctrl.Object, opts ...ctrl.DeleteOption) error {
                        resourceDeleted = true
@@ -159,6 +159,29 @@ func 
TestGarbageCollectPreserveResourcesWithSameGeneration(t *testing.T) {
        assert.False(t, resourceDeleted)
 }
 
+func TestGarbageCollectUndeploying(t *testing.T) {
+       gcTrait, environment := createNominalGCTest()
+       environment.Integration.Status.Phase = v1.IntegrationPhaseBuildComplete
+
+       deployment := getIntegrationDeployment(environment.Integration)
+       gcTrait.Client, _ = internal.NewFakeClient(deployment)
+
+       environment.Client = gcTrait.Client
+
+       resourceDeleted := false
+       fakeClient := gcTrait.Client.(*internal.FakeClient)
+       fakeClient.Intercept(&interceptor.Funcs{
+               Delete: func(ctx context.Context, client ctrl.WithWatch, obj 
ctrl.Object, opts ...ctrl.DeleteOption) error {
+                       resourceDeleted = true
+                       return nil
+               },
+       })
+       err := gcTrait.garbageCollectResources(environment)
+
+       require.NoError(t, err)
+       assert.True(t, resourceDeleted)
+}
+
 func TestGarbageCollectPreserveResourcesOwnerReferenceMismatch(t *testing.T) {
        gcTrait, environment := createNominalGCTest()
        environment.Integration.Generation = 2

Reply via email to