Manage ACP Application with run-script Task

Feature Overview

This guide shows how to manage the full lifecycle of an ACP Application — deploy, update, wait for readiness, roll back, and auto-recover on failure — using the built-in run-script Task and the kubectl-app-manager tool image.

Why run-script instead of a dedicated Task?

Rather than providing a fixed-parameter Task for each operation, this guide uses the general-purpose run-script Task paired with the kubectl-app-manager tool image. This approach gives you full control over the script logic — you can customize parameters, add conditional branches, combine multiple operations, and adapt to your specific deployment workflow without waiting for a new Task release.

The trade-off is that you write a short shell script instead of filling in a form. If this pattern is widely adopted across teams, the scripts can be promoted to a dedicated catalog Pipeline, eliminating the need to copy them between projects.

Key capabilities

  • Deploy, update, wait, and roll back ACP Applications from a pipeline.
  • Support both raw Kubernetes manifests and OCI Helm charts (with custom values).
  • Cross-cluster / cross-namespace deployment — target any cluster or namespace by mounting a kubeconfig Secret, without being limited to the pipeline's own cluster and namespace.
  • Compose individual TaskRuns into a full Pipeline with auto-rollback on failure.

Choose the Delivery Path

Use the following table to pick the pattern that matches your environment and change scope.

PathUse it whenExtra requirementValidation status in this repo
Raw-resource manifestYou want the most direct and controller-free path for create/update/rollbackNone beyond ACP Application CRDEnd-to-end verified
OCI Helm chartYour application is already packaged as an OCI chart and you want ACP to reconcile from chart sourceChart-deploy controller in the target clusterChart packaging + Harbor push verified; cluster-side deploy still depends on controller availability
Generic manifest patchYou only need a narrow field change, such as replicas, annotations, or one container imageNoneEnd-to-end verified

Quick navigation:

I want to…Go to
Deploy or update an ApplicationScenario 1
Wait for an Application to become readyScenario 2
Roll back to the previous snapshotScenario 3
Full pipeline with auto-rollbackScenario 4
Patch a specific resource fieldScenario 5
Target a different clusterCross-cluster section

About ACP Application

An ACP Application (GVK: applications.app.k8s.io/v1beta1) is a Kubernetes object that groups a set of related resources — Deployments, Services, ConfigMaps, and more — under a single lifecycle unit. The platform reconciles the object and reports a unified health status (Running, Pending, Failed, …) aggregated from the underlying workloads.

The ACP console supports multiple ways to create an Application — from a container image, from a Helm chart, from raw YAML manifests, from source code (S2I), and from an Operator-backed service. The kubectl application plugin used in this guide manages the same underlying Application object. When you create an Application with --source-type oci, the platform also creates the corresponding HelmRequest (app.alauda.io/v1) resource internally to reconcile the chart — you do not need to manage that resource directly.

Key capabilities used in this guide:

CapabilityUsed In
Create / update via raw resource manifestScenario 1 (primary path)
Create / update via OCI Helm chart (with custom values)Scenario 1 (advanced path)
Wait for readiness (kubectl application status --watch)Scenario 2
Snapshot-based rollback (kubectl application snapshot rollback)Scenario 3, 4
Cross-cluster / cross-namespace targetingCross-Cluster section
Generic resource patchScenario 5

Each update operation creates an ApplicationHistory snapshot (GVK: applicationhistories.app.k8s.io/v1beta1) that can be used for rollback.

What's in the kubectl-app-manager Image

The kubectl-app-manager image is a purpose-built tool image for Application lifecycle management. It is registered in the catalog and can be selected from the tool image selector in the run-script Task UI.

FieldValue
Registryregistry.alauda.cn:60070/devops/tektoncd/hub/kubectl-app-manager
Recommended tagv0.1 (pin for reproducibility; latest always reflects the newest fixed tag)
Main toolskubectl-application plugin, kubectl 1.33
Bundled toolsyq 4.47

Key commands used in this guide:

# Create a new Application from a raw-resource manifest
kubectl application create <appName> -n <namespace> -r resources.yaml

# Update an existing Application
kubectl application update <appName> -n <namespace> -r resources.yaml

# Create from an OCI Helm chart (requires chart-deploy controller in cluster)
kubectl application create <appName> -n <namespace> \
  --source-type oci \
  --source-address <registry>/<repo> \
  -v <chartVersion> \
  [-f values.yaml] \
  [--source-secret-ref <pullSecret>]

# Poll current state (one-shot, no waiting)
kubectl application status -n <namespace> <appName> --watch=false -o json

# Wait for Running state with timeout
kubectl application status -n <namespace> <appName> --watch=true --timeout=300s -o json

# Roll back to the previous snapshot
kubectl application snapshot rollback -n <namespace> <appName>

For a complete list of available tool images and how to select them, see Tool Images.

For hub resolver syntax, see Specifying Remote Tasks Using Hub Resolvers.

Prerequisites

Before you begin

  • Tekton Pipelines v1 is installed in your cluster.
  • You have a namespace for running pipeline tasks.
  • The ACP Application CRD (applications.app.k8s.io/v1beta1) is installed (it is part of the ACP platform installation).
  • The hub resolver is configured with a catalog named catalog. Verify with:
    kubectl get configmap hubresolver-config -n tekton-pipelines-resolvers \
      -o jsonpath='{.data.default-tekton-hub-catalog}'
    # Expected output: catalog

RBAC

