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

604 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}