feat: 工具配置增强、站点拨测与日志中心多项改进

- 工具配置:新增卸载 API/UI、Go 工具链、流水线构建自动安装依赖
- 修复 ListTools VerifyTool 递归栈溢出导致获取工具配置失败
- Node.js Alpine 安装改用 unofficial-builds musl 预编译包与 index.json 解析 LTS
- 站点拨测:详情页路由、告警自动恢复、并发拨测与默认可观测频率
- 日志中心:RBAC、查询构建器、ES/Loki 增强与审计 request/response 展示
- 发布配置:构建节点、流水线远程执行、Deploy RBAC 导航拆分
This commit is contained in:
Logan committed 2026-07-02 07:47:33 +08:00
1 parent cde7bbe540
commit 7e1c8f583c
84 files changed
+7729 -1507

No files matched your search

+246 -39
View File
@@ -1,6 +1,7 @@
package toolinstaller
import (
"bytes"
"fmt"
"io"
"log"
@@ -45,15 +46,26 @@ func (ti *ToolInstaller) ensureOnce(name string) *sync.Once {
return o
}
// ForceInstall unconditionally installs a tool (bypasses sync.Once).
// Used by the manual "安装" button in the tool config UI.
// The version parameter overrides DefaultVersion if non-empty.
func (ti *ToolInstaller) ForceInstall(toolName string, version string) error {
// InstallResult 工具安装结果(含脚本输出,供审计日志)// Logan.Li
type InstallResult struct {
Output string
VerifyOut string
Installed bool
Version string
Err error
}
// ForceInstall unconditionally installs a tool(重置 Once,允许重复安装)// Logan.Li
func (ti *ToolInstaller) ForceInstall(toolName string, version string) InstallResult {
var tool model.DeployTool
if err := ti.db.Where("name = ?", toolName).First(&tool).Error; err != nil {
return fmt.Errorf("tool %s not found in DB: %w", toolName, err)
return InstallResult{Err: fmt.Errorf("tool %s not found in DB: %w", toolName, err)}
}
ti.mu.Lock()
delete(ti.onces, toolName)
ti.mu.Unlock()
// Override version if specified
if version != "" && version != tool.DefaultVersion {
ti.db.Model(&tool).Update("default_version", version)
@@ -101,7 +113,8 @@ func (ti *ToolInstaller) EnsureAvailable(toolName string) error {
var installErr error
once := ti.ensureOnce(toolName)
once.Do(func() {
installErr = ti.installTool(&tool)
res := ti.installTool(&tool)
installErr = res.Err
})
// If install failed, reset the Once so next call can retry
if installErr != nil {
@@ -115,70 +128,206 @@ func (ti *ToolInstaller) EnsureAvailable(toolName string) error {
}
}
// checkInstalled runs the tool's check command and returns the version string.
func (ti *ToolInstaller) checkInstalled(tool model.DeployTool) (string, error) {
// First try LookPath
if tool.BinaryName != "" {
// VerifyTool 检测工具是否已安装(文件存在 + 可执行验证,不依赖 PATH)// Logan.Li
func (ti *ToolInstaller) VerifyTool(tool model.DeployTool) (bool, string) {
switch tool.Name {
case "jdk":
for _, p := range []string{"/usr/local/bin/java", "/usr/local/share/jdk/bin/java"} {
if st, err := os.Stat(p); err == nil && !st.IsDir() {
cmd := exec.Command(p, "-version")
out, _ := cmd.CombinedOutput()
ver := strings.TrimSpace(strings.Split(string(out), "\n")[0])
if ver != "" {
return true, ver
}
return true, "installed"
}
}
case "maven":
for _, p := range []string{"/usr/local/bin/mvn", "/usr/local/share/maven/bin/mvn"} {
if st, err := os.Stat(p); err == nil && !st.IsDir() {
cmd := exec.Command(p, "--version")
out, err := cmd.Output()
if err == nil {
return true, strings.TrimSpace(strings.Split(string(out), "\n")[0])
}
return true, "installed"
}
}
case "nvm":
nvmSh := "/usr/local/share/nvm/nvm.sh"
if st, err := os.Stat(nvmSh); err == nil && !st.IsDir() {
cmd := exec.Command("bash", "-c", fmt.Sprintf(`. "%s" && nvm --version`, nvmSh))
out, err := cmd.Output()
if err == nil {
return true, strings.TrimSpace(string(out))
}
return true, tool.DefaultVersion
}
case "golang":
for _, p := range []string{"/usr/local/bin/go", "/usr/local/go/bin/go"} {
if st, err := os.Stat(p); err == nil && !st.IsDir() {
cmd := exec.Command(p, "version")
out, _ := cmd.CombinedOutput()
ver := strings.TrimSpace(string(out))
if ver != "" {
return true, ver
}
return true, "installed"
}
}
case "nodejs":
for _, p := range []string{"/usr/local/bin/node"} {
if st, err := os.Stat(p); err == nil && !st.IsDir() {
cmd := exec.Command(p, "--version")
out, _ := cmd.CombinedOutput()
ver := strings.TrimSpace(string(out))
if ver != "" {
return true, ver
}
return true, "installed"
}
}
default:
if ver, err := ti.runToolCheck(tool); err == nil {
return true, ver
}
}
return false, ""
}
// SyncToolStatus 根据磁盘实际状态刷新 DB(修复审计成功但页面仍失败/安装中)// Logan.Li
func (ti *ToolInstaller) SyncToolStatus(toolID uint) model.DeployTool {
var tool model.DeployTool
if err := ti.db.First(&tool, toolID).Error; err != nil {
return tool
}
ok, ver := ti.VerifyTool(tool)
if ok {
ti.updateStatus(tool.ID, model.ToolInstalled, ver, "")
tool.Status = model.ToolInstalled
tool.InstalledVer = ver
tool.ErrorMsg = ""
} else if tool.Status == model.ToolInstalling {
// 保持安装中,由超时逻辑处理
} else if tool.Status == model.ToolInstalled {
// 已标记安装但文件不存在 — 降为未安装
ti.updateStatus(tool.ID, model.ToolNotInstalled, "", "")
tool.Status = model.ToolNotInstalled
}
ti.db.First(&tool, toolID)
return tool
}
// runToolCheck 执行 PATH/检测命令(不回调 VerifyTool,避免递归栈溢出)// Logan.Li
func (ti *ToolInstaller) runToolCheck(tool model.DeployTool) (string, error) {
if tool.BinaryName != "" && tool.BinaryName != "nvm" {
if _, err := exec.LookPath(tool.BinaryName); err != nil {
return "", fmt.Errorf("binary %s not found", tool.BinaryName)
}
}
// Run check command if provided
if tool.CheckCommand != "" {
cmd := exec.Command("sh", "-c", tool.CheckCommand)
shell := "sh"
if tool.Name == "nvm" || tool.Name == "nodejs" {
shell = "bash"
}
cmd := exec.Command(shell, "-c", tool.CheckCommand)
out, err := cmd.Output()
if err != nil {
return "", err
}
return strings.TrimSpace(strings.Split(string(out), "\n")[0]), nil
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
if len(lines) == 0 {
return "", fmt.Errorf("tool %s check empty output", tool.Name)
}
return strings.TrimSpace(lines[0]), nil
}
return "", fmt.Errorf("tool %s not available", tool.Name)
}
return "unknown", nil
// checkInstalled runs the tool's check command and returns the version string.
func (ti *ToolInstaller) checkInstalled(tool model.DeployTool) (string, error) {
if ok, ver := ti.VerifyTool(tool); ok {
return ver, nil
}
return ti.runToolCheck(tool)
}
// installTool performs the actual installation.
func (ti *ToolInstaller) installTool(tool *model.DeployTool) error {
func (ti *ToolInstaller) installTool(tool *model.DeployTool) InstallResult {
log.Printf("[ToolInstaller] Installing %s (method=%s)...", tool.Name, tool.InstallMethod)
// Mark as installing
ti.updateStatus(tool.ID, model.ToolInstalling, "", "")
var installOut string
var err error
if tool.InstallScript != "" {
err = ti.runInstallScript(tool)
installOut, err = ti.runInstallScript(tool)
} else if tool.DownloadURL != "" {
err = ti.downloadBinary(tool)
installOut, err = ti.downloadBinary(tool)
} else {
err = fmt.Errorf("no install script or download URL configured for %s", tool.Name)
}
if err != nil {
errMsg := err.Error()
if installOut != "" {
errMsg = installOut + "\n---\n" + errMsg
}
log.Printf("[ToolInstaller] ERROR installing %s: %s", tool.Name, errMsg)
ti.updateStatus(tool.ID, model.ToolFailed, "", errMsg)
return fmt.Errorf("failed to install %s: %w", tool.Name, err)
ti.updateStatus(tool.ID, model.ToolFailed, "", truncateInstallLog(errMsg, 512))
return InstallResult{Output: installOut, Err: fmt.Errorf("failed to install %s: %w", tool.Name, err)}
}
// Verify installation
ver, checkErr := ti.checkInstalled(*tool)
if checkErr != nil {
log.Printf("[ToolInstaller] WARNING: %s installed but check failed: %v", tool.Name, checkErr)
ti.updateStatus(tool.ID, model.ToolInstalled, "unknown", "")
} else {
// 安装后验证(优先文件检测,避免 PATH/source 问题)// Logan.Li
verifyOut := ti.captureVerifyOutput(*tool)
if ok, ver := ti.VerifyTool(*tool); ok {
log.Printf("[ToolInstaller] %s installed successfully: %s", tool.Name, ver)
ti.updateStatus(tool.ID, model.ToolInstalled, ver, "")
return InstallResult{Output: installOut, VerifyOut: verifyOut, Installed: true, Version: ver}
}
log.Printf("[ToolInstaller] WARNING: %s script ok but verify failed", tool.Name)
failMsg := "安装脚本执行完成但验证未通过,请点击「测试」重新检测"
ti.updateStatus(tool.ID, model.ToolFailed, "", failMsg)
return InstallResult{
Output: installOut, VerifyOut: verifyOut,
Err: fmt.Errorf("verify failed after install %s", tool.Name),
}
}
return nil
// captureVerifyOutput 执行验证命令并捕获输出(供审计)// Logan.Li
func (ti *ToolInstaller) captureVerifyOutput(tool model.DeployTool) string {
if tool.CheckCommand == "" {
return ""
}
shell := "sh"
if tool.Name == "nvm" || tool.Name == "nodejs" {
shell = "bash"
}
cmd := exec.Command(shell, "-c", tool.CheckCommand)
out, err := cmd.CombinedOutput()
text := strings.TrimSpace(string(out))
if err != nil && text == "" {
return err.Error()
}
return text
}
func truncateInstallLog(s string, n int) string {
s = strings.TrimSpace(s)
if len(s) > n {
return s[:n] + "..."
}
return s
}
// runInstallScript executes the tool's install script in a subshell.
func (ti *ToolInstaller) runInstallScript(tool *model.DeployTool) error {
func (ti *ToolInstaller) runInstallScript(tool *model.DeployTool) (string, error) {
// Write script to temp file
tmpFile, err := os.CreateTemp("", fmt.Sprintf("tool-install-%s-*.sh", tool.Name))
if err != nil {
return fmt.Errorf("cannot create temp script: %w", err)
return "", fmt.Errorf("cannot create temp script: %w", err)
}
defer os.Remove(tmpFile.Name())
@@ -189,21 +338,28 @@ func (ti *ToolInstaller) runInstallScript(tool *model.DeployTool) error {
script := fmt.Sprintf("#!/bin/sh\nset -e\nTOOL_VERSION=%s\n%s\n", version, tool.InstallScript)
if _, err := tmpFile.WriteString(script); err != nil {
return fmt.Errorf("cannot write temp script: %w", err)
return "", fmt.Errorf("cannot write temp script: %w", err)
}
tmpFile.Close()
os.Chmod(tmpFile.Name(), 0755)
cmd := exec.Command("sh", tmpFile.Name())
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
shell := "sh"
if tool.Name == "nvm" || tool.Name == "nodejs" {
shell = "bash"
}
cmd := exec.Command(shell, tmpFile.Name())
var buf bytes.Buffer
// 同时输出到容器日志便于排障 // Logan.Li
cmd.Stdout = io.MultiWriter(os.Stdout, &buf)
cmd.Stderr = io.MultiWriter(os.Stderr, &buf)
err = cmd.Run()
return buf.String(), err
}
// downloadBinary downloads a binary file to the install path.
// Uses multi-mirror fallback: tries each mirror URL in order, with HEAD verification
// to skip dead mirrors before attempting a full download.
func (ti *ToolInstaller) downloadBinary(tool *model.DeployTool) error {
func (ti *ToolInstaller) downloadBinary(tool *model.DeployTool) (string, error) {
version := tool.DefaultVersion
if version == "" {
// Try to get latest version
@@ -223,24 +379,28 @@ func (ti *ToolInstaller) downloadBinary(tool *model.DeployTool) error {
// Resolve mirror URLs — domestic mirrors prioritized when in China
mirrorURLs := GetMirrorsForTool(tool.Name, tool.DownloadURL)
if len(mirrorURLs) == 0 {
return fmt.Errorf("no download URLs configured for %s", tool.Name)
return "", fmt.Errorf("no download URLs configured for %s", tool.Name)
}
// Try each mirror in order
var lastErr error
var logBuf strings.Builder
for i, urlTemplate := range mirrorURLs {
url := strings.ReplaceAll(urlTemplate, "{version}", version)
log.Printf("[ToolInstaller] Downloading %s from mirror[%d]: %s", tool.Name, i, url)
logBuf.WriteString(fmt.Sprintf("[%d] %s\n", i, url))
err := ti.tryDownload(url, tool)
if err == nil {
return nil // success
logBuf.WriteString(fmt.Sprintf("OK: downloaded from mirror[%d]\n", i))
return logBuf.String(), nil
}
lastErr = err
logBuf.WriteString(fmt.Sprintf("FAIL mirror[%d]: %v\n", i, err))
log.Printf("[ToolInstaller] Mirror[%d] failed for %s: %v, trying next...", i, tool.Name, err)
}
return fmt.Errorf("all %d mirrors exhausted for %s, last error: %w", len(mirrorURLs), tool.Name, lastErr)
return logBuf.String(), fmt.Errorf("all %d mirrors exhausted for %s, last error: %w", len(mirrorURLs), tool.Name, lastErr)
}
// tryDownload attempts a single download from a URL.
@@ -314,10 +474,12 @@ func (ti *ToolInstaller) updateStatus(toolID uint, status model.ToolStatus, vers
"last_check": &now,
}
if version != "" {
updates["installed_version"] = version
updates["installed_ver"] = version // 与 GORM 列名一致 // Logan.Li
}
if errMsg != "" {
updates["error_msg"] = errMsg
} else if status == model.ToolInstalled {
updates["error_msg"] = ""
}
ti.db.Model(&model.DeployTool{}).Where("id = ?", toolID).Updates(updates)
}
@@ -393,4 +555,49 @@ func (ti *ToolInstaller) UpgradeTools() {
"description": up.description,
})
}
// 补全新增工具(如 golang)并校正排序 // Logan.Li
now := time.Now()
for _, def := range model.PredefinedTools() {
var existing model.DeployTool
if err := ti.db.Where("name = ?", def.Name).First(&existing).Error; err != nil {
def.CreatedAt = now
def.UpdatedAt = now
def.Status = model.ToolNotInstalled
if err := ti.db.Create(&def).Error; err != nil {
log.Printf("[ToolInstaller] Failed to seed missing tool %s: %v", def.Name, err)
} else {
log.Printf("[ToolInstaller] Added missing tool: %s", def.Name)
}
}
}
sortBumps := map[string]int{"jdk": 1, "maven": 2, "golang": 3, "nvm": 4, "nodejs": 5, "git": 6, "docker-cli": 7}
for name, order := range sortBumps {
ti.db.Model(&model.DeployTool{}).Where("name = ?", name).Update("sort_order", order)
}
// 同步 JDK/Maven/Go/NVM 安装脚本与检测命令修复 // Logan.Li
type toolPatch struct {
script string
check string
}
patchSync := map[string]toolPatch{}
for _, t := range model.PredefinedTools() {
if t.Name == "jdk" || t.Name == "maven" || t.Name == "golang" || t.Name == "nvm" || t.Name == "nodejs" {
patchSync[t.Name] = toolPatch{script: t.InstallScript, check: t.CheckCommand}
}
}
for name, p := range patchSync {
updates := map[string]interface{}{
"install_script": p.script,
"check_command": p.check,
}
if name == "nodejs" {
updates["default_version"] = "lts"
}
if name == "golang" {
updates["default_version"] = "1.24.0"
}
ti.db.Model(&model.DeployTool{}).Where("name = ?", name).Updates(updates)
}
}
+6
View File
@@ -46,6 +46,12 @@ var toolMirrors = map[string][]ToolMirror{
{Name: "Helm官方", URL: "https://get.helm.sh/helm-{version}-linux-amd64.tar.gz", Location: "global"},
{Name: "阿里云镜像", URL: "https://mirrors.aliyun.com/kubernetes/helm/{version}/helm-{version}-linux-amd64.tar.gz", Location: "domestic"},
},
"golang": {
{Name: "Go官方", URL: "https://go.dev/dl/go{version}.linux-amd64.tar.gz", Location: "global"},
{Name: "Google CN", URL: "https://golang.google.cn/dl/go{version}.linux-amd64.tar.gz", Location: "domestic"},
{Name: "阿里云镜像", URL: "https://mirrors.aliyun.com/golang/go{version}.linux-amd64.tar.gz", Location: "domestic"},
{Name: "中科大镜像", URL: "https://mirrors.ustc.edu.cn/golang/go{version}.linux-amd64.tar.gz", Location: "domestic"},
},
}
// GetMirrorsForTool 返回工具的镜像源列表(按网络环境排序)。
+193
View File
@@ -0,0 +1,193 @@
package toolinstaller
import (
"bytes"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"ezops/model"
)
// UninstallResult 工具卸载结果(含脚本输出,供审计日志)// Logan.Li
type UninstallResult struct {
Output string
Err error
}
// Uninstall 卸载指定工具并重置 DB 状态(允许重新安装)// Logan.Li
func (ti *ToolInstaller) Uninstall(toolName string) UninstallResult {
var tool model.DeployTool
if err := ti.db.Where("name = ?", toolName).First(&tool).Error; err != nil {
return UninstallResult{Err: fmt.Errorf("tool %s not found in DB: %w", toolName, err)}
}
ti.mu.Lock()
delete(ti.onces, toolName)
ti.mu.Unlock()
var buf bytes.Buffer
if err := ti.uninstallTool(&tool, &buf); err != nil {
out := strings.TrimSpace(buf.String())
log.Printf("[ToolInstaller] ERROR uninstalling %s: %v", toolName, err)
ti.resetUninstallStatus(tool.ID, out+": "+err.Error())
return UninstallResult{Output: out, Err: err}
}
ti.resetUninstallStatus(tool.ID, "")
out := strings.TrimSpace(buf.String())
log.Printf("[ToolInstaller] %s uninstalled successfully", toolName)
return UninstallResult{Output: out}
}
func (ti *ToolInstaller) resetUninstallStatus(toolID uint, errMsg string) {
now := time.Now()
updates := map[string]interface{}{
"status": model.ToolNotInstalled,
"installed_ver": "",
"last_check": &now,
}
if errMsg != "" {
updates["error_msg"] = errMsg
} else {
updates["error_msg"] = ""
}
ti.updateStatusMap(toolID, updates)
}
func (ti *ToolInstaller) updateStatusMap(toolID uint, updates map[string]interface{}) {
ti.db.Model(&model.DeployTool{}).Where("id = ?", toolID).Updates(updates)
}
func (ti *ToolInstaller) uninstallTool(tool *model.DeployTool, buf *bytes.Buffer) error {
switch tool.Name {
case "jdk":
return ti.uninstallJDK(buf)
case "maven":
return ti.uninstallMaven(buf)
case "golang":
return ti.uninstallGolang(buf)
case "nvm":
return ti.uninstallNVM(buf)
case "nodejs":
return ti.uninstallNodeJS(buf)
case "git":
return ti.uninstallApkPackage(buf, "git", "git")
case "docker-cli":
return ti.uninstallApkPackage(buf, "docker-cli", "docker")
default:
return ti.uninstallBinary(tool, buf)
}
}
func (ti *ToolInstaller) uninstallJDK(buf *bytes.Buffer) error {
buf.WriteString("[JDK] Removing symlinks and /usr/local/share/jdk\n")
removeIfExists(buf, "/usr/local/bin/java", "/usr/local/bin/javac", "/usr/local/bin/jar")
removeDirIfExists(buf, "/usr/local/share/jdk")
return nil
}
func (ti *ToolInstaller) uninstallMaven(buf *bytes.Buffer) error {
buf.WriteString("[Maven] Removing symlinks and /usr/local/share/maven\n")
removeIfExists(buf, "/usr/local/bin/mvn")
removeDirIfExists(buf, "/usr/local/share/maven")
return nil
}
func (ti *ToolInstaller) uninstallGolang(buf *bytes.Buffer) error {
buf.WriteString("[Go] Removing GOROOT and symlinks\n")
removeIfExists(buf, "/usr/local/bin/go", "/usr/local/bin/gofmt", "/etc/profile.d/golang.sh")
removeDirIfExists(buf, "/usr/local/go")
return nil
}
func (ti *ToolInstaller) uninstallNVM(buf *bytes.Buffer) error {
buf.WriteString("[NVM] Removing NVM and Node versions\n")
removeIfExists(buf, "/usr/local/bin/node", "/usr/local/bin/npm", "/usr/local/bin/npx", "/etc/profile.d/nvm.sh")
removeDirIfExists(buf, "/usr/local/share/nvm")
return nil
}
func (ti *ToolInstaller) uninstallNodeJS(buf *bytes.Buffer) error {
buf.WriteString("[Node.js] Removing symlinks and NVM node versions\n")
removeIfExists(buf, "/usr/local/bin/node", "/usr/local/bin/npm", "/usr/local/bin/npx")
nvmSh := "/usr/local/share/nvm/nvm.sh"
if st, err := os.Stat(nvmSh); err == nil && !st.IsDir() {
script := fmt.Sprintf(`export NVM_DIR="/usr/local/share/nvm"
. "%s"
CUR=$(nvm current 2>/dev/null || true)
if [ -n "$CUR" ] && [ "$CUR" != "none" ] && [ "$CUR" != "system" ]; then
nvm uninstall "$CUR" 2>/dev/null || true
fi
rm -rf "$NVM_DIR/versions/node"
`, nvmSh)
cmd := exec.Command("bash", "-c", script)
out, err := cmd.CombinedOutput()
if len(out) > 0 {
buf.Write(out)
}
if err != nil {
buf.WriteString(fmt.Sprintf("[Node.js] nvm cleanup warning: %v\n", err))
}
}
removeDirIfExists(buf, "/usr/local/share/nvm/versions/node")
return nil
}
func (ti *ToolInstaller) uninstallApkPackage(buf *bytes.Buffer, pkg, binary string) error {
buf.WriteString(fmt.Sprintf("[%s] Trying apk del %s\n", binary, pkg))
if _, err := exec.LookPath("apk"); err != nil {
buf.WriteString(fmt.Sprintf("[%s] apk not found, skip package removal\n", binary))
return nil
}
cmd := exec.Command("apk", "del", "--no-cache", pkg)
out, err := cmd.CombinedOutput()
if len(out) > 0 {
buf.Write(out)
}
if err != nil {
// 可能未通过 apk 安装,仅标记卸载完成 // Logan.Li
buf.WriteString(fmt.Sprintf("[%s] apk del skipped: %v\n", binary, err))
}
return nil
}
func (ti *ToolInstaller) uninstallBinary(tool *model.DeployTool, buf *bytes.Buffer) error {
path := tool.InstallPath
if path == "" && tool.BinaryName != "" {
path = filepath.Join("/usr/local/bin", tool.BinaryName)
}
if path == "" {
return fmt.Errorf("no install path configured for %s", tool.Name)
}
buf.WriteString(fmt.Sprintf("[%s] Removing %s\n", tool.Name, path))
removeIfExists(buf, path)
return nil
}
func removeIfExists(buf *bytes.Buffer, paths ...string) {
for _, p := range paths {
if err := os.Remove(p); err != nil {
if !os.IsNotExist(err) {
buf.WriteString(fmt.Sprintf("remove %s: %v\n", p, err))
}
} else {
buf.WriteString(fmt.Sprintf("removed %s\n", p))
}
}
}
func removeDirIfExists(buf *bytes.Buffer, dir string) {
if err := os.RemoveAll(dir); err != nil {
if !os.IsNotExist(err) {
buf.WriteString(fmt.Sprintf("remove dir %s: %v\n", dir, err))
}
} else {
buf.WriteString(fmt.Sprintf("removed dir %s\n", dir))
}
}