mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 00:17:39 +01:00
Improve host agent binary handling and docker installer purge (Related to #693)
This commit is contained in:
318
cmd/pulse/agent_binaries.go
Normal file
318
cmd/pulse/agent_binaries.go
Normal file
@@ -0,0 +1,318 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type hostAgentBinary struct {
|
||||
platform string
|
||||
arch string
|
||||
filenames []string
|
||||
}
|
||||
|
||||
var requiredHostAgentBinaries = []hostAgentBinary{
|
||||
{platform: "linux", arch: "amd64", filenames: []string{"pulse-host-agent-linux-amd64"}},
|
||||
{platform: "linux", arch: "arm64", filenames: []string{"pulse-host-agent-linux-arm64"}},
|
||||
{platform: "linux", arch: "armv7", filenames: []string{"pulse-host-agent-linux-armv7"}},
|
||||
{platform: "darwin", arch: "amd64", filenames: []string{"pulse-host-agent-darwin-amd64"}},
|
||||
{platform: "darwin", arch: "arm64", filenames: []string{"pulse-host-agent-darwin-arm64"}},
|
||||
{
|
||||
platform: "windows",
|
||||
arch: "amd64",
|
||||
filenames: []string{"pulse-host-agent-windows-amd64", "pulse-host-agent-windows-amd64.exe"},
|
||||
},
|
||||
{
|
||||
platform: "windows",
|
||||
arch: "arm64",
|
||||
filenames: []string{"pulse-host-agent-windows-arm64", "pulse-host-agent-windows-arm64.exe"},
|
||||
},
|
||||
{
|
||||
platform: "windows",
|
||||
arch: "386",
|
||||
filenames: []string{"pulse-host-agent-windows-386", "pulse-host-agent-windows-386.exe"},
|
||||
},
|
||||
}
|
||||
|
||||
func validateAgentBinaries() {
|
||||
binDirs := hostAgentSearchPaths()
|
||||
missing := findMissingHostAgentBinaries(binDirs)
|
||||
if len(missing) == 0 {
|
||||
log.Info().Msg("All host agent binaries available for download")
|
||||
return
|
||||
}
|
||||
|
||||
missingPlatforms := make([]string, 0, len(missing))
|
||||
for key := range missing {
|
||||
missingPlatforms = append(missingPlatforms, key)
|
||||
}
|
||||
sort.Strings(missingPlatforms)
|
||||
|
||||
log.Warn().
|
||||
Strs("missing_platforms", missingPlatforms).
|
||||
Msg("Host agent binaries missing - attempting to download bundle from GitHub release")
|
||||
|
||||
if err := downloadAndInstallHostAgentBinaries(binDirs[0]); err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Str("target_dir", binDirs[0]).
|
||||
Strs("missing_platforms", missingPlatforms).
|
||||
Msg("Failed to automatically install host agent binaries; install script downloads will fail")
|
||||
return
|
||||
}
|
||||
|
||||
remaining := findMissingHostAgentBinaries(binDirs)
|
||||
if len(remaining) == 0 {
|
||||
log.Info().Msg("Host agent binaries restored from GitHub release bundle")
|
||||
return
|
||||
}
|
||||
|
||||
stillMissing := make([]string, 0, len(remaining))
|
||||
for key := range remaining {
|
||||
stillMissing = append(stillMissing, key)
|
||||
}
|
||||
sort.Strings(stillMissing)
|
||||
log.Warn().
|
||||
Strs("missing_platforms", stillMissing).
|
||||
Msg("Host agent binaries still missing after automatic restoration attempt")
|
||||
}
|
||||
|
||||
func hostAgentSearchPaths() []string {
|
||||
primary := strings.TrimSpace(os.Getenv("PULSE_BIN_DIR"))
|
||||
if primary == "" {
|
||||
primary = "/opt/pulse/bin"
|
||||
}
|
||||
|
||||
dirs := []string{primary, "./bin", "."}
|
||||
seen := make(map[string]struct{}, len(dirs))
|
||||
result := make([]string, 0, len(dirs))
|
||||
for _, dir := range dirs {
|
||||
clean := filepath.Clean(dir)
|
||||
if _, ok := seen[clean]; ok {
|
||||
continue
|
||||
}
|
||||
seen[clean] = struct{}{}
|
||||
result = append(result, clean)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func findMissingHostAgentBinaries(binDirs []string) map[string]hostAgentBinary {
|
||||
missing := make(map[string]hostAgentBinary)
|
||||
for _, binary := range requiredHostAgentBinaries {
|
||||
if !hostAgentBinaryExists(binDirs, binary.filenames) {
|
||||
key := fmt.Sprintf("%s-%s", binary.platform, binary.arch)
|
||||
missing[key] = binary
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
func hostAgentBinaryExists(binDirs, filenames []string) bool {
|
||||
for _, dir := range binDirs {
|
||||
for _, name := range filenames {
|
||||
path := filepath.Join(dir, name)
|
||||
if info, err := os.Stat(path); err == nil && !info.IsDir() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func downloadAndInstallHostAgentBinaries(targetDir string) error {
|
||||
version := strings.TrimSpace(Version)
|
||||
if version == "" || strings.EqualFold(version, "dev") {
|
||||
return fmt.Errorf("cannot download host agent bundle for non-release version %q", version)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(targetDir, 0o755); err != nil {
|
||||
return fmt.Errorf("failed to ensure bin directory %s: %w", targetDir, err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://github.com/rcourtman/Pulse/releases/download/%[1]s/pulse-%[1]s.tar.gz", version)
|
||||
tempFile, err := os.CreateTemp("", "pulse-host-agent-*.tar.gz")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temporary archive file: %w", err)
|
||||
}
|
||||
defer os.Remove(tempFile.Name())
|
||||
|
||||
client := &http.Client{Timeout: 2 * time.Minute}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download host agent bundle from %s: %w", url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return fmt.Errorf("unexpected status %d downloading %s: %s", resp.StatusCode, url, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
if _, err := io.Copy(tempFile, resp.Body); err != nil {
|
||||
return fmt.Errorf("failed to save host agent bundle: %w", err)
|
||||
}
|
||||
|
||||
if err := tempFile.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close temporary bundle file: %w", err)
|
||||
}
|
||||
|
||||
if err := extractHostAgentBinaries(tempFile.Name(), targetDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractHostAgentBinaries(archivePath, targetDir string) error {
|
||||
file, err := os.Open(archivePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open host agent bundle: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
gzReader, err := gzip.NewReader(file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create gzip reader: %w", err)
|
||||
}
|
||||
defer gzReader.Close()
|
||||
|
||||
tr := tar.NewReader(gzReader)
|
||||
type pendingLink struct {
|
||||
path string
|
||||
target string
|
||||
}
|
||||
var symlinks []pendingLink
|
||||
|
||||
for {
|
||||
header, err := tr.Next()
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
return fmt.Errorf("failed to read host agent bundle: %w", err)
|
||||
}
|
||||
|
||||
if header == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if header.Typeflag != tar.TypeReg && header.Typeflag != tar.TypeRegA && header.Typeflag != tar.TypeSymlink {
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(header.Name, "bin/") {
|
||||
continue
|
||||
}
|
||||
|
||||
base := path.Base(header.Name)
|
||||
if !strings.HasPrefix(base, "pulse-host-agent-") {
|
||||
continue
|
||||
}
|
||||
|
||||
destPath := filepath.Join(targetDir, base)
|
||||
|
||||
switch header.Typeflag {
|
||||
case tar.TypeReg, tar.TypeRegA:
|
||||
if err := writeHostAgentFile(destPath, tr, header.FileInfo().Mode()); err != nil {
|
||||
return err
|
||||
}
|
||||
case tar.TypeSymlink:
|
||||
symlinks = append(symlinks, pendingLink{
|
||||
path: destPath,
|
||||
target: header.Linkname,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for _, link := range symlinks {
|
||||
if err := os.Remove(link.path); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to replace existing symlink %s: %w", link.path, err)
|
||||
}
|
||||
if err := os.Symlink(link.target, link.path); err != nil {
|
||||
// Fallback: copy the referenced file if symlinks are not permitted
|
||||
source := filepath.Join(targetDir, link.target)
|
||||
if err := copyHostAgentFile(source, link.path); err != nil {
|
||||
return fmt.Errorf("failed to create symlink %s -> %s: %w", link.path, link.target, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeHostAgentFile(destination string, reader io.Reader, mode os.FileMode) error {
|
||||
if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create directory for %s: %w", destination, err)
|
||||
}
|
||||
|
||||
tmpFile, err := os.CreateTemp(filepath.Dir(destination), "pulse-host-agent-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temporary file for %s: %w", destination, err)
|
||||
}
|
||||
defer os.Remove(tmpFile.Name())
|
||||
|
||||
if _, err := io.Copy(tmpFile, reader); err != nil {
|
||||
tmpFile.Close()
|
||||
return fmt.Errorf("failed to extract %s: %w", destination, err)
|
||||
}
|
||||
|
||||
if err := tmpFile.Chmod(normalizeExecutableMode(mode)); err != nil {
|
||||
tmpFile.Close()
|
||||
return fmt.Errorf("failed to set permissions on %s: %w", destination, err)
|
||||
}
|
||||
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
return fmt.Errorf("failed to finalize %s: %w", destination, err)
|
||||
}
|
||||
|
||||
if err := os.Rename(tmpFile.Name(), destination); err != nil {
|
||||
return fmt.Errorf("failed to install %s: %w", destination, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyHostAgentFile(source, destination string) error {
|
||||
src, err := os.Open(source)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open %s for fallback copy: %w", source, err)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil {
|
||||
return fmt.Errorf("failed to prepare directory for %s: %w", destination, err)
|
||||
}
|
||||
|
||||
dst, err := os.OpenFile(destination, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create fallback copy %s: %w", destination, err)
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
if _, err := io.Copy(dst, src); err != nil {
|
||||
return fmt.Errorf("failed to copy %s to %s: %w", source, destination, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeExecutableMode(mode os.FileMode) os.FileMode {
|
||||
perms := mode.Perm()
|
||||
if perms&0o111 == 0 {
|
||||
perms |= 0o755
|
||||
}
|
||||
return (mode &^ os.ModePerm) | perms
|
||||
}
|
||||
@@ -353,46 +353,3 @@ func performAutoImport() error {
|
||||
log.Info().Msg("Configuration auto-imported successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateAgentBinaries checks if agent binaries are available for download
|
||||
// and logs warnings if any are missing
|
||||
func validateAgentBinaries() {
|
||||
binDirs := []string{"/opt/pulse/bin", "./bin", "."}
|
||||
platforms := []struct {
|
||||
name string
|
||||
file string
|
||||
}{
|
||||
{"linux-amd64", "pulse-host-agent-linux-amd64"},
|
||||
{"linux-arm64", "pulse-host-agent-linux-arm64"},
|
||||
{"linux-armv7", "pulse-host-agent-linux-armv7"},
|
||||
{"darwin-amd64", "pulse-host-agent-darwin-amd64"},
|
||||
{"darwin-arm64", "pulse-host-agent-darwin-arm64"},
|
||||
{"windows-amd64", "pulse-host-agent-windows-amd64"},
|
||||
}
|
||||
|
||||
missing := []string{}
|
||||
searchedPaths := []string{}
|
||||
|
||||
for _, platform := range platforms {
|
||||
found := false
|
||||
for _, dir := range binDirs {
|
||||
path := filepath.Join(dir, platform.file)
|
||||
searchedPaths = append(searchedPaths, path)
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
missing = append(missing, platform.name)
|
||||
}
|
||||
}
|
||||
|
||||
if len(missing) > 0 {
|
||||
log.Warn().
|
||||
Strs("missing_platforms", missing).
|
||||
Msg("Host agent binaries missing - install script downloads will fail. Rebuild Docker image or run build-release.sh to generate all platform binaries.")
|
||||
} else {
|
||||
log.Info().Msg("All host agent binaries available for download")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,18 @@ curl -fSL http://pulse.example.com/install-docker-agent.sh -o /tmp/pulse-install
|
||||
rm -f /tmp/pulse-install-docker-agent.sh
|
||||
```
|
||||
|
||||
### Removing the agent
|
||||
|
||||
Re-run the installer with `--uninstall` to stop the service, delete the systemd unit, and keep the logs for later review:
|
||||
|
||||
```bash
|
||||
curl -fSL http://pulse.example.com/install-docker-agent.sh -o /tmp/pulse-install-docker-agent.sh && \
|
||||
sudo bash /tmp/pulse-install-docker-agent.sh --uninstall && \
|
||||
rm -f /tmp/pulse-install-docker-agent.sh
|
||||
```
|
||||
|
||||
Add `--purge` when you also want the installer to remove `/var/log/pulse-docker-agent`, the env/config files under `/etc/pulse`, and the `pulse-docker` service account. Purging cleans up `/etc/passwd` so container processes that run as UID 999 stop showing up as the `pulse-docker` user in `ps` output.
|
||||
|
||||
### Quick install for Podman (system service)
|
||||
|
||||
Use the multi-runtime installer when you want the agent to run against Podman as a systemd service. The script takes care of enabling `podman.socket`, creating a dedicated service account, and wiring the correct runtime socket automatically:
|
||||
|
||||
@@ -23,6 +23,8 @@ Pulse protects the initial Quick Security Setup screen with a one-time bootstrap
|
||||
| Docker container | `/data/.bootstrap_token` inside the container or the mounted host volume |
|
||||
| Helm / Kubernetes | The persistent volume mounted at `/data` |
|
||||
|
||||
> **Tip for LXC deployments:** If your Pulse container runs inside an LXC with a custom hostname (common with docker-compose/systemd units), export `PULSE_LXC_CTID=<ctid>` in the service environment. The setup wizard falls back to this variable when it cannot auto-detect the numeric CTID, so the on-screen instructions show `pct exec <ctid>` with the correct value instead of a placeholder.
|
||||
|
||||
**For Proxmox Quick Install (LXC):**
|
||||
The installer creates an LXC container, so the token is inside the container, not on the PVE host. Use one of these commands from your Proxmox host:
|
||||
```bash
|
||||
|
||||
@@ -365,6 +365,11 @@ ensure_service_user() {
|
||||
if [[ -n "$existing_home" ]]; then
|
||||
SERVICE_HOME="$existing_home"
|
||||
fi
|
||||
if ! service_user_managed_by_installer; then
|
||||
if [[ "$SERVICE_HOME" == "$SERVICE_HOME_DEFAULT" ]] || [[ "$SERVICE_HOME" == "$SERVICE_HOME_LEGACY" ]] || [[ "$SERVICE_HOME" == "/home/$SERVICE_USER" ]]; then
|
||||
write_service_user_marker "$SERVICE_USER" "$SERVICE_HOME"
|
||||
fi
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
@@ -398,6 +403,7 @@ ensure_service_user() {
|
||||
fi
|
||||
if [[ "$SERVICE_USER_CREATED" == "true" ]]; then
|
||||
log_success "Created service user: $SERVICE_USER"
|
||||
write_service_user_marker "$SERVICE_USER_ACTUAL" "$SERVICE_HOME"
|
||||
fi
|
||||
return
|
||||
fi
|
||||
@@ -444,6 +450,78 @@ get_user_home_path() {
|
||||
fi
|
||||
}
|
||||
|
||||
write_service_user_marker() {
|
||||
local user="$1"
|
||||
local home="$2"
|
||||
local dir
|
||||
dir=$(dirname "$SERVICE_USER_MARKER")
|
||||
mkdir -p "$dir"
|
||||
|
||||
local tmp
|
||||
if command -v mktemp >/dev/null 2>&1; then
|
||||
tmp=$(mktemp "${SERVICE_USER_MARKER}.XXXXXX")
|
||||
else
|
||||
tmp="${SERVICE_USER_MARKER}.tmp.$$"
|
||||
fi
|
||||
{
|
||||
printf 'user=%s\n' "$user"
|
||||
if [[ -n "$home" ]]; then
|
||||
printf 'home=%s\n' "$home"
|
||||
fi
|
||||
} > "$tmp"
|
||||
chmod 600 "$tmp" >/dev/null 2>&1 || true
|
||||
mv "$tmp" "$SERVICE_USER_MARKER"
|
||||
}
|
||||
|
||||
service_user_managed_by_installer() {
|
||||
if [[ ! -f "$SERVICE_USER_MARKER" ]]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
local recorded_user
|
||||
recorded_user=$(grep -m1 '^user=' "$SERVICE_USER_MARKER" 2>/dev/null | cut -d= -f2-)
|
||||
if [[ "$recorded_user" == "$SERVICE_USER" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
remove_service_user_if_managed() {
|
||||
if ! id -u "$SERVICE_USER" >/dev/null 2>&1; then
|
||||
rm -f "$SERVICE_USER_MARKER" >/dev/null 2>&1 || true
|
||||
return
|
||||
fi
|
||||
|
||||
if ! service_user_managed_by_installer; then
|
||||
log_info "Preserving existing service user $SERVICE_USER (not created by installer)"
|
||||
return
|
||||
fi
|
||||
|
||||
if command -v pgrep >/dev/null 2>&1; then
|
||||
if pgrep -u "$SERVICE_USER" >/dev/null 2>&1; then
|
||||
log_warn "Service user $SERVICE_USER still owns running processes; skipping removal"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
if command -v userdel >/dev/null 2>&1; then
|
||||
if userdel -r "$SERVICE_USER" >/dev/null 2>&1; then
|
||||
log_success "Removed service user: $SERVICE_USER"
|
||||
rm -f "$SERVICE_USER_MARKER" >/dev/null 2>&1 || true
|
||||
return
|
||||
fi
|
||||
elif command -v deluser >/dev/null 2>&1; then
|
||||
if deluser --remove-home "$SERVICE_USER" >/dev/null 2>&1; then
|
||||
log_success "Removed service user: $SERVICE_USER"
|
||||
rm -f "$SERVICE_USER_MARKER" >/dev/null 2>&1 || true
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
log_warn "Failed to remove service user $SERVICE_USER; remove manually if desired"
|
||||
}
|
||||
|
||||
ensure_snap_home_compatibility() {
|
||||
if [[ "$SERVICE_USER_ACTUAL" == "root" ]]; then
|
||||
return
|
||||
@@ -486,11 +564,21 @@ ensure_snap_home_compatibility() {
|
||||
detect_snap_docker() {
|
||||
SNAP_DOCKER_DETECTED="false"
|
||||
|
||||
# Check if the active docker binary resolves to a snap path
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
local docker_path
|
||||
docker_path=$(readlink -f "$(command -v docker)" 2>/dev/null || echo "")
|
||||
if [[ "$docker_path" == /snap/* ]] || [[ "$docker_path" == /var/lib/snapd/snap/* ]]; then
|
||||
local docker_cmd docker_resolved
|
||||
docker_cmd="$(command -v docker 2>/dev/null || true)"
|
||||
if [[ -n "$docker_cmd" ]]; then
|
||||
if [[ "$docker_cmd" == /snap/* ]] || [[ "$docker_cmd" == /var/lib/snapd/snap/* ]]; then
|
||||
SNAP_DOCKER_DETECTED="true"
|
||||
fi
|
||||
|
||||
docker_resolved=$(readlink -f "$docker_cmd" 2>/dev/null || echo "")
|
||||
if [[ "$docker_resolved" == /snap/* ]] || [[ "$docker_resolved" == /var/lib/snapd/snap/* ]]; then
|
||||
SNAP_DOCKER_DETECTED="true"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$SNAP_DOCKER_DETECTED" != "true" ]] && command -v snap >/dev/null 2>&1; then
|
||||
if snap list docker >/dev/null 2>&1; then
|
||||
SNAP_DOCKER_DETECTED="true"
|
||||
fi
|
||||
fi
|
||||
@@ -960,6 +1048,7 @@ SYSTEMD_SUPPLEMENTARY_GROUPS_LINE=""
|
||||
SNAP_DOCKER_DETECTED="false"
|
||||
SNAP_DOCKER_GROUP_CREATED="false"
|
||||
DOCKER_SOCKET_PATHS="/var/run/docker.sock"
|
||||
SERVICE_USER_MARKER="/etc/pulse/.pulse-docker-agent.user"
|
||||
|
||||
# Parse arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
@@ -1304,6 +1393,7 @@ if [ "$UNINSTALL" = true ]; then
|
||||
else
|
||||
log_info "Agent log file already absent: $LOG_PATH"
|
||||
fi
|
||||
remove_service_user_if_managed
|
||||
if [[ -n "$SERVICE_HOME" && "$SERVICE_HOME" != "/" && -d "$SERVICE_HOME" ]]; then
|
||||
rm -rf "$SERVICE_HOME"
|
||||
log_success "Removed service home directory: $SERVICE_HOME"
|
||||
|
||||
Reference in New Issue
Block a user