1. 工具安装bug修复 (P0 critical):
- 根因: ToolConfigHandler.InstallTool的goroutine只打了日志,
从未调用实际安装逻辑
- 修复: 注入ToolInstaller到handler, 新增ForceInstall方法绕过sync.Once
- 影响: 流水线工具配置页「安装」按钮现在能真正触发下载安装
2. K8s→Kubernetes命名统一:
- Dashboard/Prometheus/AIOpsAssistant等8个文件12处用户可见文案
- 内部变量名/文件名/API group保持不变
3. 资源可视化编辑器 (新增ResourceVisualEditor.tsx):
- ConfigMap: 键值对表格编辑器(增删改)
- Secret: 键值对编辑器(自动Base64编解码)
- Service: 端口/类型/选择器可视化配置
- Ingress: Host/Path路由规则可视化编辑
- PVC: 存储大小/访问模式/StorageClass配置
- 双模式: 可视化编辑 ↔ YAML编辑一键切换
- NamespaceResourcePage: 所有资源类型添加编辑按钮入口
355 lines
10 KiB
Go
355 lines
10 KiB
Go
package toolinstaller
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"ezops/internal/models"
|
|
|
|
"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
|
|
}
|
|
|
|
// 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 {
|
|
var tool models.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)
|
|
}
|
|
|
|
// 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 models.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 == models.ToolPreinstall || tool.InstallMethod == models.ToolLazy {
|
|
if inst, err := ti.checkInstalled(tool); err == nil {
|
|
// Update status if needed
|
|
if tool.Status != models.ToolInstalled {
|
|
ti.updateStatus(tool.ID, models.ToolInstalled, inst, "")
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
switch tool.InstallMethod {
|
|
case models.ToolPreinstall:
|
|
return fmt.Errorf("tool %s should be preinstalled but not found", toolName)
|
|
case models.ToolDisabled:
|
|
return fmt.Errorf("tool %s is disabled", toolName)
|
|
case models.ToolLazy:
|
|
// Use sync.Once to prevent concurrent installs
|
|
var installErr error
|
|
once := ti.ensureOnce(toolName)
|
|
once.Do(func() {
|
|
installErr = ti.installTool(&tool)
|
|
})
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// checkInstalled runs the tool's check command and returns the version string.
|
|
func (ti *ToolInstaller) checkInstalled(tool models.DeployTool) (string, error) {
|
|
// First try LookPath
|
|
if tool.BinaryName != "" {
|
|
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)
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return strings.TrimSpace(strings.Split(string(out), "\n")[0]), nil
|
|
}
|
|
|
|
return "unknown", nil
|
|
}
|
|
|
|
// installTool performs the actual installation.
|
|
func (ti *ToolInstaller) installTool(tool *models.DeployTool) error {
|
|
log.Printf("[ToolInstaller] Installing %s (method=%s)...", tool.Name, tool.InstallMethod)
|
|
|
|
// Mark as installing
|
|
ti.updateStatus(tool.ID, models.ToolInstalling, "", "")
|
|
|
|
var err error
|
|
if tool.InstallScript != "" {
|
|
err = ti.runInstallScript(tool)
|
|
} else if tool.DownloadURL != "" {
|
|
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()
|
|
log.Printf("[ToolInstaller] ERROR installing %s: %s", tool.Name, errMsg)
|
|
ti.updateStatus(tool.ID, models.ToolFailed, "", errMsg)
|
|
return 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, models.ToolInstalled, "unknown", "")
|
|
} else {
|
|
log.Printf("[ToolInstaller] %s installed successfully: %s", tool.Name, ver)
|
|
ti.updateStatus(tool.ID, models.ToolInstalled, ver, "")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// runInstallScript executes the tool's install script in a subshell.
|
|
func (ti *ToolInstaller) runInstallScript(tool *models.DeployTool) 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)
|
|
|
|
cmd := exec.Command("sh", tmpFile.Name())
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
return cmd.Run()
|
|
}
|
|
|
|
// 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 *models.DeployTool) 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
|
|
for i, urlTemplate := range mirrorURLs {
|
|
url := strings.ReplaceAll(urlTemplate, "{version}", version)
|
|
log.Printf("[ToolInstaller] Downloading %s from mirror[%d]: %s", tool.Name, i, url)
|
|
|
|
err := ti.tryDownload(url, tool)
|
|
if err == nil {
|
|
return nil // success
|
|
}
|
|
lastErr = 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)
|
|
}
|
|
|
|
// tryDownload attempts a single download from a URL.
|
|
func (ti *ToolInstaller) tryDownload(url string, tool *models.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 models.ToolStatus, version string, errMsg string) {
|
|
now := time.Now()
|
|
updates := map[string]interface{}{
|
|
"status": status,
|
|
"last_check": &now,
|
|
}
|
|
if version != "" {
|
|
updates["installed_version"] = version
|
|
}
|
|
if errMsg != "" {
|
|
updates["error_msg"] = errMsg
|
|
}
|
|
ti.db.Model(&models.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(&models.DeployTool{}).Count(&count)
|
|
if count > 0 {
|
|
return
|
|
}
|
|
|
|
tools := models.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 == models.ToolPreinstall {
|
|
if ver, err := ti.checkInstalled(tools[i]); err == nil {
|
|
tools[i].Status = models.ToolInstalled
|
|
tools[i].InstalledVer = ver
|
|
} else {
|
|
tools[i].Status = models.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))
|
|
}
|
|
}
|