Repository: kubean-io/kubean (go)

Issue:
usage issue
When i create one KuBeanClusterOps with some incorrect values, such ops status is as following:
  action: ""
  digest: 92df34c6021b9e5c38ef756fc79a988f
  status: ""
Now, i don't know how about this ops's process or status. Then i create anther ops again then i check the second ops status is :
status:
  action: ""
  digest: aada123ae06c2bed942d9a5861056328
  status: Blocked
and the logs:
I0722 03:38:00.030160       1 controller.go:238] clusterOps e2e-cluster1-install-2 is blocked and waiting for other clusterOps completed
so, i guess the first ops is in the endless loop; Can we mark the first ops as status:"Failed" and to do the second ops?

Pull request description:
optimize block status
<!--  

Thanks for sending a pull request!  Here are some tips for you:
# todo

-->

**What type of PR is this?**
> Uncomment only one ` /kind <>` line, hit enter to put that in a new line, and remove leading whitespaces from that line:
>
> /kind api-change
> /kind bug
> /kind cleanup
> /kind design
> /kind documentation
> /kind failing-test
> /kind feature
> /kind flake

/kind bug

**What this PR does / why we need it**:

* fix the bug which causes endless loop to block the other clusterOps  when it has wrong preHook  or postHook  or action args 

**Which issue(s) this PR fixes**:
<!--
*Automatically closes linked issue when PR is merged.
Usage: `Fixes #<issue number>`, or `Fixes (paste link of issue)`.
_If PR is about `failing-tests or flakes`, please post the related issues/tests in a comment and do not use `Fixes`_*
-->
Fixes #21 

**Special notes for your reviewer**:

**make sure your commit is signed off**


Patch:
diff --git a/.github/workflows/tag.yaml b/.github/workflows/tag.yaml
index e23633cfe..30b879daa 100644
--- a/.github/workflows/tag.yaml
+++ b/.github/workflows/tag.yaml
@@ -33,11 +33,11 @@ jobs:
         with:
           fetch-depth: 0
 
-      # - name: Restore offline-build-cr-file cache
-      #   uses: actions/cache@v3
-      #   with:
-      #     path: charts/kubean/templates/kubeancomponentsversion.cr.yaml
-      #     key: offline-build-cr-file
+      - name: Restore offline-build-cr-file cache
+        uses: actions/cache@v3
+        with:
+          path: charts/kubean/templates/kubeancomponentsversion.cr.yaml
+          key: offline-build-cr-file
 
       - name: update helm version and image tag
         run: |
