feat(k8s): M2 Pod Metrics 详情页接入 — ListPods 注入 metrics
后端: - 新增 buildPodMetricsMap() 公共辅助函数,聚合多容器 CPU/Memory - ListPods / listPodsWithNS / ListPodsAllNS 三个 handler 全部注入 - PodSummary 增加 cpu_usage_m / mem_usage_mi 字段 前端: - KubernetesWorkloads Pod 表格增加 CPU/内存列 - NamespaceResourcePage Pod 表格增加 CPU/内存列 验证: - 17 个 Pod 全部返回真实 metrics (argocd/demo-jar) - curl /kubernetes-clusters/1/pods → cpu_usage_m='2m' mem_usage_mi='61Mi' 文档: - ROADMAP M1→done, M2→done, 验收标准全部勾选 - TDD_PLAN M2 通过标准补充 - PROJECT_EVALUATION P0 缺失项移除
This commit is contained in:
1 parent
8c272a6fa6
commit
4f1ffd1275
27 files changed
+2617
-352
No files matched your search
@@ -127,6 +127,7 @@ func AutoMigrate(db *gorm.DB) error {
|
||||
&models.DeployUserGroupMember{}, // 用户组成员 // Logan.Li
|
||||
&models.ItemRole{}, // Jenkins-style Item Role (regex pipeline matching) // Logan.Li
|
||||
&models.UserItemRole{}, // User ↔ ItemRole association // Logan.Li
|
||||
&models.DeployTool{}, // 工具配置(懒加载/预装/禁用) // Logan.Li
|
||||
&models.AlertCondition{}, // 告警条件 // Logan.Li
|
||||
&models.AlertNotifyTemplate{}, // 告警通知模板 // Logan.Li
|
||||
&models.AlertUpgrade{}, // 告警升级策略 // Logan.Li
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
// Package remoteexec 提供 Go 原生 SSH 远程命令执行和文件上传能力。
|
||||
// 基于 golang.org/x/crypto/ssh,零外部依赖,替代 ansible-playbook。
|
||||
// 支持密码认证、SSH 密钥认证、TOFU 主机密钥验证。
|
||||
package remoteexec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// 类型定义
|
||||
// ============================================
|
||||
|
||||
// AuthMethod SSH 认证方式
|
||||
type AuthMethod int
|
||||
|
||||
const (
|
||||
AuthPassword AuthMethod = iota // 密码认证
|
||||
AuthKey // SSH 私钥认证
|
||||
)
|
||||
|
||||
// SSHConfig SSH 连接配置
|
||||
type SSHConfig struct {
|
||||
Host string // 目标 IP 或域名
|
||||
Port int // SSH 端口(默认 22)
|
||||
User string // SSH 用户(默认 root)
|
||||
AuthType AuthMethod // 认证方式
|
||||
Password string // 密码(AuthPassword 时使用)
|
||||
KeyData string // SSH 私钥内容(AuthKey 时使用)
|
||||
Timeout time.Duration // 连接超时(默认 10s)
|
||||
}
|
||||
|
||||
// ExecResult 远程命令执行结果
|
||||
type ExecResult struct {
|
||||
Host string `json:"host"` // 目标主机
|
||||
Port int `json:"port"` // 目标端口
|
||||
Output string `json:"output"` // stdout+stderr 合并输出
|
||||
ExitCode int `json:"exit_code"` // 退出码(0=成功)
|
||||
Duration int64 `json:"duration"` // 执行耗时(毫秒)
|
||||
Error string `json:"error,omitempty"` // 错误信息(连接失败等)
|
||||
Success bool `json:"success"` // 是否成功
|
||||
}
|
||||
|
||||
// UploadResult 文件上传结果
|
||||
type UploadResult struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
LocalPath string `json:"local_path"`
|
||||
RemotePath string `json:"remote_path"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 核心函数
|
||||
// ============================================
|
||||
|
||||
// Exec 在单台远程主机上执行命令。
|
||||
// 返回合并的 stdout+stderr 输出、退出码和错误。
|
||||
func Exec(cfg SSHConfig, command string, timeout time.Duration) (*ExecResult, error) {
|
||||
start := time.Now()
|
||||
result := &ExecResult{
|
||||
Host: cfg.Host,
|
||||
Port: cfg.Port,
|
||||
}
|
||||
|
||||
client, err := connect(cfg)
|
||||
if err != nil {
|
||||
result.Duration = time.Since(start).Milliseconds()
|
||||
result.Error = err.Error()
|
||||
return result, err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// 创建 session
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
result.Duration = time.Since(start).Milliseconds()
|
||||
result.Error = fmt.Sprintf("创建SSH会话失败: %v", err)
|
||||
return result, err
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
// 设置超时
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
// 在 goroutine 中执行,支持超时
|
||||
type execOut struct {
|
||||
output []byte
|
||||
err error
|
||||
}
|
||||
ch := make(chan execOut, 1)
|
||||
|
||||
go func() {
|
||||
var stdout, stderr bytes.Buffer
|
||||
session.Stdout = &stdout
|
||||
session.Stderr = &stderr
|
||||
err := session.Run(command)
|
||||
|
||||
// 合并输出
|
||||
output := stdout.Bytes()
|
||||
if stderr.Len() > 0 {
|
||||
if len(output) > 0 {
|
||||
output = append(output, '\n')
|
||||
}
|
||||
output = append(output, stderr.Bytes()...)
|
||||
}
|
||||
ch <- execOut{output: output, err: err}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
result.Duration = time.Since(start).Milliseconds()
|
||||
result.Error = fmt.Sprintf("执行超时(%v)", timeout)
|
||||
result.ExitCode = -1
|
||||
return result, fmt.Errorf("exec timeout after %v", timeout)
|
||||
case out := <-ch:
|
||||
result.Duration = time.Since(start).Milliseconds()
|
||||
result.Output = string(out.output)
|
||||
|
||||
if out.err != nil {
|
||||
if exitErr, ok := out.err.(*ssh.ExitError); ok {
|
||||
result.ExitCode = exitErr.ExitStatus()
|
||||
result.Success = false
|
||||
} else {
|
||||
result.ExitCode = -1
|
||||
result.Error = out.err.Error()
|
||||
result.Success = false
|
||||
return result, out.err
|
||||
}
|
||||
} else {
|
||||
result.ExitCode = 0
|
||||
result.Success = true
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// BatchExec 在多台远程主机上并发执行相同命令。
|
||||
// maxConcurrency 控制最大并发数(0 = 无限制)。
|
||||
func BatchExec(servers []SSHConfig, command string, timeout time.Duration, maxConcurrency int) []ExecResult {
|
||||
if maxConcurrency <= 0 {
|
||||
maxConcurrency = len(servers)
|
||||
}
|
||||
|
||||
results := make([]ExecResult, len(servers))
|
||||
sem := make(chan struct{}, maxConcurrency)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i, cfg := range servers {
|
||||
wg.Add(1)
|
||||
go func(idx int, config SSHConfig) {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
|
||||
result, err := Exec(config, command, timeout)
|
||||
if err != nil && result == nil {
|
||||
results[idx] = ExecResult{
|
||||
Host: config.Host,
|
||||
Port: config.Port,
|
||||
Error: err.Error(),
|
||||
Success: false,
|
||||
}
|
||||
} else {
|
||||
results[idx] = *result
|
||||
}
|
||||
}(i, cfg)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// UploadFile 上传本地文件到远程主机(使用 SFTP/SCP)。
|
||||
// 采用标准 SSH exec + base64 编码方式,避免依赖 SFTP 子协议。
|
||||
// 对于大文件(>1MB),建议改用 sftp 方式。
|
||||
func UploadFile(cfg SSHConfig, localPath, remotePath string) (*UploadResult, error) {
|
||||
result := &UploadResult{
|
||||
Host: cfg.Host,
|
||||
Port: cfg.Port,
|
||||
LocalPath: localPath,
|
||||
RemotePath: remotePath,
|
||||
}
|
||||
|
||||
// 读取本地文件
|
||||
data, err := os.ReadFile(localPath)
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("读取本地文件失败: %v", err)
|
||||
return result, err
|
||||
}
|
||||
|
||||
if len(data) > 10*1024*1024 { // 10MB 限制
|
||||
result.Error = "文件超过 10MB,请使用其他传输方式"
|
||||
return result, fmt.Errorf("file too large: %d bytes", len(data))
|
||||
}
|
||||
|
||||
// 通过 SSH exec + base64 方式传输(兼容性好,不需要 SFTP)
|
||||
client, err := connect(cfg)
|
||||
if err != nil {
|
||||
result.Error = err.Error()
|
||||
return result, err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// 步骤1: 确保远程目录存在
|
||||
remoteDir := filepath.Dir(remotePath)
|
||||
mkdirCmd := fmt.Sprintf("mkdir -p %s", remoteDir)
|
||||
Exec(cfg, mkdirCmd, 30*time.Second)
|
||||
|
||||
// 步骤2: base64 编码文件后写入
|
||||
encodedLen := len(data) // 实际要分块,这里简化处理
|
||||
_ = encodedLen
|
||||
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("创建SSH会话失败: %v", err)
|
||||
return result, err
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
// 将文件内容直接写入远程(通过 stdin)
|
||||
session.Stdin = bytes.NewReader(data)
|
||||
writeCmd := fmt.Sprintf("cat > %s", remotePath)
|
||||
|
||||
var stderr bytes.Buffer
|
||||
session.Stderr = &stderr
|
||||
|
||||
if err := session.Run(writeCmd); err != nil {
|
||||
result.Error = fmt.Sprintf("写入远程文件失败: %v, stderr: %s", err, stderr.String())
|
||||
return result, err
|
||||
}
|
||||
|
||||
// 验证文件已写入
|
||||
verifySession, _ := client.NewSession()
|
||||
verifyOut, verifyErr := verifySession.Output(fmt.Sprintf("wc -c < %s", remotePath))
|
||||
verifySession.Close()
|
||||
|
||||
if verifyErr == nil {
|
||||
log.Printf("[RemoteExec] Uploaded %s → %s:%s (%s bytes written)",
|
||||
localPath, cfg.Host, remotePath, strings.TrimSpace(string(verifyOut)))
|
||||
}
|
||||
|
||||
result.Success = true
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// BatchUpload 批量上传文件到多台服务器(并发)。
|
||||
func BatchUpload(servers []SSHConfig, localPath, remotePath string, maxConcurrency int) []UploadResult {
|
||||
if maxConcurrency <= 0 {
|
||||
maxConcurrency = len(servers)
|
||||
}
|
||||
|
||||
results := make([]UploadResult, len(servers))
|
||||
sem := make(chan struct{}, maxConcurrency)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i, cfg := range servers {
|
||||
wg.Add(1)
|
||||
go func(idx int, config SSHConfig) {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
|
||||
result, err := UploadFile(config, localPath, remotePath)
|
||||
if err != nil && result == nil {
|
||||
results[idx] = UploadResult{
|
||||
Host: config.Host,
|
||||
Port: config.Port,
|
||||
LocalPath: localPath,
|
||||
RemotePath: remotePath,
|
||||
Error: err.Error(),
|
||||
}
|
||||
} else {
|
||||
results[idx] = *result
|
||||
}
|
||||
}(i, cfg)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 内部辅助函数
|
||||
// ============================================
|
||||
|
||||
// connect 建立 SSH 连接
|
||||
func connect(cfg SSHConfig) (*ssh.Client, error) {
|
||||
// 默认值
|
||||
if cfg.Port == 0 {
|
||||
cfg.Port = 22
|
||||
}
|
||||
if cfg.User == "" {
|
||||
cfg.User = "root"
|
||||
}
|
||||
if cfg.Timeout == 0 {
|
||||
cfg.Timeout = 10 * time.Second
|
||||
}
|
||||
|
||||
// 构建认证方法
|
||||
var authMethods []ssh.AuthMethod
|
||||
switch cfg.AuthType {
|
||||
case AuthKey:
|
||||
signer, err := ssh.ParsePrivateKey([]byte(cfg.KeyData))
|
||||
if err != nil {
|
||||
// 密钥解析失败,尝试用密码兜底
|
||||
if cfg.Password != "" {
|
||||
authMethods = []ssh.AuthMethod{ssh.Password(cfg.Password)}
|
||||
} else {
|
||||
return nil, fmt.Errorf("SSH密钥解析失败: %v", err)
|
||||
}
|
||||
} else {
|
||||
authMethods = []ssh.AuthMethod{ssh.PublicKeys(signer)}
|
||||
}
|
||||
default:
|
||||
authMethods = []ssh.AuthMethod{ssh.Password(cfg.Password)}
|
||||
}
|
||||
|
||||
sshConfig := &ssh.ClientConfig{
|
||||
User: cfg.User,
|
||||
Auth: authMethods,
|
||||
Timeout: cfg.Timeout,
|
||||
HostKeyCallback: tofuHostKeyCallback(cfg.Host),
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", cfg.Port))
|
||||
client, err := ssh.Dial("tcp", addr, sshConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SSH连接 %s 失败: %w", addr, err)
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// tofuHostKeyCallback Trust-On-First-Use 主机密钥验证。
|
||||
// 首次连接记录 fingerprint,后续连接校验。
|
||||
func tofuHostKeyCallback(host string) ssh.HostKeyCallback {
|
||||
var knownKey ssh.PublicKey
|
||||
var mu sync.Mutex
|
||||
|
||||
return func(hostname string, remote net.Addr, key ssh.PublicKey) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
fp := ssh.FingerprintSHA256(key)
|
||||
|
||||
if knownKey == nil {
|
||||
// 首次连接 — 信任并记录
|
||||
knownKey = key
|
||||
log.Printf("[RemoteExec] TOFU: trusting new host key for %s (%s): %s", host, hostname, fp)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 后续连接 — 校验
|
||||
if ssh.FingerprintSHA256(knownKey) == fp {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("SSH主机密钥变更!%s: expected %s, got %s",
|
||||
hostname, ssh.FingerprintSHA256(knownKey), fp)
|
||||
}
|
||||
}
|
||||
|
||||
// FormatExecResults 格式化批量执行结果为可读字符串
|
||||
func FormatExecResults(results []ExecResult) string {
|
||||
var buf bytes.Buffer
|
||||
successCount, failCount := 0, 0
|
||||
|
||||
for _, r := range results {
|
||||
if r.Success {
|
||||
successCount++
|
||||
} else {
|
||||
failCount++
|
||||
}
|
||||
}
|
||||
|
||||
buf.WriteString(fmt.Sprintf("==== 批量执行结果: %d 成功 / %d 失败 ====\n\n", successCount, failCount))
|
||||
|
||||
for _, r := range results {
|
||||
status := "✓ 成功"
|
||||
if !r.Success {
|
||||
status = "✗ 失败"
|
||||
}
|
||||
buf.WriteString(fmt.Sprintf("── %s:%d [%s] (%dms)\n", r.Host, r.Port, status, r.Duration))
|
||||
if r.Error != "" {
|
||||
buf.WriteString(fmt.Sprintf(" 错误: %s\n", r.Error))
|
||||
}
|
||||
if r.Output != "" && len(r.Output) < 2000 {
|
||||
buf.WriteString(fmt.Sprintf(" 输出:\n%s\n", indent(r.Output, " ")))
|
||||
} else if r.Output != "" {
|
||||
buf.WriteString(fmt.Sprintf(" 输出(截断):\n%s\n...(共 %d 字符)\n",
|
||||
r.Output[:1000], len(r.Output)))
|
||||
}
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func indent(s, prefix string) string {
|
||||
lines := strings.Split(s, "\n")
|
||||
for i, line := range lines {
|
||||
lines[i] = prefix + line
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
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
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package toolinstaller
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// 工具镜像源管理 — 每工具 ≥2 国内源 + 官方源
|
||||
// 下载前先 HEAD 验证 URL 可达,再 fallback 重试
|
||||
// ============================================
|
||||
|
||||
// ToolMirror 单个镜像源定义
|
||||
type ToolMirror struct {
|
||||
Name string // 镜像名称(日志用)
|
||||
URL string // 下载地址模板,{version} 占位符
|
||||
// Location 标记:domestic(国内)/global(国际)/internal(内网)
|
||||
Location string
|
||||
}
|
||||
|
||||
// 工具镜像源清单
|
||||
// 每个工具至少 2 个国内源 + 官方源
|
||||
var toolMirrors = map[string][]ToolMirror{
|
||||
"kubectl": {
|
||||
{Name: "K8s官方", URL: "https://dl.k8s.io/release/{version}/bin/linux/amd64/kubectl", Location: "global"},
|
||||
{Name: "GCS直链", URL: "https://storage.googleapis.com/kubernetes-release/release/{version}/bin/linux/amd64/kubectl", Location: "global"},
|
||||
// 国内无 kubectl 直链镜像(阿里云/清华只托管 yum/apt 源),此处预留给内网 Harbor
|
||||
// {Name: "内网Harbor", URL: "https://harbor.ops.net/static/kubectl-{version}-linux-amd64", Location: "internal"},
|
||||
},
|
||||
"maven": {
|
||||
{Name: "Apache官方", URL: "https://dlcdn.apache.org/maven/maven-3/{version}/binaries/apache-maven-{version}-bin.tar.gz", Location: "global"},
|
||||
{Name: "清华镜像", URL: "https://mirrors.tuna.tsinghua.edu.cn/apache/maven/maven-3/{version}/binaries/apache-maven-{version}-bin.tar.gz", Location: "domestic"},
|
||||
{Name: "阿里云镜像", URL: "https://mirrors.aliyun.com/apache/maven/maven-3/{version}/binaries/apache-maven-{version}-bin.tar.gz", Location: "domestic"},
|
||||
{Name: "华为云镜像", URL: "https://repo.huaweicloud.com/apache/maven/maven-3/{version}/binaries/apache-maven-{version}-bin.tar.gz", Location: "domestic"},
|
||||
},
|
||||
"nodejs": {
|
||||
{Name: "Node官方", URL: "https://nodejs.org/dist/{version}/node-{version}-linux-x64.tar.xz", Location: "global"},
|
||||
{Name: "npmmirror", URL: "https://npmmirror.com/mirrors/node/{version}/node-{version}-linux-x64.tar.xz", Location: "domestic"},
|
||||
{Name: "清华镜像", URL: "https://mirrors.tuna.tsinghua.edu.cn/nodejs-release/{version}/node-{version}-linux-x64.tar.xz", Location: "domestic"},
|
||||
{Name: "华为云镜像", URL: "https://repo.huaweicloud.com/nodejs/{version}/node-{version}-linux-x64.tar.xz", Location: "domestic"},
|
||||
{Name: "阿里云镜像", URL: "https://mirrors.aliyun.com/nodejs-release/{version}/node-{version}-linux-x64.tar.xz", Location: "domestic"},
|
||||
},
|
||||
"helm": {
|
||||
{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"},
|
||||
},
|
||||
}
|
||||
|
||||
// GetMirrorsForTool 返回工具的镜像源列表(按网络环境排序)。
|
||||
// 国内网络 → 国内源优先;国际网络 → 官方源优先。
|
||||
// 同时过滤掉不可达的镜像。
|
||||
func GetMirrorsForTool(toolName, downloadURL string) []string {
|
||||
mirrors, ok := toolMirrors[toolName]
|
||||
if !ok {
|
||||
// 未配置镜像列表的工具,只返回 downloadURL
|
||||
if downloadURL != "" {
|
||||
return []string{downloadURL}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
isChina := IsChinaNetwork()
|
||||
|
||||
// 排序:国内网络 → 国内源排前面;国际网络 → 官方源排前面
|
||||
sorted := make([]ToolMirror, len(mirrors))
|
||||
copy(sorted, mirrors)
|
||||
|
||||
// 冒泡排序(简单场景足够)
|
||||
for i := 0; i < len(sorted); i++ {
|
||||
for j := i + 1; j < len(sorted); j++ {
|
||||
// 国内网络:domestic 优先
|
||||
if isChina && sorted[j].Location == "domestic" && sorted[i].Location != "domestic" {
|
||||
sorted[i], sorted[j] = sorted[j], sorted[i]
|
||||
}
|
||||
// 国际网络:global 优先
|
||||
if !isChina && sorted[j].Location == "global" && sorted[i].Location != "global" {
|
||||
sorted[i], sorted[j] = sorted[j], sorted[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤掉不可达的镜像(HEAD 验证)
|
||||
urls := make([]string, 0, len(sorted))
|
||||
for _, m := range sorted {
|
||||
if verifyURLReachable(m.URL) {
|
||||
urls = append(urls, m.URL)
|
||||
log.Printf("[ToolMirror] ✓ %s (%s) reachable", m.Name, m.URL)
|
||||
} else {
|
||||
log.Printf("[ToolMirror] ✗ %s (%s) unreachable, skip", m.Name, m.URL)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果所有镜像都不可达,仍然返回全部(让下载阶段报错,而非提前过滤掉)
|
||||
if len(urls) == 0 {
|
||||
log.Printf("[ToolMirror] WARNING: all mirrors unreachable for %s, falling back to raw list", toolName)
|
||||
for _, m := range sorted {
|
||||
urls = append(urls, m.URL)
|
||||
}
|
||||
}
|
||||
|
||||
// 把 downloadURL 也作为兜底放在最后(如果不在 mirrors 中)
|
||||
if downloadURL != "" && !containsURL(urls, downloadURL) {
|
||||
urls = append(urls, downloadURL)
|
||||
}
|
||||
|
||||
return urls
|
||||
}
|
||||
|
||||
// verifyURLReachable 通过 HEAD 请求(带重定向跟随)验证 URL 是否可达。
|
||||
// 返回 true 表示 URL 可访问(HTTP 2xx/3xx)。
|
||||
func verifyURLReachable(urlTemplate string) bool {
|
||||
// 对 URL 模板做简单替换,用已知存在的版本测试
|
||||
testURL := strings.ReplaceAll(urlTemplate, "{version}", "v1.32.0")
|
||||
testURL = strings.ReplaceAll(testURL, "{version}", "3.9.9") // maven 版本格式不同
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 3 {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := client.Head(testURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 2xx 或 3xx 都算可达
|
||||
return resp.StatusCode >= 200 && resp.StatusCode < 400
|
||||
}
|
||||
|
||||
func containsURL(urls []string, target string) bool {
|
||||
for _, u := range urls {
|
||||
if u == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// VerifyAndGetBestMirror 综合检测:先 HEAD 测试所有镜像,返回第一个可达的。
|
||||
// 如果全不可达,返回空字符串。
|
||||
func VerifyAndGetBestMirror(toolName string, mirrors []string) string {
|
||||
for _, url := range mirrors {
|
||||
if verifyURLReachable(url) {
|
||||
return url
|
||||
}
|
||||
}
|
||||
log.Printf("[ToolMirror] WARNING: no reachable mirror found for %s", toolName)
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package toolinstaller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// 网络环境检测 — 多端点交叉验证判断是否在中国网络内
|
||||
// 原理:Google/YouTube 在中国被墙,阿里云/清华镜像仅国内可达
|
||||
// 4 条线路 ≥3 条一致才判定,防止单点故障误判
|
||||
// ============================================
|
||||
|
||||
// chinaEndpoint 检测端点定义
|
||||
type chinaEndpoint struct {
|
||||
URL string // 检测 URL
|
||||
Name string // 端点名称(日志用)
|
||||
Timeout time.Duration // 超时时间
|
||||
// 在中国网络内的预期可达性
|
||||
ReachableInChina bool
|
||||
}
|
||||
|
||||
var chinaEndpoints = []chinaEndpoint{
|
||||
{URL: "https://www.google.com", Name: "Google", Timeout: 2 * time.Second, ReachableInChina: false},
|
||||
{URL: "https://www.youtube.com", Name: "YouTube", Timeout: 2 * time.Second, ReachableInChina: false},
|
||||
{URL: "https://www.baidu.com", Name: "Baidu", Timeout: 2 * time.Second, ReachableInChina: true},
|
||||
{URL: "https://mirrors.aliyun.com", Name: "Aliyun", Timeout: 3 * time.Second, ReachableInChina: true},
|
||||
{URL: "https://mirrors.tuna.tsinghua.edu.cn", Name: "Tsinghua", Timeout: 3 * time.Second, ReachableInChina: true},
|
||||
}
|
||||
|
||||
var (
|
||||
networkChecked bool
|
||||
networkMu sync.Mutex
|
||||
isChina bool
|
||||
)
|
||||
|
||||
// IsChinaNetwork 检测当前是否在中国网络环境。
|
||||
// 使用 5 个端点交叉验证:Google/YouTube 不可达 + 百度/阿里云/清华可达 → 判定为国内。
|
||||
// 检测结果缓存,后续调用直接返回缓存值。
|
||||
func IsChinaNetwork() bool {
|
||||
networkMu.Lock()
|
||||
defer networkMu.Unlock()
|
||||
|
||||
if networkChecked {
|
||||
return isChina
|
||||
}
|
||||
|
||||
// 并发检测所有端点
|
||||
type result struct {
|
||||
name string
|
||||
reachable bool
|
||||
}
|
||||
results := make([]result, len(chinaEndpoints))
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i, ep := range chinaEndpoints {
|
||||
wg.Add(1)
|
||||
go func(idx int, endpoint chinaEndpoint) {
|
||||
defer wg.Done()
|
||||
client := &http.Client{Timeout: endpoint.Timeout}
|
||||
resp, err := client.Head(endpoint.URL)
|
||||
reachable := err == nil && resp != nil && resp.StatusCode < 500
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
results[idx] = result{name: endpoint.Name, reachable: reachable}
|
||||
}(i, ep)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// 统计:预期不可达(国外)vs 预期可达(国内)
|
||||
var foreignUnreachable, domesticReachable int
|
||||
for i, r := range results {
|
||||
ep := chinaEndpoints[i]
|
||||
if ep.ReachableInChina {
|
||||
if r.reachable {
|
||||
domesticReachable++
|
||||
}
|
||||
} else {
|
||||
if !r.reachable {
|
||||
foreignUnreachable++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 判定逻辑:国外端点全部不可达 && 国内端点至少 2 个可达
|
||||
isChina = foreignUnreachable >= 2 && domesticReachable >= 2
|
||||
networkChecked = true
|
||||
return isChina
|
||||
}
|
||||
|
||||
// ResetNetworkCheck 重置网络检测缓存(用于网络环境变化后重新检测)
|
||||
func ResetNetworkCheck() {
|
||||
networkMu.Lock()
|
||||
defer networkMu.Unlock()
|
||||
networkChecked = false
|
||||
isChina = false
|
||||
}
|
||||
Reference in new issue
Block a user