The ServiceAccount running the pipeline tasks must have permission to manage Application and ApplicationHistory objects, as well as the underlying Kubernetes resources (Deployments, Services, etc.).

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: acp-app-manager
  namespace: <YOUR_NAMESPACE>
rules:
  - apiGroups: [app.k8s.io]
    resources: [applications, applicationhistories]
    verbs: [get, list, watch, create, update, patch, delete]
  - apiGroups: [apps]
    resources: [deployments, replicasets, statefulsets, daemonsets]
    verbs: [get, list, watch, create, update, patch, delete]
  - apiGroups: [""]
    resources: [services, configmaps, secrets, serviceaccounts, persistentvolumeclaims]
    verbs: [get, list, watch, create, update, patch, delete]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: acp-app-manager
  namespace: <YOUR_NAMESPACE>
subjects:
  - kind: ServiceAccount
    name: <YOUR_SERVICE_ACCOUNT>
    namespace: <YOUR_NAMESPACE>
roleRef:
  kind: Role
  name: acp-app-manager
  apiGroup: rbac.authorization.k8s.io

Note: When running through the ACP pipeline gateway, the --as impersonation flag is not applied to kubectl application sub-commands. Use a ServiceAccount with the required permissions directly rather than relying on gateway impersonation.

Cross-Cluster / Cross-Namespace Deployment

All scenarios in this guide accept an optional kubeconfigPath argument. When set, every kubectl application … call targets the cluster and namespace described by that kubeconfig file instead of the pod's own ServiceAccount credentials.

This lets a single pipeline manage applications across multiple clusters and namespaces without any code changes — only the binding changes.

Important: For kubectl application ... commands, export KUBECONFIG instead of prepending --kubeconfig to the command. The plugin rejects global flags placed before the plugin name.

How to set it up

Step 1 — Store the target cluster kubeconfig in a Secret:

kubectl create secret generic target-cluster-kubeconfig \
  --from-file=config=./kubeconfig-target.yaml \
  -n <PIPELINE_NAMESPACE>

Step 2 — Bind the Secret to the run-script Task's secret workspace:

workspaces:
  - name: secret
    secret:
      secretName: target-cluster-kubeconfig

Step 3 — Pass the mount path as the kubeconfigPath argument:

- name: args
  value:
    - <appName>
    - <appNamespace>
    - /workspace/secret/config   # kubeconfigPath

Inside the script, export the path before invoking kubectl application:

[ -n "${KUBECONFIG_PATH}" ] && export KUBECONFIG="${KUBECONFIG_PATH}"
kubectl application status -n "${APP_NS}" "${APP_NAME}" ...

When kubeconfigPath is empty (the default), the pod uses its own projected ServiceAccount token — no secret binding needed for same-cluster pipelines.

Inputs Reference

The following table lists the parameters, workspaces, and results used across the scenarios in this guide. Naming follows the catalog Task conventions: params use camelCase, workspaces and results use kebab-case.

Script args (positional)

The run-script Task passes the args array to the script as positional parameters $1, $2, $3, … in order. For example, args: [demo-app, my-ns, ""] maps to APP_NAME=$1, APP_NS=$2, KUBECONFIG_PATH=$3 inside the script.

PositionNameDefaultDescription
$1appNameACP Application object name
$2appNamespaceNamespace of the Application
$3varies by scenariokubeconfigPath (raw-resource, wait, rollback) or chartAddress (OCI)
$4+scenario-specifice.g., timeoutSeconds for wait/rollback; chartVersion, chartPullSecret, kubeconfigPath, valuesYAML for OCI chart path

Refer to each scenario's args list for the exact positional mapping.

Workspaces (run-script Task)

NameRequiredPurpose
sourceNoSource files (manifests, values files). When bound, script CWD switches here.
secretNoCredential files, e.g., a kubeconfig Secret for cross-cluster access
configNoExtra configuration files
cacheNoBuild cache (rarely needed here)

Results (run-script Task)

NameDescription
string-resultJSON payload or plain string written by the script
overview-markdownMarkdown rendered in the run overview tab

Scenario 1 — Deploy or Update an Application

This script detects whether the Application exists and calls create or update accordingly. The result payload is written to string-result so downstream tasks can inspect the action taken.

Raw-resource manifest path (primary)

Use this when your workloads are described as plain Kubernetes manifests. No chart-deploy engine is required.

apiVersion: tekton.dev/v1
kind: TaskRun
metadata:
  generateName: deploy-app-
  namespace: <YOUR_NAMESPACE>
