Repository: kubeclipper/kubeclipper
Pull request #741 (2024-07-31 11:01:16)

Add a systemctl util and apply it to container runtimes
### What type of PR is this?

/kind feature

### What this PR does / why we need it:

Add a systemctl util package and apply it to container runtimes deployment.

### Does this PR introduced a user-facing change?

```release-note
NONE
```

kubeclipper-bot: [APPROVALNOTIFIER] This PR is **NOT APPROVED**

This pull-request has been approved by: *<a href="https://github.com/kubeclipper/kubeclipper/pull/741#" title="Author self-approved">crazytaxii</a>*
To complete the [pull request process](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process), please assign **wu-wenxiang** after the PR has been reviewed.
You can assign the PR to them by writing `/assign @wu-wenxiang` in a comment when ready.

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=kubeclipper%2Fkubeclipper).

<details open>
Needs approval from an approver in each of these files:

- **[OWNERS](https://github.com/kubeclipper/kubeclipper/blob/master/OWNERS)**

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":["wu-wenxiang"]} -->
crazytaxii: /assign @lixd
kubeclipper-bot: [APPROVALNOTIFIER] This PR is **NOT APPROVED**

This pull-request has been approved by: *<a href="https://github.com/kubeclipper/kubeclipper/pull/741#" title="Author self-approved">crazytaxii</a>*
To complete the [pull request process](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process), please ask for approval from **lixd** after the PR has been reviewed.

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=kubeclipper%2Fkubeclipper).

<details open>
Needs approval from an approver in each of these files:

