Files
aiops/backend/pkg/toolinstaller/installer.go
T
attacker c624ca5b4a feat(ops): U4 后端镜像瘦身 282MB→107MB (62%降幅)
- 移除 git/docker-cli/docker-cli-buildx (~100MB) 改为懒加载
- PipelineHandler 注入 ToolInstaller, runCheckout/runImageBuild 调 EnsureAvailable
- UpgradeTools 自动升级 DB 中 git/docker-cli preinstall→lazy
- 仅内置 linux-amd64 agent (~12MB节省), arm64/darwin 通过下载API
- kubectl 改用 install-kubectl.sh 下载 stripped 版本
- .dockerignore 排除 backend/kubectl 60MB 二进制
- Dockerfile.embed + backend/Dockerfile 同步更新
- 17/17 集成测试通过
2026-06-27 14:28:09 +08:00

397 lines
12 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 (
"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
}
// 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 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)
}
// 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() {
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 model.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 *model.DeployTool) error {
log.Printf("[ToolInstaller] Installing %s (method=%s)...", tool.Name, tool.InstallMethod)
// Mark as installing
ti.updateStatus(tool.ID, model.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, model.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, model.ToolInstalled, "unknown", "")
} else {
log.Printf("[ToolInstaller] %s installed successfully: %s", tool.Name, ver)
ti.updateStatus(tool.ID, model.ToolInstalled, ver, "")
}
return nil
}
// runInstallScript executes the tool's install script in a subshell.
func (ti *ToolInstaller) runInstallScript(tool *model.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 *model.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 *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_version"] = version
}
if errMsg != "" {
updates["error_msg"] = errMsg
}
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,
})
}
}