spec:
  serviceAccountName: <YOUR_SERVICE_ACCOUNT>
  taskRef:
    resolver: hub
    params:
      - { name: kind,    value: task    }
      - { name: catalog, value: catalog }
      - { name: name,    value: run-script }
      - { name: version, value: "0.1"  }
  params:
    - name: image
      value: registry.alauda.cn:60070/devops/tektoncd/hub/kubectl-app-manager:v0.1
    - name: imagePullPolicy
      value: IfNotPresent
    - name: args
      value:
        - "<APP_NAME>"       # 1: appName
        - "<APP_NAMESPACE>"  # 2: appNamespace
        - ""                 # 3: kubeconfigPath (empty = in-cluster)
    - name: script
      value: |
        # Deploy or update ACP Application from an inline resources manifest.
        # Args: 1=appName 2=appNamespace 3=kubeconfigPath
        set -euo pipefail
        APP_NAME="${1:?appName required}"
        APP_NS="${2:?appNamespace required}"
        KUBECONFIG_PATH="${3:-}"

        # Export KUBECONFIG instead of prepending --kubeconfig because
        # kubectl-application rejects global flags before the plugin name.
        [ -n "${KUBECONFIG_PATH}" ] && export KUBECONFIG="${KUBECONFIG_PATH}"

        # Keep resource names aligned with appName so users only edit one field
        # for the common case where the Application and its main workload share a name.
        cat > /tmp/resources.yaml <<RESOURCES_EOF
        apiVersion: apps/v1
        kind: Deployment
        metadata:
          name: ${APP_NAME}
          labels:
            app.kubernetes.io/name: ${APP_NAME}
        spec:
          replicas: 1
          selector:
            matchLabels:
              app.kubernetes.io/name: ${APP_NAME}
          template:
            metadata:
              labels:
                app.kubernetes.io/name: ${APP_NAME}
            spec:
              containers:
                - name: app
                  image: <YOUR_IMAGE>
                  ports:
                    - containerPort: 80
        ---
        apiVersion: v1
        kind: Service
        metadata:
          name: ${APP_NAME}
          labels:
            app.kubernetes.io/name: ${APP_NAME}
        spec:
          selector:
            app.kubernetes.io/name: ${APP_NAME}
          ports:
            - {port: 80, targetPort: 80, name: http}
        RESOURCES_EOF

        STATE_JSON=$(kubectl application status -n "${APP_NS}" "${APP_NAME}" --watch=false -o json 2>/dev/null || true)
        [ -z "${STATE_JSON}" ] && STATE_JSON='{"state":"NotFound"}'
        STATE=$(printf '%s' "${STATE_JSON}" | yq e '.state' -)

        if [ "${STATE}" = "NotFound" ]; then
          kubectl application create "${APP_NAME}" -n "${APP_NS}" -r /tmp/resources.yaml
          ACTION="created"
        else
          kubectl application update "${APP_NAME}" -n "${APP_NS}" -r /tmp/resources.yaml
          ACTION="updated"
        fi
        echo "==> ${ACTION} ${APP_NS}/${APP_NAME}"

        printf '{"appNamespace":"%s","appName":"%s","action":"%s"}' \
          "${APP_NS}" "${APP_NAME}" "${ACTION}" > "$(results.string-result.path)"

        cat > "$(results.overview-markdown.path)" <<EOF
        ### ACP Application ${ACTION}

        | Field | Value |
        | --- | --- |
        | Namespace | \`${APP_NS}\` |
        | Application | \`${APP_NAME}\` |
        | Action | \`${ACTION}\` |
        EOF

Using a git repository for resources: Instead of inlining the manifest, bind the source workspace to a git-clone step output and reference the file path directly:

# When source workspace is bound, the script CWD switches to /workspace/source
kubectl application create "${APP_NAME}" -n "${APP_NS}" -r ./k8s/resources.yaml

OCI Helm Chart path (advanced)

Use this when your application is packaged as an OCI Helm chart. This path requires the cluster to have a chart-deploy controller (captain) installed and running.

Replace the args and script params in the TaskRun above with the following:

    - name: args
      value:
        - "<APP_NAME>"                              # 1: appName
        - "<APP_NAMESPACE>"                         # 2: appNamespace
        - "<OCI_REGISTRY>/<PROJECT>/<CHART>"        # 3: chartAddress (without oci://)
        - "<CHART_VERSION>"                         # 4: chartVersion
        - ""                                        # 5: chartPullSecret (empty = public)
        - ""                                        # 6: kubeconfigPath (empty = in-cluster)
        - ""                                        # 7: valuesYAML (empty = keep current values)
    - name: script
      value: |
        # Deploy or update ACP Application from an OCI Helm chart.
        # Args: 1=appName 2=appNamespace 3=chartAddress 4=chartVersion
        #       5=chartPullSecret 6=kubeconfigPath 7=valuesYAML
        set -euo pipefail
        APP_NAME="${1:?appName required}"
        APP_NS="${2:?appNamespace required}"
        SOURCE_ADDRESS="${3:?chartAddress required}"
        CHART_VERSION="${4:?chartVersion required}"
        CHART_PULL_SECRET="${5:-}"
        KUBECONFIG_PATH="${6:-}"
        VALUES_YAML="${7:-}"

        [ -n "${KUBECONFIG_PATH}" ] && export KUBECONFIG="${KUBECONFIG_PATH}"

        # Write custom Helm values to a temp file when provided.
        VALUES_ARGS=()
        if [ -n "${VALUES_YAML}" ]; then
          printf '%s' "${VALUES_YAML}" > /tmp/values.yaml
          yq eval --inplace --prettyPrint /tmp/values.yaml
          echo "==> Custom values:"
          cat /tmp/values.yaml
          VALUES_ARGS=(-f /tmp/values.yaml)
        fi

        STATE_JSON=$(kubectl application status -n "${APP_NS}" "${APP_NAME}" --watch=false -o json 2>/dev/null || true)
        [ -z "${STATE_JSON}" ] && STATE_JSON='{"state":"NotFound"}'
        STATE=$(printf '%s' "${STATE_JSON}" | yq e '.state' -)

        if [ "${STATE}" = "NotFound" ]; then
          PREV_VERSION=""
          CREATE_ARGS=(application create "${APP_NAME}" -n "${APP_NS}" \
            --source-type oci --source-address "${SOURCE_ADDRESS}" -v "${CHART_VERSION}")
          [ -n "${CHART_PULL_SECRET}" ] && CREATE_ARGS+=(--source-secret-ref "${CHART_PULL_SECRET}")
          kubectl "${CREATE_ARGS[@]}" ${VALUES_ARGS[@]+"${VALUES_ARGS[@]}"}
          ACTION="created"
        else
          PREV_VERSION=$(kubectl get application.app.k8s.io -n "${APP_NS}" "${APP_NAME}" \
            -o jsonpath='{.metadata.annotations.app\.cpaas\.io/chart\.version}' 2>/dev/null || true)
          UPDATE_ARGS=(application update "${APP_NAME}" -n "${APP_NS}" \
            --source-type oci --source-address "${SOURCE_ADDRESS}" -v "${CHART_VERSION}")
          [ -n "${CHART_PULL_SECRET}" ] && UPDATE_ARGS+=(--source-secret-ref "${CHART_PULL_SECRET}")
          kubectl "${UPDATE_ARGS[@]}" ${VALUES_ARGS[@]+"${VALUES_ARGS[@]}"}
          ACTION="updated"
        fi

        CURR_VERSION=$(kubectl get application.app.k8s.io -n "${APP_NS}" "${APP_NAME}" \
          -o jsonpath='{.metadata.annotations.app\.cpaas\.io/chart\.version}' 2>/dev/null || true)

        printf '{"appNamespace":"%s","appName":"%s","action":"%s","previousVersion":"%s","currentVersion":"%s"}' \
          "${APP_NS}" "${APP_NAME}" "${ACTION}" "${PREV_VERSION:-}" "${CURR_VERSION:-}" \
          > "$(results.string-result.path)"

Requirement: OCI Helm Chart deployment requires the cluster's chart-deploy (captain) controller to be present. Without it, the Application object is created but stays Pending indefinitely. Use the raw-resource path if captain is not installed in your cluster.

HTTP chart repository: If your chart is served from an HTTP repository instead of an OCI registry, change --source-type oci to --source-type HTTP and set --source-address to the HTTP repository URL.

Scenario 2 — Wait for the Application to Become Ready

This script uses kubectl application status --watch=true --timeout to wait for the Application to reach Running state. The plugin handles polling internally and exits with a non-zero code on timeout, which causes the TaskRun (and any enclosing Pipeline task) to fail — allowing the finally block to react.

apiVersion: tekton.dev/v1
kind: TaskRun
metadata:
  generateName: wait-app-
  namespace: <YOUR_NAMESPACE>
spec:
  serviceAccountName: <YOUR_SERVICE_ACCOUNT>
  taskRef:
    resolver: hub
    params:
      - { name: kind,    value: task    }
      - { name: catalog, value: catalog }
      - { name: name,    value: run-script }
      - { name: version, value: "0.1"  }
  params:
    - name: image
      value: registry.alauda.cn:60070/devops/tektoncd/hub/kubectl-app-manager:v0.1
    - name: imagePullPolicy
      value: IfNotPresent
    - name: args
      value:
        - "<APP_NAME>"       # 1: appName
        - "<APP_NAMESPACE>"  # 2: appNamespace
        - ""                 # 3: kubeconfigPath (empty = in-cluster)
        - "300"              # 4: timeoutSeconds
    - name: script
      value: |
        # Wait for ACP Application to reach Running state.
        # Args: 1=appName 2=appNamespace 3=kubeconfigPath 4=timeoutSeconds
        set -euo pipefail
        APP_NAME="${1:?appName required}"
        APP_NS="${2:?appNamespace required}"
        KUBECONFIG_PATH="${3:-}"
        TIMEOUT="${4:-300}"

        [ -n "${KUBECONFIG_PATH}" ] && export KUBECONFIG="${KUBECONFIG_PATH}"

        echo "==> Waiting up to ${TIMEOUT}s for ${APP_NS}/${APP_NAME} to reach Running state"
        STATUS_JSON=$(kubectl application status -n "${APP_NS}" "${APP_NAME}" \
          --timeout="${TIMEOUT}s" --watch=true -o json || true)

        STATE=$(printf '%s' "${STATUS_JSON}" | yq e '.state' -)
        printf '%s' "${STATE}" > "$(results.string-result.path)"

        if [ "${STATE}" != "Running" ]; then
          echo "ERROR: ${APP_NS}/${APP_NAME} did not reach Running (last state: ${STATE})"
          exit 1
        fi
        echo "Application ${APP_NS}/${APP_NAME} is Running."

Expected output — normal path:

==> Waiting up to 300s for devops-doc-test/demo-app to reach Running state
Application devops-doc-test/demo-app is Running.

Expected output — timeout path:

==> Waiting up to 30s for devops-doc-test/demo-app to reach Running state
ERROR: devops-doc-test/demo-app did not reach Running (last state: Pending)

Scenario 3 — Rollback to the Previous Snapshot

This script triggers kubectl application snapshot rollback which reverts the Application to its most recent ApplicationHistory snapshot, then waits for the Application to return to Running.

apiVersion: tekton.dev/v1
kind: TaskRun
metadata:
  generateName: rollback-app-
  namespace: <YOUR_NAMESPACE>
spec:
  serviceAccountName: <YOUR_SERVICE_ACCOUNT>
  taskRef:
    resolver: hub
    params:
      - { name: kind,    value: task    }
      - { name: catalog, value: catalog }
      - { name: name,    value: run-script }
      - { name: version, value: "0.1"  }
  params:
    - name: image
      value: registry.alauda.cn:60070/devops/tektoncd/hub/kubectl-app-manager:v0.1
    - name: imagePullPolicy
      value: IfNotPresent
    - name: args
      value:
        - "<APP_NAME>"       # 1: appName
        - "<APP_NAMESPACE>"  # 2: appNamespace
        - ""                 # 3: kubeconfigPath (empty = in-cluster)
        - "300"              # 4: timeoutSeconds
    - name: script
      value: |
        # Rollback ACP Application to its previous snapshot.
        # Args: 1=appName 2=appNamespace 3=kubeconfigPath 4=timeoutSeconds
        set -euo pipefail
        APP_NAME="${1:?appName required}"
        APP_NS="${2:?appNamespace required}"
        KUBECONFIG_PATH="${3:-}"
        TIMEOUT="${4:-300}"

        [ -n "${KUBECONFIG_PATH}" ] && export KUBECONFIG="${KUBECONFIG_PATH}"

        echo "==> Triggering snapshot rollback for ${APP_NS}/${APP_NAME}"
        if ! kubectl application snapshot rollback -n "${APP_NS}" "${APP_NAME}"; then
          echo "==> Rollback command failed (application may have no previous snapshot)."
          printf 'NoSnapshot' > "$(results.string-result.path)"
          exit 0
        fi
        echo "==> Rollback triggered; waiting up to ${TIMEOUT}s for Running state"

        STATUS_JSON=$(kubectl application status -n "${APP_NS}" "${APP_NAME}" \
          --timeout="${TIMEOUT}s" --watch=true -o json || true)

        STATE=$(printf '%s' "${STATUS_JSON}" | yq e '.state' -)
        printf '%s' "${STATE}" > "$(results.string-result.path)"

        if [ "${STATE}" != "Running" ]; then
          echo "ERROR: rollback failed, ${APP_NS}/${APP_NAME} entered state: ${STATE}"
          exit 1
        fi
        echo "==> Rollback succeeded; ${APP_NS}/${APP_NAME} is Running."

Expected output:

==> Triggering snapshot rollback for devops-doc-test/demo-app
==> Rollback triggered; waiting up to 300s for Running state
==> Rollback succeeded; devops-doc-test/demo-app is Running.

To list available snapshots:

# List all ApplicationHistory snapshots for a namespace
kubectl get applicationhistory -n <namespace>

# Rollback rolls back to the previous (n-1) snapshot by default.
kubectl application snapshot rollback -n <namespace> <appName>

Scenario 4 — Production Pipeline: Deploy, Wait, and Auto-Rollback

This Pipeline combines the three scripts above into a production-ready deploy flow:

  1. deploy-app — creates or updates the Application
  2. wait-app — waits until Running or times out (runs after deploy-app)
  3. rollback-app (in finally) — rolls back automatically if wait-app failed

The when guard on the finally task ensures rollback only fires on failure. retries: 2 gives the rollback two additional attempts if the first one fails.

apiVersion: tekton.dev/v1
kind: Pipeline
metadata:
  name: acp-app-deploy-wait-rollback
  namespace: <YOUR_NAMESPACE>
spec:
  params:
    - name: appName
      type: string
      description: Name of the ACP Application object.
    - name: appNamespace
      type: string
      description: Namespace where the ACP Application lives.
    - name: resourcesYAML
      type: string
      description: |
        Multi-document YAML string containing all Kubernetes resources
        (Deployments, Services, ConfigMaps, …) to associate with the Application.
    - name: waitTimeout
      type: string
      default: "300"
      description: Seconds to wait for the Application to reach Running state.
    - name: rollbackTimeout
      type: string
      default: "300"
      description: Seconds to wait for the rollback to complete.
    - name: scriptImage
      type: string
      default: registry.alauda.cn:60070/devops/tektoncd/hub/kubectl-app-manager:v0.1
      description: |
        Tool image containing kubectl and the kubectl-application plugin.
        Pin to a specific tag (e.g. v0.1) for reproducible runs.
        If using imagePullPolicy: IfNotPresent, ensure the tag is pinned — using
        'latest' with IfNotPresent may serve a stale cached image after upgrades.
    - name: kubeconfigPath
      type: string
      default: ""
      description: |
        Path to a kubeconfig file inside the task pod. Leave empty to use
        the pod's in-cluster ServiceAccount credentials.
        Typically /workspace/secret/config when the kubeconfig workspace is bound.

  workspaces:
    - name: kubeconfig
      optional: true
      description: |
        Optional Secret containing a kubeconfig file for cross-cluster targeting.
        When bound, the file is available at /workspace/secret/<key> inside the task
        pod (because it maps to the run-script Task's 'secret' workspace).
        Set kubeconfigPath to /workspace/secret/<key>.

  tasks:
    - name: deploy-app
      taskRef:
        resolver: hub
        params:
          - { name: kind,    value: task    }
          - { name: catalog, value: catalog }
          - { name: name,    value: run-script }
          - { name: version, value: "0.1"  }
      workspaces:
        - name: secret
          workspace: kubeconfig
      params:
        - name: image
          value: $(params.scriptImage)
        - name: imagePullPolicy
          value: IfNotPresent
        - name: args
          value:
            - $(params.appName)
            - $(params.appNamespace)
            - $(params.kubeconfigPath)
        - name: script
          value: |
            set -euo pipefail
            APP_NAME="${1:?appName required}"
            APP_NS="${2:?appNamespace required}"
            KUBECONFIG_PATH="${3:-}"

            [ -n "${KUBECONFIG_PATH}" ] && export KUBECONFIG="${KUBECONFIG_PATH}"

            cat > /tmp/resources.yaml <<'RESOURCES_EOF'
            $(params.resourcesYAML)
            RESOURCES_EOF

            STATE_JSON=$(kubectl application status -n "${APP_NS}" "${APP_NAME}" --watch=false -o json 2>/dev/null || true)
            [ -z "${STATE_JSON}" ] && STATE_JSON='{"state":"NotFound"}'
            STATE=$(printf '%s' "${STATE_JSON}" | yq e '.state' -)

            if [ "${STATE}" = "NotFound" ]; then
              kubectl application create "${APP_NAME}" -n "${APP_NS}" -r /tmp/resources.yaml
              ACTION="created"
            else
              kubectl application update "${APP_NAME}" -n "${APP_NS}" -r /tmp/resources.yaml
              ACTION="updated"
            fi
            echo "==> ${ACTION} ${APP_NS}/${APP_NAME}"
            printf '{"appNamespace":"%s","appName":"%s","action":"%s"}' \
              "${APP_NS}" "${APP_NAME}" "${ACTION}" > "$(results.string-result.path)"

    - name: wait-app
      runAfter: [deploy-app]
      taskRef:
        resolver: hub
        params:
          - { name: kind,    value: task    }
          - { name: catalog, value: catalog }
          - { name: name,    value: run-script }
          - { name: version, value: "0.1"  }
      workspaces:
        - name: secret
          workspace: kubeconfig
      params:
        - name: image
          value: $(params.scriptImage)
        - name: imagePullPolicy
          value: IfNotPresent
        - name: args
          value:
            - $(params.appName)
            - $(params.appNamespace)
            - $(params.kubeconfigPath)
            - $(params.waitTimeout)
        - name: script
          value: |
            set -euo pipefail
            APP_NAME="${1:?appName required}"
            APP_NS="${2:?appNamespace required}"
            KUBECONFIG_PATH="${3:-}"
            TIMEOUT="${4:-300}"

            [ -n "${KUBECONFIG_PATH}" ] && export KUBECONFIG="${KUBECONFIG_PATH}"

            echo "==> Waiting up to ${TIMEOUT}s for ${APP_NS}/${APP_NAME} to reach Running state"
            STATUS_JSON=$(kubectl application status -n "${APP_NS}" "${APP_NAME}" \
              --timeout="${TIMEOUT}s" --watch=true -o json || true)

            STATE=$(printf '%s' "${STATUS_JSON}" | yq e '.state' -)
            printf '%s' "${STATE}" > "$(results.string-result.path)"

            if [ "${STATE}" != "Running" ]; then
              echo "ERROR: ${APP_NS}/${APP_NAME} did not reach Running (last state: ${STATE})"
              exit 1
            fi
            echo "Application ${APP_NS}/${APP_NAME} is Running."

  finally:
    - name: rollback-app
      # Only trigger when wait-app failed — skip on a clean success path.
      when:
        - input: $(tasks.wait-app.status)
          operator: in
          values: ["Failed"]
      taskRef:
        resolver: hub
        params:
          - { name: kind,    value: task    }
          - { name: catalog, value: catalog }
          - { name: name,    value: run-script }
          - { name: version, value: "0.1"  }
      retries: 2
      workspaces:
        - name: secret
          workspace: kubeconfig
      params:
        - name: image
          value: $(params.scriptImage)
        - name: imagePullPolicy
          value: IfNotPresent
        - name: args
          value:
            - $(params.appName)
            - $(params.appNamespace)
            - $(params.kubeconfigPath)
            - $(params.rollbackTimeout)
        - name: script
          value: |
            set -euo pipefail
            APP_NAME="${1:?appName required}"
            APP_NS="${2:?appNamespace required}"
            KUBECONFIG_PATH="${3:-}"
            TIMEOUT="${4:-300}"

            [ -n "${KUBECONFIG_PATH}" ] && export KUBECONFIG="${KUBECONFIG_PATH}"

            echo "==> Triggering snapshot rollback for ${APP_NS}/${APP_NAME}"
            if ! kubectl application snapshot rollback -n "${APP_NS}" "${APP_NAME}"; then
              echo "==> Rollback command failed (application may have no previous snapshot)."
              printf 'NoSnapshot' > "$(results.string-result.path)"
              exit 0
            fi

            STATUS_JSON=$(kubectl application status -n "${APP_NS}" "${APP_NAME}" \
              --timeout="${TIMEOUT}s" --watch=true -o json || true)

            STATE=$(printf '%s' "${STATUS_JSON}" | yq e '.state' -)
            printf '%s' "${STATE}" > "$(results.string-result.path)"

            if [ "${STATE}" != "Running" ]; then
              echo "ERROR: rollback failed, ${APP_NS}/${APP_NAME} entered state: ${STATE}"
              exit 1
            fi
            echo "==> Rollback succeeded; ${APP_NS}/${APP_NAME} is Running."

Running the Pipeline

Happy path — good image, Application reaches Running:

apiVersion: tekton.dev/v1
kind: PipelineRun
metadata:
  generateName: acp-app-deploy-
  namespace: <YOUR_NAMESPACE>
spec:
  taskRunTemplate:
    serviceAccountName: <YOUR_SERVICE_ACCOUNT>
  pipelineRef:
    name: acp-app-deploy-wait-rollback
  params:
    - name: appName
      value: my-app
    - name: appNamespace
      value: <YOUR_NAMESPACE>
    - name: waitTimeout
      value: "300"
    - name: rollbackTimeout
      value: "300"
    - name: resourcesYAML
      value: |
        apiVersion: apps/v1
        kind: Deployment
        metadata:
          name: my-app
          labels:
            app.kubernetes.io/name: my-app
        spec:
          replicas: 2
          selector:
            matchLabels:
              app.kubernetes.io/name: my-app
          template:
            metadata:
              labels:
                app.kubernetes.io/name: my-app
            spec:
              containers:
                - name: app
                  image: <YOUR_IMAGE>:<YOUR_TAG>
                  ports:
                    - containerPort: 8080
        ---
        apiVersion: v1
        kind: Service
        metadata:
          name: my-app
        spec:
          selector:
            app.kubernetes.io/name: my-app
          ports:
            - {port: 80, targetPort: 8080, name: http}

Expected result: Tasks Completed: 2 (Failed: 0), Skipped: 1 (rollback-app is skipped because wait-app succeeded.)

Failure path — bad image, wait-app times out, rollback fires:

To test this path, change the resourcesYAML param to use a non-existent image tag and reduce waitTimeout so the failure surfaces quickly:

  params:
    - name: appName
      value: my-app
    - name: appNamespace
      value: <YOUR_NAMESPACE>
    - name: waitTimeout
      value: "60"       # short timeout so the test completes quickly
    - name: rollbackTimeout
      value: "300"
    - name: resourcesYAML
      value: |
        apiVersion: apps/v1
        kind: Deployment
        metadata:
          name: my-app
        spec:
          template:
            spec:
              containers:
                - name: app
                  image: <YOUR_REGISTRY>/my-app:nonexistent-tag   # intentionally bad

The pipeline will:

  1. deploy-app — succeeds (writes the bad spec to the Application)
  2. wait-app — fails after waitTimeout seconds (Application stays Pending)
  3. rollback-app (finally) — triggers, rolls back to the previous snapshot

Expected result: Tasks Completed: 3 (Failed: 1), Skipped: 0 The Application returns to Running after the rollback completes.

Cross-cluster variant

To target a different cluster, bind the kubeconfig workspace and set kubeconfigPath to the mounted file path:

spec:
  taskRunTemplate:
    serviceAccountName: <YOUR_SERVICE_ACCOUNT>
  workspaces:
    - name: kubeconfig         # matches the Pipeline workspace name
      secret:
        secretName: target-cluster-kubeconfig
  params:
    - name: kubeconfigPath
      value: /workspace/secret/config   # mounted by run-script's 'secret' workspace
    # ... other params unchanged

The Pipeline maps its kubeconfig workspace to the secret workspace of each task. The file is available at /workspace/secret/<key> inside the task pod, where <key> is the Secret key name you used when creating the Secret (e.g., config for --from-file=config=./kubeconfig-target.yaml).

Scenario 5 — Generic Manifest Patch (Fallback)

Use this when you need a fine-grained field update that kubectl application update does not cover — for example, patching a single environment variable or annotation without replacing the entire resource manifest.

apiVersion: tekton.dev/v1
kind: TaskRun
metadata:
  generateName: patch-app-resource-
  namespace: <YOUR_NAMESPACE>
spec:
  serviceAccountName: <YOUR_SERVICE_ACCOUNT>
  taskRef:
    resolver: hub
    params:
      - { name: kind,    value: task    }
      - { name: catalog, value: catalog }
      - { name: name,    value: run-script }
      - { name: version, value: "0.1"  }
  params:
    - name: image
      value: registry.alauda.cn:60070/devops/tektoncd/hub/kubectl-app-manager:v0.1
    - name: imagePullPolicy
      value: IfNotPresent
    - name: args
      value:
        - "<APP_NAME>"       # 1: appName
        - "<APP_NAMESPACE>"  # 2: appNamespace
        - ""                 # 3: kubeconfigPath (empty = in-cluster)
        - "Deployment"       # 4: resourceKind
        - "<RESOURCE_NAME>"  # 5: resourceName (defaults to appName if empty)
    - name: script
      value: |
        # Generic JSON merge patch on a resource owned by an ACP Application.
        # Args: 1=appName 2=appNamespace 3=kubeconfigPath 4=resourceKind 5=resourceName
        set -euo pipefail
        APP_NAME="${1:?appName required}"
        APP_NS="${2:?appNamespace required}"
        KUBECONFIG_PATH="${3:-}"
        RESOURCE_KIND="${4:-Deployment}"
        RESOURCE_NAME="${5:-${APP_NAME}}"

        [ -n "${KUBECONFIG_PATH}" ] && export KUBECONFIG="${KUBECONFIG_PATH}"

        # Customize this patch JSON for your use case.
        # --type=merge uses RFC 7386 JSON Merge Patch: scalar fields are merged,
        # but arrays are REPLACED entirely. For array-safe patching (e.g., updating
        # one container in a multi-container pod), use --type=strategic instead.
        # Examples:
        #   Scale replicas:  '{"spec":{"replicas":3}}'
        #   Add annotation:  '{"metadata":{"annotations":{"deploy.env":"staging"}}}'
        PATCH='{"spec":{"replicas":3}}'

        echo "==> Patching ${RESOURCE_KIND} ${APP_NS}/${RESOURCE_NAME}"
        kubectl patch "${RESOURCE_KIND}" "${RESOURCE_NAME}" \
          -n "${APP_NS}" --type=merge --patch "${PATCH}"

        printf '{"namespace":"%s","kind":"%s","name":"%s"}' \
          "${APP_NS}" "${RESOURCE_KIND}" "${RESOURCE_NAME}" \
          > "$(results.string-result.path)"

Trigger from a New Artifact

You can wire Scenario 4's Pipeline to run automatically whenever a new OCI image or Helm chart is pushed to a registry, by using the ACP artifact trigger mechanism.

The trigger generates a PipelineRun from a template and injects artifact metadata (registry URL, digest, tag) as parameters:

# PipelineRun template — placeholders are filled by the artifact trigger
apiVersion: tekton.dev/v1
kind: PipelineRun
metadata:
  generateName: acp-app-deploy-
  namespace: <YOUR_NAMESPACE>
spec:
  taskRunTemplate:
    serviceAccountName: <YOUR_SERVICE_ACCOUNT>
  pipelineRef:
    name: acp-app-deploy-wait-rollback
  params:
    - name: appName
      value: my-app
    - name: appNamespace
      value: <YOUR_NAMESPACE>
    - name: waitTimeout
      value: "300"
    - name: rollbackTimeout
      value: "300"
    - name: resourcesYAML
      value: |
        apiVersion: apps/v1
        kind: Deployment
        metadata:
          name: my-app
        spec:
          template:
            spec:
              containers:
                - name: app
                  # $(ARTIFACT_URL) and $(ARTIFACT_TAG) are injected by the trigger
                  image: $(ARTIFACT_URL):$(ARTIFACT_TAG)

For full trigger configuration, see the ACP artifact trigger documentation in your platform's how-to guides.

Validation

After submitting a PipelineRun or TaskRun, check progress with:

# List recent PipelineRuns (newest last)
kubectl get pipelinerun -n <namespace> --sort-by=.metadata.creationTimestamp

# List TaskRuns created by a specific PipelineRun
kubectl get taskrun -n <namespace> -l tekton.dev/pipelineRun=<pipelineRunName>

# Check Application state (requires kubectl-application plugin installed locally)
kubectl application status -n <namespace> <appName> --watch=false -o json
# Without the plugin, check the ACP Application object directly:
kubectl get application.app.k8s.io -n <namespace> <appName> -o jsonpath='{.status}'

# Inspect task results from a TaskRun (use the name from the list above)
kubectl get taskrun <taskRunName> -n <namespace> \
  -o jsonpath='{.status.taskResults}'

# View Application history snapshots
kubectl get applicationhistory -n <namespace>

Manual Rollback

To roll back an Application outside of a pipeline:

# List available snapshots
kubectl get applicationhistory -n <namespace>

# Roll back to the previous (n-1) snapshot
kubectl application snapshot rollback -n <namespace> <appName>

# Verify the result
kubectl application status -n <namespace> <appName> --watch=false -o json

snapshot rollback always rolls back to the most recent previous snapshot. To target a specific snapshot, inspect the ApplicationHistory objects and use kubectl apply to restore the specific resource spec from that snapshot's .spec.resources field directly.

Troubleshooting

Forbidden / permission errors

Ensure the ServiceAccount has a Role with applications and applicationhistories permissions in the target namespace. See the Prerequisites section.

The ACP pipeline gateway's --as impersonation does not propagate to kubectl application sub-commands. Assign permissions to the ServiceAccount directly.

Application stays Pending after OCI chart deploy

This means the cluster does not have a chart-deploy (captain) controller. Either:

  • Switch to the raw-resource manifest path (-r resources.yaml), or
  • Install the chart-deploy controller in your cluster.

Chart pull secret not taking effect

Ensure the Secret is in the same namespace as the Application, and that its name matches the --source-secret-ref argument exactly. Verify with:

kubectl get secret <pullSecret> -n <namespace>

wait-app times out unexpectedly

Check the underlying workload state directly:

kubectl get deployment <appName> -n <namespace>
kubectl describe pod -n <namespace> -l app.kubernetes.io/name=<appName>

Common causes: image pull errors, readiness probe failures, resource quota exceeded.

rollback-app (finally) did not trigger

The finally task's when condition is:

- input: $(tasks.wait-app.status)
  operator: in
  values: ["Failed"]

This only matches when wait-app actually ran and failed. If wait-app was Skipped (e.g., because deploy-app also failed), Tekton evaluates $(tasks.wait-app.status) as None, which does not match "Failed", so rollback-app is also skipped.

Verify by inspecting the PipelineRun:

kubectl get pipelinerun <pipelinerunName> -n <namespace> \
  -o jsonpath='{.status.childReferences[*]}'

rollback-app skipped — no previous snapshot exists

This happens on the very first deployment: the Application was just created and has no history yet. The rollback script catches the failure from kubectl application snapshot rollback and exits cleanly with NoSnapshot written to string-result.

In this case the PipelineRun ends with Failed status from wait-app, but rollback-app completes successfully (skip). Fix the root cause of the deployment failure, then manually delete the broken Application and re-run.

deploy-app itself failed — rollback did not trigger

If deploy-app fails (e.g., due to an RBAC error or network issue), wait-app is Skipped rather than Failed. The when condition on rollback-app checks $(tasks.wait-app.status) == Failed, so rollback is also skipped.

In this case the Application was not updated, so no rollback is needed. Fix the root cause of the deploy-app failure and re-run the PipelineRun.

Cross-cluster kubeconfig invalid or expired

If the kubeconfig Secret was rotated or the certificate expired, the task will fail with an authentication error. Recreate the Secret with a fresh kubeconfig:

kubectl delete secret target-cluster-kubeconfig -n <namespace>
kubectl create secret generic target-cluster-kubeconfig \
  --from-file=config=./kubeconfig-new.yaml \
  -n <namespace>

See Also