- **[OWNERS](https://github.com/kubeclipper/kubeclipper/blob/master/OWNERS)**

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":["lixd"]} -->
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/kubeclipper/kubeclipper/pull/741?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubeclipper) Report
Attention: Patch coverage is `33.62069%` with `77 lines` in your changes missing coverage. Please review.
> Project coverage is 14.59%. Comparing base [(`42cfd9c`)](https://app.codecov.io/gh/kubeclipper/kubeclipper/commit/42cfd9c52f3865068fa2b3eac80ddb402a1e4b7b?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubeclipper) to head [(`8143983`)](https://app.codecov.io/gh/kubeclipper/kubeclipper/commit/8143983744c8eab624710fadd6df1bab92c64694?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubeclipper).
> Report is 6 commits behind head on master.

| [Files](https://app.codecov.io/gh/kubeclipper/kubeclipper/pull/741?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubeclipper) | Patch % | Lines |
|---|---|---|
| [pkg/utils/systemctl/systemctl.go](https://app.codecov.io/gh/kubeclipper/kubeclipper/pull/741?src=pr&el=tree&filepath=pkg%2Futils%2Fsystemctl%2Fsystemctl.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubeclipper#diff-cGtnL3V0aWxzL3N5c3RlbWN0bC9zeXN0ZW1jdGwuZ28=) | 42.39% | [43 Missing and 10 partials :warning: ](https://app.codecov.io/gh/kubeclipper/kubeclipper/pull/741?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubeclipper) |
| [pkg/scheme/core/v1/cri/containerd.go](https://app.codecov.io/gh/kubeclipper/kubeclipper/pull/741?src=pr&el=tree&filepath=pkg%2Fscheme%2Fcore%2Fv1%2Fcri%2Fcontainerd.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubeclipper#diff-cGtnL3NjaGVtZS9jb3JlL3YxL2NyaS9jb250YWluZXJkLmdv) | 0.00% | [24 Missing :warning: ](https://app.codecov.io/gh/kubeclipper/kubeclipper/pull/741?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubeclipper) |

<details><summary>Additional details and impacted files</summary>


[![Impacted file tree graph](https://app.codecov.io/gh/kubeclipper/kubeclipper/pull/741/graphs/tree.svg?width=650&height=150&src=pr&token=3PWFG2MHTE&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubeclipper)](https://app.codecov.io/gh/kubeclipper/kubeclipper/pull/741?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubeclipper)

```diff
@@            Coverage Diff             @@
##           master     #741      +/-   ##
==========================================
+ Coverage   14.45%   14.59%   +0.13%     
==========================================
  Files         142      143       +1     
  Lines       16745    16838      +93     
==========================================
+ Hits         2421     2457      +36     
- Misses      14032    14079      +47     
- Partials      292      302      +10     
```

| [Files](https://app.codecov.io/gh/kubeclipper/kubeclipper/pull/741?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubeclipper) | Coverage Δ | |
|---|---|---|
| [pkg/scheme/core/v1/cri/containerd.go](https://app.codecov.io/gh/kubeclipper/kubeclipper/pull/741?src=pr&el=tree&filepath=pkg%2Fscheme%2Fcore%2Fv1%2Fcri%2Fcontainerd.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubeclipper#diff-cGtnL3NjaGVtZS9jb3JlL3YxL2NyaS9jb250YWluZXJkLmdv) | `12.88% <0.00%> (-0.24%)` | :arrow_down: |
| [pkg/utils/systemctl/systemctl.go](https://app.codecov.io/gh/kubeclipper/kubeclipper/pull/741?src=pr&el=tree&filepath=pkg%2Futils%2Fsystemctl%2Fsystemctl.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubeclipper#diff-cGtnL3V0aWxzL3N5c3RlbWN0bC9zeXN0ZW1jdGwuZ28=) | `42.39% <42.39%> (ø)` | |

</details>
kubeclipper-bot: LGTM label has been added.  <details>Git tree hash: dc8b226ae412fe069cfcbfd16bf657fa6e7e3d1c</details>
kubeclipper-bot: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/kubeclipper/kubeclipper/pull/741#" title="Author self-approved">crazytaxii</a>*, *<a href="https://github.com/kubeclipper/kubeclipper/pull/741#pullrequestreview-2214307890" title="Approved">lixd</a>*

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=kubeclipper%2Fkubeclipper).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[OWNERS](https://github.com/kubeclipper/kubeclipper/blob/master/OWNERS)~~ [lixd]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->

Patch:
diff --git a/go.mod b/go.mod
index fbfebdc..8422e4d 100644
--- a/go.mod
+++ b/go.mod
@@ -6,6 +6,7 @@ require (
 	github.com/Masterminds/sprig/v3 v3.2.2
 	github.com/containerd/containerd v1.6.19
 	github.com/coreos/go-oidc/v3 v3.6.0
+	github.com/coreos/go-systemd/v22 v22.3.2
 	github.com/dlclark/regexp2 v1.11.2
 	github.com/emicklei/go-restful v2.9.6+incompatible
 	github.com/emicklei/go-restful-openapi v0.0.0-00010101000000-000000000000
@@ -92,7 +93,6 @@ require (
 	github.com/containerd/ttrpc v1.1.0 // indirect
 	github.com/containerd/typeurl v1.0.2 // indirect
 	github.com/coreos/go-semver v0.3.0 // indirect
-	github.com/coreos/go-systemd/v22 v22.3.2 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
 	github.com/docker/cli v24.0.0+incompatible // indirect
@@ -118,6 +118,7 @@ require (
 	github.com/go-openapi/swag v0.19.14 // indirect
 	github.com/go-stack/stack v1.8.0 // indirect
 	github.com/gobwas/glob v0.2.3 // indirect
+	github.com/godbus/dbus/v5 v5.0.6 // indirect
 	github.com/gogo/googleapis v1.4.0 // indirect
 	github.com/gogo/protobuf v1.3.2 // indirect
 	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
diff --git a/go.sum b/go.sum
index e80f2a1..3b6551a 100644
--- a/go.sum
+++ b/go.sum
@@ -744,6 +744,7 @@ github.com/godbus/dbus v0.0.0-20180201030542-885f9cc04c9c/go.mod h1:/YcGZj5zSblf
 github.com/godbus/dbus v0.0.0-20190422162347-ade71ed3457e/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4=
 github.com/godbus/dbus/v5 v5.0.3/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
 github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/godbus/dbus/v5 v5.0.6 h1:mkgN1ofwASrYnJ5W6U/BxG15eXXXjirgZc7CLqkcaro=
 github.com/godbus/dbus/v5 v5.0.6/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
 github.com/gogo/googleapis v1.2.0/go.mod h1:Njal3psf3qN6dwBtQfUmBZh2ybovJ0tlu3o/AC7HYjU=
 github.com/gogo/googleapis v1.4.0 h1:zgVt4UpGxcqVOw97aRGxT4svlcmdK35fynLNctY32zI=
diff --git a/pkg/scheme/core/v1/cri/containerd.go b/pkg/scheme/core/v1/cri/containerd.go
index bd1e1f3..72ab6b7 100644
--- a/pkg/scheme/core/v1/cri/containerd.go
+++ b/pkg/scheme/core/v1/cri/containerd.go
@@ -43,9 +43,14 @@ import (
 	"github.com/kubeclipper/kubeclipper/pkg/utils/cmdutil"
 	"github.com/kubeclipper/kubeclipper/pkg/utils/fileutil"
 	"github.com/kubeclipper/kubeclipper/pkg/utils/strutil"
+	"github.com/kubeclipper/kubeclipper/pkg/utils/systemctl"
 	tmplutil "github.com/kubeclipper/kubeclipper/pkg/utils/template"
 )
 
+const (
+	containerdSystemdUnitName = "containerd.service"
+)
+
 type ContainerdRunnable struct {
 	Base
 	RegistryConfigDir   string `json:"registryConfigDir"`
@@ -166,9 +171,8 @@ func (runnable ContainerdRunnable) Install(ctx context.Context, opts component.O
 }
 
 func (runnable ContainerdRunnable) Uninstall(ctx context.Context, opts component.Options) ([]byte, error) {
-	if err := runnable.disableContainerdService(ctx, opts.DryRun); err != nil {
-		return nil, err
-	}
+	runnable.disableContainerdService(ctx, opts.DryRun)
+
 	// remove related binary configuration files
 	instance, err := downloader.NewInstance(ctx, criContainerd, runnable.Version, runtime.GOARCH, !runnable.Offline, opts.DryRun)
 	if err != nil {
@@ -193,6 +197,10 @@ func (runnable ContainerdRunnable) Uninstall(ctx context.Context, opts component
 	if err = os.RemoveAll(containerdDefaultDataDir); err == nil {
 		logger.Debug("remove containerd systemd config successfully")
 	}
+	// reload systemd daemon
+	if err = systemctl.ReloadDeamon(ctx); err != nil {
+		logger.Warn("failed to reload systemd daemon", zap.Error(err))
+	}
 	logger.Debug("uninstall containerd successfully")
 	return nil, nil
 }
@@ -241,32 +249,42 @@ func (runnable *ContainerdRunnable) setupContainerdConfig(ctx context.Context, d
 }
 
 func (runnable *ContainerdRunnable) enableContainerdService(ctx context.Context, dryRun bool) error {
-	_, err := cmdutil.RunCmdWithContext(ctx, dryRun, "systemctl", "daemon-reload")
-	if err != nil {
+	if dryRun {
+		logger.Debugf("dry run enable and restart systemd unit %s", containerdSystemdUnitName)
+		return nil
+	}
+
+	if err := systemctl.ReloadDeamon(ctx); err != nil {
 		return err
 	}
-	_, err = cmdutil.RunCmdWithContext(ctx, dryRun, "systemctl", "enable", "containerd")
-	if err != nil {
+	logger.Debug("reload systemd daemon successfully")
+	if err := systemctl.EnableUnit(ctx, containerdSystemdUnitName); err != nil {
 		return err
 	}
-	// restart containerd to active config, if it is already running
-	_, err = cmdutil.RunCmdWithContext(ctx, dryRun, "systemctl", "restart", "containerd")
-	if err != nil {
+	logger.Debugf("enable systemd unit %s successfully", containerdSystemdUnitName)
+	if err := systemctl.RestartUnit(ctx, containerdSystemdUnitName); err != nil {
 		return err
 	}
-	logger.Debug("enable containerd systemd service successfully")
+	logger.Debugf("restart systemd unit %s successfully", containerdSystemdUnitName)
 	return nil
 }
 
-func (runnable *ContainerdRunnable) disableContainerdService(ctx context.Context, dryRun bool) error {
-	// the following command execution error is ignored
-	if _, err := cmdutil.RunCmdWithContext(ctx, dryRun, "systemctl", "stop", "containerd"); err != nil {
-		logger.Warn("stop systemd containerd service failed", zap.Error(err))
+func (runnable *ContainerdRunnable) disableContainerdService(ctx context.Context, dryRun bool) {
+	if dryRun {
+		logger.Debugf("dry run stop and disable systemd unit %s", containerdSystemdUnitName)
+		return
+	}
+
+	if err := systemctl.StopUnit(ctx, containerdSystemdUnitName); err != nil {
+		logger.Warnf("failed to stop systemd unit %s", containerdSystemdUnitName, zap.Error(err))
+	} else {
+		logger.Debugf("stop systemd unit %s successfully", containerdSystemdUnitName)
 	}
-	if _, err := cmdutil.RunCmdWithContext(ctx, dryRun, "systemctl", "disable", "containerd"); err != nil {
-		logger.Warn("disable systemd containerd service failed", zap.Error(err))
+	if err := systemctl.DisableUnit(ctx, containerdSystemdUnitName); err != nil {
+		logger.Warnf("failed to disable systemd unit %s", containerdSystemdUnitName, zap.Error(err))
+	} else {
+		logger.Debugf("disable systemd unit %s successfully", containerdSystemdUnitName)
 	}
-	return nil
 }
 
 func (runnable *ContainerdRunnable) renderTo(w io.Writer) error {
diff --git a/pkg/scheme/core/v1/cri/docker.go b/pkg/scheme/core/v1/cri/docker.go
index 71817b5..2834a08 100644
--- a/pkg/scheme/core/v1/cri/docker.go
+++ b/pkg/scheme/core/v1/cri/docker.go
@@ -38,12 +38,16 @@ import (
 	"github.com/kubeclipper/kubeclipper/pkg/logger"
 	v1 "github.com/kubeclipper/kubeclipper/pkg/scheme/core/v1"
 	"github.com/kubeclipper/kubeclipper/pkg/simple/downloader"
-	"github.com/kubeclipper/kubeclipper/pkg/utils/cmdutil"
 	"github.com/kubeclipper/kubeclipper/pkg/utils/fileutil"
 	"github.com/kubeclipper/kubeclipper/pkg/utils/strutil"
+	"github.com/kubeclipper/kubeclipper/pkg/utils/systemctl"
 	tmplutil "github.com/kubeclipper/kubeclipper/pkg/utils/template"
 )
 
+const (
+	dockerSystemdUnitName = "docker.service"
+)
+
 type DockerRunnable struct {
 	Base
 	InsecureRegistry []string `json:"insecureRegistry,omitempty"`
@@ -147,9 +151,8 @@ func (runnable DockerRunnable) Install(ctx context.Context, opts component.Optio
 }
 
 func (runnable DockerRunnable) Uninstall(ctx context.Context, opts component.Options) ([]byte, error) {
-	if err := runnable.disableDockerService(ctx, opts.DryRun); err != nil {
-		return nil, err
-	}
+	runnable.disableDockerService(ctx, opts.DryRun)
+
 	// remove related binary configuration files
 	instance, err := downloader.NewInstance(ctx, criDocker, runnable.Version, runtime.GOARCH, !runnable.Offline, opts.DryRun)
 	if err != nil {
@@ -176,6 +179,11 @@ func (runnable DockerRunnable) Uninstall(ctx context.Context, opts component.Opt
 	if err = os.RemoveAll(dockerDefaultConfigDir); err == nil {
 		logger.Debug("remove docker config dir successfully")
 	}
+	// reload systemd daemon
+	if err = systemctl.ReloadDeamon(ctx); err != nil {
+		logger.Warn("failed to reload systemd daemon", zap.Error(err))
+	}
+	logger.Debug("uninstall docker successfully")
 	return nil, nil
 }
 
@@ -202,26 +210,42 @@ func (runnable *DockerRunnable) renderTo(w io.Writer) error {
 }
 
 func (runnable *DockerRunnable) enableDockerService(ctx context.Context, dryRun bool) error {
-	_, err := cmdutil.RunCmdWithContext(ctx, dryRun, "systemctl", "daemon-reload")
-	if err != nil {
+	if dryRun {
+		logger.Debugf("dry run enable systemd unit %s", dockerSystemdUnitName)
+		return nil
+	}
+
+	if err := systemctl.ReloadDeamon(ctx); err != nil {
 		return err
 	}
-	_, err = cmdutil.RunCmdWithContext(ctx, dryRun, "systemctl", "enable", "docker", "--now")
-	if err != nil {
+	logger.Debugf("reload systemd daemon successfully")
+	if err := systemctl.EnableUnit(ctx, dockerSystemdUnitName); err != nil {
 		return err
 	}
+	logger.Debugf("enable systemd unit %s successfully", dockerSystemdUnitName)
+	if err := systemctl.RestartUnit(ctx, dockerSystemdUnitName); err != nil {
+		return err
+	}
+	logger.Debugf("restart systemd unit %s successfully", dockerSystemdUnitName)
 	return nil
 }
 
-func (runnable *DockerRunnable) disableDockerService(ctx context.Context, dryRun bool) error {
-	// the following command execution error is ignored
-	if _, err := cmdutil.RunCmdWithContext(ctx, dryRun, "systemctl", "stop", "docker"); err != nil {
-		logger.Warn("stop systemd docker service failed", zap.Error(err))
+func (runnable *DockerRunnable) disableDockerService(ctx context.Context, dryRun bool) {
+	if dryRun {
+		logger.Debugf("dry run stop and disable systemd unit %s", dockerSystemdUnitName)
+		return
+	}
+
+	if err := systemctl.StopUnit(ctx, dockerSystemdUnitName); err != nil {
+		logger.Warnf("failed to stop systemd unit %s", dockerSystemdUnitName, zap.Error(err))
+	} else {
+		logger.Debugf("stop systemd unit %s successfully", dockerSystemdUnitName)
 	}
-	if _, err := cmdutil.RunCmdWithContext(ctx, dryRun, "systemctl", "disable", "docker"); err != nil {
-		logger.Warn("disable systemd docker service failed", zap.Error(err))
+	if err := systemctl.DisableUnit(ctx, dockerSystemdUnitName); err != nil {
+		logger.Warnf("failed to disable systemd unit %s", dockerSystemdUnitName, zap.Error(err))
+	} else {
+		logger.Debugf("disable systemd unit %s successfully", dockerSystemdUnitName)
 	}
-	return nil
 }
 
 type DockerInsecureRegistryConfigure struct {
@@ -268,9 +292,8 @@ func (d *DockerInsecureRegistryConfigure) Install(ctx context.Context, opts comp
 		if err != nil {
 			return nil, fmt.Errorf("write config file to: %s :%w", configFile, err)
 		}
-		_, err = cmdutil.RunCmdWithContext(ctx, opts.DryRun, "bash", "-c", "systemctl reload docker")
-		if err != nil {
-			return nil, fmt.Errorf("reload docker:%w", err)
+		if err := systemctl.ReloadUnit(ctx, dockerSystemdUnitName); err != nil {
+			return nil, err
 		}
 	}
 	return nil, nil
diff --git a/pkg/utils/systemctl/systemctl.go b/pkg/utils/systemctl/systemctl.go
new file mode 100644
index 0000000..39683d9
--- /dev/null
+++ b/pkg/utils/systemctl/systemctl.go
@@ -0,0 +1,201 @@
+package systemctl
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/coreos/go-systemd/v22/dbus"
+
+	"github.com/kubeclipper/kubeclipper/pkg/logger"
+)
+
+const (
+	defaultJobMode = "replace"
+
+	loadStateNotFound   = "not-found"
+	activeStateInactive = "inactive"
+	activeStateActive   = "active"
+)
+
+// lookupUnitStatus returns the status of the unit if it exists.
+// equivalent to `systemctl show <unit-name> --property=ActiveState`
+func lookupUnitStatus(ctx context.Context, conn *dbus.Conn, name string) (*dbus.UnitStatus, error) {
+	units, err := conn.ListUnitsByNamesContext(ctx, []string{name})
+	if err != nil {
+		return nil, fmt.Errorf("failed to lookup unit %s: %w", name, err)
+	}
+	if len(units) == 0 || units[0].LoadState == loadStateNotFound {
+		return nil, nil
+	}
+	return &units[0], nil
+}
+
+// ReloadDeamon reloads the systemd daemon.
+// equivalent to `systemctl daemon-reload`
+func ReloadDeamon(ctx context.Context) error {
+	conn, err := dbus.NewWithContext(ctx)
+	if err != nil {
+		return fmt.Errorf("failed to connect dbus: %w", err)
+	}
+	defer conn.Close()
+
+	return conn.ReloadContext(ctx)
+}
+
+// EnableUnit enables the unit if it exists.
+// equivalent to `systemctl enable <unit-name> --force`
+func EnableUnit(ctx context.Context, name string) error {
+	conn, err := dbus.NewWithContext(ctx)
+	if err != nil {
+		return fmt.Errorf("failed to connect dbus: %w", err)
+	}
+	defer conn.Close()
+
+	us, err := lookupUnitStatus(ctx, conn, name)
+	if err != nil {
+		return err
+	}
+	if us == nil {
+		return fmt.Errorf("unit %s not found", name)
+	}
+
+	if _, _, err = conn.EnableUnitFilesContext(ctx, []string{name}, false, true); err != nil {
+		return fmt.Errorf("failed to enable unit %s: %w", name, err)
+	}
+
+	return nil
+}
+
+// DisableUnit disables the unit if it exists.
+// equivalent to `systemctl disable <unit-name> --force`
+func DisableUnit(ctx context.Context, name string) error {
+	conn, err := dbus.NewWithContext(ctx)
+	if err != nil {
+		return fmt.Errorf("failed to connect dbus: %w", err)
+	}
+	defer conn.Close()
+
+	us, err := lookupUnitStatus(ctx, conn, name)
+	if err != nil || us == nil {
+		return err
+	}
+
+	if _, err = conn.DisableUnitFilesContext(ctx, []string{name}, false); err != nil {
+		return fmt.Errorf("failed to disable unit %s: %w", name, err)
+	}
+
+	return nil
+}
+
+// StartUnit starts a unit idempotently if it exists.
+// equivalent to `systemctl start <unit-name>`
+func StartUnit(ctx context.Context, name string) error {
+	conn, err := dbus.NewWithContext(ctx)
+	if err != nil {
+		return fmt.Errorf("failed to connect dbus: %w", err)
+	}
+	defer conn.Close()
+
+	us, err := lookupUnitStatus(ctx, conn, name)
+	if err != nil {
+		return err
+	}
+	if us == nil {
+		return fmt.Errorf("unit %s not found", name)
+	}
+	if us.ActiveState == activeStateActive {
+		logger.Debugf("unit %s is already active", name)
+		return nil
+	}
+
+	res := make(chan string)
+	if _, err := conn.StartUnitContext(ctx, name, defaultJobMode, res); err != nil {
+		return fmt.Errorf("failed to start unit %s: %w", name, err)
+	}
+	if result := <-res; result != "done" {
+		return fmt.Errorf("failed to start unit %s: %s", name, result)
+	}
+
+	return nil
+}
+
+// RestartUnit restarts a unit idempotently only if it exists.
+// equivalent to `systemctl restart <unit-name>`
+func RestartUnit(ctx context.Context, name string) error {
+	conn, err := dbus.NewWithContext(ctx)
+	if err != nil {
+		return fmt.Errorf("failed to connect dbus: %w", err)
+	}
+	defer conn.Close()
+
+	us, err := lookupUnitStatus(ctx, conn, name)
+	if err != nil {
+		return err
+	}
+	if us == nil {
+		return fmt.Errorf("unit %s not found", name)
+	}
+
+	res := make(chan string)
+	if _, err := conn.RestartUnitContext(ctx, name, defaultJobMode, res); err != nil {
+		return fmt.Errorf("failed to restart unit %s: %w", name, err)
+	}
+	if result := <-res; result != "done" {
+		return fmt.Errorf("failed to restart unit %s: %s", name, result)
+	}
+
+	return nil
+}
+
+// StartUnit stops a unit idempotently if it exists.
+// equivalent to `systemctl stop <unit-name>`
+func StopUnit(ctx context.Context, name string) error {
+	conn, err := dbus.NewWithContext(ctx)
+	if err != nil {
+		return fmt.Errorf("failed to connect dbus: %w", err)
+	}
+	defer conn.Close()
+
+	us, err := lookupUnitStatus(ctx, conn, name)
+	if err != nil {
+		return err
+	}
+	if us == nil {
+		logger.Warnf("unit %s not found", name)
+		return nil
+	}
+	if us.ActiveState == activeStateInactive {
+		logger.Debugf("unit %s is already inactive", name)
+		return nil
+	}
+
+	res := make(chan string)
+	if _, err := conn.StopUnitContext(ctx, name, defaultJobMode, res); err != nil {
+		return fmt.Errorf("failed to stop unit %s: %w", name, err)
+	}
+	if result := <-res; result != "done" {
+		return fmt.Errorf("failed to stop unit %s: %s", name, result)
+	}
+
+	return nil
+}
+
+// ReloadUnit reloads a unit.
+// equivalent to `systemctl reload <unit-name>`
+func ReloadUnit(ctx context.Context, name string) error {
+	conn, err := dbus.NewWithContext(ctx)
+	if err != nil {
+		return fmt.Errorf("failed to connect dbus: %w", err)
+	}
+	defer conn.Close()
+
+	res := make(chan string)
+	if _, err := conn.ReloadUnitContext(ctx, name, defaultJobMode, res); err != nil {
+		return fmt.Errorf("failed to reload unit %s: %w", name, err)
+	}
+	if result := <-res; result != "done" {
+		return fmt.Errorf("failed to reload unit %s: %s", name, result)
+	}
+
+	return nil
+}


Test patch:
diff --git a/pkg/utils/systemctl/systemctl_test.go b/pkg/utils/systemctl/systemctl_test.go
new file mode 100644
index 0000000..0e7e5bc
--- /dev/null
+++ b/pkg/utils/systemctl/systemctl_test.go
@@ -0,0 +1,294 @@
+package systemctl
+
+import (
+	"context"
+	"testing"
+
+	"github.com/coreos/go-systemd/v22/dbus"
+)
+
+func Test_lookupUnitStatus(t *testing.T) {
+	ctx := context.Background()
+	conn, err := dbus.NewWithContext(ctx)
+	if err != nil {
+		t.Errorf("failed to connect dbus: %v", err)
+		return
+	}
+	defer conn.Close()
+
+	type args struct {
+		ctx  context.Context
+		unit string
+	}
+	tests := []struct {
+		name       string
+		args       args
+		wantStatus bool
+		wantErr    bool
+	}{
+		{
+			name: "case1",
+			args: args{
+				ctx:  context.Background(),
+				unit: "systemd-journald.service",
+			},
+			wantStatus: true,
+			wantErr:    false,
+		},
+		{
+			name: "case2",
+			args: args{
+				ctx:  context.Background(),
+				unit: "foo",
+			},
+			wantStatus: false,
+			wantErr:    false,
+		},
+		{
+			name: "case3",
+			args: args{
+				ctx:  context.Background(),
+				unit: "systemd-journald",
+			},
+			wantStatus: false,
+			wantErr:    false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			status, err := lookupUnitStatus(ctx, conn, tt.args.unit)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("lookupUnitStatus() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+			if (status != nil) != tt.wantStatus {
+				t.Errorf("lookupUnitStatus() = %v, hasStatus %v", status, tt.wantStatus)
+			}
+		})
+	}
+}
+
+func TestStartUnit(t *testing.T) {
+	type args struct {
+		ctx  context.Context
+		unit string
+	}
+	tests := []struct {
+		name    string
+		args    args
+		wantErr bool
+	}{
+		{
+			name: "case1",
+			args: args{
+				ctx:  context.Background(),
+				unit: "systemd-journald.service",
+			},
+			wantErr: false,
+		},
+		{
+			name: "case2",
+			args: args{
+				ctx:  context.Background(),
+				unit: "systemd-journald",
+			},
+			wantErr: true,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if err := StartUnit(tt.args.ctx, tt.args.unit); (err != nil) != tt.wantErr {
+				t.Errorf("StartUnit() error = %v, wantErr %v", err, tt.wantErr)
+			}
+		})
+	}
+}
+
+func TestStopUnit(t *testing.T) {
+	type args struct {
+		ctx  context.Context
+		unit string
+	}
+	tests := []struct {
+		name    string
+		args    args
+		wantErr bool
+	}{
+		{
+			name: "case1",
+			args: args{
+				ctx:  context.Background(),
+				unit: "systemd-journald",
+			},
+			wantErr: false,
+		},
+		// {
+		// 	name: "case2",
+		// 	args: args{
+		// 		ctx:  context.Background(),
+		// 		unit: "sshd.service",
+		// 	},
+		// 	wantErr: false,
+		// },
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if err := StopUnit(tt.args.ctx, tt.args.unit); (err != nil) != tt.wantErr {
+				t.Errorf("StopUnit() error = %v, wantErr %v", err, tt.wantErr)
+			}
+		})
+	}
+}
+
+func TestEnableUnit(t *testing.T) {
+	type args struct {
+		ctx  context.Context
+		unit string
+	}
+	tests := []struct {
+		name    string
+		args    args
+		wantErr bool
+	}{
+		{
+			name: "case1",
+			args: args{
+				ctx:  context.Background(),
+				unit: "foo",
+			},
+			wantErr: true,
+		},
+		// {
+		// 	name: "case2",
+		// 	args: args{
+		// 		ctx:  context.Background(),
+		// 		unit: "sshd.service",
+		// 	},
+		// 	wantErr: false,
+		// },
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if err := EnableUnit(tt.args.ctx, tt.args.unit); (err != nil) != tt.wantErr {
+				t.Errorf("EnableUnit() error = %v, wantErr %v", err, tt.wantErr)
+			}
+		})
+	}
+}
+
+func TestDisableUnit(t *testing.T) {
+	type args struct {
+		ctx  context.Context
+		unit string
+	}
+	tests := []struct {
+		name    string
+		args    args
+		wantErr bool
+	}{
+		{
+			name: "case1",
+			args: args{
+				ctx:  context.Background(),
+				unit: "foo",
+			},
+			wantErr: false,
+		},
+		// {
+		// 	name: "case2",
+		// 	args: args{
+		// 		ctx:  context.Background(),
+		// 		unit: "sshd.service",
+		// 	},
+		// 	wantErr: false,
+		// },
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if err := DisableUnit(tt.args.ctx, tt.args.unit); (err != nil) != tt.wantErr {
+				t.Errorf("DisableUnit() error = %v, wantErr %v", err, tt.wantErr)
+			}
+		})
+	}
+}
+
+func TestRestartUnit(t *testing.T) {
+	type args struct {
+		ctx  context.Context
+		unit string
+	}
+	tests := []struct {
+		name    string
+		args    args
+		wantErr bool
+	}{
+		{
+			name: "case1",
+			args: args{
+				ctx:  context.Background(),
+				unit: "foo",
+			},
+			wantErr: true,
+		},
+		// {
+		// 	name: "case2",
+		// 	args: args{
+		// 		ctx:  context.Background(),
+		// 		unit: "sshd.service",
+		// 	},
+		// 	wantErr: false,
+		// },
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if err := RestartUnit(tt.args.ctx, tt.args.unit); (err != nil) != tt.wantErr {
+				t.Errorf("RestartUnit() error = %v, wantErr %v", err, tt.wantErr)
+			}
+		})
+	}
+}
+
+func TestReloadUnit(t *testing.T) {
+	type args struct {
+		ctx  context.Context
+		unit string
+	}
+	tests := []struct {
+		name    string
+		args    args
+		wantErr bool
+	}{
+		{
+			name: "case1",
+			args: args{
+				ctx:  context.Background(),
+				unit: "foo",
+			},
+			wantErr: true,
+		},
+		{
+			name: "case2",
+			args: args{
+				ctx:  context.Background(),
+				unit: "chronyd.service",
+			},
+			wantErr: true,
+		},
+		// {
+		// 	name: "case3",
+		// 	args: args{
+		// 		ctx:  context.Background(),
+		// 		unit: "sshd.service",
+		// 	},
+		// 	wantErr: false,
+		// },
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if err := ReloadUnit(tt.args.ctx, tt.args.unit); (err != nil) != tt.wantErr {
+				t.Errorf("ReloadUnit() error = %v, wantErr %v", err, tt.wantErr)
+			}
+		})
+	}
+}