feat: 工具配置增强、站点拨测与日志中心多项改进
- 工具配置:新增卸载 API/UI、Go 工具链、流水线构建自动安装依赖 - 修复 ListTools VerifyTool 递归栈溢出导致获取工具配置失败 - Node.js Alpine 安装改用 unofficial-builds musl 预编译包与 index.json 解析 LTS - 站点拨测:详情页路由、告警自动恢复、并发拨测与默认可观测频率 - 日志中心:RBAC、查询构建器、ES/Loki 增强与审计 request/response 展示 - 发布配置:构建节点、流水线远程执行、Deploy RBAC 导航拆分
This commit is contained in:
1 parent
cde7bbe540
commit
7e1c8f583c
84 files changed
+7729
-1507
No files matched your search
@@ -99,6 +99,7 @@ func AutoMigrate(db *gorm.DB) error {
|
||||
&model.MetricData{},
|
||||
&model.Pipeline{},
|
||||
&model.PipelineRun{},
|
||||
&model.BuildNode{},
|
||||
&model.AppRelease{},
|
||||
&model.CronJob{},
|
||||
&model.Script{},
|
||||
@@ -157,6 +158,9 @@ func AutoMigrate(db *gorm.DB) error {
|
||||
&model.DeployUserGroupMember{}, // 用户组成员 // Logan.Li
|
||||
&model.ItemRole{}, // Jenkins-style Item Role (regex pipeline matching) // Logan.Li
|
||||
&model.UserItemRole{}, // User ↔ ItemRole association // Logan.Li
|
||||
&model.LogScopeRole{}, // 日志查询范围权限(正则匹配索引/Label)// Logan.Li
|
||||
&model.UserLogScopeRole{}, // 用户 ↔ 日志范围权限 // Logan.Li
|
||||
&model.BuildMasterConfig{}, // Master 构建节点并发配置 // Logan.Li
|
||||
&model.DeployTool{}, // 工具配置(懒加载/预装/禁用) // Logan.Li
|
||||
&model.AlertCondition{}, // 告警条件 // Logan.Li
|
||||
&model.AlertNotifyTemplate{}, // 告警通知模板 // Logan.Li
|
||||
@@ -216,4 +220,15 @@ func applySchemaPatches(db *gorm.DB) {
|
||||
patchLogDSColumn("AuthType", "auth_type")
|
||||
patchLogDSColumn("Token", "token")
|
||||
patchLogDSColumn("Headers", "headers")
|
||||
|
||||
// 日志权限关联表:AutoMigrate 在 MySQL 索引冲突时可能只创建策略表未创建用户关联表 // Logan.Li
|
||||
ensureTable := func(tbl interface{}, name string) {
|
||||
if !m.HasTable(tbl) {
|
||||
if err := db.AutoMigrate(tbl); err != nil {
|
||||
log.Printf("[DB] 补表 %s 失败: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
ensureTable(&model.LogScopeRole{}, "monitor_log_scope_roles")
|
||||
ensureTable(&model.UserLogScopeRole{}, "monitor_user_log_scope_roles")
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package logaccess
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"ezops/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// QueryContext 日志查询授权上下文 // Logan.Li
|
||||
type QueryContext struct {
|
||||
Engine string
|
||||
DatasourceID uint
|
||||
Index string
|
||||
Query string
|
||||
}
|
||||
|
||||
var (
|
||||
lokiSelectorRe = regexp.MustCompile(`\{([^}]*)\}`)
|
||||
lokiLabelRe = regexp.MustCompile(`([a-zA-Z_][\w.-]*)\s*(=|!=|=~|!~)\s*("([^"]*)"|'([^']*)'|([^\s,}]+))`)
|
||||
esAppRe = regexp.MustCompile(`(?i)\bapp:(\*|"[^"]+"|'[^']+'|[^\s)]+)`)
|
||||
)
|
||||
|
||||
// GetUserLogScopeRoles 获取用户已启用的日志权限策略 // Logan.Li
|
||||
func GetUserLogScopeRoles(db *gorm.DB, userID uint) ([]model.LogScopeRole, error) {
|
||||
var roles []model.LogScopeRole
|
||||
err := db.Table("monitor_log_scope_roles").
|
||||
Joins("JOIN monitor_user_log_scope_roles ON monitor_user_log_scope_roles.log_scope_role_id = monitor_log_scope_roles.id").
|
||||
Where("monitor_user_log_scope_roles.user_id = ? AND monitor_log_scope_roles.status = 1", userID).
|
||||
Find(&roles).Error
|
||||
return roles, err
|
||||
}
|
||||
|
||||
// UserHasAssignments 用户是否已分配日志权限(未分配则保持兼容:不拦截) // Logan.Li
|
||||
func UserHasAssignments(db *gorm.DB, userID uint) bool {
|
||||
var n int64
|
||||
db.Model(&model.UserLogScopeRole{}).Where("user_id = ?", userID).Count(&n)
|
||||
return n > 0
|
||||
}
|
||||
|
||||
// CanQuery 校验用户是否允许执行该日志查询 // Logan.Li
|
||||
func CanQuery(db *gorm.DB, userID uint, isAdmin bool, qc QueryContext) (bool, string) {
|
||||
if isAdmin {
|
||||
return true, ""
|
||||
}
|
||||
if !UserHasAssignments(db, userID) {
|
||||
return true, ""
|
||||
}
|
||||
roles, err := GetUserLogScopeRoles(db, userID)
|
||||
if err != nil || len(roles) == 0 {
|
||||
return false, "未分配可用的日志查询权限,请联系管理员"
|
||||
}
|
||||
for _, r := range roles {
|
||||
if roleAllowsQuery(r, qc) {
|
||||
return true, ""
|
||||
}
|
||||
}
|
||||
return false, "无权查询该日志范围(索引/Label 未命中授权策略)"
|
||||
}
|
||||
|
||||
// FilterDataSources 按授权过滤可见数据源 // Logan.Li
|
||||
func FilterDataSources(db *gorm.DB, userID uint, isAdmin bool, sources []model.LogDataSource) []model.LogDataSource {
|
||||
if isAdmin || !UserHasAssignments(db, userID) {
|
||||
return sources
|
||||
}
|
||||
roles, _ := GetUserLogScopeRoles(db, userID)
|
||||
if len(roles) == 0 {
|
||||
return []model.LogDataSource{}
|
||||
}
|
||||
out := make([]model.LogDataSource, 0, len(sources))
|
||||
for _, ds := range sources {
|
||||
if datasourceAllowed(roles, ds) {
|
||||
out = append(out, ds)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MatchRolesForPreview 返回命中预览的策略列表 // Logan.Li
|
||||
func MatchRolesForPreview(roles []model.LogScopeRole, qc QueryContext, sample string) []model.LogScopeRole {
|
||||
matched := make([]model.LogScopeRole, 0)
|
||||
for _, r := range roles {
|
||||
if roleAllowsQuery(r, qc) {
|
||||
matched = append(matched, r)
|
||||
continue
|
||||
}
|
||||
if sample != "" && regexpMatch(r.Pattern, sample) && engineMatches(r.Engine, qc.Engine) {
|
||||
if r.DatasourceID == nil || *r.DatasourceID == 0 || *r.DatasourceID == qc.DatasourceID {
|
||||
if fieldMatchesEngine(r.MatchField, qc.Engine) {
|
||||
matched = append(matched, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return matched
|
||||
}
|
||||
|
||||
func datasourceAllowed(roles []model.LogScopeRole, ds model.LogDataSource) bool {
|
||||
for _, r := range roles {
|
||||
if !engineMatches(r.Engine, ds.Type) {
|
||||
continue
|
||||
}
|
||||
if r.DatasourceID == nil || *r.DatasourceID == 0 || *r.DatasourceID == ds.ID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func roleAllowsQuery(r model.LogScopeRole, qc QueryContext) bool {
|
||||
if !engineMatches(r.Engine, qc.Engine) {
|
||||
return false
|
||||
}
|
||||
if r.DatasourceID != nil && *r.DatasourceID > 0 && *r.DatasourceID != qc.DatasourceID {
|
||||
return false
|
||||
}
|
||||
switch r.MatchField {
|
||||
case "index":
|
||||
if qc.Engine != "elasticsearch" {
|
||||
return false
|
||||
}
|
||||
idx := strings.TrimSpace(qc.Index)
|
||||
if idx == "" {
|
||||
idx = "*"
|
||||
}
|
||||
return regexpMatch(r.Pattern, idx)
|
||||
case "app", "namespace", "job":
|
||||
values := extractFieldValues(qc.Engine, qc.Query, r.MatchField)
|
||||
if len(values) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, v := range values {
|
||||
if regexpMatch(r.Pattern, v) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func engineMatches(roleEngine, queryEngine string) bool {
|
||||
re := strings.ToLower(strings.TrimSpace(roleEngine))
|
||||
qe := strings.ToLower(strings.TrimSpace(queryEngine))
|
||||
if re == "" || re == "*" {
|
||||
return true
|
||||
}
|
||||
return re == qe
|
||||
}
|
||||
|
||||
func fieldMatchesEngine(field, engine string) bool {
|
||||
if field == "index" {
|
||||
return engine == "elasticsearch"
|
||||
}
|
||||
return engine == "loki"
|
||||
}
|
||||
|
||||
func regexpMatch(pattern, value string) bool {
|
||||
if pattern == "" || value == "" {
|
||||
return false
|
||||
}
|
||||
matched, err := regexp.MatchString(pattern, value)
|
||||
return err == nil && matched
|
||||
}
|
||||
|
||||
func extractFieldValues(engine, query, field string) []string {
|
||||
if engine == "loki" {
|
||||
return extractLokiLabelValues(query, field)
|
||||
}
|
||||
if field == "app" {
|
||||
return extractESAppValues(query)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractLokiLabelValues(query, field string) []string {
|
||||
m := lokiSelectorRe.FindStringSubmatch(query)
|
||||
if len(m) < 2 {
|
||||
return nil
|
||||
}
|
||||
inner := m[1]
|
||||
var values []string
|
||||
for _, part := range lokiLabelRe.FindAllStringSubmatch(inner, -1) {
|
||||
if len(part) < 7 || part[1] != field {
|
||||
continue
|
||||
}
|
||||
op := part[2]
|
||||
val := part[4]
|
||||
if val == "" {
|
||||
val = part[5]
|
||||
}
|
||||
if val == "" {
|
||||
val = part[6]
|
||||
}
|
||||
if op == "=" || op == "=~" {
|
||||
values = append(values, val)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func extractESAppValues(query string) []string {
|
||||
var values []string
|
||||
for _, m := range esAppRe.FindAllStringSubmatch(query, -1) {
|
||||
if len(m) < 2 {
|
||||
continue
|
||||
}
|
||||
v := strings.Trim(m[1], `"'`)
|
||||
if v != "" {
|
||||
values = append(values, v)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
// DescribeRole 策略可读描述 // Logan.Li
|
||||
func DescribeRole(r model.LogScopeRole) string {
|
||||
ds := "全部数据源"
|
||||
if r.DatasourceID != nil && *r.DatasourceID > 0 {
|
||||
ds = fmt.Sprintf("数据源#%d", *r.DatasourceID)
|
||||
}
|
||||
return fmt.Sprintf("%s · %s · %s=%s", r.Engine, ds, r.MatchField, r.Pattern)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ package remoteexec
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
@@ -191,6 +192,112 @@ func UploadFile(cfg SSHConfig, localPath, remotePath string) (*UploadResult, err
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// DownloadFile 从远程主机下载文件到本地(二进制安全,供流水线回拉产物) // Logan.Li
|
||||
func DownloadFile(cfg SSHConfig, remotePath, localPath string) error {
|
||||
client, err := connect(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建SSH会话失败: %v", err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
stdout, err := session.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取stdout失败: %v", err)
|
||||
}
|
||||
|
||||
remoteDir := filepath.Dir(remotePath)
|
||||
mkdirCmd := fmt.Sprintf("mkdir -p %s", remoteDir)
|
||||
_, _ = execWithClient(client, cfg, mkdirCmd, 30*time.Second)
|
||||
|
||||
if err := session.Start(fmt.Sprintf("cat %s", shellQuote(remotePath))); err != nil {
|
||||
return fmt.Errorf("启动远程读取失败: %v", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(localPath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.Create(localPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err := io.Copy(f, stdout); err != nil {
|
||||
return fmt.Errorf("写入本地文件失败: %v", err)
|
||||
}
|
||||
if err := session.Wait(); err != nil {
|
||||
return fmt.Errorf("远程读取失败: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// StreamRemoteTar 将远程目录打包为 tar.gz 流式写入本地文件 // Logan.Li
|
||||
func StreamRemoteTar(cfg SSHConfig, remoteDir, localTarPath string, timeout time.Duration) error {
|
||||
if timeout <= 0 {
|
||||
timeout = 30 * time.Minute
|
||||
}
|
||||
client, err := connect(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建SSH会话失败: %v", err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
stdout, err := session.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取stdout失败: %v", err)
|
||||
}
|
||||
|
||||
cmd := fmt.Sprintf("tar czf - -C %s .", shellQuote(remoteDir))
|
||||
if err := session.Start(cmd); err != nil {
|
||||
return fmt.Errorf("启动远程打包失败: %v", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(localTarPath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.Create(localTarPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, copyErr := io.Copy(f, stdout)
|
||||
done <- copyErr
|
||||
}()
|
||||
|
||||
select {
|
||||
case copyErr := <-done:
|
||||
if copyErr != nil {
|
||||
return fmt.Errorf("写入 tar 失败: %v", copyErr)
|
||||
}
|
||||
case <-time.After(timeout):
|
||||
return fmt.Errorf("下载工作区超时")
|
||||
}
|
||||
if err := session.Wait(); err != nil {
|
||||
return fmt.Errorf("远程打包失败: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// shellQuote 简单 shell 路径转义 // Logan.Li
|
||||
func shellQuote(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
|
||||
}
|
||||
|
||||
// BatchUpload 批量上传文件到多台服务器(并发)。
|
||||
func BatchUpload(servers []SSHConfig, localPath, remotePath string, maxConcurrency int) []UploadResult {
|
||||
if maxConcurrency <= 0 {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package siteprobe
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"ezops/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ResolveRecoveredProbeAlerts 拨测恢复后立即关闭探测类活跃告警 // Logan.Li
|
||||
func ResolveRecoveredProbeAlerts(db *gorm.DB, taskID uint, lastResult string) {
|
||||
if lastResult != "success" {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
db.Model(&model.SiteAlert{}).
|
||||
Where("task_id = ? AND status = ? AND trigger_type IN ?",
|
||||
taskID, model.SiteAlertStatusActive,
|
||||
[]string{"probe_fail", "probe_timeout", "http_status_error"}).
|
||||
Updates(map[string]interface{}{
|
||||
"status": model.SiteAlertStatusResolved, "resolved_at": now,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package siteprobe
|
||||
|
||||
import "strings"
|
||||
|
||||
// DefaultProbeFrequency 各协议推荐默认拨测间隔(秒)// Logan.Li
|
||||
func DefaultProbeFrequency(taskType string) int {
|
||||
switch strings.ToLower(taskType) {
|
||||
case "ssl":
|
||||
return 3600 // 证书日级变化,1 小时足够
|
||||
case "dns":
|
||||
return 300 // 5 分钟
|
||||
case "tcp", "ping":
|
||||
return 120 // 2 分钟
|
||||
default:
|
||||
return 60 // HTTP 1 分钟
|
||||
}
|
||||
}
|
||||
|
||||
// MinProbeFrequency 各协议最小拨测间隔(秒),防止频率过高压垮平台 // Logan.Li
|
||||
func MinProbeFrequency(taskType string) int {
|
||||
switch strings.ToLower(taskType) {
|
||||
case "ssl":
|
||||
return 600 // 最少 10 分钟
|
||||
case "dns":
|
||||
return 60
|
||||
default:
|
||||
return 30
|
||||
}
|
||||
}
|
||||
|
||||
// NormalizeProbeFrequency 规范化拨测频率 // Logan.Li
|
||||
func NormalizeProbeFrequency(taskType string, freq int) int {
|
||||
if freq <= 0 {
|
||||
return DefaultProbeFrequency(taskType)
|
||||
}
|
||||
min := MinProbeFrequency(taskType)
|
||||
if freq < min {
|
||||
return min
|
||||
}
|
||||
return freq
|
||||
}
|
||||
@@ -67,15 +67,22 @@ func (w *Worker) runOnce() {
|
||||
var tasks []model.SiteTask
|
||||
w.db.Where("status = ?", model.SiteTaskStatusRunning).Find(&tasks)
|
||||
now := time.Now()
|
||||
sem := make(chan struct{}, 8) // 最多 8 路并发拨测,避免串行阻塞 // Logan.Li
|
||||
var wg sync.WaitGroup
|
||||
for _, task := range tasks {
|
||||
if task.Frequency <= 0 {
|
||||
task.Frequency = 60
|
||||
}
|
||||
task.Frequency = NormalizeProbeFrequency(string(task.TaskType), task.Frequency)
|
||||
if task.LastCheckAt != nil && now.Sub(*task.LastCheckAt) < time.Duration(task.Frequency)*time.Second {
|
||||
continue
|
||||
}
|
||||
w.probeTask(task)
|
||||
wg.Add(1)
|
||||
go func(t model.SiteTask) {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
w.probeTask(t)
|
||||
}(task)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func (w *Worker) probeTask(task model.SiteTask) {
|
||||
@@ -110,9 +117,11 @@ func (w *Worker) probeTask(task model.SiteTask) {
|
||||
avg = (task.AvgResponse + outcome.ResponseTime) / 2
|
||||
}
|
||||
w.db.Model(&model.SiteTask{}).Where("id = ?", task.ID).Updates(map[string]interface{}{
|
||||
"task_type": task.TaskType,
|
||||
"last_result": lastResult,
|
||||
"task_type": task.TaskType,
|
||||
"last_result": lastResult,
|
||||
"last_check_at": now,
|
||||
"avg_response": avg,
|
||||
"avg_response": avg,
|
||||
})
|
||||
// 拨测恢复后即时关闭探测类告警,与统计栏 down_count 同步 // Logan.Li
|
||||
ResolveRecoveredProbeAlerts(w.db, task.ID, lastResult)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package toolinstaller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@@ -45,15 +46,26 @@ func (ti *ToolInstaller) ensureOnce(name string) *sync.Once {
|
||||
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 {
|
||||
// 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 fmt.Errorf("tool %s not found in DB: %w", toolName, err)
|
||||
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)
|
||||
@@ -101,7 +113,8 @@ func (ti *ToolInstaller) EnsureAvailable(toolName string) error {
|
||||
var installErr error
|
||||
once := ti.ensureOnce(toolName)
|
||||
once.Do(func() {
|
||||
installErr = ti.installTool(&tool)
|
||||
res := ti.installTool(&tool)
|
||||
installErr = res.Err
|
||||
})
|
||||
// If install failed, reset the Once so next call can retry
|
||||
if installErr != nil {
|
||||
@@ -115,70 +128,206 @@ func (ti *ToolInstaller) EnsureAvailable(toolName string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 != "" {
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// Run check command if provided
|
||||
if tool.CheckCommand != "" {
|
||||
cmd := exec.Command("sh", "-c", 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
|
||||
}
|
||||
return strings.TrimSpace(strings.Split(string(out), "\n")[0]), nil
|
||||
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)
|
||||
}
|
||||
|
||||
return "unknown", nil
|
||||
// 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) error {
|
||||
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 != "" {
|
||||
err = ti.runInstallScript(tool)
|
||||
installOut, err = ti.runInstallScript(tool)
|
||||
} else if tool.DownloadURL != "" {
|
||||
err = ti.downloadBinary(tool)
|
||||
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, "", errMsg)
|
||||
return fmt.Errorf("failed to install %s: %w", tool.Name, err)
|
||||
ti.updateStatus(tool.ID, model.ToolFailed, "", truncateInstallLog(errMsg, 512))
|
||||
return InstallResult{Output: installOut, Err: 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 {
|
||||
// 安装后验证(优先文件检测,避免 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),
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
// 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) error {
|
||||
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)
|
||||
return "", fmt.Errorf("cannot create temp script: %w", err)
|
||||
}
|
||||
defer os.Remove(tmpFile.Name())
|
||||
|
||||
@@ -189,21 +338,28 @@ func (ti *ToolInstaller) runInstallScript(tool *model.DeployTool) error {
|
||||
|
||||
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)
|
||||
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()
|
||||
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) error {
|
||||
func (ti *ToolInstaller) downloadBinary(tool *model.DeployTool) (string, error) {
|
||||
version := tool.DefaultVersion
|
||||
if version == "" {
|
||||
// Try to get latest version
|
||||
@@ -223,24 +379,28 @@ func (ti *ToolInstaller) downloadBinary(tool *model.DeployTool) error {
|
||||
// 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)
|
||||
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 {
|
||||
return nil // success
|
||||
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 fmt.Errorf("all %d mirrors exhausted for %s, last error: %w", len(mirrorURLs), tool.Name, lastErr)
|
||||
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.
|
||||
@@ -314,10 +474,12 @@ func (ti *ToolInstaller) updateStatus(toolID uint, status model.ToolStatus, vers
|
||||
"last_check": &now,
|
||||
}
|
||||
if version != "" {
|
||||
updates["installed_version"] = 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)
|
||||
}
|
||||
@@ -393,4 +555,49 @@ func (ti *ToolInstaller) UpgradeTools() {
|
||||
"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)
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,12 @@ var toolMirrors = map[string][]ToolMirror{
|
||||
{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"},
|
||||
},
|
||||
"golang": {
|
||||
{Name: "Go官方", URL: "https://go.dev/dl/go{version}.linux-amd64.tar.gz", Location: "global"},
|
||||
{Name: "Google CN", URL: "https://golang.google.cn/dl/go{version}.linux-amd64.tar.gz", Location: "domestic"},
|
||||
{Name: "阿里云镜像", URL: "https://mirrors.aliyun.com/golang/go{version}.linux-amd64.tar.gz", Location: "domestic"},
|
||||
{Name: "中科大镜像", URL: "https://mirrors.ustc.edu.cn/golang/go{version}.linux-amd64.tar.gz", Location: "domestic"},
|
||||
},
|
||||
}
|
||||
|
||||
// GetMirrorsForTool 返回工具的镜像源列表(按网络环境排序)。
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
package toolinstaller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ezops/model"
|
||||
)
|
||||
|
||||
// UninstallResult 工具卸载结果(含脚本输出,供审计日志)// Logan.Li
|
||||
type UninstallResult struct {
|
||||
Output string
|
||||
Err error
|
||||
}
|
||||
|
||||
// Uninstall 卸载指定工具并重置 DB 状态(允许重新安装)// Logan.Li
|
||||
func (ti *ToolInstaller) Uninstall(toolName string) UninstallResult {
|
||||
var tool model.DeployTool
|
||||
if err := ti.db.Where("name = ?", toolName).First(&tool).Error; err != nil {
|
||||
return UninstallResult{Err: fmt.Errorf("tool %s not found in DB: %w", toolName, err)}
|
||||
}
|
||||
|
||||
ti.mu.Lock()
|
||||
delete(ti.onces, toolName)
|
||||
ti.mu.Unlock()
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := ti.uninstallTool(&tool, &buf); err != nil {
|
||||
out := strings.TrimSpace(buf.String())
|
||||
log.Printf("[ToolInstaller] ERROR uninstalling %s: %v", toolName, err)
|
||||
ti.resetUninstallStatus(tool.ID, out+": "+err.Error())
|
||||
return UninstallResult{Output: out, Err: err}
|
||||
}
|
||||
|
||||
ti.resetUninstallStatus(tool.ID, "")
|
||||
out := strings.TrimSpace(buf.String())
|
||||
log.Printf("[ToolInstaller] %s uninstalled successfully", toolName)
|
||||
return UninstallResult{Output: out}
|
||||
}
|
||||
|
||||
func (ti *ToolInstaller) resetUninstallStatus(toolID uint, errMsg string) {
|
||||
now := time.Now()
|
||||
updates := map[string]interface{}{
|
||||
"status": model.ToolNotInstalled,
|
||||
"installed_ver": "",
|
||||
"last_check": &now,
|
||||
}
|
||||
if errMsg != "" {
|
||||
updates["error_msg"] = errMsg
|
||||
} else {
|
||||
updates["error_msg"] = ""
|
||||
}
|
||||
ti.updateStatusMap(toolID, updates)
|
||||
}
|
||||
|
||||
func (ti *ToolInstaller) updateStatusMap(toolID uint, updates map[string]interface{}) {
|
||||
ti.db.Model(&model.DeployTool{}).Where("id = ?", toolID).Updates(updates)
|
||||
}
|
||||
|
||||
func (ti *ToolInstaller) uninstallTool(tool *model.DeployTool, buf *bytes.Buffer) error {
|
||||
switch tool.Name {
|
||||
case "jdk":
|
||||
return ti.uninstallJDK(buf)
|
||||
case "maven":
|
||||
return ti.uninstallMaven(buf)
|
||||
case "golang":
|
||||
return ti.uninstallGolang(buf)
|
||||
case "nvm":
|
||||
return ti.uninstallNVM(buf)
|
||||
case "nodejs":
|
||||
return ti.uninstallNodeJS(buf)
|
||||
case "git":
|
||||
return ti.uninstallApkPackage(buf, "git", "git")
|
||||
case "docker-cli":
|
||||
return ti.uninstallApkPackage(buf, "docker-cli", "docker")
|
||||
default:
|
||||
return ti.uninstallBinary(tool, buf)
|
||||
}
|
||||
}
|
||||
|
||||
func (ti *ToolInstaller) uninstallJDK(buf *bytes.Buffer) error {
|
||||
buf.WriteString("[JDK] Removing symlinks and /usr/local/share/jdk\n")
|
||||
removeIfExists(buf, "/usr/local/bin/java", "/usr/local/bin/javac", "/usr/local/bin/jar")
|
||||
removeDirIfExists(buf, "/usr/local/share/jdk")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ti *ToolInstaller) uninstallMaven(buf *bytes.Buffer) error {
|
||||
buf.WriteString("[Maven] Removing symlinks and /usr/local/share/maven\n")
|
||||
removeIfExists(buf, "/usr/local/bin/mvn")
|
||||
removeDirIfExists(buf, "/usr/local/share/maven")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ti *ToolInstaller) uninstallGolang(buf *bytes.Buffer) error {
|
||||
buf.WriteString("[Go] Removing GOROOT and symlinks\n")
|
||||
removeIfExists(buf, "/usr/local/bin/go", "/usr/local/bin/gofmt", "/etc/profile.d/golang.sh")
|
||||
removeDirIfExists(buf, "/usr/local/go")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ti *ToolInstaller) uninstallNVM(buf *bytes.Buffer) error {
|
||||
buf.WriteString("[NVM] Removing NVM and Node versions\n")
|
||||
removeIfExists(buf, "/usr/local/bin/node", "/usr/local/bin/npm", "/usr/local/bin/npx", "/etc/profile.d/nvm.sh")
|
||||
removeDirIfExists(buf, "/usr/local/share/nvm")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ti *ToolInstaller) uninstallNodeJS(buf *bytes.Buffer) error {
|
||||
buf.WriteString("[Node.js] Removing symlinks and NVM node versions\n")
|
||||
removeIfExists(buf, "/usr/local/bin/node", "/usr/local/bin/npm", "/usr/local/bin/npx")
|
||||
|
||||
nvmSh := "/usr/local/share/nvm/nvm.sh"
|
||||
if st, err := os.Stat(nvmSh); err == nil && !st.IsDir() {
|
||||
script := fmt.Sprintf(`export NVM_DIR="/usr/local/share/nvm"
|
||||
. "%s"
|
||||
CUR=$(nvm current 2>/dev/null || true)
|
||||
if [ -n "$CUR" ] && [ "$CUR" != "none" ] && [ "$CUR" != "system" ]; then
|
||||
nvm uninstall "$CUR" 2>/dev/null || true
|
||||
fi
|
||||
rm -rf "$NVM_DIR/versions/node"
|
||||
`, nvmSh)
|
||||
cmd := exec.Command("bash", "-c", script)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if len(out) > 0 {
|
||||
buf.Write(out)
|
||||
}
|
||||
if err != nil {
|
||||
buf.WriteString(fmt.Sprintf("[Node.js] nvm cleanup warning: %v\n", err))
|
||||
}
|
||||
}
|
||||
removeDirIfExists(buf, "/usr/local/share/nvm/versions/node")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ti *ToolInstaller) uninstallApkPackage(buf *bytes.Buffer, pkg, binary string) error {
|
||||
buf.WriteString(fmt.Sprintf("[%s] Trying apk del %s\n", binary, pkg))
|
||||
if _, err := exec.LookPath("apk"); err != nil {
|
||||
buf.WriteString(fmt.Sprintf("[%s] apk not found, skip package removal\n", binary))
|
||||
return nil
|
||||
}
|
||||
cmd := exec.Command("apk", "del", "--no-cache", pkg)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if len(out) > 0 {
|
||||
buf.Write(out)
|
||||
}
|
||||
if err != nil {
|
||||
// 可能未通过 apk 安装,仅标记卸载完成 // Logan.Li
|
||||
buf.WriteString(fmt.Sprintf("[%s] apk del skipped: %v\n", binary, err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ti *ToolInstaller) uninstallBinary(tool *model.DeployTool, buf *bytes.Buffer) error {
|
||||
path := tool.InstallPath
|
||||
if path == "" && tool.BinaryName != "" {
|
||||
path = filepath.Join("/usr/local/bin", tool.BinaryName)
|
||||
}
|
||||
if path == "" {
|
||||
return fmt.Errorf("no install path configured for %s", tool.Name)
|
||||
}
|
||||
buf.WriteString(fmt.Sprintf("[%s] Removing %s\n", tool.Name, path))
|
||||
removeIfExists(buf, path)
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeIfExists(buf *bytes.Buffer, paths ...string) {
|
||||
for _, p := range paths {
|
||||
if err := os.Remove(p); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
buf.WriteString(fmt.Sprintf("remove %s: %v\n", p, err))
|
||||
}
|
||||
} else {
|
||||
buf.WriteString(fmt.Sprintf("removed %s\n", p))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func removeDirIfExists(buf *bytes.Buffer, dir string) {
|
||||
if err := os.RemoveAll(dir); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
buf.WriteString(fmt.Sprintf("remove dir %s: %v\n", dir, err))
|
||||
}
|
||||
} else {
|
||||
buf.WriteString(fmt.Sprintf("removed dir %s\n", dir))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user