diff --git a/artifacts/gen_repo_conf.sh b/artifacts/gen_repo_conf.sh
deleted file mode 100644
index 01a0a1be1..000000000
--- a/artifacts/gen_repo_conf.sh
+++ /dev/null
@@ -1,170 +0,0 @@
-#!/bin/bash
-
-set -eo pipefail
-
-ISO_IMG_FILE=''
-LINUX_DISTRIBUTION=''
-REPO_BASE_URL=''
-ISO_MOUNT_PATH=''
-
-MARK_NAME=Kubean
-ISO_REPO_CONF=${MARK_NAME}-ISO.repo
-URL_REPO_CONF=${MARK_NAME}-URL.repo
-
-YUM_REPOS_PATH=/etc/yum.repos.d
-YUM_REPOS_BAK_PATH=/etc/yum.repos.d.bak
-
-function check_iso_img() {
-  if [ -z "${ISO_IMG_FILE}" ] || [ ! -f ${ISO_IMG_FILE} ]; then
-    echo "iso image: \${ISO_IMG_FILE} should exist."
-    exit 1
-  fi
-}
-
-function check_repo_url() {
-  if [ -z "${REPO_BASE_URL}" ]; then
-    echo "repo url: \${REPO_BASE_URL} is empty."
-    exit 1
-  fi
-}
-
-function mount_iso_file() {
-  ISO_MOUNT_PATH=/mnt/${LINUX_DISTRIBUTION}-iso
-  mkdir -p ${ISO_MOUNT_PATH}
-  mount -o loop,ro ${ISO_IMG_FILE} ${ISO_MOUNT_PATH}
-}
-
-function backup_yum_repo() {
-  if [ $(ls -A /etc/yum.repos.d/ | grep ${MARK_NAME} | wc -l) -eq 0 ]; then
-    mkdir -p ${YUM_REPOS_BAK_PATH}
-    mv ${YUM_REPOS_PATH}/* ${YUM_REPOS_BAK_PATH}
-  fi
-}
-
-function generate_yum_repo() {
-  MODE=$1
-  echo "MODE: $MODE"
-  backup_yum_repo
-
-  if [ ${MODE} == "iso" ]; then
-    cat >${YUM_REPOS_PATH}/${ISO_REPO_CONF} <<EOF
-[kubean iso]
-name=Kubean ISO Repo
-baseurl=file://${ISO_MOUNT_PATH}
-enabled=1
-gpgcheck=0
-sslverify=0
-EOF
-    echo "generate: ${YUM_REPOS_PATH}/${ISO_REPO_CONF}"
-  fi
-
-  if [ ${MODE} == "url" ]; then
-    cat >${YUM_REPOS_PATH}/${URL_REPO_CONF} <<EOF
-[kubean extra]
-name=Kubean Extra Repo
-baseurl=${REPO_BASE_URL}
-enabled=1
-gpgcheck=0
-sslverify=0
-EOF
-    echo "generate: ${YUM_REPOS_PATH}/${URL_REPO_CONF}"
-  fi
-}
-
-function gen_repo_conf_with_iso() {
-  shift
-  LINUX_DISTRIBUTION=$1
-  ISO_IMG_FILE=$2
-
-  echo "LINUX_DISTRIBUTION: $LINUX_DISTRIBUTION, ISO_IMG_FILE: $ISO_IMG_FILE"
-
-  case $LINUX_DISTRIBUTION in
-  centos)
-    check_iso_img
-    mount_iso_file
-    generate_yum_repo iso
-    ;;
-
-  debian | ubuntu)
-    echo "this linux distribution is temporarily not supported."
-    ;;
-
-  *)
-    echo "unknown linux distribution, currently only supports centos."
-    ;;
-  esac
-}
-
-function gen_repo_conf_with_url() {
-  shift
-  LINUX_DISTRIBUTION=$1
-  REPO_BASE_URL=$2
-
-  echo "LINUX_DISTRIBUTION: $LINUX_DISTRIBUTION, REPO_BASE_URL: $REPO_BASE_URL"
-
-  case $LINUX_DISTRIBUTION in
-  centos)
-    check_repo_url
-    generate_yum_repo url
-    ;;
-
-  debian | ubuntu)
-    echo "this linux distribution is temporarily not supported."
-    ;;
-
-  *)
-    echo "unknown linux distribution, currently only supports centos."
-    ;;
-  esac
-}
-
-function show_usage() {
-  local cmd=$(basename $0)
-  cat <<EOF
-Usage:
-  $cmd <command>
-Examples:
-# Mount the ISO image and generate the repo configuration file
-./gen_repo_conf.sh --iso-mode \${linux_distribution} \${iso_image_file}
-./gen_repo_conf.sh -im centos CentOS-7-x86_64-Everything-2207-02.iso
-
-# Generate repo configuration file according to url
-./gen_repo_conf.sh --url-mode \${linux_distribution} \${repo_base_url}
-./gen_repo_conf.sh -um centos http://10.8.172.10:8010/centos/7/
-
-Available Commands:
-  -im, --iso-mode      use the iso image as the repo source
-  -um, --url-mode      use url as repo source
-EOF
-}
-
-function pdie() {
-  show_usage
-  echo "$0: ERROR: ${1-}" 1>&2
-  exit "${2-1}"
-}
-
-while [[ $# -gt 0 ]]; do
-  case $1 in
-  -im | --iso-mode)
-    gen_repo_conf_with_iso $@
-    exit 0
-    ;;
-  -um | --url-mode)
-    gen_repo_conf_with_url $@
-    exit 0
-    ;;
-  -h | --help)
-    show_usage
-    exit 0
-    ;;
-  *)
-    pdie "This command does not exist: $1"
-    ;;
-  esac
-done
-
-# handle non-option arguments
-if [[ $# -ne 1 ]]; then
-  pdie "Required command not specified."
-fi
diff --git a/artifacts/make_local_iso_repo.sh b/artifacts/make_local_iso_repo.sh
new file mode 100644
index 000000000..cd8be5cfa
--- /dev/null
+++ b/artifacts/make_local_iso_repo.sh
@@ -0,0 +1,54 @@
+#!/bin/bash
+
+set -eo pipefail
+
+ISO_IMG_FILE=${ISO_IMG_FILE:-"linux.iso"}
+LINUX_DISTRIBUTION=${1:-'centos'}
+
+ISO_MOUNT_PATH=/mnt/${LINUX_DISTRIBUTION}-iso
+
+function check_dependencies() {
+  if [ ! -f ${ISO_IMG_FILE} ]; then
+    echo "iso image: [${ISO_IMG_FILE}] should exist."
+    exit 1
+  fi
+}
+
+function mount_iso_file() {
+  mkdir -p ${ISO_MOUNT_PATH}
+  mount -o loop,ro ${ISO_IMG_FILE} ${ISO_MOUNT_PATH}
+}
+
+function generate_yum_repo() {
+  REPOS_PATH=/etc/yum.repos.d
+  REPOS_BAK_PATH=/etc/yum.repos.d.bak
+  mkdir -p ${REPOS_BAK_PATH}
+  mv ${REPOS_PATH}/* ${REPOS_BAK_PATH}
+  cat >${REPOS_PATH}/iso.repo <<EOF
+[iso]
+name=Local ISO Repo
+baseurl=file://${ISO_MOUNT_PATH}
+enabled=1
+gpgcheck=0
+sslverify=0
+EOF
+  # yum clean all
+  # yum makecache
+  # yum list
+}
+
+case $LINUX_DISTRIBUTION in
+centos)
+  check_dependencies
+  mount_iso_file
+  generate_yum_repo
+  ;;
+
+debian | ubuntu)
+  echo "this linux distribution is temporarily not supported."
+  ;;
+
+*)
+  echo "unknown linux distribution, currently only supports centos."
+  ;;
+esac
diff --git a/doc/offline.zh.md b/doc/offline.zh.md
index b111d11b3..8bd8ce9c5 100644
--- a/doc/offline.zh.md
+++ b/doc/offline.zh.md
@@ -97,13 +97,13 @@ OS Packages 主要用于解决 docker-ce 的安装依赖, 但在实际的离线
 
 > 注: 我们需要提前下载主机对应的 ISO 系统发行版镜像, 当前仅支持 Centos 发行版的 ISO 镜像源创建;
 
-这里需要使用到 `gen_repo_conf.sh`, 该脚本目前位于 `artifacts/` 目录下, 执行如下命令即可创建 ISO 镜像源:
+这里需要使用到 `make_local_iso_repo.sh`, 该脚本目前位于 `artifacts/` 目录下, 执行如下命令即可创建 ISO 镜像源:
 
 ``` bash
-$ ./gen_repo_conf.sh --iso-mode ${linux_distribution} ${iso_image_file}
+$ ISO_IMG_FILE=${iso_image_file} ./make_local_iso_repo.sh ${linux_distribution}
 
 # 比如:
-$ ./gen_repo_conf.sh --iso-mode centos iso/CentOS-7-x86_64-Everything-2207-02.iso
+$ ISO_IMG_FILE=CentOS-7-x86_64-Everything-2207-02.iso ./make_local_iso_repo.sh centos
 ```
 
 ## 建立本地 extras 镜像源
@@ -114,7 +114,7 @@ $ ./gen_repo_conf.sh --iso-mode centos iso/CentOS-7-x86_64-Everything-2207-02.is
 
 在需要安装 K8S 集群的机器上，新建文件 `/etc/yum.repos.d/localextras.repo` ，内容如下
 
-``` ini
+```yaml
 [ localextras ]
   name=localextras
   baseurl=${minio_address}/centos/$releasever/os/$basearch
@@ -122,15 +122,6 @@ $ ./gen_repo_conf.sh --iso-mode centos iso/CentOS-7-x86_64-Everything-2207-02.is
   gpgcheck=0
 ```
 
-或者, 可以使用脚本 `artifacts/gen_repo_conf.sh`, 执行如下命令即可创建 Extra Repo:
-
-``` bash
-$ ./gen_repo_conf.sh --url-mode ${linux_distribution} ${repo_base_url}
-
-# 比如:
-$ ./gen_repo_conf.sh --url-mode centos ${minio_address}/centos/$releasever/os/$basearch
-```
-
 * 需要将 `${minio_address}` 替换为实际 `minio API Server` 的地址
 
 ## 部署集群前的配置
diff --git a/pkg/controllers/cluster/controller.go b/pkg/controllers/cluster/controller.go
index 8310550c8..5b91ab2ce 100644
--- a/pkg/controllers/cluster/controller.go
+++ b/pkg/controllers/cluster/controller.go
@@ -21,6 +21,7 @@ import (
 
 const (
 	RequeueAfter = time.Second * 5
+	OpsBackupNum = 5
 )
 
 type Controller struct {
@@ -98,6 +99,29 @@ func (c *Controller) UpdateStatus(cluster *kubeanclusterv1alpha1.KuBeanCluster)
 	return nil
 }
 
+// CleanExcessClusterOps clean up excess KuBeanClusterOps.
+func (c *Controller) CleanExcessClusterOps(cluster *kubeanclusterv1alpha1.KuBeanCluster) (bool, error) {
+	listOpt := metav1.ListOptions{LabelSelector: fmt.Sprintf("clusterName=%s", cluster.Name)}
+	clusterOpsList, err := c.KubeanClusterOpsSet.KubeanclusteropsV1alpha1().KuBeanClusterOps().List(context.Background(), listOpt)
+	if err != nil {
+		return false, err
+	}
+	if len(clusterOpsList.Items) <= OpsBackupNum {
+		return false, nil
+	}
+
+	// clusterOps list sort by creation timestamp
+	sort.Slice(clusterOpsList.Items, func(i, j int) bool {
+		return clusterOpsList.Items[i].CreationTimestamp.After(clusterOpsList.Items[j].CreationTimestamp.Time)
+	})
+	excessClusterOpsList := clusterOpsList.Items[OpsBackupNum:]
+	for _, item := range excessClusterOpsList {
+		klog.Warningf("Delete KuBeanClusterOps: name: %s, createTime: %s", item.Name, item.CreationTimestamp.String())
+		c.KubeanClusterOpsSet.KubeanclusteropsV1alpha1().KuBeanClusterOps().Delete(context.Background(), item.Name, metav1.DeleteOptions{})
+	}
+	return true, nil
+}
+
 func (c *Controller) Reconcile(ctx context.Context, req controllerruntime.Request) (controllerruntime.Result, error) {
 	cluster := &kubeanclusterv1alpha1.KuBeanCluster{}
 	if err := c.Client.Get(ctx, req.NamespacedName, cluster); err != nil {
@@ -107,6 +131,16 @@ func (c *Controller) Reconcile(ctx context.Context, req controllerruntime.Reques
 		klog.Error(err)
 		return controllerruntime.Result{RequeueAfter: RequeueAfter}, err
 	}
+
+	needRequeue, err := c.CleanExcessClusterOps(cluster)
+	if err != nil {
+		klog.Error(err)
+		return controllerruntime.Result{RequeueAfter: RequeueAfter}, err
+	}
+	if needRequeue {
+		return controllerruntime.Result{RequeueAfter: RequeueAfter}, nil
+	}
+
 	if err := c.UpdateStatus(cluster); err != nil {
 		klog.Error(err)
 	}
diff --git a/pkg/controllers/clusterops/controller.go b/pkg/controllers/clusterops/controller.go
index de19525c8..b0feca4fd 100644
--- a/pkg/controllers/clusterops/controller.go
+++ b/pkg/controllers/clusterops/controller.go
@@ -4,10 +4,10 @@ import (
 	"context"
 	"crypto/md5"
 	"fmt"
-	"sort"
 	"strconv"
 	"strings"
 	"time"
+	"unicode"
 
 	"github.com/kubean-io/kubean/pkg/util/entrypoint"
 
@@ -30,7 +30,6 @@ import (
 
 const (
 	RequeueAfter          = time.Millisecond * 500
-	OpsBackupNum          = 5
 	LoopForJobStatus      = time.Second * 3
 	KubeanClusterLabelKey = "clusterName"
 )
@@ -180,6 +179,17 @@ func (c *Controller) CurrentJobNeedBlock(clusterOps *kubeanclusteropsv1alpha1.Ku
 	return len(runningClusterOpsList) != 0, nil
 }
 
+func IsValidImageName(image string) bool {
+	isNumberOrLetter := func(r rune) bool {
+		return unicode.IsLetter(r) || unicode.IsNumber(r)
+	}
+	if len(image) == 0 || strings.Contains(image, " ") {
+		return false
+	}
+	runeSlice := []rune(image)
+	return isNumberOrLetter(runeSlice[0]) && isNumberOrLetter(runeSlice[len(runeSlice)-1])
+}
+
 func (c *Controller) Reconcile(ctx context.Context, req controllerruntime.Request) (controllerruntime.Result, error) {
 	clusterOps := &kubeanclusteropsv1alpha1.KuBeanClusterOps{}
 	if err := c.Client.Get(ctx, req.NamespacedName, clusterOps); err != nil {
@@ -189,6 +199,21 @@ func (c *Controller) Reconcile(ctx context.Context, req controllerruntime.Reques
 		klog.Error(err)
 		return controllerruntime.Result{RequeueAfter: RequeueAfter}, err
 	}
+
+	if clusterOps.Status.Status == kubeanclusteropsv1alpha1.FailedStatus || clusterOps.Status.Status == kubeanclusteropsv1alpha1.SucceededStatus {
+		// return early
+		return controllerruntime.Result{Requeue: false}, nil
+	}
+
+	if !IsValidImageName(clusterOps.Spec.Image) {
+		klog.Errorf("clusterOps %s has wrong image format and update status Failed", clusterOps.Name)
+		clusterOps.Status.Status = kubeanclusteropsv1alpha1.FailedStatus
+		if err := c.Client.Status().Update(ctx, clusterOps); err != nil {
+			klog.Error(err)
+		}
+		return controllerruntime.Result{Requeue: false}, nil
+	}
+
 	cluster, err := c.GetKuBeanCluster(clusterOps)
 	if err != nil {
 		klog.Error(err)
@@ -220,6 +245,15 @@ func (c *Controller) Reconcile(ctx context.Context, req controllerruntime.Reques
 	}
 
 	needRequeue, err = c.CreateEntryPointShellConfigMap(clusterOps)
+	if argsErr, ok := err.(entrypoint.ArgsError); ok {
+		// preHook or postHook or action error args
+		klog.Errorf("clusterOps %s wrong args %s and update status Failed", clusterOps.Name, argsErr.Error())
+		clusterOps.Status.Status = kubeanclusteropsv1alpha1.FailedStatus
+		if err := c.Client.Status().Update(ctx, clusterOps); err != nil {
+			klog.Error(err)
+		}
+		return controllerruntime.Result{Requeue: false}, err
+	}
 	if err != nil {
 		klog.Error(err)
 		return controllerruntime.Result{RequeueAfter: RequeueAfter}, err
@@ -255,15 +289,6 @@ func (c *Controller) Reconcile(ctx context.Context, req controllerruntime.Reques
 		return controllerruntime.Result{RequeueAfter: RequeueAfter}, nil
 	}
 
-	needRequeue, err = c.CleanExcessClusterOps(cluster)
-	if err != nil {
-		klog.Error(err)
-		return controllerruntime.Result{RequeueAfter: RequeueAfter}, err
-	}
-	if needRequeue {
-		return controllerruntime.Result{RequeueAfter: RequeueAfter}, nil
-	}
-
 	needRequeue, err = c.UpdateStatusLoop(clusterOps, c.FetchJobStatus)
 	if err != nil {
 		klog.Error(err)
@@ -593,29 +618,6 @@ func (c *Controller) BackUpDataRef(clusterOps *kubeanclusteropsv1alpha1.KuBeanCl
 	return false, nil // needRequeue,err
 }
 
-// CleanExcessClusterOps clean up excess KuBeanClusterOps.
-func (c *Controller) CleanExcessClusterOps(cluster *kubeanclusterv1alpha1.KuBeanCluster) (bool, error) {
-	listOpt := metav1.ListOptions{LabelSelector: fmt.Sprintf("clusterName=%s", cluster.Name)}
-	clusterOpsList, err := c.KubeanClusterOpsSet.KubeanclusteropsV1alpha1().KuBeanClusterOps().List(context.Background(), listOpt)
-	if err != nil {
-		return false, err
-	}
-	if len(clusterOpsList.Items) <= OpsBackupNum {
-		return false, nil
-	}
-
-	// clusterOps list sort by creation timestamp
-	sort.Slice(clusterOpsList.Items, func(i, j int) bool {
-		return clusterOpsList.Items[i].CreationTimestamp.After(clusterOpsList.Items[j].CreationTimestamp.Time)
-	})
-	excessClusterOpsList := clusterOpsList.Items[OpsBackupNum:]
-	for _, item := range excessClusterOpsList {
-		klog.Warningf("Delete KuBeanClusterOps: name: %s, createTime: %s", item.Name, item.CreationTimestamp.String())
-		c.KubeanClusterOpsSet.KubeanclusteropsV1alpha1().KuBeanClusterOps().Delete(context.Background(), item.Name, metav1.DeleteOptions{})
-	}
-	return true, nil
-}
-
 func (c *Controller) SetupWithManager(mgr controllerruntime.Manager) error {
 	return utilerrors.NewAggregate([]error{
 		controllerruntime.NewControllerManagedBy(mgr).For(&kubeanclusteropsv1alpha1.KuBeanClusterOps{}).Complete(c),
diff --git a/pkg/util/entrypoint/entrypoint.go b/pkg/util/entrypoint/entrypoint.go
index 7ad936783..55a29d6a4 100644
--- a/pkg/util/entrypoint/entrypoint.go
+++ b/pkg/util/entrypoint/entrypoint.go
@@ -105,6 +105,14 @@ func NewActions() *Actions {
 	return actions
 }
 
+type ArgsError struct {
+	msg string
+}
+
+func (argsError ArgsError) Error() string {
+	return argsError.msg
+}
+
 type EntryPoint struct {
 	PreHookCMDs  []string
 	SprayCMD     string
@@ -120,7 +128,7 @@ func NewEntryPoint() *EntryPoint {
 
 func (ep *EntryPoint) buildPlaybookCmd(action, extraArgs string, isPrivateKey bool) (string, error) {
 	if _, ok := ep.Actions.Playbooks.Dict[action]; !ok {
-		return "", fmt.Errorf("unknown playbook type, the currently supported ranges include: %s", ep.Actions.Playbooks.List)
+		return "", ArgsError{fmt.Sprintf("unknown playbook type, the currently supported ranges include: %s", ep.Actions.Playbooks.List)}
 	}
 	playbookCmd := "ansible-playbook -i /conf/hosts.yml -b --become-user root -e \"@/conf/group_vars.yml\""
 	if isPrivateKey {
@@ -144,13 +152,13 @@ func (ep *EntryPoint) hookRunPart(actionType, action, extraArgs string, isPrivat
 	if actionType == PBAction {
 		playbookCmd, err := ep.buildPlaybookCmd(action, extraArgs, isPrivateKey)
 		if err != nil {
-			return "", fmt.Errorf("buildPlaybookCmd: %w", err)
+			return "", ArgsError{fmt.Sprintf("buildPlaybookCmd: %s", err)}
 		}
 		hookRunCmd = playbookCmd
 	} else if actionType == SHAction {
 		hookRunCmd = action
 	} else {
-		return "", fmt.Errorf("unknown action type, the currently supported ranges include: %s", ep.Actions.Types)
+		return "", ArgsError{fmt.Sprintf("unknown action type, the currently supported ranges include: %s", ep.Actions.Types)}
 	}
 	return hookRunCmd, nil
 }
@@ -158,7 +166,7 @@ func (ep *EntryPoint) hookRunPart(actionType, action, extraArgs string, isPrivat
 func (ep *EntryPoint) PreHookRunPart(actionType, action, extraArgs string, isPrivateKey bool) error {
 	prehook, err := ep.hookRunPart(actionType, action, extraArgs, isPrivateKey)
 	if err != nil {
-		return fmt.Errorf("prehook: %w", err)
+		return ArgsError{fmt.Sprintf("prehook: %s", err)}
 	}
 	ep.PreHookCMDs = append(ep.PreHookCMDs, prehook)
 	return nil
@@ -167,7 +175,7 @@ func (ep *EntryPoint) PreHookRunPart(actionType, action, extraArgs string, isPri
 func (ep *EntryPoint) PostHookRunPart(actionType, action, extraArgs string, isPrivateKey bool) error {
 	posthook, err := ep.hookRunPart(actionType, action, extraArgs, isPrivateKey)
 	if err != nil {
-		return fmt.Errorf("posthook: %w", err)
+		return ArgsError{fmt.Sprintf("posthook: %s", err)}
 	}
 	ep.PostHookCMDs = append(ep.PostHookCMDs, posthook)
 	return nil
@@ -191,7 +199,7 @@ func (ep *EntryPoint) kubeconfPostbackPart(action string, isPrivateKey bool) err
 	}
 	posthook, err := ep.hookRunPart(SHAction, script, "", false)
 	if err != nil {
-		return fmt.Errorf("posthook: %w", err)
+		return ArgsError{fmt.Sprintf("posthook: %s", err)}
 	}
 	ep.PostHookCMDs = append(ep.PostHookCMDs, posthook)
 	return nil
@@ -201,16 +209,16 @@ func (ep *EntryPoint) SprayRunPart(actionType, action, extraArgs string, isPriva
 	if actionType == PBAction {
 		playbookCmd, err := ep.buildPlaybookCmd(action, extraArgs, isPrivateKey)
 		if err != nil {
-			return fmt.Errorf("buildPlaybookCmd: %w", err)
+			return ArgsError{fmt.Sprintf("buildPlaybookCmd: %s", err)}
 		}
 		ep.SprayCMD = playbookCmd
 	} else if actionType == SHAction {
 		ep.SprayCMD = action
 	} else {
-		return fmt.Errorf("unknown action type, the currently supported ranges include: %s", ep.Actions.Types)
+		return ArgsError{fmt.Sprintf("unknown action type, the currently supported ranges include: %s", ep.Actions.Types)}
 	}
 	if err := ep.kubeconfPostbackPart(action, isPrivateKey); err != nil {
-		return fmt.Errorf("failed to set kubeconfig postback: %s", err)
+		return ArgsError{fmt.Sprintf("failed to set kubeconfig postback: %s", err)}
 	}
 	return nil
 }


Test patch:
diff --git a/pkg/controllers/clusterops/controller_test.go b/pkg/controllers/clusterops/controller_test.go
index 7e049b30e..d3718a1f1 100644
--- a/pkg/controllers/clusterops/controller_test.go
+++ b/pkg/controllers/clusterops/controller_test.go
@@ -609,3 +609,59 @@ func TestCurrentJobNeedBlock(t *testing.T) {
 		})
 	}
 }
+
+func TestIsValidImageName(t *testing.T) {
+	testCases := []struct {
+		name string
+		args string
+		want bool
+	}{
+		{
+			name: "empty string",
+			args: "",
+			want: false,
+		},
+		{
+			name: "space string",
+			args: " ",
+			want: false,
+		},
+		{
+			name: "underscore string",
+			args: "_",
+			want: false,
+		},
+		{
+			name: "dot string",
+			args: ".",
+			want: false,
+		},
+		{
+			name: "one letter",
+			args: "a",
+			want: true,
+		},
+		{
+			name: "ubuntu",
+			args: "ubuntu",
+			want: true,
+		},
+		{
+			name: "ubuntu with tag",
+			args: "ubuntu:14.01",
+			want: true,
+		},
+		{
+			name: "valid image name",
+			args: "ghcr.io/kubean-io/kubean/spray-job:latest",
+			want: true,
+		},
+	}
+	for _, testCase := range testCases {
+		t.Run(testCase.name, func(t *testing.T) {
+			if IsValidImageName(testCase.args) != testCase.want {
+				t.Fatal()
+			}
+		})
+	}
+}