package toolinstaller import ( "bytes" "fmt" "io" "log" "net/http" "os" "os/exec" "path/filepath" "strings" "sync" "time" "ezops/model" "gorm.io/gorm" ) // ToolInstaller handles lazy installation of devops tools. // Each tool gets a sync.Once to prevent concurrent installations. type ToolInstaller struct { db *gorm.DB onces map[string]*sync.Once mu sync.Mutex } // New creates a ToolInstaller. func New(db *gorm.DB) *ToolInstaller { return &ToolInstaller{ db: db, onces: make(map[string]*sync.Once), } } // ensureOnce returns a sync.Once for the given tool name. func (ti *ToolInstaller) ensureOnce(name string) *sync.Once { ti.mu.Lock() defer ti.mu.Unlock() if o, ok := ti.onces[name]; ok { return o } o := &sync.Once{} ti.onces[name] = o return o } // 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 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) tool.DefaultVersion = version } return ti.installTool(&tool) } // EnsureAvailable checks if a tool is available and installs it if configured for lazy loading. // Returns nil if the tool is ready, or an error if installation failed. func (ti *ToolInstaller) EnsureAvailable(toolName string) error { // Find tool in DB var tool model.DeployTool if err := ti.db.Where("name = ?", toolName).First(&tool).Error; err != nil { // Tool not in DB — try to find it by path if _, lookErr := exec.LookPath(toolName); lookErr == nil { return nil // available even without DB config } return fmt.Errorf("tool %s not configured in DB", toolName) } if !tool.Enabled { return fmt.Errorf("tool %s is disabled", toolName) } // Check if already installed if tool.InstallMethod == model.ToolPreinstall || tool.InstallMethod == model.ToolLazy { if inst, err := ti.checkInstalled(tool); err == nil { // Update status if needed if tool.Status != model.ToolInstalled { ti.updateStatus(tool.ID, model.ToolInstalled, inst, "") } return nil } } switch tool.InstallMethod { case model.ToolPreinstall: return fmt.Errorf("tool %s should be preinstalled but not found", toolName) case model.ToolDisabled: return fmt.Errorf("tool %s is disabled", toolName) case model.ToolLazy: // Use sync.Once to prevent concurrent installs var installErr error once := ti.ensureOnce(toolName) once.Do(func() { res := ti.installTool(&tool) installErr = res.Err }) // If install failed, reset the Once so next call can retry if installErr != nil { ti.mu.Lock() delete(ti.onces, toolName) ti.mu.Unlock() } return installErr default: return fmt.Errorf("unknown install method: %s", tool.InstallMethod) } } // 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) } } if 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 } 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) } // 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) 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 != "" { installOut, err = ti.runInstallScript(tool) } else if tool.DownloadURL != "" { 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, "", truncateInstallLog(errMsg, 512)) return InstallResult{Output: installOut, Err: fmt.Errorf("failed to install %s: %w", tool.Name, err)} } // 安装后验证(优先文件检测,避免 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), } } // 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) (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) } defer os.Remove(tmpFile.Name()) version := tool.DefaultVersion if version == "" { version = "latest" } 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) } tmpFile.Close() os.Chmod(tmpFile.Name(), 0755) 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) (string, error) { version := tool.DefaultVersion if version == "" { // Try to get latest version if tool.VersionURL != "" { v, err := ti.fetchVersion(tool.VersionURL) if err != nil { log.Printf("[ToolInstaller] WARNING: cannot fetch version for %s: %v, using default", tool.Name, err) } else { version = v } } } if version == "" { version = "latest" } // 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) } // 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 { 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 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. func (ti *ToolInstaller) tryDownload(url string, tool *model.DeployTool) error { // Use a client with timeout for the download phase. // Large files (kubectl ~50MB) can take minutes on slow networks. client := &http.Client{Timeout: 30 * time.Minute} resp, err := client.Get(url) if err != nil { return fmt.Errorf("download failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("download returned HTTP %d", resp.StatusCode) } // Write to temp file first, then rename (atomic-ish) installPath := tool.InstallPath if installPath == "" { installPath = filepath.Join("/usr/local/bin", tool.BinaryName) } dir := filepath.Dir(installPath) tmpFile, err := os.CreateTemp(dir, fmt.Sprintf(".%s-download-*", tool.Name)) if err != nil { return fmt.Errorf("cannot create temp file: %w", err) } defer os.Remove(tmpFile.Name()) if _, err := io.Copy(tmpFile, resp.Body); err != nil { tmpFile.Close() return fmt.Errorf("failed to save download: %w", err) } tmpFile.Close() os.Chmod(tmpFile.Name(), 0755) if err := os.Rename(tmpFile.Name(), installPath); err != nil { // Fallback: copy data, _ := os.ReadFile(tmpFile.Name()) if writeErr := os.WriteFile(installPath, data, 0755); writeErr != nil { return fmt.Errorf("cannot move binary to %s: %w", installPath, writeErr) } } return nil } // fetchVersion gets the latest version string from a URL. func (ti *ToolInstaller) fetchVersion(url string) (string, error) { client := &http.Client{Timeout: 15 * time.Second} resp, err := client.Get(url) if err != nil { return "", err } defer resp.Body.Close() body, err := io.ReadAll(io.LimitReader(resp.Body, 256)) if err != nil { return "", err } return strings.TrimSpace(string(body)), nil } // updateStatus updates the tool's status in the database. func (ti *ToolInstaller) updateStatus(toolID uint, status model.ToolStatus, version string, errMsg string) { now := time.Now() updates := map[string]interface{}{ "status": status, "last_check": &now, } if 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) } // SeedTools inserts predefined tools into the database if the table is empty. func (ti *ToolInstaller) SeedTools() { var count int64 ti.db.Model(&model.DeployTool{}).Count(&count) if count > 0 { // U4: 即使已有数据也升级工具配置(git/docker-cli 从 preinstall→lazy) ti.UpgradeTools() return } tools := model.PredefinedTools() now := time.Now() for i := range tools { tools[i].CreatedAt = now tools[i].UpdatedAt = now // Check if preinstalled tools are actually available if tools[i].InstallMethod == model.ToolPreinstall { if ver, err := ti.checkInstalled(tools[i]); err == nil { tools[i].Status = model.ToolInstalled tools[i].InstalledVer = ver } else { tools[i].Status = model.ToolNotInstalled } } } if err := ti.db.Create(&tools).Error; err != nil { log.Printf("[ToolInstaller] Seed failed: %v", err) } else { log.Printf("[ToolInstaller] Seeded %d tools", len(tools)) } // U4: 新种子也执行升级 ti.UpgradeTools() } // UpgradeTools updates existing tool configs to match latest predefined definitions. // U4: git/docker-cli 从 preinstall 迁移到 lazy(镜像瘦身)。 func (ti *ToolInstaller) UpgradeTools() { upgrades := map[string]struct { installMethod model.ToolInstallMethod installScript string description string }{ "git": { installMethod: model.ToolLazy, installScript: "apk add --no-cache git", description: "版本控制工具,流水线代码拉取使用(首次使用自动安装)", }, "docker-cli": { installMethod: model.ToolLazy, installScript: "apk add --no-cache docker-cli", description: "Docker 命令行工具,流水线镜像构建/推送使用(首次使用自动安装)", }, } for name, up := range upgrades { var tool model.DeployTool if err := ti.db.Where("name = ?", name).First(&tool).Error; err != nil { continue // tool not in DB, skip } if tool.InstallMethod == up.installMethod && tool.InstallScript == up.installScript { continue // already up to date } log.Printf("[ToolInstaller] U4 upgrade: %s %s→%s", name, tool.InstallMethod, up.installMethod) ti.db.Model(&model.DeployTool{}).Where("name = ?", name).Updates(map[string]interface{}{ "install_method": up.installMethod, "install_script": up.installScript, "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) } }