1e6ac95cdb
- 新增SSH密钥库管理(集中托管私钥、自动指纹识别) - 新增模板中心(24个预置模板,7大分类,一键创建Playbook) - 新增文件分发功能(内容/文件双模式,权限设置) - 新增系统信息接口(版本/主机数/运行时间) - 主机支持密码/SSH密钥双认证方式 - 全新暗色玻璃拟态UI(侧边栏导航、SSE实时日志流) - 修复UpdateHost ID丢失bug - 添加.gitignore排除敏感数据
1060 lines
26 KiB
Go
1060 lines
26 KiB
Go
package services
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"crypto/md5"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/ansible-deploy/internal/models"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// AnsibleService Ansible服务
|
|
type AnsibleService struct {
|
|
config *Config
|
|
hosts map[string]*models.Host
|
|
groups map[string]*models.HostGroup
|
|
inventoryPath string
|
|
tasks map[string]*models.TaskExecution
|
|
taskLock sync.RWMutex
|
|
sshKeys map[string]*models.SSHKey
|
|
sshKeyDir string
|
|
startTime time.Time
|
|
}
|
|
|
|
// NewAnsibleService 创建Ansible服务
|
|
func NewAnsibleService(cfg *Config) *AnsibleService {
|
|
sshKeyDir := filepath.Join(cfg.InventoryDir, "ssh_keys")
|
|
os.MkdirAll(sshKeyDir, 0700)
|
|
|
|
svc := &AnsibleService{
|
|
config: cfg,
|
|
hosts: make(map[string]*models.Host),
|
|
groups: make(map[string]*models.HostGroup),
|
|
inventoryPath: filepath.Join(cfg.InventoryDir, "hosts"),
|
|
tasks: make(map[string]*models.TaskExecution),
|
|
sshKeys: make(map[string]*models.SSHKey),
|
|
sshKeyDir: sshKeyDir,
|
|
startTime: time.Now(),
|
|
}
|
|
|
|
svc.groups["all"] = &models.HostGroup{Name: "all", Description: "所有主机"}
|
|
svc.groups["ungrouped"] = &models.HostGroup{Name: "ungrouped", Description: "未分组主机"}
|
|
|
|
svc.loadHosts()
|
|
svc.loadGroups()
|
|
svc.loadSSHKeys()
|
|
|
|
return svc
|
|
}
|
|
|
|
// ===== SSH密钥管理 =====
|
|
|
|
func (s *AnsibleService) loadSSHKeys() {
|
|
files, _ := os.ReadDir(s.sshKeyDir)
|
|
for _, f := range files {
|
|
if f.IsDir() || !strings.HasSuffix(f.Name(), ".pem") {
|
|
continue
|
|
}
|
|
name := strings.TrimSuffix(f.Name(), ".pem")
|
|
path := filepath.Join(s.sshKeyDir, f.Name())
|
|
fingerprint := s.getKeyFingerprint(path)
|
|
info, _ := f.Info()
|
|
s.sshKeys[name] = &models.SSHKey{
|
|
Name: name,
|
|
Path: path,
|
|
Fingerprint: fingerprint,
|
|
CreatedAt: info.ModTime(),
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *AnsibleService) getKeyFingerprint(path string) string {
|
|
cmd := exec.Command("ssh-keygen", "-lf", path)
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
fields := strings.Fields(string(out))
|
|
if len(fields) >= 2 {
|
|
return fields[1]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (s *AnsibleService) ListSSHKeys() []models.SSHKey {
|
|
var keys []models.SSHKey
|
|
for _, k := range s.sshKeys {
|
|
keys = append(keys, *k)
|
|
}
|
|
return keys
|
|
}
|
|
|
|
func (s *AnsibleService) AddSSHKey(name string, privateKey string) error {
|
|
if name == "" {
|
|
return fmt.Errorf("密钥名称不能为空")
|
|
}
|
|
if strings.Contains(name, "/") || strings.Contains(name, "..") {
|
|
return fmt.Errorf("密钥名称包含非法字符")
|
|
}
|
|
keyPath := filepath.Join(s.sshKeyDir, name+".pem")
|
|
if _, err := os.Stat(keyPath); err == nil {
|
|
return fmt.Errorf("密钥已存在: %s", name)
|
|
}
|
|
if err := os.WriteFile(keyPath, []byte(privateKey), 0600); err != nil {
|
|
return fmt.Errorf("写入密钥失败: %v", err)
|
|
}
|
|
fingerprint := s.getKeyFingerprint(keyPath)
|
|
s.sshKeys[name] = &models.SSHKey{
|
|
Name: name,
|
|
Path: keyPath,
|
|
Fingerprint: fingerprint,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *AnsibleService) DeleteSSHKey(name string) error {
|
|
keyPath := filepath.Join(s.sshKeyDir, name+".pem")
|
|
if _, err := os.Stat(keyPath); os.IsNotExist(err) {
|
|
return fmt.Errorf("密钥不存在: %s", name)
|
|
}
|
|
os.Remove(keyPath)
|
|
delete(s.sshKeys, name)
|
|
return nil
|
|
}
|
|
|
|
// resolveSSHKeyPath 解析SSH密钥路径(名称或完整路径)
|
|
func (s *AnsibleService) resolveSSHKeyPath(keyRef string) string {
|
|
if keyRef == "" {
|
|
return ""
|
|
}
|
|
// 如果是完整路径,直接返回
|
|
if strings.HasPrefix(keyRef, "/") {
|
|
return keyRef
|
|
}
|
|
// 尝试从密钥库查找
|
|
if k, ok := s.sshKeys[keyRef]; ok {
|
|
return k.Path
|
|
}
|
|
// 尝试加.pem后缀
|
|
keyPath := filepath.Join(s.sshKeyDir, keyRef+".pem")
|
|
if _, err := os.Stat(keyPath); err == nil {
|
|
return keyPath
|
|
}
|
|
return keyRef
|
|
}
|
|
|
|
// ===== 文件分发 =====
|
|
|
|
func (s *AnsibleService) DistributeFile(req models.FileDistributeRequest) (*models.TaskExecution, error) {
|
|
task := &models.TaskExecution{
|
|
ID: s.generateID(),
|
|
Name: "文件分发 → " + req.DestPath,
|
|
Hosts: req.Hosts,
|
|
Status: "running",
|
|
StartTime: time.Now(),
|
|
TotalHosts: len(req.Hosts),
|
|
}
|
|
|
|
s.taskLock.Lock()
|
|
s.tasks[task.ID] = task
|
|
s.taskLock.Unlock()
|
|
|
|
go s.runFileDistribute(task, req)
|
|
return task, nil
|
|
}
|
|
|
|
func (s *AnsibleService) runFileDistribute(task *models.TaskExecution, req models.FileDistributeRequest) {
|
|
var sw syncWriter
|
|
sw.buf = bytes.NewBuffer(nil)
|
|
|
|
for i, hostName := range req.Hosts {
|
|
host := s.findHostByName(hostName)
|
|
if host == nil {
|
|
sw.WriteString(fmt.Sprintf("[%s] ✗ 主机不存在\n", hostName))
|
|
s.taskLock.Lock()
|
|
task.FailedHosts++
|
|
task.Progress = i + 1
|
|
task.Output = sw.String()
|
|
s.taskLock.Unlock()
|
|
continue
|
|
}
|
|
|
|
start := time.Now()
|
|
var args []string
|
|
|
|
if req.Content != "" {
|
|
// 使用copy模块分发内容
|
|
args = []string{
|
|
host.Name, "-i", s.inventoryPath,
|
|
"-m", "copy",
|
|
"-a", fmt.Sprintf("content='%s' dest=%s", strings.ReplaceAll(req.Content, "'", "\\'"), req.DestPath),
|
|
"-u", host.Username,
|
|
}
|
|
} else {
|
|
// 使用copy模块分发文件
|
|
copyArgs := fmt.Sprintf("src=%s dest=%s", req.SourceFile, req.DestPath)
|
|
if req.Owner != "" {
|
|
copyArgs += " owner=" + req.Owner
|
|
}
|
|
if req.Group != "" {
|
|
copyArgs += " group=" + req.Group
|
|
}
|
|
if req.Mode != "" {
|
|
copyArgs += " mode=" + req.Mode
|
|
}
|
|
args = []string{
|
|
host.Name, "-i", s.inventoryPath,
|
|
"-m", "copy",
|
|
"-a", copyArgs,
|
|
"-u", host.Username,
|
|
}
|
|
}
|
|
|
|
// 认证
|
|
if host.AuthType == "sshkey" && host.SSHKey != "" {
|
|
keyPath := s.resolveSSHKeyPath(host.SSHKey)
|
|
args = append(args, "--private-key", keyPath)
|
|
} else if host.Password != "" {
|
|
args = append(args, "--extra-vars", fmt.Sprintf("ansible_password=%s", host.Password))
|
|
}
|
|
if host.Port != 0 && host.Port != 22 {
|
|
args = append(args, "--extra-vars", fmt.Sprintf("ansible_port=%d", host.Port))
|
|
}
|
|
|
|
cmd := exec.Command(s.config.AnsiblePath, args...)
|
|
cmd.Env = append(os.Environ(), "ANSIBLE_HOST_KEY_CHECKING=False")
|
|
output, err := cmd.CombinedOutput()
|
|
duration := time.Since(start).Milliseconds()
|
|
|
|
s.taskLock.Lock()
|
|
if err != nil {
|
|
sw.WriteString(fmt.Sprintf("[%s] ✗ 失败 (%dms): %s\n", hostName, duration, string(output)))
|
|
task.FailedHosts++
|
|
} else {
|
|
sw.WriteString(fmt.Sprintf("[%s] ✓ 成功 (%dms)\n", hostName, duration))
|
|
task.SuccessHosts++
|
|
}
|
|
task.Progress = i + 1
|
|
task.Output = sw.String()
|
|
s.taskLock.Unlock()
|
|
}
|
|
|
|
s.taskLock.Lock()
|
|
task.EndTime = time.Now()
|
|
if task.FailedHosts > 0 {
|
|
task.Status = "failed"
|
|
} else {
|
|
task.Status = "success"
|
|
}
|
|
s.taskLock.Unlock()
|
|
}
|
|
|
|
// ===== 系统信息 =====
|
|
|
|
func (s *AnsibleService) GetSystemInfo() models.SystemInfo {
|
|
info := models.SystemInfo{
|
|
Version: "2.0.0",
|
|
AnsiblePath: s.config.AnsiblePath,
|
|
Hostname: getHostname(),
|
|
OS: runtime.GOOS,
|
|
Arch: runtime.GOARCH,
|
|
HostCount: len(s.hosts),
|
|
TaskCount: len(s.tasks),
|
|
Uptime: time.Since(s.startTime).Round(time.Second).String(),
|
|
}
|
|
|
|
// 获取组数量(排除系统组)
|
|
groupCount := 0
|
|
for name := range s.groups {
|
|
if name != "all" && name != "ungrouped" {
|
|
groupCount++
|
|
}
|
|
}
|
|
info.GroupCount = groupCount
|
|
|
|
// Playbook数量
|
|
files, _ := os.ReadDir(s.config.PlaybookDir)
|
|
for _, f := range files {
|
|
if !f.IsDir() && strings.HasSuffix(f.Name(), ".yml") {
|
|
info.PlaybookCount++
|
|
}
|
|
}
|
|
|
|
// Ansible版本
|
|
cmd := exec.Command("ansible", "--version")
|
|
out, err := cmd.Output()
|
|
if err == nil {
|
|
lines := strings.Split(string(out), "\n")
|
|
if len(lines) > 0 {
|
|
info.AnsibleVer = strings.TrimSpace(lines[0])
|
|
}
|
|
}
|
|
|
|
return info
|
|
}
|
|
|
|
func getHostname() string {
|
|
h, _ := os.Hostname()
|
|
return h
|
|
}
|
|
|
|
// ===== 原有功能 =====
|
|
|
|
func (s *AnsibleService) loadGroups() {
|
|
groupsFile := filepath.Join(s.config.InventoryDir, "groups.json")
|
|
data, err := os.ReadFile(groupsFile)
|
|
if err != nil {
|
|
return
|
|
}
|
|
var groups map[string]models.HostGroup
|
|
if err := json.Unmarshal(data, &groups); err == nil {
|
|
for name, g := range groups {
|
|
if name != "all" && name != "ungrouped" {
|
|
gcopy := g
|
|
s.groups[name] = &gcopy
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *AnsibleService) generateID() string {
|
|
hash := md5.New()
|
|
hash.Write([]byte(time.Now().String() + strconv.Itoa(os.Getpid())))
|
|
return hex.EncodeToString(hash.Sum(nil))[:8]
|
|
}
|
|
|
|
func (s *AnsibleService) loadInventory() {
|
|
invFile := filepath.Join(s.config.InventoryDir, "hosts")
|
|
data, err := os.ReadFile(invFile)
|
|
if err != nil {
|
|
return
|
|
}
|
|
scanner := bufio.NewScanner(bytes.NewReader(data))
|
|
var currentGroup string
|
|
groupVars := make(map[string]map[string]string)
|
|
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
|
currentGroup = strings.Trim(line, "[]")
|
|
continue
|
|
}
|
|
if strings.Contains(line, "=") {
|
|
parts := strings.SplitN(line, "=", 2)
|
|
if len(parts) == 2 {
|
|
if groupVars[currentGroup] == nil {
|
|
groupVars[currentGroup] = make(map[string]string)
|
|
}
|
|
groupVars[currentGroup][strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
|
|
}
|
|
}
|
|
if strings.Contains(line, "ansible_host") {
|
|
re := regexp.MustCompile(`(\S+)\s+ansible_host=(\S+)`)
|
|
if matches := re.FindStringSubmatch(line); len(matches) == 3 {
|
|
host := &models.Host{
|
|
ID: s.generateID(),
|
|
Name: matches[1],
|
|
IP: matches[2],
|
|
Status: "unknown",
|
|
}
|
|
s.hosts[host.ID] = host
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *AnsibleService) loadHosts() {
|
|
hostsFile := filepath.Join(s.config.InventoryDir, "hosts.json")
|
|
data, err := os.ReadFile(hostsFile)
|
|
if err != nil {
|
|
return
|
|
}
|
|
var hosts []models.Host
|
|
if err := json.Unmarshal(data, &hosts); err == nil {
|
|
for _, h := range hosts {
|
|
host := h
|
|
if host.ID == "" {
|
|
host.ID = s.generateID()
|
|
}
|
|
if host.Port == 0 {
|
|
host.Port = 22
|
|
}
|
|
if host.Username == "" {
|
|
host.Username = "root"
|
|
}
|
|
if host.Status == "" {
|
|
host.Status = "pending"
|
|
}
|
|
s.hosts[host.ID] = &host
|
|
}
|
|
s.saveHosts()
|
|
}
|
|
}
|
|
|
|
func (s *AnsibleService) saveHosts() error {
|
|
hostsFile := filepath.Join(s.config.InventoryDir, "hosts.json")
|
|
var hosts []models.Host
|
|
for _, h := range s.hosts {
|
|
hcopy := *h
|
|
if hcopy.ID == "" {
|
|
hcopy.ID = s.generateID()
|
|
h.ID = hcopy.ID
|
|
}
|
|
hosts = append(hosts, hcopy)
|
|
}
|
|
data, _ := json.MarshalIndent(hosts, "", " ")
|
|
if err := os.WriteFile(hostsFile, data, 0644); err != nil {
|
|
return err
|
|
}
|
|
s.updateInventoryFile()
|
|
return nil
|
|
}
|
|
|
|
func (s *AnsibleService) updateInventoryFile() {
|
|
var lines []string
|
|
lines = append(lines, "# Ansible Inventory File")
|
|
lines = append(lines, "# Generated by ansible-deploy")
|
|
lines = append(lines, "")
|
|
|
|
groupedHosts := make(map[string][]models.Host)
|
|
for _, h := range s.hosts {
|
|
if len(h.Groups) == 0 {
|
|
groupedHosts["ungrouped"] = append(groupedHosts["ungrouped"], *h)
|
|
} else {
|
|
for _, g := range h.Groups {
|
|
groupedHosts[g] = append(groupedHosts[g], *h)
|
|
}
|
|
}
|
|
}
|
|
|
|
for group, hosts := range groupedHosts {
|
|
lines = append(lines, fmt.Sprintf("[%s]", group))
|
|
for _, h := range hosts {
|
|
line := fmt.Sprintf(" %s ansible_host=%s", h.Name, h.IP)
|
|
if h.Port != 0 && h.Port != 22 {
|
|
line += fmt.Sprintf(" ansible_port=%d", h.Port)
|
|
}
|
|
if h.Username != "" {
|
|
line += fmt.Sprintf(" ansible_user=%s", h.Username)
|
|
}
|
|
if h.AuthType == "sshkey" && h.SSHKey != "" {
|
|
keyPath := s.resolveSSHKeyPath(h.SSHKey)
|
|
line += fmt.Sprintf(" ansible_ssh_private_key_file=%s", keyPath)
|
|
}
|
|
lines = append(lines, line)
|
|
}
|
|
lines = append(lines, "")
|
|
}
|
|
|
|
invFile := filepath.Join(s.config.InventoryDir, "hosts")
|
|
os.WriteFile(invFile, []byte(strings.Join(lines, "\n")), 0644)
|
|
}
|
|
|
|
func (s *AnsibleService) ListHosts() []models.Host {
|
|
var hosts []models.Host
|
|
for _, h := range s.hosts {
|
|
hosts = append(hosts, *h)
|
|
}
|
|
return hosts
|
|
}
|
|
|
|
func (s *AnsibleService) AddHost(host models.Host) error {
|
|
host.ID = s.generateID()
|
|
host.CreatedAt = time.Now()
|
|
host.UpdatedAt = time.Now()
|
|
host.Status = "pending"
|
|
s.hosts[host.ID] = &host
|
|
return s.saveHosts()
|
|
}
|
|
|
|
func (s *AnsibleService) DeleteHost(id string) error {
|
|
if _, ok := s.hosts[id]; !ok {
|
|
return fmt.Errorf("主机不存在")
|
|
}
|
|
delete(s.hosts, id)
|
|
return s.saveHosts()
|
|
}
|
|
|
|
func (s *AnsibleService) UpdateHost(id string, host models.Host) error {
|
|
if _, ok := s.hosts[id]; !ok {
|
|
return fmt.Errorf("主机不存在")
|
|
}
|
|
host.ID = id
|
|
host.UpdatedAt = time.Now()
|
|
s.hosts[id] = &host
|
|
return s.saveHosts()
|
|
}
|
|
|
|
func (s *AnsibleService) findHostByName(name string) *models.Host {
|
|
for _, h := range s.hosts {
|
|
if h.Name == name {
|
|
return h
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *AnsibleService) ListGroups() []models.HostGroup {
|
|
var groups []models.HostGroup
|
|
for _, g := range s.groups {
|
|
gcopy := *g
|
|
var hostList []models.Host
|
|
for _, h := range s.hosts {
|
|
if gcopy.Name == "all" {
|
|
hcopy := *h
|
|
hostList = append(hostList, hcopy)
|
|
continue
|
|
}
|
|
for _, hGroup := range h.Groups {
|
|
if hGroup == gcopy.Name {
|
|
hcopy := *h
|
|
hostList = append(hostList, hcopy)
|
|
break
|
|
}
|
|
}
|
|
if len(h.Groups) == 0 && gcopy.Name == "ungrouped" {
|
|
hcopy := *h
|
|
hostList = append(hostList, hcopy)
|
|
}
|
|
}
|
|
gcopy.HostList = hostList
|
|
groups = append(groups, gcopy)
|
|
}
|
|
return groups
|
|
}
|
|
|
|
func (s *AnsibleService) CreateGroup(group models.HostGroup) error {
|
|
if _, ok := s.groups[group.Name]; ok {
|
|
return fmt.Errorf("组已存在")
|
|
}
|
|
s.groups[group.Name] = &group
|
|
return s.saveGroups()
|
|
}
|
|
|
|
func (s *AnsibleService) DeleteGroup(name string) error {
|
|
if name == "all" || name == "ungrouped" {
|
|
return fmt.Errorf("不能删除系统组")
|
|
}
|
|
delete(s.groups, name)
|
|
return s.saveGroups()
|
|
}
|
|
|
|
func (s *AnsibleService) UpdateGroup(name string, group models.HostGroup) error {
|
|
if _, ok := s.groups[name]; !ok {
|
|
return fmt.Errorf("组不存在")
|
|
}
|
|
s.groups[name] = &group
|
|
return s.saveGroups()
|
|
}
|
|
|
|
func (s *AnsibleService) saveGroups() error {
|
|
groupsFile := filepath.Join(s.config.InventoryDir, "groups.json")
|
|
data, _ := json.MarshalIndent(s.groups, "", " ")
|
|
return os.WriteFile(groupsFile, data, 0644)
|
|
}
|
|
|
|
func (s *AnsibleService) TestConnection(hostID string) (*models.CommandResult, error) {
|
|
host, ok := s.hosts[hostID]
|
|
if !ok {
|
|
return nil, fmt.Errorf("主机不存在")
|
|
}
|
|
|
|
start := time.Now()
|
|
result := &models.CommandResult{Host: host.Name, Success: false}
|
|
|
|
args := []string{
|
|
host.Name,
|
|
"-i", s.inventoryPath,
|
|
"-m", "ping",
|
|
"-u", host.Username,
|
|
}
|
|
|
|
if host.AuthType == "sshkey" && host.SSHKey != "" {
|
|
keyPath := s.resolveSSHKeyPath(host.SSHKey)
|
|
args = append(args, "--private-key", keyPath)
|
|
} else if host.Password != "" {
|
|
args = append(args, "--extra-vars", fmt.Sprintf("ansible_password=%s", host.Password))
|
|
}
|
|
if host.Port != 0 && host.Port != 22 {
|
|
args = append(args, "--extra-vars", fmt.Sprintf("ansible_port=%d", host.Port))
|
|
}
|
|
|
|
cmd := exec.Command(s.config.AnsiblePath, args...)
|
|
cmd.Env = append(os.Environ(), "ANSIBLE_HOST_KEY_CHECKING=False")
|
|
output, err := cmd.CombinedOutput()
|
|
result.Duration = time.Since(start).Milliseconds()
|
|
result.Output = string(output)
|
|
|
|
if err != nil {
|
|
result.Error = err.Error()
|
|
host.Status = "offline"
|
|
} else {
|
|
result.Success = true
|
|
if strings.Contains(string(output), "SUCCESS") || strings.Contains(string(output), "pong") {
|
|
host.Status = "online"
|
|
} else {
|
|
host.Status = "offline"
|
|
}
|
|
}
|
|
host.LastCheck = time.Now()
|
|
s.saveHosts()
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (s *AnsibleService) ExecuteCommand(req models.CommandRequest) ([]models.CommandResult, error) {
|
|
var results []models.CommandResult
|
|
for _, hostName := range req.Hosts {
|
|
result := s.runCommand(hostName, req.Command, req.Timeout)
|
|
results = append(results, result)
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
func (s *AnsibleService) runCommand(hostName string, command string, timeout int) models.CommandResult {
|
|
start := time.Now()
|
|
result := models.CommandResult{Host: hostName, Success: false}
|
|
|
|
host := s.findHostByName(hostName)
|
|
if host == nil {
|
|
result.Error = "主机不存在"
|
|
return result
|
|
}
|
|
|
|
if timeout == 0 {
|
|
timeout = s.config.SSHTimeout
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
|
|
defer cancel()
|
|
|
|
args := []string{
|
|
host.Name,
|
|
"-i", s.inventoryPath,
|
|
"-m", "shell",
|
|
"-a", command,
|
|
"-u", host.Username,
|
|
}
|
|
|
|
if host.AuthType == "sshkey" && host.SSHKey != "" {
|
|
keyPath := s.resolveSSHKeyPath(host.SSHKey)
|
|
args = append(args, "--private-key", keyPath)
|
|
} else if host.Password != "" {
|
|
args = append(args, "--extra-vars", fmt.Sprintf("ansible_password=%s", host.Password))
|
|
}
|
|
if host.Port != 0 && host.Port != 22 {
|
|
args = append(args, "--extra-vars", fmt.Sprintf("ansible_port=%d", host.Port))
|
|
}
|
|
|
|
cmd := exec.CommandContext(ctx, s.config.AnsiblePath, args...)
|
|
cmd.Env = append(os.Environ(), "ANSIBLE_HOST_KEY_CHECKING=False")
|
|
output, err := cmd.CombinedOutput()
|
|
result.Duration = time.Since(start).Milliseconds()
|
|
result.Output = string(output)
|
|
|
|
if err != nil {
|
|
result.Error = err.Error()
|
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
|
result.ExitCode = exitErr.ExitCode()
|
|
}
|
|
} else {
|
|
result.Success = true
|
|
result.ExitCode = 0
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func (s *AnsibleService) BatchExecute(req models.CommandRequest) *models.BatchCommandResult {
|
|
result := &models.BatchCommandResult{
|
|
TaskID: s.generateID(),
|
|
Total: len(req.Hosts),
|
|
Results: make([]models.CommandResult, 0),
|
|
}
|
|
|
|
task := &models.TaskExecution{
|
|
ID: result.TaskID,
|
|
Name: "批量命令执行",
|
|
Hosts: req.Hosts,
|
|
Status: "running",
|
|
StartTime: time.Now(),
|
|
TotalHosts: len(req.Hosts),
|
|
}
|
|
|
|
s.taskLock.Lock()
|
|
s.tasks[result.TaskID] = task
|
|
s.taskLock.Unlock()
|
|
|
|
if req.Parallel {
|
|
var wg sync.WaitGroup
|
|
results := make(chan models.CommandResult, len(req.Hosts))
|
|
parallelism := s.config.MaxParallelism
|
|
if parallelism <= 0 {
|
|
parallelism = 10
|
|
}
|
|
semaphore := make(chan struct{}, parallelism)
|
|
|
|
for _, host := range req.Hosts {
|
|
wg.Add(1)
|
|
go func(h string) {
|
|
defer wg.Done()
|
|
semaphore <- struct{}{}
|
|
defer func() { <-semaphore }()
|
|
r := s.runCommand(h, req.Command, req.Timeout)
|
|
results <- r
|
|
}(host)
|
|
}
|
|
|
|
go func() {
|
|
wg.Wait()
|
|
close(results)
|
|
}()
|
|
|
|
for r := range results {
|
|
result.Results = append(result.Results, r)
|
|
if r.Success {
|
|
result.Success++
|
|
} else {
|
|
result.Failed++
|
|
}
|
|
s.updateTaskProgress(result.TaskID, 1)
|
|
}
|
|
} else {
|
|
for _, host := range req.Hosts {
|
|
r := s.runCommand(host, req.Command, req.Timeout)
|
|
result.Results = append(result.Results, r)
|
|
if r.Success {
|
|
result.Success++
|
|
} else {
|
|
result.Failed++
|
|
}
|
|
s.updateTaskProgress(result.TaskID, 1)
|
|
}
|
|
}
|
|
|
|
task.Status = "completed"
|
|
task.EndTime = time.Now()
|
|
|
|
return result
|
|
}
|
|
|
|
func (s *AnsibleService) updateTaskProgress(taskID string, increment int) {
|
|
s.taskLock.Lock()
|
|
defer s.taskLock.Unlock()
|
|
if task, ok := s.tasks[taskID]; ok {
|
|
task.Progress += increment
|
|
task.SuccessHosts = task.Progress
|
|
if task.Progress >= task.TotalHosts {
|
|
task.Status = "completed"
|
|
task.EndTime = time.Now()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *AnsibleService) ListTasks() []*models.TaskExecution {
|
|
s.taskLock.RLock()
|
|
defer s.taskLock.RUnlock()
|
|
var tasks []*models.TaskExecution
|
|
for _, t := range s.tasks {
|
|
tasks = append(tasks, t)
|
|
}
|
|
return tasks
|
|
}
|
|
|
|
func (s *AnsibleService) GetTask(id string) *models.TaskExecution {
|
|
s.taskLock.RLock()
|
|
defer s.taskLock.RUnlock()
|
|
return s.tasks[id]
|
|
}
|
|
|
|
func (s *AnsibleService) CancelTask(id string) error {
|
|
s.taskLock.Lock()
|
|
defer s.taskLock.Unlock()
|
|
if task, ok := s.tasks[id]; ok {
|
|
if task.Status == "running" {
|
|
task.Status = "cancelled"
|
|
task.EndTime = time.Now()
|
|
return nil
|
|
}
|
|
return fmt.Errorf("任务无法取消")
|
|
}
|
|
return fmt.Errorf("任务不存在")
|
|
}
|
|
|
|
func (s *AnsibleService) ExecutePlaybook(req models.PlaybookExecutionRequest) (*models.TaskExecution, error) {
|
|
playbookPath := filepath.Join(s.config.PlaybookDir, req.Name+".yml")
|
|
if _, err := os.Stat(playbookPath); os.IsNotExist(err) {
|
|
return nil, fmt.Errorf("Playbook不存在: %s", req.Name)
|
|
}
|
|
|
|
task := &models.TaskExecution{
|
|
ID: s.generateID(),
|
|
Name: req.Name,
|
|
Playbook: playbookPath,
|
|
Hosts: req.Hosts,
|
|
Status: "running",
|
|
StartTime: time.Now(),
|
|
TotalHosts: len(req.Hosts),
|
|
SuccessHosts: 0,
|
|
FailedHosts: 0,
|
|
}
|
|
|
|
s.taskLock.Lock()
|
|
s.tasks[task.ID] = task
|
|
s.taskLock.Unlock()
|
|
|
|
go s.runPlaybook(task, playbookPath, req)
|
|
|
|
return task, nil
|
|
}
|
|
|
|
func (s *AnsibleService) runPlaybook(task *models.TaskExecution, playbookPath string, req models.PlaybookExecutionRequest) {
|
|
var args []string
|
|
|
|
args = append(args, "-i", s.inventoryPath)
|
|
|
|
if len(req.Hosts) > 0 {
|
|
args = append(args, "-l", strings.Join(req.Hosts, ","))
|
|
}
|
|
if len(req.ExtraVars) > 0 {
|
|
varsJSON, _ := json.Marshal(req.ExtraVars)
|
|
args = append(args, "-e", string(varsJSON))
|
|
}
|
|
if len(req.Tags) > 0 {
|
|
args = append(args, "-t", strings.Join(req.Tags, ","))
|
|
}
|
|
if len(req.SkipTags) > 0 {
|
|
args = append(args, "--skip-tags", strings.Join(req.SkipTags, ","))
|
|
}
|
|
if req.Verbose != "" {
|
|
args = append(args, "-"+req.Verbose)
|
|
}
|
|
if req.Diff {
|
|
args = append(args, "-D")
|
|
}
|
|
if req.Check {
|
|
args = append(args, "-C")
|
|
}
|
|
if req.Become != nil {
|
|
if *req.Become {
|
|
args = append(args, "-b")
|
|
} else {
|
|
args = append(args, "--no-become")
|
|
}
|
|
}
|
|
if req.Forks > 0 {
|
|
args = append(args, "-f", strconv.Itoa(req.Forks))
|
|
}
|
|
if req.ExtraArgs != "" {
|
|
extraParts := strings.Fields(req.ExtraArgs)
|
|
args = append(args, extraParts...)
|
|
}
|
|
|
|
args = append(args, playbookPath)
|
|
|
|
cmd := exec.Command("ansible-playbook", args...)
|
|
|
|
if req.Timeout > 0 {
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(req.Timeout)*time.Second)
|
|
defer cancel()
|
|
cmd = exec.CommandContext(ctx, "ansible-playbook", args...)
|
|
}
|
|
|
|
sw := &syncWriter{buf: bytes.NewBuffer(nil)}
|
|
cmd.Stdout = sw
|
|
cmd.Stderr = sw
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
ticker := time.NewTicker(200 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
var lastLen int
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
s.taskLock.Lock()
|
|
sw.mu.Lock()
|
|
currentLen := sw.buf.Len()
|
|
if currentLen > lastLen {
|
|
task.Output = sw.buf.String()
|
|
lastLen = currentLen
|
|
}
|
|
sw.mu.Unlock()
|
|
s.taskLock.Unlock()
|
|
case <-done:
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
err := cmd.Run()
|
|
close(done)
|
|
|
|
sw.mu.Lock()
|
|
finalOutput := sw.buf.String()
|
|
sw.mu.Unlock()
|
|
|
|
s.taskLock.Lock()
|
|
task.Output = finalOutput
|
|
task.EndTime = time.Now()
|
|
if err != nil {
|
|
task.Status = "failed"
|
|
task.Error = err.Error()
|
|
} else {
|
|
task.Status = "success"
|
|
}
|
|
s.taskLock.Unlock()
|
|
}
|
|
|
|
type syncWriter struct {
|
|
buf *bytes.Buffer
|
|
mu sync.Mutex
|
|
}
|
|
|
|
func (w *syncWriter) Write(p []byte) (n int, err error) {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
return w.buf.Write(p)
|
|
}
|
|
|
|
func (w *syncWriter) String() string {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
return w.buf.String()
|
|
}
|
|
|
|
func (w *syncWriter) WriteString(s string) (n int, err error) {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
return w.buf.WriteString(s)
|
|
}
|
|
|
|
func (s *AnsibleService) ListPlaybooks() []models.Playbook {
|
|
var playbooks []models.Playbook
|
|
files, _ := os.ReadDir(s.config.PlaybookDir)
|
|
for _, f := range files {
|
|
if !f.IsDir() && strings.HasSuffix(f.Name(), ".yml") {
|
|
name := strings.TrimSuffix(f.Name(), ".yml")
|
|
playbookPath := filepath.Join(s.config.PlaybookDir, f.Name())
|
|
playbook := models.Playbook{Name: name, Path: playbookPath}
|
|
|
|
data, err := os.ReadFile(playbookPath)
|
|
if err == nil {
|
|
var playEntries []map[string]interface{}
|
|
if yaml.Unmarshal(data, &playEntries) == nil && len(playEntries) > 0 {
|
|
first := playEntries[0]
|
|
if nameVal, ok := first["name"]; ok {
|
|
playbook.Description = fmt.Sprintf("%v", nameVal)
|
|
}
|
|
if varsVal, ok := first["vars"]; ok {
|
|
if varsMap, ok := varsVal.(map[string]interface{}); ok {
|
|
playbook.Variables = varsMap
|
|
}
|
|
}
|
|
}
|
|
}
|
|
playbooks = append(playbooks, playbook)
|
|
}
|
|
}
|
|
return playbooks
|
|
}
|
|
|
|
func (s *AnsibleService) GetPlaybook(name string) (*models.Playbook, error) {
|
|
playbookPath := filepath.Join(s.config.PlaybookDir, name+".yml")
|
|
data, err := os.ReadFile(playbookPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Playbook不存在")
|
|
}
|
|
var playbook models.Playbook
|
|
playbook.Name = name
|
|
playbook.Path = playbookPath
|
|
if err := yaml.Unmarshal(data, &playbook); err != nil {
|
|
return nil, fmt.Errorf("Playbook解析失败")
|
|
}
|
|
return &playbook, nil
|
|
}
|
|
|
|
func (s *AnsibleService) CreatePlaybook(name string, content string) error {
|
|
if name == "" {
|
|
return fmt.Errorf("Playbook名称不能为空")
|
|
}
|
|
if strings.Contains(name, "/") || strings.Contains(name, "..") {
|
|
return fmt.Errorf("Playbook名称包含非法字符")
|
|
}
|
|
playbookPath := filepath.Join(s.config.PlaybookDir, name+".yml")
|
|
if _, err := os.Stat(playbookPath); err == nil {
|
|
return fmt.Errorf("Playbook已存在: %s", name)
|
|
}
|
|
var dummy interface{}
|
|
if err := yaml.Unmarshal([]byte(content), &dummy); err != nil {
|
|
return fmt.Errorf("YAML格式错误: %v", err)
|
|
}
|
|
return os.WriteFile(playbookPath, []byte(content), 0644)
|
|
}
|
|
|
|
func (s *AnsibleService) DeletePlaybook(name string) error {
|
|
if strings.Contains(name, "/") || strings.Contains(name, "..") {
|
|
return fmt.Errorf("Playbook名称包含非法字符")
|
|
}
|
|
playbookPath := filepath.Join(s.config.PlaybookDir, name+".yml")
|
|
if _, err := os.Stat(playbookPath); os.IsNotExist(err) {
|
|
return fmt.Errorf("Playbook不存在: %s", name)
|
|
}
|
|
return os.Remove(playbookPath)
|
|
}
|
|
|
|
func (s *AnsibleService) UpdatePlaybook(name string, content string) error {
|
|
if strings.Contains(name, "/") || strings.Contains(name, "..") {
|
|
return fmt.Errorf("Playbook名称包含非法字符")
|
|
}
|
|
playbookPath := filepath.Join(s.config.PlaybookDir, name+".yml")
|
|
if _, err := os.Stat(playbookPath); os.IsNotExist(err) {
|
|
return fmt.Errorf("Playbook不存在: %s", name)
|
|
}
|
|
var dummy interface{}
|
|
if err := yaml.Unmarshal([]byte(content), &dummy); err != nil {
|
|
return fmt.Errorf("YAML格式错误: %v", err)
|
|
}
|
|
return os.WriteFile(playbookPath, []byte(content), 0644)
|
|
}
|
|
|
|
func (s *AnsibleService) GetPlaybookContent(name string) (string, error) {
|
|
if strings.Contains(name, "/") || strings.Contains(name, "..") {
|
|
return "", fmt.Errorf("Playbook名称包含非法字符")
|
|
}
|
|
playbookPath := filepath.Join(s.config.PlaybookDir, name+".yml")
|
|
data, err := os.ReadFile(playbookPath)
|
|
if err != nil {
|
|
return "", fmt.Errorf("Playbook不存在")
|
|
}
|
|
return string(data), nil
|
|
}
|
|
|
|
func (s *AnsibleService) CheckAnsibleInstalled() bool {
|
|
cmd := exec.Command("ansible", "--version")
|
|
err := cmd.Run()
|
|
return err == nil
|
|
}
|
|
|
|
func (s *AnsibleService) GetInventoryPath() string {
|
|
return s.inventoryPath
|
|
}
|