diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3a0c641 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# 二进制文件 +ansible-deploy + +# 敏感数据 - 主机配置含密码 +inventory/hosts.json +inventory/groups.json +inventory/hosts + +# SSH私钥 +inventory/ssh_keys/ + +# 日志 +logs/ + +# 系统文件 +.DS_Store +*.log diff --git a/README.md b/README.md index baa4b38..3ca7991 100644 --- a/README.md +++ b/README.md @@ -1,96 +1,86 @@ -# Ansible批量部署工具 +# ⚡ Ansible Deploy Pro -基于 Go + Ansible 的批量运维部署系统,支持批量执行命令、Playbook管理、主机分组等功能。 +基于 Go + Ansible 的企业级批量运维部署平台,支持 SSH 密钥管理、Playbook 模板中心、文件分发、实时日志流等功能。 -## 功能特性 +## ✨ 功能特性 -- 🌐 **Web界面** - 简洁美观的Web管理界面 -- 🖥️ **主机管理** - 支持添加、删除、编辑主机 -- 📁 **分组管理** - 灵活的主机组管理 -- 💻 **命令执行** - 批量并行/串行执行命令 -- 📜 **Playbook管理** - 预置Playbook模板,支持快速执行 -- 📊 **任务跟踪** - 实时任务进度监控 -- 🔄 **自动刷新** - 数据自动同步 +- 🎨 **现代化 Web 界面** - 暗色玻璃拟态设计,侧边栏导航,流畅动画 +- 🖥️ **主机管理** - 支持密码/SSH密钥双认证,连接测试,分组管理 +- 🔑 **SSH 密钥库** - 集中管理 SSH 私钥,自动指纹识别 +- 📜 **Playbook 管理** - 在线编辑、变量解析、执行选项配置 +- 🏪 **模板中心** - 20+ 预置模板(Docker/Nginx/MySQL/Redis/安全加固/监控等),一键创建 +- 📦 **文件分发** - 批量分发文件/内容到远程主机 +- 💻 **命令执行** - 批量并行/串行执行命令,快捷命令 +- 📊 **任务中心** - SSE 实时日志流,进度追踪,任务取消 +- 📈 **仪表盘** - 主机状态概览、任务统计、系统信息 -## 快速开始 +## 🚀 快速开始 ### 1. 安装依赖 ```bash -# 安装Go +# 安装 Go curl -fsSL https://go.dev/dl/go1.21.linux-amd64.tar.gz | tar -C /usr/local -xzf - -# 安装Ansible +# 安装 Ansible pip install ansible -# 安装SSH +# 安装 SSH apt install openssh-client # Debian/Ubuntu -yum install openssh-clients # CentOS/RHEL ``` -### 2. 构建项目 +### 2. 构建 ```bash -cd /root/ansible-deploy +cd ansible-deploy go mod tidy go build -o ansible-deploy cmd/main.go ``` -### 3. 配置SSH免密登录 +### 3. 启动 ```bash -# 生成SSH密钥 -ssh-keygen -t rsa - -# 复制到目标主机 -ssh-copy-id user@hostname -``` - -### 4. 启动服务 - -```bash -# 默认端口8080 +# 默认端口 8080 ./ansible-deploy -# 自定义端口 -./ansible-deploy -port 9000 - -# 自定义配置 -./ansible-deploy -config /path/to/config.yaml +# 自定义端口和配置 +./ansible-deploy -port 9000 -config /path/to/config.yaml ``` -### 5. 访问Web界面 +### 4. 访问 -打开浏览器访问: `http://localhost:8080` +打开浏览器: `http://localhost:8080` -## 配置说明 +## 📁 目录结构 -配置文件位于 `config/config.yaml`: - -```yaml -# Ansible路径 -ansible_path: /usr/bin/ansible - -# 资产清单目录 -inventory_dir: ~/ansible-deploy/inventory - -# Playbook目录 -playbook_dir: ~/ansible-deploy/playbooks - -# 日志目录 -log_dir: ~/ansible-deploy/logs - -# SSH超时(秒) -ssh_timeout: 30 - -# 最大并发数 -max_parallelism: 10 +``` +ansible-deploy/ +├── cmd/ +│ └── main.go # 主程序入口 +├── config/ +│ └── config.yaml # 配置文件 +├── internal/ +│ ├── handlers/ +│ │ └── handlers.go # HTTP 处理器 +│ ├── models/ +│ │ └── models.go # 数据模型 +│ └── services/ +│ ├── ansible.go # Ansible 核心服务 +│ ├── config.go # 配置加载 +│ └── templates.go # Playbook 模板库 +├── web/dist/ +│ └── index.html # 前端界面 +├── playbooks/ # Playbook 目录 +├── inventory/ # 资产清单 +│ ├── hosts.json # 主机数据 +│ ├── groups.json # 分组数据 +│ └── ssh_keys/ # SSH 密钥存储 +└── README.md ``` -## API接口 +## 🔌 API 接口 ### 主机管理 - | 方法 | 路径 | 说明 | |------|------|------| | GET | `/api/hosts` | 获取主机列表 | @@ -100,113 +90,78 @@ max_parallelism: 10 | POST | `/api/hosts/test/:id` | 测试连接 | ### 主机组 - | 方法 | 路径 | 说明 | |------|------|------| | GET | `/api/groups` | 获取组列表 | | POST | `/api/groups` | 创建组 | -| PUT | `/api/groups/:name` | 更新组 | | DELETE | `/api/groups/:name` | 删除组 | -### 命令执行 - -| 方法 | 路径 | 说明 | -|------|------|------| -| POST | `/api/command/execute` | 执行命令 | -| POST | `/api/command/batch` | 批量执行 | - ### Playbook - | 方法 | 路径 | 说明 | |------|------|------| -| GET | `/api/playbooks` | 列出Playbook | -| POST | `/api/playbooks/execute` | 执行Playbook | +| GET | `/api/playbooks` | 列出 Playbook | +| POST | `/api/playbooks` | 创建 Playbook | +| GET | `/api/playbooks/:name/content` | 获取内容 | +| PUT | `/api/playbooks/:name` | 更新 Playbook | +| DELETE | `/api/playbooks/:name` | 删除 Playbook | +| POST | `/api/playbooks/execute` | 执行 Playbook | + +### 模板中心 +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/templates` | 列出模板 | +| GET | `/api/templates/categories` | 模板分类 | +| GET | `/api/templates/:id` | 模板详情 | +| POST | `/api/templates/deploy` | 从模板创建 | + +### SSH 密钥 +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/sshkeys` | 列出密钥 | +| POST | `/api/sshkeys` | 添加密钥 | +| DELETE | `/api/sshkeys/:name` | 删除密钥 | + +### 文件分发 +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/api/files/distribute` | 分发文件 | ### 任务 - | 方法 | 路径 | 说明 | |------|------|------| -| GET | `/api/tasks` | 获取任务列表 | -| GET | `/api/tasks/:id` | 获取任务详情 | +| GET | `/api/tasks` | 任务列表 | +| GET | `/api/tasks/:id` | 任务详情 | +| GET | `/api/tasks/:id/stream` | SSE 实时日志 | | DELETE | `/api/tasks/:id` | 取消任务 | -## 使用示例 +### 系统 +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/system/info` | 系统信息 | -### 添加主机 +## 📋 预置模板 -```bash -curl -X POST http://localhost:8080/api/hosts \ - -H "Content-Type: application/json" \ - -d '{ - "name": "web-server-01", - "ip": "192.168.1.100", - "port": 22, - "username": "root", - "password": "your-password", - "groups": ["webservers"] - }' -``` - -### 批量执行命令 - -```bash -curl -X POST http://localhost:8080/api/command/batch \ - -H "Content-Type: application/json" \ - -d '{ - "hosts": ["web1", "web2", "web3"], - "command": "df -h", - "parallel": true - }' -``` - -### 执行Playbook - -```bash -curl -X POST http://localhost:8080/api/playbooks/execute \ - -H "Content-Type: application/json" \ - -d '{ - "name": "update-packages", - "hosts": ["all"], - "extra_vars": {} - }' -``` - -## 预置Playbook - -| 文件 | 说明 | -|------|------| -| `update-packages.yml` | 更新系统包 | -| `deploy-docker.yml` | 安装Docker | -| `deploy-nginx.yml` | 部署Nginx | -| `check-system.yml` | 系统信息检查 | - -## 目录结构 - -``` -ansible-deploy/ -├── cmd/ -│ └── main.go # 主程序入口 -├── config/ -│ └── config.yaml # 配置文件 -├── internal/ -│ ├── handlers/ # HTTP处理器 -│ ├── models/ # 数据模型 -│ └── services/ # 业务逻辑 -├── web/ -│ └── dist/ -│ └── index.html # 前端页面 -├── playbooks/ # Playbook目录 -├── scripts/ -│ └── install.sh # 安装脚本 -└── README.md -``` - -## 注意事项 - -1. **SSH免密** - 建议配置SSH密钥对实现免密登录 -2. **权限** - 部分操作需要sudo权限,确保用户有sudo权限 -3. **防火墙** - 确保SSH端口开放 -4. **Python** - Ansible需要目标主机安装Python +| 分类 | 模板 | 说明 | +|------|------|------| +| 🖥️ 系统管理 | 系统包更新 | 自动更新软件包 | +| | 系统信息采集 | 采集 OS/硬件/网络信息 | +| | 资源监控检查 | CPU/内存/磁盘告警 | +| | 日志清理 | 清理日志和临时文件 | +| | 时间同步(NTP) | 配置 chrony 时间同步 | +| 🌐 Web服务 | 部署 Nginx | 安装配置 Nginx + 反向代理 | +| | 部署 Apache | 安装配置 Apache | +| 🗄️ 数据库 | 部署 MySQL | 安装 MySQL + 基础优化 | +| | 部署 Redis | 安装 Redis + 内存配置 | +| | 部署 PostgreSQL | 安装 PostgreSQL | +| 🐳 容器化 | 部署 Docker | Docker CE + 镜像加速 | +| | 部署 Portainer | 容器管理面板 | +| 🔧 DevOps | 部署 Node.js | Node.js + PM2 | +| | 部署 Python 应用 | Gunicorn + Systemd | +| | 数据备份 | 定时备份 + 自动清理 | +| 🔒 安全加固 | 安全加固 | SSH加固 + 防火墙 + fail2ban | +| | SSL 证书部署 | Let's Encrypt 自动申请 | +| 📊 监控告警 | 部署 Node Exporter | Prometheus 指标采集 | +| | 部署 Grafana | 可视化监控面板 | ## License diff --git a/ansible-deploy b/ansible-deploy deleted file mode 100755 index e6a946e..0000000 Binary files a/ansible-deploy and /dev/null differ diff --git a/cmd/main.go b/cmd/main.go index 4352fec..9af6e7b 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -12,7 +12,7 @@ import ( var ( configPath string - port string + port string ) func init() { @@ -23,23 +23,17 @@ func init() { func main() { flag.Parse() - // 加载配置 cfg, err := services.LoadConfig(configPath) if err != nil { log.Printf("配置加载失败: %v,使用默认配置", err) cfg = services.DefaultConfig() } - // 初始化Ansible服务 ansibleService := services.NewAnsibleService(cfg) - - // 初始化处理器 h := handlers.NewAnsibleHandler(ansibleService) - // 初始化Web服务 r := gin.Default() - // CORS配置 r.Use(func(c *gin.Context) { c.Header("Access-Control-Allow-Origin", "*") c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") @@ -51,11 +45,9 @@ func main() { c.Next() }) - // 静态文件 r.Static("/static", "./web/dist") r.Static("/assets", "./web/dist/assets") - // API路由 api := r.Group("/api") { // 主机管理 @@ -64,6 +56,7 @@ func main() { api.DELETE("/hosts/:id", h.DeleteHost) api.PUT("/hosts/:id", h.UpdateHost) api.POST("/hosts/test/:id", h.TestConnection) + api.GET("/hosts/test/:id", h.TestConnection) // 主机组管理 api.GET("/groups", h.ListGroups) @@ -80,18 +73,34 @@ func main() { api.POST("/playbooks/execute", h.ExecutePlaybook) api.GET("/playbooks/:name", h.GetPlaybook) + // Playbook模板 + api.GET("/templates", h.ListTemplates) + api.GET("/templates/categories", h.ListTemplateCategories) + api.GET("/templates/:id", h.GetTemplate) + api.POST("/templates/deploy", h.CreateFromTemplate) + // 命令执行 api.POST("/command/execute", h.ExecuteCommand) api.POST("/command/batch", h.BatchExecute) + // 文件分发 + api.POST("/files/distribute", h.DistributeFile) + + // SSH密钥管理 + api.GET("/sshkeys", h.ListSSHKeys) + api.POST("/sshkeys", h.AddSSHKey) + api.DELETE("/sshkeys/:name", h.DeleteSSHKey) + // 任务执行 api.GET("/tasks", h.ListTasks) api.GET("/tasks/:id", h.GetTask) api.GET("/tasks/:id/stream", h.StreamTaskOutput) api.DELETE("/tasks/:id", h.CancelTask) + + // 系统信息 + api.GET("/system/info", h.GetSystemInfo) } - // 前端路由 - 禁止缓存确保始终返回最新版本 r.GET("/", func(c *gin.Context) { c.Header("Cache-Control", "no-cache, no-store, must-revalidate") c.Header("Pragma", "no-cache") @@ -99,12 +108,11 @@ func main() { c.File("./web/dist/index.html") }) - // 创建必要目录 os.MkdirAll(cfg.InventoryDir, 0755) os.MkdirAll(cfg.PlaybookDir, 0755) os.MkdirAll(cfg.LogDir, 0755) - log.Printf("Ansible部署工具启动,监听端口: %s", port) + log.Printf("🚀 Ansible Deploy v2.0 启动,监听端口: %s", port) if err := r.Run(":" + port); err != nil { log.Fatalf("服务启动失败: %v", err) } diff --git a/config/config.yaml b/config/config.yaml index ba247ab..f3781da 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -1,16 +1,16 @@ -# Ansible Deploy 配置文件 +# Ansible Deploy Pro 配置文件 # Ansible安装路径 ansible_path: /usr/bin/ansible # 资产清单目录 -inventory_dir: /root/ansible-deploy/inventory +inventory_dir: ./inventory # Playbook目录 -playbook_dir: /root/ansible-deploy/playbooks +playbook_dir: ./playbooks # 日志目录 -log_dir: /root/ansible-deploy/logs +log_dir: ./logs # SSH连接超时时间(秒) ssh_timeout: 30 @@ -20,11 +20,3 @@ max_parallelism: 10 # 输出格式 (json, yaml, plain) callback_plugin: json - -# SSH连接选项 -ssh_options: - strict_host_key_checking: no - user_known_hosts_file: /dev/null - connect_timeout: 10 - password_authentication: yes - key_authentication: yes diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 09a2090..3b0991a 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -19,314 +19,335 @@ func NewAnsibleHandler(svc *services.AnsibleService) *AnsibleHandler { return &AnsibleHandler{service: svc} } -// ListHosts 获取主机列表 +// ===== 主机管理 ===== + func (h *AnsibleHandler) ListHosts(c *gin.Context) { hosts := h.service.ListHosts() - c.JSON(http.StatusOK, gin.H{ - "code": 0, - "msg": "success", - "data": hosts, - }) + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": hosts}) } -// AddHost 添加主机 func (h *AnsibleHandler) AddHost(c *gin.Context) { var host models.Host if err := c.ShouldBindJSON(&host); err != nil { - c.JSON(http.StatusBadRequest, gin.H{ - "code": 400, - "msg": "参数错误: " + err.Error(), - }) + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "参数错误: " + err.Error()}) return } - if err := h.service.AddHost(host); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "code": 500, - "msg": err.Error(), - }) + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": err.Error()}) return } - - c.JSON(http.StatusOK, gin.H{ - "code": 0, - "msg": "主机添加成功", - }) + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "主机添加成功"}) } -// DeleteHost 删除主机 func (h *AnsibleHandler) DeleteHost(c *gin.Context) { id := c.Param("id") if err := h.service.DeleteHost(id); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "code": 500, - "msg": err.Error(), - }) + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": err.Error()}) return } - - c.JSON(http.StatusOK, gin.H{ - "code": 0, - "msg": "主机删除成功", - }) + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "主机删除成功"}) } -// UpdateHost 更新主机 func (h *AnsibleHandler) UpdateHost(c *gin.Context) { id := c.Param("id") var host models.Host if err := c.ShouldBindJSON(&host); err != nil { - c.JSON(http.StatusBadRequest, gin.H{ - "code": 400, - "msg": "参数错误", - }) + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "参数错误"}) return } - if err := h.service.UpdateHost(id, host); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "code": 500, - "msg": err.Error(), - }) + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": err.Error()}) return } - - c.JSON(http.StatusOK, gin.H{ - "code": 0, - "msg": "主机更新成功", - }) + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "主机更新成功"}) } -// TestConnection 测试连接 func (h *AnsibleHandler) TestConnection(c *gin.Context) { id := c.Param("id") result, err := h.service.TestConnection(id) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "code": 500, - "msg": err.Error(), - }) + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": err.Error()}) return } - - c.JSON(http.StatusOK, gin.H{ - "code": 0, - "msg": "success", - "data": result, - }) + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": result}) } -// ListGroups 获取组列表 +// ===== 主机组管理 ===== + func (h *AnsibleHandler) ListGroups(c *gin.Context) { groups := h.service.ListGroups() - c.JSON(http.StatusOK, gin.H{ - "code": 0, - "msg": "success", - "data": groups, - }) + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": groups}) } -// CreateGroup 创建组 func (h *AnsibleHandler) CreateGroup(c *gin.Context) { var group models.HostGroup if err := c.ShouldBindJSON(&group); err != nil { - c.JSON(http.StatusBadRequest, gin.H{ - "code": 400, - "msg": "参数错误", - }) + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "参数错误"}) return } - if err := h.service.CreateGroup(group); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "code": 500, - "msg": err.Error(), - }) + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": err.Error()}) return } - - c.JSON(http.StatusOK, gin.H{ - "code": 0, - "msg": "组创建成功", - }) + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "组创建成功"}) } -// DeleteGroup 删除组 func (h *AnsibleHandler) DeleteGroup(c *gin.Context) { name := c.Param("name") if err := h.service.DeleteGroup(name); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "code": 500, - "msg": err.Error(), - }) + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": err.Error()}) return } - - c.JSON(http.StatusOK, gin.H{ - "code": 0, - "msg": "组删除成功", - }) + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "组删除成功"}) } -// UpdateGroup 更新组 func (h *AnsibleHandler) UpdateGroup(c *gin.Context) { name := c.Param("name") var group models.HostGroup if err := c.ShouldBindJSON(&group); err != nil { - c.JSON(http.StatusBadRequest, gin.H{ - "code": 400, - "msg": "参数错误", - }) + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "参数错误"}) return } - if err := h.service.UpdateGroup(name, group); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "code": 500, - "msg": err.Error(), - }) + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": err.Error()}) return } - - c.JSON(http.StatusOK, gin.H{ - "code": 0, - "msg": "组更新成功", - }) + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "组更新成功"}) } -// ListPlaybooks 列出Playbooks +// ===== Playbook管理 ===== + func (h *AnsibleHandler) ListPlaybooks(c *gin.Context) { playbooks := h.service.ListPlaybooks() - c.JSON(http.StatusOK, gin.H{ - "code": 0, - "msg": "success", - "data": playbooks, - }) + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": playbooks}) } -// GetPlaybook 获取Playbook详情 func (h *AnsibleHandler) GetPlaybook(c *gin.Context) { name := c.Param("name") playbook, err := h.service.GetPlaybook(name) if err != nil { - c.JSON(http.StatusNotFound, gin.H{ - "code": 404, - "msg": err.Error(), - }) + c.JSON(http.StatusNotFound, gin.H{"code": 404, "msg": err.Error()}) return } - - c.JSON(http.StatusOK, gin.H{ - "code": 0, - "msg": "success", - "data": playbook, - }) + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": playbook}) } -// ExecutePlaybook 执行Playbook func (h *AnsibleHandler) ExecutePlaybook(c *gin.Context) { var req models.PlaybookExecutionRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{ - "code": 400, - "msg": "参数错误: " + err.Error(), - }) + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "参数错误: " + err.Error()}) return } - task, err := h.service.ExecutePlaybook(req) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "code": 500, - "msg": err.Error(), - }) + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": err.Error()}) return } - - c.JSON(http.StatusOK, gin.H{ - "code": 0, - "msg": "任务已启动", - "taskId": task.ID, - }) + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "任务已启动", "taskId": task.ID}) } -// ExecuteCommand 执行命令 +func (h *AnsibleHandler) CreatePlaybook(c *gin.Context) { + var req struct { + Name string `json:"name" binding:"required"` + Content string `json:"content"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "参数错误: " + err.Error()}) + return + } + if req.Content == "" { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "Playbook内容不能为空"}) + return + } + if err := h.service.CreatePlaybook(req.Name, req.Content); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "Playbook创建成功"}) +} + +func (h *AnsibleHandler) DeletePlaybook(c *gin.Context) { + name := c.Param("name") + if err := h.service.DeletePlaybook(name); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "Playbook删除成功"}) +} + +func (h *AnsibleHandler) GetPlaybookContent(c *gin.Context) { + name := c.Param("name") + content, err := h.service.GetPlaybookContent(name) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"code": 404, "msg": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": content}) +} + +func (h *AnsibleHandler) UpdatePlaybook(c *gin.Context) { + name := c.Param("name") + var req struct { + Content string `json:"content" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "参数错误"}) + return + } + if err := h.service.UpdatePlaybook(name, req.Content); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "Playbook更新成功"}) +} + +// ===== Playbook模板 ===== + +func (h *AnsibleHandler) ListTemplates(c *gin.Context) { + templates := services.GetPlaybookTemplates() + category := c.Query("category") + if category != "" { + var filtered []services.PlaybookTemplate + for _, t := range templates { + if t.Category == category { + filtered = append(filtered, t) + } + } + templates = filtered + } + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": templates}) +} + +func (h *AnsibleHandler) ListTemplateCategories(c *gin.Context) { + cats := services.GetTemplateCategories() + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": cats}) +} + +func (h *AnsibleHandler) GetTemplate(c *gin.Context) { + id := c.Param("id") + for _, t := range services.GetPlaybookTemplates() { + if t.ID == id { + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": t}) + return + } + } + c.JSON(http.StatusNotFound, gin.H{"code": 404, "msg": "模板不存在"}) +} + +func (h *AnsibleHandler) CreateFromTemplate(c *gin.Context) { + var req struct { + TemplateID string `json:"template_id" binding:"required"` + PlaybookName string `json:"playbook_name" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "参数错误: " + err.Error()}) + return + } + for _, t := range services.GetPlaybookTemplates() { + if t.ID == req.TemplateID { + if err := h.service.CreatePlaybook(req.PlaybookName, t.Content); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "Playbook创建成功"}) + return + } + } + c.JSON(http.StatusNotFound, gin.H{"code": 404, "msg": "模板不存在"}) +} + +// ===== 命令执行 ===== + func (h *AnsibleHandler) ExecuteCommand(c *gin.Context) { var req models.CommandRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{ - "code": 400, - "msg": "参数错误: " + err.Error(), - }) + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "参数错误: " + err.Error()}) return } - results, err := h.service.ExecuteCommand(req) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "code": 500, - "msg": err.Error(), - }) + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": err.Error()}) return } - - c.JSON(http.StatusOK, gin.H{ - "code": 0, - "msg": "success", - "data": results, - }) + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": results}) } -// BatchExecute 批量执行 func (h *AnsibleHandler) BatchExecute(c *gin.Context) { var req models.CommandRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{ - "code": 400, - "msg": "参数错误", - }) + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "参数错误"}) return } - result := h.service.BatchExecute(req) - c.JSON(http.StatusOK, gin.H{ - "code": 0, - "msg": "任务已启动", - "data": result, - }) + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "任务已启动", "data": result}) } -// ListTasks 获取任务列表 +// ===== 文件分发 ===== + +func (h *AnsibleHandler) DistributeFile(c *gin.Context) { + var req models.FileDistributeRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "参数错误: " + err.Error()}) + return + } + if req.Content == "" && req.SourceFile == "" { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "请提供文件内容或源文件路径"}) + return + } + task, err := h.service.DistributeFile(req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "文件分发任务已启动", "taskId": task.ID}) +} + +// ===== SSH密钥管理 ===== + +func (h *AnsibleHandler) ListSSHKeys(c *gin.Context) { + keys := h.service.ListSSHKeys() + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": keys}) +} + +func (h *AnsibleHandler) AddSSHKey(c *gin.Context) { + var req models.SSHKeyCreateRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "参数错误: " + err.Error()}) + return + } + if err := h.service.AddSSHKey(req.Name, req.PrivateKey); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "SSH密钥添加成功"}) +} + +func (h *AnsibleHandler) DeleteSSHKey(c *gin.Context) { + name := c.Param("name") + if err := h.service.DeleteSSHKey(name); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "SSH密钥已删除"}) +} + +// ===== 任务管理 ===== + func (h *AnsibleHandler) ListTasks(c *gin.Context) { tasks := h.service.ListTasks() - c.JSON(http.StatusOK, gin.H{ - "code": 0, - "msg": "success", - "data": tasks, - }) + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": tasks}) } -// GetTask 获取任务详情 func (h *AnsibleHandler) GetTask(c *gin.Context) { id := c.Param("id") task := h.service.GetTask(id) if task == nil { - c.JSON(http.StatusNotFound, gin.H{ - "code": 404, - "msg": "任务不存在", - }) + c.JSON(http.StatusNotFound, gin.H{"code": 404, "msg": "任务不存在"}) return } - - c.JSON(http.StatusOK, gin.H{ - "code": 0, - "msg": "success", - "data": task, - }) + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": task}) } -// StreamTaskOutput SSE 流式推送任务日志 func (h *AnsibleHandler) StreamTaskOutput(c *gin.Context) { id := c.Param("id") task := h.service.GetTask(id) @@ -341,34 +362,26 @@ func (h *AnsibleHandler) StreamTaskOutput(c *gin.Context) { lastLen := 0 for { - // 检查客户端是否断开 if c.Request.Context().Err() != nil { return } - task := h.service.GetTask(id) if task == nil { return } - output := task.Output if len(output) > lastLen { - // 只发送增量 increment := output[lastLen:] lastLen = len(output) c.SSEvent("log", increment) c.Writer.Flush() } - if task.Status != "running" { - // 任务完成,发送最终状态 c.SSEvent("status", task.Status) c.SSEvent("error", task.Error) c.Writer.Flush() return } - - // 等 500ms 再推送 select { case <-time.After(500 * time.Millisecond): case <-c.Request.Context().Done(): @@ -377,132 +390,18 @@ func (h *AnsibleHandler) StreamTaskOutput(c *gin.Context) { } } -// CancelTask 取消任务 func (h *AnsibleHandler) CancelTask(c *gin.Context) { id := c.Param("id") if err := h.service.CancelTask(id); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "code": 500, - "msg": err.Error(), - }) + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": err.Error()}) return } - - c.JSON(http.StatusOK, gin.H{ - "code": 0, - "msg": "任务已取消", - }) + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "任务已取消"}) } -// CreatePlaybook 创建Playbook -func (h *AnsibleHandler) CreatePlaybook(c *gin.Context) { - var req struct { - Name string `json:"name" binding:"required"` - Content string `json:"content"` - } +// ===== 系统信息 ===== - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{ - "code": 400, - "msg": "参数错误: " + err.Error(), - }) - return - } - - if req.Content == "" { - c.JSON(http.StatusBadRequest, gin.H{ - "code": 400, - "msg": "Playbook内容不能为空", - }) - return - } - - if err := h.service.CreatePlaybook(req.Name, req.Content); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "code": 500, - "msg": err.Error(), - }) - return - } - - c.JSON(http.StatusOK, gin.H{ - "code": 0, - "msg": "Playbook创建成功", - }) -} - -// DeletePlaybook 删除Playbook -func (h *AnsibleHandler) DeletePlaybook(c *gin.Context) { - name := c.Param("name") - if err := h.service.DeletePlaybook(name); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "code": 500, - "msg": err.Error(), - }) - return - } - - c.JSON(http.StatusOK, gin.H{ - "code": 0, - "msg": "Playbook删除成功", - }) -} - -// GetPlaybookContent 获取Playbook内容 -func (h *AnsibleHandler) GetPlaybookContent(c *gin.Context) { - name := c.Param("name") - content, err := h.service.GetPlaybookContent(name) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{ - "code": 404, - "msg": err.Error(), - }) - return - } - - c.JSON(http.StatusOK, gin.H{ - "code": 0, - "msg": "success", - "data": content, - }) -} - -// UpdatePlaybook 更新Playbook -func (h *AnsibleHandler) UpdatePlaybook(c *gin.Context) { - name := c.Param("name") - var req struct { - Content string `json:"content" binding:"required"` - } - - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{ - "code": 400, - "msg": "参数错误", - }) - return - } - - if err := h.service.UpdatePlaybook(name, req.Content); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "code": 500, - "msg": err.Error(), - }) - return - } - - c.JSON(http.StatusOK, gin.H{ - "code": 0, - "msg": "Playbook更新成功", - }) -} - -// WebSocketLogs WebSocket日志 -func (h *AnsibleHandler) WebSocketLogs(c *gin.Context) { - taskID := c.Param("taskId") - _ = taskID - // WebSocket实现需要单独处理,这里返回提示 - c.JSON(http.StatusOK, gin.H{ - "code": 0, - "msg": "WebSocket连接", - }) +func (h *AnsibleHandler) GetSystemInfo(c *gin.Context) { + info := h.service.GetSystemInfo() + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": info}) } diff --git a/internal/models/models.go b/internal/models/models.go index 48aeda8..1e4db10 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -4,53 +4,41 @@ import "time" // Host 主机信息 type Host struct { - ID string `json:"id"` - Name string `json:"name"` - IP string `json:"ip"` - Port int `json:"port"` - Username string `json:"username"` - Password string `json:"password,omitempty"` - SSHKey string `json:"ssh_key,omitempty"` - AuthType string `json:"auth_type,omitempty"` // password 或 sshkey - Groups []string `json:"groups"` + ID string `json:"id"` + Name string `json:"name"` + IP string `json:"ip"` + Port int `json:"port"` + Username string `json:"username"` + Password string `json:"password,omitempty"` + SSHKey string `json:"ssh_key,omitempty"` // SSH密钥名称或路径 + AuthType string `json:"auth_type,omitempty"` // password 或 sshkey + Groups []string `json:"groups"` Vars map[string]string `json:"vars,omitempty"` - Status string `json:"status"` - LastCheck time.Time `json:"last_check,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + Status string `json:"status"` + OS string `json:"os,omitempty"` + LastCheck time.Time `json:"last_check,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // HostGroup 主机组 type HostGroup struct { - Name string `json:"name"` - Description string `json:"description"` - Hosts []string `json:"hosts"` - HostList []Host `json:"host_list,omitempty"` // 组内主机的详细信息 + Name string `json:"name"` + Description string `json:"description"` + Hosts []string `json:"hosts"` + HostList []Host `json:"host_list,omitempty"` Vars map[string]string `json:"vars,omitempty"` - Children []string `json:"children,omitempty"` -} - -// Inventory 资产清单 -type Inventory struct { - All *InventoryGroup `yaml:"all"` - Ungrouped *InventoryGroup `yaml:"ungrouped,omitempty"` -} - -// InventoryGroup 资产组 -type InventoryGroup struct { - Children map[string]*InventoryGroup `yaml:"children,omitempty"` - Hosts map[string]Host `yaml:"hosts,omitempty"` - Vars map[string]interface{} `yaml:"vars,omitempty"` + Children []string `json:"children,omitempty"` } // Playbook Playbook定义 type Playbook struct { - Name string `json:"name"` - Path string `json:"path"` - Description string `json:"description"` + Name string `json:"name"` + Path string `json:"path"` + Description string `json:"description"` Variables map[string]interface{} `json:"variables,omitempty"` - Hosts string `json:"hosts"` - Tasks []Task `json:"tasks"` + Hosts string `json:"hosts"` + Tasks []Task `json:"tasks"` } // Task 任务定义 @@ -65,62 +53,62 @@ type Task struct { // TaskExecution 任务执行记录 type TaskExecution struct { - ID string `json:"id"` - Name string `json:"name"` - Playbook string `json:"playbook"` - Hosts []string `json:"hosts"` - Status string `json:"status"` // pending, running, success, failed, cancelled - StartTime time.Time `json:"start_time"` - EndTime time.Time `json:"end_time,omitempty"` - Progress int `json:"progress"` - TotalHosts int `json:"total_hosts"` - SuccessHosts int `json:"success_hosts"` - FailedHosts int `json:"failed_hosts"` - Output string `json:"output,omitempty"` - Error string `json:"error,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Playbook string `json:"playbook"` + Hosts []string `json:"hosts"` + Status string `json:"status"` + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time,omitempty"` + Progress int `json:"progress"` + TotalHosts int `json:"total_hosts"` + SuccessHosts int `json:"success_hosts"` + FailedHosts int `json:"failed_hosts"` + Output string `json:"output,omitempty"` + Error string `json:"error,omitempty"` } // CommandRequest 命令执行请求 type CommandRequest struct { - Hosts []string `json:"hosts" binding:"required"` - Command string `json:"command" binding:"required"` - Parallel bool `json:"parallel"` - Timeout int `json:"timeout"` + Hosts []string `json:"hosts" binding:"required"` + Command string `json:"command" binding:"required"` + Parallel bool `json:"parallel"` + Timeout int `json:"timeout"` } // CommandResult 命令执行结果 type CommandResult struct { - Host string `json:"host"` - Success bool `json:"success"` - Output string `json:"output"` - Error string `json:"error,omitempty"` - ExitCode int `json:"exit_code"` - Duration int64 `json:"duration_ms"` + Host string `json:"host"` + Success bool `json:"success"` + Output string `json:"output"` + Error string `json:"error,omitempty"` + ExitCode int `json:"exit_code"` + Duration int64 `json:"duration_ms"` } // BatchCommandResult 批量命令结果 type BatchCommandResult struct { - TaskID string `json:"task_id"` - Total int `json:"total"` - Success int `json:"success"` - Failed int `json:"failed"` - Results []CommandResult `json:"results"` + TaskID string `json:"task_id"` + Total int `json:"total"` + Success int `json:"success"` + Failed int `json:"failed"` + Results []CommandResult `json:"results"` } // PlaybookExecutionRequest Playbook执行请求 type PlaybookExecutionRequest struct { - Name string `json:"name" binding:"required"` - Hosts []string `json:"hosts"` - ExtraVars map[string]interface{} `json:"extra_vars"` - Tags []string `json:"tags,omitempty"` // 只执行指定tags - SkipTags []string `json:"skip_tags,omitempty"` // 跳过指定tags - Verbose string `json:"verbose,omitempty"` // v, vv, vvv, vvvv - Diff bool `json:"diff,omitempty"` // 显示文件差异 - Check bool `json:"check,omitempty"` // dry-run模式 - Become *bool `json:"become,omitempty"` // 是否提权,nil表示使用playbook默认 - Forks int `json:"forks,omitempty"` // 并发数 - Timeout int `json:"timeout,omitempty"` // 超时(秒) - ExtraArgs string `json:"extra_args,omitempty"` // 自定义额外参数 + Name string `json:"name" binding:"required"` + Hosts []string `json:"hosts"` + ExtraVars map[string]interface{} `json:"extra_vars"` + Tags []string `json:"tags,omitempty"` + SkipTags []string `json:"skip_tags,omitempty"` + Verbose string `json:"verbose,omitempty"` + Diff bool `json:"diff,omitempty"` + Check bool `json:"check,omitempty"` + Become *bool `json:"become,omitempty"` + Forks int `json:"forks,omitempty"` + Timeout int `json:"timeout,omitempty"` + ExtraArgs string `json:"extra_args,omitempty"` } // LogEntry 日志条目 @@ -130,3 +118,43 @@ type LogEntry struct { Host string `json:"host"` Message string `json:"message"` } + +// SSHKey SSH密钥 +type SSHKey struct { + Name string `json:"name"` + Fingerprint string `json:"fingerprint,omitempty"` + Path string `json:"path"` + CreatedAt time.Time `json:"created_at"` +} + +// SSHKeyCreateRequest 创建SSH密钥请求 +type SSHKeyCreateRequest struct { + Name string `json:"name" binding:"required"` + PrivateKey string `json:"private_key" binding:"required"` +} + +// FileDistributeRequest 文件分发请求 +type FileDistributeRequest struct { + Hosts []string `json:"hosts" binding:"required"` + Content string `json:"content"` // 文件内容(与SourceFile二选一) + SourceFile string `json:"source_file"` // 服务器上的源文件路径 + DestPath string `json:"dest_path" binding:"required"` // 目标路径 + Owner string `json:"owner"` + Group string `json:"group"` + Mode string `json:"mode"` // 如 0644 +} + +// SystemInfo 系统信息 +type SystemInfo struct { + Version string `json:"version"` + AnsiblePath string `json:"ansible_path"` + AnsibleVer string `json:"ansible_version"` + Hostname string `json:"hostname"` + OS string `json:"os"` + Arch string `json:"arch"` + HostCount int `json:"host_count"` + GroupCount int `json:"group_count"` + PlaybookCount int `json:"playbook_count"` + TaskCount int `json:"task_count"` + Uptime string `json:"uptime"` +} diff --git a/internal/services/ansible.go b/internal/services/ansible.go index 6f2a5b4..0e2b1ac 100644 --- a/internal/services/ansible.go +++ b/internal/services/ansible.go @@ -12,6 +12,7 @@ import ( "os/exec" "path/filepath" "regexp" + "runtime" "strconv" "strings" "sync" @@ -23,43 +24,303 @@ import ( // AnsibleService Ansible服务 type AnsibleService struct { - config *Config - hosts map[string]*models.Host - groups map[string]*models.HostGroup + config *Config + hosts map[string]*models.Host + groups map[string]*models.HostGroup inventoryPath string - tasks map[string]*models.TaskExecution - taskLock sync.RWMutex + 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), + 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), + 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 } -// loadGroups 加载主机组列表 +// ===== 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 { @@ -71,22 +332,18 @@ func (s *AnsibleService) loadGroups() { } } -// generateID 生成唯一ID func (s *AnsibleService) generateID() string { hash := md5.New() - hash.Write([]byte(time.Now().String())) + hash.Write([]byte(time.Now().String() + strconv.Itoa(os.Getpid()))) return hex.EncodeToString(hash.Sum(nil))[:8] } -// loadInventory 加载资产清单 func (s *AnsibleService) loadInventory() { invFile := filepath.Join(s.config.InventoryDir, "hosts") data, err := os.ReadFile(invFile) if err != nil { return } - - // 解析INI格式的inventory scanner := bufio.NewScanner(bytes.NewReader(data)) var currentGroup string groupVars := make(map[string]map[string]string) @@ -96,14 +353,10 @@ func (s *AnsibleService) loadInventory() { 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 { @@ -113,15 +366,13 @@ func (s *AnsibleService) loadInventory() { 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], + ID: s.generateID(), + Name: matches[1], + IP: matches[2], Status: "unknown", } s.hosts[host.ID] = host @@ -130,70 +381,59 @@ func (s *AnsibleService) loadInventory() { } } -// loadHosts 加载主机列表 func (s *AnsibleService) loadHosts() { - // 从hosts.json加载详细配置(唯一数据源) 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() + 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 } - 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() } } -// 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 - // 确保每个主机都有ID,并更新map中的指针 if hcopy.ID == "" { hcopy.ID = s.generateID() - h.ID = hcopy.ID // 更新map中的指针 + h.ID = hcopy.ID } hosts = append(hosts, hcopy) } - data, _ := json.MarshalIndent(hosts, "", " ") if err := os.WriteFile(hostsFile, data, 0644); err != nil { return err } - - // 更新inventory文件 s.updateInventoryFile() return nil } -// updateInventoryFile 更新inventory文件 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 { @@ -205,7 +445,6 @@ func (s *AnsibleService) updateInventoryFile() { } } - // 输出每个组 for group, hosts := range groupedHosts { lines = append(lines, fmt.Sprintf("[%s]", group)) for _, h := range hosts { @@ -217,7 +456,8 @@ func (s *AnsibleService) updateInventoryFile() { line += fmt.Sprintf(" ansible_user=%s", h.Username) } if h.AuthType == "sshkey" && h.SSHKey != "" { - line += fmt.Sprintf(" ansible_ssh_private_key_file=%s", h.SSHKey) + keyPath := s.resolveSSHKeyPath(h.SSHKey) + line += fmt.Sprintf(" ansible_ssh_private_key_file=%s", keyPath) } lines = append(lines, line) } @@ -228,7 +468,6 @@ func (s *AnsibleService) updateInventoryFile() { os.WriteFile(invFile, []byte(strings.Join(lines, "\n")), 0644) } -// ListHosts 获取主机列表 func (s *AnsibleService) ListHosts() []models.Host { var hosts []models.Host for _, h := range s.hosts { @@ -237,18 +476,15 @@ func (s *AnsibleService) ListHosts() []models.Host { return hosts } -// AddHost 添加主机 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() } -// DeleteHost 删除主机 func (s *AnsibleService) DeleteHost(id string) error { if _, ok := s.hosts[id]; !ok { return fmt.Errorf("主机不存在") @@ -257,26 +493,32 @@ func (s *AnsibleService) DeleteHost(id string) error { return s.saveHosts() } -// UpdateHost 更新主机 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() } -// ListGroups 获取主机组列表 +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 - // 动态展开组内主机(通过 host.Groups 字段关联,而非 group.Hosts) var hostList []models.Host for _, h := range s.hosts { if gcopy.Name == "all" { - // all 组包含所有主机 hcopy := *h hostList = append(hostList, hcopy) continue @@ -288,7 +530,6 @@ func (s *AnsibleService) ListGroups() []models.HostGroup { break } } - // 也检查主机的默认组(ungrouped) if len(h.Groups) == 0 && gcopy.Name == "ungrouped" { hcopy := *h hostList = append(hostList, hcopy) @@ -300,7 +541,6 @@ func (s *AnsibleService) ListGroups() []models.HostGroup { return groups } -// CreateGroup 创建主机组 func (s *AnsibleService) CreateGroup(group models.HostGroup) error { if _, ok := s.groups[group.Name]; ok { return fmt.Errorf("组已存在") @@ -309,7 +549,6 @@ func (s *AnsibleService) CreateGroup(group models.HostGroup) error { return s.saveGroups() } -// DeleteGroup 删除主机组 func (s *AnsibleService) DeleteGroup(name string) error { if name == "all" || name == "ungrouped" { return fmt.Errorf("不能删除系统组") @@ -318,7 +557,6 @@ func (s *AnsibleService) DeleteGroup(name string) error { return s.saveGroups() } -// UpdateGroup 更新主机组 func (s *AnsibleService) UpdateGroup(name string, group models.HostGroup) error { if _, ok := s.groups[name]; !ok { return fmt.Errorf("组不存在") @@ -327,14 +565,12 @@ func (s *AnsibleService) UpdateGroup(name string, group models.HostGroup) error return s.saveGroups() } -// 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) } -// TestConnection 测试主机连接 func (s *AnsibleService) TestConnection(hostID string) (*models.CommandResult, error) { host, ok := s.hosts[hostID] if !ok { @@ -342,12 +578,8 @@ func (s *AnsibleService) TestConnection(hostID string) (*models.CommandResult, e } start := time.Now() - result := &models.CommandResult{ - Host: host.Name, - Success: false, - } + result := &models.CommandResult{Host: host.Name, Success: false} - // 构建ansible命令 args := []string{ host.Name, "-i", s.inventoryPath, @@ -355,22 +587,17 @@ func (s *AnsibleService) TestConnection(hostID string) (*models.CommandResult, e "-u", host.Username, } - // 认证方式:SSH Key 或 密码 if host.AuthType == "sshkey" && host.SSHKey != "" { - // SSH Key 认证 - args = append(args, "--private-key", 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)) } - - // 如果端口不是22,通过extra-vars传递 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...) - // 通过环境变量禁用SSH主机密钥检查 cmd.Env = append(os.Environ(), "ANSIBLE_HOST_KEY_CHECKING=False") output, err := cmd.CombinedOutput() result.Duration = time.Since(start).Milliseconds() @@ -388,41 +615,25 @@ func (s *AnsibleService) TestConnection(hostID string) (*models.CommandResult, e } } host.LastCheck = time.Now() - - // 持久化状态 s.saveHosts() return result, nil } -// ExecuteCommand 执行单个命令 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 } -// runCommand 在主机上执行命令 func (s *AnsibleService) runCommand(hostName string, command string, timeout int) models.CommandResult { start := time.Now() - result := models.CommandResult{ - Host: hostName, - Success: false, - } + result := models.CommandResult{Host: hostName, Success: false} - // 查找主机获取认证信息 - var host *models.Host - for _, h := range s.hosts { - if h.Name == hostName { - host = h - break - } - } + host := s.findHostByName(hostName) if host == nil { result.Error = "主机不存在" return result @@ -443,20 +654,17 @@ func (s *AnsibleService) runCommand(hostName string, command string, timeout int "-u", host.Username, } - // 认证方式:SSH Key 或 密码 if host.AuthType == "sshkey" && host.SSHKey != "" { - args = append(args, "--private-key", 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)) } - - // 如果端口不是22,通过extra-vars传递 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...) - // 通过环境变量禁用SSH主机密钥检查 cmd.Env = append(os.Environ(), "ANSIBLE_HOST_KEY_CHECKING=False") output, err := cmd.CombinedOutput() result.Duration = time.Since(start).Milliseconds() @@ -475,7 +683,6 @@ func (s *AnsibleService) runCommand(hostName string, command string, timeout int return result } -// BatchExecute 批量执行命令 func (s *AnsibleService) BatchExecute(req models.CommandRequest) *models.BatchCommandResult { result := &models.BatchCommandResult{ TaskID: s.generateID(), @@ -484,11 +691,11 @@ func (s *AnsibleService) BatchExecute(req models.CommandRequest) *models.BatchCo } task := &models.TaskExecution{ - ID: result.TaskID, - Name: "批量命令执行", - Hosts: req.Hosts, - Status: "running", - StartTime: time.Now(), + ID: result.TaskID, + Name: "批量命令执行", + Hosts: req.Hosts, + Status: "running", + StartTime: time.Now(), TotalHosts: len(req.Hosts), } @@ -496,11 +703,9 @@ func (s *AnsibleService) BatchExecute(req models.CommandRequest) *models.BatchCo 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 @@ -513,7 +718,6 @@ func (s *AnsibleService) BatchExecute(req models.CommandRequest) *models.BatchCo defer wg.Done() semaphore <- struct{}{} defer func() { <-semaphore }() - r := s.runCommand(h, req.Command, req.Timeout) results <- r }(host) @@ -534,7 +738,6 @@ func (s *AnsibleService) BatchExecute(req models.CommandRequest) *models.BatchCo 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) @@ -553,11 +756,9 @@ func (s *AnsibleService) BatchExecute(req models.CommandRequest) *models.BatchCo return result } -// updateTaskProgress 更新任务进度 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 @@ -568,11 +769,9 @@ func (s *AnsibleService) updateTaskProgress(taskID string, increment int) { } } -// ListTasks 获取任务列表 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) @@ -580,18 +779,15 @@ func (s *AnsibleService) ListTasks() []*models.TaskExecution { return tasks } -// GetTask 获取单个任务 func (s *AnsibleService) GetTask(id string) *models.TaskExecution { s.taskLock.RLock() defer s.taskLock.RUnlock() return s.tasks[id] } -// CancelTask 取消任务 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" @@ -603,80 +799,60 @@ func (s *AnsibleService) CancelTask(id string) error { return fmt.Errorf("任务不存在") } -// ExecutePlaybook 执行Playbook 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), + ID: s.generateID(), + Name: req.Name, + Playbook: playbookPath, + Hosts: req.Hosts, + Status: "running", + StartTime: time.Now(), + TotalHosts: len(req.Hosts), SuccessHosts: 0, - FailedHosts: 0, + FailedHosts: 0, } s.taskLock.Lock() s.tasks[task.ID] = task s.taskLock.Unlock() - // 启动异步执行 go s.runPlaybook(task, playbookPath, req) return task, nil } -// runPlaybook 运行Playbook func (s *AnsibleService) runPlaybook(task *models.TaskExecution, playbookPath string, req models.PlaybookExecutionRequest) { var args []string - // 添加inventory args = append(args, "-i", s.inventoryPath) - // 添加hosts限制 if len(req.Hosts) > 0 { args = append(args, "-l", strings.Join(req.Hosts, ",")) } - - // 添加extra-vars if len(req.ExtraVars) > 0 { varsJSON, _ := json.Marshal(req.ExtraVars) args = append(args, "-e", string(varsJSON)) } - - // 添加tags if len(req.Tags) > 0 { args = append(args, "-t", strings.Join(req.Tags, ",")) } - - // 添加skip-tags if len(req.SkipTags) > 0 { args = append(args, "--skip-tags", strings.Join(req.SkipTags, ",")) } - - // 添加verbose if req.Verbose != "" { args = append(args, "-"+req.Verbose) } - - // 显示文件差异 if req.Diff { args = append(args, "-D") } - - // dry-run模式 if req.Check { args = append(args, "-C") } - - // 是否提权 if req.Become != nil { if *req.Become { args = append(args, "-b") @@ -684,37 +860,28 @@ func (s *AnsibleService) runPlaybook(task *models.TaskExecution, playbookPath st 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...) } - // playbook路径放最后 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...) } - // 实时写入日志的 Writer sw := &syncWriter{buf: bytes.NewBuffer(nil)} cmd.Stdout = sw cmd.Stderr = sw - // 启动 goroutine 实时搬运日志到 task.Output done := make(chan struct{}) go func() { ticker := time.NewTicker(200 * time.Millisecond) @@ -739,9 +906,8 @@ func (s *AnsibleService) runPlaybook(task *models.TaskExecution, playbookPath st }() err := cmd.Run() - close(done) // 通知 goroutine 退出 + close(done) - // 最终同步一次完整日志 sw.mu.Lock() finalOutput := sw.buf.String() sw.mu.Unlock() @@ -758,7 +924,6 @@ func (s *AnsibleService) runPlaybook(task *models.TaskExecution, playbookPath st s.taskLock.Unlock() } -// syncWriter 线程安全的 Writer type syncWriter struct { buf *bytes.Buffer mu sync.Mutex @@ -776,32 +941,29 @@ func (w *syncWriter) String() string { return w.buf.String() } -// ListPlaybooks 列出可用Playbooks +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, - } + playbook := models.Playbook{Name: name, Path: playbookPath} - // 解析YAML获取描述和变量信息 data, err := os.ReadFile(playbookPath) if err == nil { - // 尝试解析为playbook列表 var playEntries []map[string]interface{} if yaml.Unmarshal(data, &playEntries) == nil && len(playEntries) > 0 { first := playEntries[0] - // 提取注释中的描述(name字段) if nameVal, ok := first["name"]; ok { playbook.Description = fmt.Sprintf("%v", nameVal) } - // 提取vars if varsVal, ok := first["vars"]; ok { if varsMap, ok := varsVal.(map[string]interface{}); ok { playbook.Variables = varsMap @@ -809,158 +971,75 @@ func (s *AnsibleService) ListPlaybooks() []models.Playbook { } } } - playbooks = append(playbooks, playbook) } } - return playbooks } -// GetPlaybook 获取Playbook详情 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 - - // 简单解析YAML if err := yaml.Unmarshal(data, &playbook); err != nil { return nil, fmt.Errorf("Playbook解析失败") } - return &playbook, nil } -// WebSocketLogs WebSocket日志流 -func (s *AnsibleService) WebSocketLogs(taskID string) (<-chan models.LogEntry, error) { - logChan := make(chan models.LogEntry, 100) - - go func() { - defer close(logChan) - - ticker := time.NewTicker(500 * time.Millisecond) - defer ticker.Stop() - - for { - select { - case <-ticker.C: - s.taskLock.RLock() - task, ok := s.tasks[taskID] - s.taskLock.RUnlock() - - if !ok { - return - } - - entry := models.LogEntry{ - Time: time.Now().Format("15:04:05"), - Level: "info", - Host: "system", - Message: fmt.Sprintf("Progress: %d/%d", task.Progress, task.TotalHosts), - } - logChan <- entry - - if task.Status == "completed" || task.Status == "failed" { - return - } - } - } - }() - - return logChan, nil -} - -// ParseAnsibleOutput 解析Ansible输出 -func (s *AnsibleService) ParseAnsibleOutput(output string) (map[string]interface{}, error) { - var result map[string]interface{} - if err := json.Unmarshal([]byte(output), &result); err != nil { - return nil, err - } - return result, nil -} - -// GetTaskOutput 获取任务输出 -func (s *AnsibleService) GetTaskOutput(taskID string) string { - s.taskLock.RLock() - defer s.taskLock.RUnlock() - - if task, ok := s.tasks[taskID]; ok { - return task.Output - } - return "" -} - -// CreatePlaybook 创建Playbook(通过内容) 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) } - - // 验证YAML格式 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) } -// DeletePlaybook 删除Playbook 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) } -// UpdatePlaybook 更新Playbook内容 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) } - - // 验证YAML格式 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) } -// GetPlaybookContent 获取Playbook原始内容 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 { @@ -969,14 +1048,12 @@ func (s *AnsibleService) GetPlaybookContent(name string) (string, error) { return string(data), nil } -// CheckAnsibleInstalled 检查Ansible是否安装 func (s *AnsibleService) CheckAnsibleInstalled() bool { cmd := exec.Command("ansible", "--version") err := cmd.Run() return err == nil } -// GetInventoryPath 获取inventory路径 func (s *AnsibleService) GetInventoryPath() string { return s.inventoryPath } diff --git a/internal/services/templates.go b/internal/services/templates.go new file mode 100644 index 0000000..99acb38 --- /dev/null +++ b/internal/services/templates.go @@ -0,0 +1,1658 @@ +package services + +// PlaybookTemplate Playbook模板定义 +type PlaybookTemplate struct { + ID string `json:"id"` + Name string `json:"name"` + Category string `json:"category"` + Description string `json:"description"` + Icon string `json:"icon"` + Content string `json:"content"` + Tags []string `json:"tags"` +} + +// TemplateCategory 模板分类 +type TemplateCategory struct { + ID string `json:"id"` + Name string `json:"name"` + Icon string `json:"icon"` + Count int `json:"count"` +} + +// GetTemplateCategories 获取所有模板分类 +func GetTemplateCategories() []TemplateCategory { + cats := map[string]TemplateCategory{} + for _, t := range GetPlaybookTemplates() { + c, ok := cats[t.Category] + if !ok { + c = TemplateCategory{ID: t.Category, Name: categoryNames[t.Category], Icon: categoryIcons[t.Category]} + } + c.Count++ + cats[t.Category] = c + } + var result []TemplateCategory + // 按固定顺序输出 + order := []string{"system", "web", "database", "container", "devops", "security", "monitoring"} + for _, id := range order { + if c, ok := cats[id]; ok { + result = append(result, c) + } + } + return result +} + +var categoryNames = map[string]string{ + "system": "系统管理", + "web": "Web服务", + "database": "数据库", + "container": "容器化", + "devops": "DevOps", + "security": "安全加固", + "monitoring": "监控告警", +} + +var categoryIcons = map[string]string{ + "system": "🖥️", + "web": "🌐", + "database": "🗄️", + "container": "🐳", + "devops": "🔧", + "security": "🔒", + "monitoring": "📊", +} + +// GetPlaybookTemplates 获取所有Playbook模板 +func GetPlaybookTemplates() []PlaybookTemplate { + return []PlaybookTemplate{ + // ===== 系统管理 ===== + { + ID: "update-packages", Name: "系统包更新", Category: "system", + Description: "自动更新系统软件包,支持Debian/RedHat系列", + Icon: "📦", Tags: []string{"update", "apt", "yum"}, + Content: `--- +- name: 系统包更新 + hosts: "{{ target_hosts | default('all') }}" + become: yes + vars: + update_cache: yes + upgrade_type: dist # dist(完整升级) 或 safe(安全升级) + + tasks: + - name: 更新apt缓存 + apt: + update_cache: yes + when: ansible_os_family == "Debian" + + - name: 升级所有包(Debian) + apt: + upgrade: "{{ upgrade_type }}" + autoremove: yes + autoclean: yes + when: ansible_os_family == "Debian" + + - name: 更新yum缓存 + yum: + update_cache: yes + when: ansible_os_family == "RedHat" + + - name: 升级所有包(RedHat) + yum: + name: "*" + state: latest + when: ansible_os_family == "RedHat" + + - name: 检查是否需要重启 + stat: + path: /var/run/reboot-required + register: reboot_required + when: ansible_os_family == "Debian" + + - name: 提示重启 + debug: + msg: "⚠️ 系统需要重启以完成更新" + when: ansible_os_family == "Debian" and reboot_required.stat.exists +`, + }, + { + ID: "check-system", Name: "系统信息采集", Category: "system", + Description: "采集操作系统、硬件、网络等详细信息", + Icon: "🔍", Tags: []string{"info", "facts", "inventory"}, + Content: `--- +- name: 系统信息采集 + hosts: "{{ target_hosts | default('all') }}" + gather_facts: yes + + tasks: + - name: 操作系统信息 + debug: + msg: | + ══════════ 系统信息 ══════════ + 主机名: {{ ansible_facts['hostname'] }} + 系统: {{ ansible_facts['distribution'] }} {{ ansible_facts['distribution_version'] }} + 内核: {{ ansible_facts['kernel'] }} + 架构: {{ ansible_facts['architecture'] }} + CPU: {{ ansible_facts['processor_vcpus'] }} vCPUs + 内存: {{ (ansible_facts['memtotal_mb'] / 1024) | round(2) }} GB + IP: {{ ansible_facts['default_ipv4']['address'] | default('N/A') }} + ═══════════════════════════════ + + - name: 磁盘使用 + shell: df -h | grep -E '^/dev/' + register: disk_info + + - name: 显示磁盘 + debug: + msg: "{{ disk_info.stdout_lines }}" + + - name: 运行时间 + shell: uptime -p + register: uptime_info + + - name: 显示运行时间 + debug: + msg: "运行时间: {{ uptime_info.stdout }}" +`, + }, + { + ID: "check-resources", Name: "资源监控检查", Category: "system", + Description: "检查CPU/内存/磁盘使用率,超阈值告警", + Icon: "📈", Tags: []string{"monitor", "cpu", "memory", "disk"}, + Content: `--- +- name: 资源监控检查 + hosts: "{{ target_hosts | default('all') }}" + gather_facts: yes + vars: + warn_threshold: 80 + crit_threshold: 90 + + tasks: + - name: 获取CPU使用率 + shell: top -bn1 | grep "Cpu(s)" | awk '{print $2}' | cut -d'%' -f1 + register: cpu_result + changed_when: false + + - name: 获取内存使用率 + shell: free | grep Mem | awk '{printf "%.0f", $3/$2*100}' + register: mem_result + changed_when: false + + - name: 获取磁盘使用率 + shell: df -h / | tail -1 | awk '{print $5}' | tr -d '%' + register: disk_result + changed_when: false + + - name: 显示资源报告 + debug: + msg: | + ═══════ {{ inventory_hostname }} 资源报告 ═══════ + CPU 使用率: {{ cpu_result.stdout }}% + 内存使用率: {{ mem_result.stdout }}% + 磁盘使用率: {{ disk_result.stdout }}% + 告警阈值: {{ warn_threshold }}% / 严重: {{ crit_threshold }}% + ═══════════════════════════════════════ + + - name: 严重告警 + fail: + msg: "🚨 严重: {{ inventory_hostname }} 资源使用率超过 {{ crit_threshold }}%" + when: > + (cpu_result.stdout | int >= crit_threshold) or + (mem_result.stdout | int >= crit_threshold) or + (disk_result.stdout | int >= crit_threshold) +`, + }, + { + ID: "log-cleanup", Name: "日志清理", Category: "system", + Description: "清理系统日志、临时文件,释放磁盘空间", + Icon: "🧹", Tags: []string{"cleanup", "log", "disk"}, + Content: `--- +- name: 日志清理 + hosts: "{{ target_hosts | default('all') }}" + become: yes + vars: + log_retention_days: 30 + tmp_retention_days: 7 + + tasks: + - name: 清理journal日志 + shell: journalctl --vacuum-time={{ log_retention_days }}d + register: journal_result + changed_when: "'Vacuuming' in journal_result.stdout" + + - name: 清理旧日志文件 + find: + paths: /var/log + patterns: "*.gz,*.old,*.[0-9]" + age: "{{ log_retention_days }}d" + register: old_logs + + - name: 删除旧日志 + file: + path: "{{ item.path }}" + state: absent + loop: "{{ old_logs.files }}" + + - name: 清理临时文件 + find: + paths: /tmp + age: "{{ tmp_retention_days }}d" + register: tmp_files + + - name: 删除临时文件 + file: + path: "{{ item.path }}" + state: absent + loop: "{{ tmp_files.files }}" + ignore_errors: yes + + - name: 清理apt缓存 + apt: + autoclean: yes + when: ansible_os_family == "Debian" + + - name: 显示磁盘空间 + shell: df -h / + register: disk_after + + - name: 清理完成 + debug: + msg: "{{ disk_after.stdout_lines }}" +`, + }, + { + ID: "time-sync", Name: "时间同步(NTP)", Category: "system", + Description: "配置NTP时间同步,确保集群时间一致", + Icon: "⏰", Tags: []string{"ntp", "time", "chrony"}, + Content: `--- +- name: 时间同步配置 + hosts: "{{ target_hosts | default('all') }}" + become: yes + vars: + ntp_servers: + - ntp.aliyun.com + - ntp1.aliyun.com + - pool.ntp.org + + tasks: + - name: 安装chrony + package: + name: chrony + state: present + + - name: 配置NTP服务器 + lineinfile: + path: /etc/chrony.conf + regexp: "^server " + line: "server {{ item }} iburst" + create: yes + loop: "{{ ntp_servers }}" + + - name: 启动chrony + service: + name: chronyd + state: started + enabled: yes + + - name: 验证时间同步 + shell: chronyc tracking + register: chrony_status + changed_when: false + + - name: 显示同步状态 + debug: + msg: "{{ chrony_status.stdout_lines }}" +`, + }, + + // ===== Web服务 ===== + { + ID: "deploy-nginx", Name: "部署Nginx", Category: "web", + Description: "安装配置Nginx,支持自定义站点和反向代理", + Icon: "🌐", Tags: []string{"nginx", "web", "proxy"}, + Content: `--- +- name: 部署Nginx + hosts: "{{ target_hosts | default('all') }}" + become: yes + vars: + nginx_worker_processes: auto + nginx_worker_connections: 1024 + server_name: localhost + listen_port: 80 + proxy_pass: "" + root_dir: /var/www/html + + tasks: + - name: 安装Nginx + package: + name: nginx + state: present + + - name: 创建网站目录 + file: + path: "{{ root_dir }}" + state: directory + owner: www-data + group: www-data + mode: '0755' + when: ansible_os_family == "Debian" + + - name: 创建网站目录(RedHat) + file: + path: "{{ root_dir }}" + state: directory + owner: nginx + group: nginx + mode: '0755' + when: ansible_os_family == "RedHat" + + - name: 部署默认页面 + copy: + content: | + + {{ server_name }} +

Deployed by Ansible Deploy

+

Server: {{ inventory_hostname }}

+ dest: "{{ root_dir }}/index.html" + when: proxy_pass == "" + + - name: 配置Nginx站点 + copy: + content: | + server { + listen {{ listen_port }}; + server_name {{ server_name }}; + {% if proxy_pass != "" %} + location / { + proxy_pass {{ proxy_pass }}; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + {% else %} + root {{ root_dir }}; + index index.html; + location / { + try_files $uri $uri/ =404; + } + {% endif %} + } + dest: /etc/nginx/conf.d/{{ server_name }}.conf + notify: Reload Nginx + + - name: 启动Nginx + service: + name: nginx + state: started + enabled: yes + + handlers: + - name: Reload Nginx + service: + name: nginx + state: reloaded +`, + }, + { + ID: "deploy-apache", Name: "部署Apache", Category: "web", + Description: "安装配置Apache HTTP Server", + Icon: "🪶", Tags: []string{"apache", "httpd", "web"}, + Content: `--- +- name: 部署Apache + hosts: "{{ target_hosts | default('all') }}" + become: yes + vars: + server_name: localhost + listen_port: 80 + document_root: /var/www/html + + tasks: + - name: 安装Apache(Debian) + apt: + name: apache2 + state: present + when: ansible_os_family == "Debian" + + - name: 安装Apache(RedHat) + yum: + name: httpd + state: present + when: ansible_os_family == "RedHat" + + - name: 创建网站目录 + file: + path: "{{ document_root }}" + state: directory + mode: '0755' + + - name: 部署测试页面 + copy: + content: "

Apache on {{ inventory_hostname }}

" + dest: "{{ document_root }}/index.html" + + - name: 启动Apache(Debian) + service: + name: apache2 + state: started + enabled: yes + when: ansible_os_family == "Debian" + + - name: 启动Apache(RedHat) + service: + name: httpd + state: started + enabled: yes + when: ansible_os_family == "RedHat" +`, + }, + + // ===== 数据库 ===== + { + ID: "deploy-mysql", Name: "部署MySQL", Category: "database", + Description: "安装MySQL/MariaDB,配置root密码和基础优化", + Icon: "🐬", Tags: []string{"mysql", "mariadb", "database"}, + Content: `--- +- name: 部署MySQL + hosts: "{{ target_hosts | default('all') }}" + become: yes + vars: + mysql_root_password: "ChangeMe@2024" + mysql_port: 3306 + mysql_max_connections: 500 + mysql_datadir: /var/lib/mysql + + tasks: + - name: 安装MySQL(Debian) + apt: + name: + - mysql-server + - mysql-client + - python3-pymysql + state: present + when: ansible_os_family == "Debian" + + - name: 安装MariaDB(RedHat) + yum: + name: + - mariadb-server + - mariadb + - python3-PyMySQL + state: present + when: ansible_os_family == "RedHat" + + - name: 启动MySQL + service: + name: "{{ 'mysql' if ansible_os_family == 'Debian' else 'mariadb' }}" + state: started + enabled: yes + + - name: 设置root密码 + shell: | + mysql -u root -e "ALTER USER 'root'@'localhost' IDENTIFIED BY '{{ mysql_root_password }}'; FLUSH PRIVILEGES;" + ignore_errors: yes + no_log: true + + - name: 优化配置 + copy: + content: | + [mysqld] + port = {{ mysql_port }} + max_connections = {{ mysql_max_connections }} + datadir = {{ mysql_datadir }} + innodb_buffer_pool_size = 256M + character-set-server = utf8mb4 + collation-server = utf8mb4_unicode_ci + slow_query_log = 1 + slow_query_log_file = /var/log/mysql/slow.log + long_query_time = 2 + dest: /etc/mysql/conf.d/optimize.cnf + when: ansible_os_family == "Debian" + notify: Restart MySQL + + - name: 验证MySQL + shell: mysql -u root -p{{ mysql_root_password }} -e "SELECT VERSION();" + register: mysql_version + changed_when: false + no_log: true + + - name: 显示版本 + debug: + msg: "MySQL版本: {{ mysql_version.stdout_lines[-1] | default('unknown') }}" + + handlers: + - name: Restart MySQL + service: + name: "{{ 'mysql' if ansible_os_family == 'Debian' else 'mariadb' }}" + state: restarted +`, + }, + { + ID: "deploy-redis", Name: "部署Redis", Category: "database", + Description: "安装Redis,配置密码、内存限制和持久化", + Icon: "🔴", Tags: []string{"redis", "cache", "database"}, + Content: `--- +- name: 部署Redis + hosts: "{{ target_hosts | default('all') }}" + become: yes + vars: + redis_port: 6379 + redis_password: "ChangeMe@2024" + redis_maxmemory: "512mb" + redis_maxmemory_policy: allkeys-lru + redis_bind: "0.0.0.0" + + tasks: + - name: 安装Redis + package: + name: redis-server + state: present + when: ansible_os_family == "Debian" + + - name: 安装Redis(RedHat) + yum: + name: redis + state: present + when: ansible_os_family == "RedHat" + + - name: 配置Redis + lineinfile: + path: /etc/redis/redis.conf + regexp: "{{ item.regexp }}" + line: "{{ item.line }}" + loop: + - { regexp: '^bind ', line: 'bind {{ redis_bind }}' } + - { regexp: '^port ', line: 'port {{ redis_port }}' } + - { regexp: '^requirepass ', line: 'requirepass {{ redis_password }}' } + - { regexp: '^maxmemory ', line: 'maxmemory {{ redis_maxmemory }}' } + - { regexp: '^maxmemory-policy ', line: 'maxmemory-policy {{ redis_maxmemory_policy }}' } + notify: Restart Redis + + - name: 启动Redis + service: + name: "{{ 'redis-server' if ansible_os_family == 'Debian' else 'redis' }}" + state: started + enabled: yes + + - name: 验证Redis + shell: redis-cli -a {{ redis_password }} ping + register: redis_ping + changed_when: false + no_log: true + + - name: 显示状态 + debug: + msg: "Redis状态: {{ redis_ping.stdout }}" + + handlers: + - name: Restart Redis + service: + name: "{{ 'redis-server' if ansible_os_family == 'Debian' else 'redis' }}" + state: restarted +`, + }, + { + ID: "deploy-postgresql", Name: "部署PostgreSQL", Category: "database", + Description: "安装PostgreSQL,配置用户和基础优化", + Icon: "🐘", Tags: []string{"postgresql", "postgres", "database"}, + Content: `--- +- name: 部署PostgreSQL + hosts: "{{ target_hosts | default('all') }}" + become: yes + vars: + postgres_version: "15" + postgres_password: "ChangeMe@2024" + postgres_port: 5432 + postgres_max_connections: 200 + + tasks: + - name: 安装PostgreSQL(Debian) + apt: + name: + - postgresql + - postgresql-contrib + - python3-psycopg2 + state: present + when: ansible_os_family == "Debian" + + - name: 安装PostgreSQL(RedHat) + yum: + name: + - postgresql-server + - postgresql-contrib + - python3-psycopg2 + state: present + when: ansible_os_family == "RedHat" + + - name: 初始化数据库(RedHat) + shell: postgresql-setup --initdb + when: ansible_os_family == "RedHat" + ignore_errors: yes + + - name: 启动PostgreSQL + service: + name: postgresql + state: started + enabled: yes + + - name: 设置postgres密码 + become_user: postgres + shell: psql -c "ALTER USER postgres PASSWORD '{{ postgres_password }}';" + no_log: true + + - name: 验证安装 + become_user: postgres + shell: psql -c "SELECT version();" + register: pg_version + changed_when: false + + - name: 显示版本 + debug: + msg: "{{ pg_version.stdout_lines[0] | default('PostgreSQL installed') }}" +`, + }, + + // ===== 容器化 ===== + { + ID: "deploy-docker", Name: "部署Docker", Category: "container", + Description: "安装Docker CE + Docker Compose,配置镜像加速", + Icon: "🐳", Tags: []string{"docker", "container", "compose"}, + Content: `--- +- name: 部署Docker + hosts: "{{ target_hosts | default('all') }}" + become: yes + vars: + docker_mirror: "https://mirror.ccs.tencentyun.com" + docker_data_root: /var/lib/docker + docker_log_max_size: "100m" + docker_log_max_file: "3" + + tasks: + - name: 安装依赖(Debian) + apt: + name: + - apt-transport-https + - ca-certificates + - curl + - gnupg + - lsb-release + state: present + when: ansible_os_family == "Debian" + + - name: 添加Docker GPG密钥 + shell: | + curl -fsSL https://download.docker.com/linux/{{ ansible_distribution | lower }}/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg + when: ansible_os_family == "Debian" + ignore_errors: yes + + - name: 添加Docker仓库 + shell: | + echo "deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/{{ ansible_distribution | lower }} {{ ansible_distribution_release }} stable" > /etc/apt/sources.list.d/docker.list + when: ansible_os_family == "Debian" + + - name: 安装Docker(Debian) + apt: + name: + - docker-ce + - docker-ce-cli + - containerd.io + - docker-compose-plugin + state: present + update_cache: yes + when: ansible_os_family == "Debian" + + - name: 安装Docker(RedHat) + yum: + name: + - docker + - docker-compose-plugin + state: present + when: ansible_os_family == "RedHat" + + - name: 配置Docker + copy: + content: | + { + "data-root": "{{ docker_data_root }}", + "registry-mirrors": ["{{ docker_mirror }}"], + "log-driver": "json-file", + "log-opts": { + "max-size": "{{ docker_log_max_size }}", + "max-file": "{{ docker_log_max_file }}" + }, + "storage-driver": "overlay2", + "live-restore": true + } + dest: /etc/docker/daemon.json + notify: Restart Docker + + - name: 启动Docker + service: + name: docker + state: started + enabled: yes + + - name: 添加用户到docker组 + user: + name: "{{ ansible_user }}" + groups: docker + append: yes + ignore_errors: yes + + - name: 验证Docker + shell: docker version --format '{{.Server.Version}}' + register: docker_ver + changed_when: false + + - name: 显示版本 + debug: + msg: "Docker版本: {{ docker_ver.stdout }}" + + handlers: + - name: Restart Docker + service: + name: docker + state: restarted +`, + }, + { + ID: "deploy-portainer", Name: "部署Portainer", Category: "container", + Description: "部署Portainer CE容器管理面板", + Icon: "🎛️", Tags: []string{"portainer", "docker", "ui"}, + Content: `--- +- name: 部署Portainer + hosts: "{{ target_hosts | default('all') }}" + become: yes + vars: + portainer_port: 9443 + portainer_data: /opt/portainer + + tasks: + - name: 创建数据目录 + file: + path: "{{ portainer_data }}" + state: directory + + - name: 拉取并启动Portainer + shell: | + docker volume create portainer_data + docker run -d \ + -p 8000:8000 \ + -p {{ portainer_port }}:9443 \ + --name portainer \ + --restart=always \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v portainer_data:/data \ + portainer/portainer-ce:latest + register: portainer_result + ignore_errors: yes + + - name: 显示访问地址 + debug: + msg: "Portainer已部署: https://{{ ansible_host | default(inventory_hostname) }}:{{ portainer_port }}" +`, + }, + + // ===== DevOps ===== + { + ID: "deploy-nodejs", Name: "部署Node.js", Category: "devops", + Description: "安装Node.js + PM2进程管理器", + Icon: "💚", Tags: []string{"nodejs", "npm", "pm2"}, + Content: `--- +- name: 部署Node.js + hosts: "{{ target_hosts | default('all') }}" + become: yes + vars: + node_version: "20" + install_pm2: true + + tasks: + - name: 安装NodeSource仓库 + shell: | + curl -fsSL https://deb.nodesource.com/setup_{{ node_version }}.x | bash - + when: ansible_os_family == "Debian" + + - name: 安装Node.js(Debian) + apt: + name: nodejs + state: present + update_cache: yes + when: ansible_os_family == "Debian" + + - name: 安装Node.js(RedHat) + yum: + name: nodejs + state: present + when: ansible_os_family == "RedHat" + + - name: 安装PM2 + npm: + name: pm2 + global: yes + when: install_pm2 + + - name: 设置PM2开机启动 + shell: pm2 startup systemd -u {{ ansible_user }} --hp /home/{{ ansible_user }} + when: install_pm2 + ignore_errors: yes + + - name: 验证安装 + shell: node --version && npm --version + register: node_ver + changed_when: false + + - name: 显示版本 + debug: + msg: "{{ node_ver.stdout_lines }}" +`, + }, + { + ID: "deploy-python-app", Name: "部署Python应用", Category: "devops", + Description: "配置Python环境 + Gunicorn + Systemd服务", + Icon: "🐍", Tags: []string{"python", "gunicorn", "flask", "django"}, + Content: `--- +- name: 部署Python应用 + hosts: "{{ target_hosts | default('all') }}" + become: yes + vars: + app_name: myapp + app_dir: /opt/myapp + python_version: python3 + venv_dir: /opt/myapp/venv + gunicorn_port: 8000 + gunicorn_workers: 4 + app_user: www-data + + tasks: + - name: 安装Python和依赖 + package: + name: + - "{{ python_version }}" + - "{{ python_version }}-venv" + - python3-pip + state: present + + - name: 创建应用目录 + file: + path: "{{ app_dir }}" + state: directory + owner: "{{ app_user }}" + mode: '0755' + + - name: 创建虚拟环境 + shell: "{{ python_version }} -m venv {{ venv_dir }}" + args: + creates: "{{ venv_dir }}/bin/activate" + + - name: 安装Gunicorn + pip: + name: gunicorn + virtualenv: "{{ venv_dir }}" + + - name: 创建Systemd服务 + copy: + content: | + [Unit] + Description={{ app_name }} Gunicorn Service + After=network.target + + [Service] + User={{ app_user }} + WorkingDirectory={{ app_dir }} + ExecStart={{ venv_dir }}/bin/gunicorn --workers {{ gunicorn_workers }} --bind 0.0.0.0:{{ gunicorn_port }} app:app + Restart=always + + [Install] + WantedBy=multi-user.target + dest: /etc/systemd/system/{{ app_name }}.service + notify: Restart App + + - name: 启动服务 + systemd: + name: "{{ app_name }}" + state: started + enabled: yes + daemon_reload: yes + + handlers: + - name: Restart App + systemd: + name: "{{ app_name }}" + state: restarted + daemon_reload: yes +`, + }, + { + ID: "backup-data", Name: "数据备份", Category: "devops", + Description: "定时备份指定目录到本地或远程", + Icon: "💾", Tags: []string{"backup", "cron", "archive"}, + Content: `--- +- name: 数据备份 + hosts: "{{ target_hosts | default('all') }}" + become: yes + vars: + backup_source: /data + backup_dest: /backup + backup_retention_days: 7 + backup_hour: "2" + backup_minute: "0" + + tasks: + - name: 创建备份目录 + file: + path: "{{ backup_dest }}" + state: directory + mode: '0700' + + - name: 创建备份脚本 + copy: + content: | + #!/bin/bash + DATE=$(date +%Y%m%d_%H%M%S) + BACKUP_FILE="{{ backup_dest }}/backup_${DATE}.tar.gz" + tar -czf "$BACKUP_FILE" {{ backup_source }} 2>/dev/null + find {{ backup_dest }} -name "backup_*.tar.gz" -mtime +{{ backup_retention_days }} -delete + echo "Backup completed: $BACKUP_FILE" + dest: /usr/local/bin/backup.sh + mode: '0755' + + - name: 配置定时任务 + cron: + name: "daily-backup" + hour: "{{ backup_hour }}" + minute: "{{ backup_minute }}" + job: "/usr/local/bin/backup.sh >> /var/log/backup.log 2>&1" + + - name: 立即执行一次备份 + shell: /usr/local/bin/backup.sh + register: backup_result + + - name: 备份结果 + debug: + msg: "{{ backup_result.stdout }}" +`, + }, + + // ===== 安全加固 ===== + { + ID: "security-hardening", Name: "安全加固", Category: "security", + Description: "SSH加固、防火墙配置、禁用root远程登录", + Icon: "🔒", Tags: []string{"security", "ssh", "firewall", "hardening"}, + Content: `--- +- name: 安全加固 + hosts: "{{ target_hosts | default('all') }}" + become: yes + vars: + ssh_port: 22 + disable_root_login: true + enable_firewall: true + allowed_ssh_users: [] + + tasks: + - name: 修改SSH端口 + lineinfile: + path: /etc/ssh/sshd_config + regexp: "^#?Port " + line: "Port {{ ssh_port }}" + notify: Restart SSH + + - name: 禁用root登录 + lineinfile: + path: /etc/ssh/sshd_config + regexp: "^#?PermitRootLogin " + line: "PermitRootLogin no" + when: disable_root_login + notify: Restart SSH + + - name: 禁用密码认证(仅密钥) + lineinfile: + path: /etc/ssh/sshd_config + regexp: "^#?PasswordAuthentication " + line: "PasswordAuthentication no" + notify: Restart SSH + + - name: 设置SSH超时 + lineinfile: + path: /etc/ssh/sshd_config + regexp: "^#?ClientAliveInterval " + line: "ClientAliveInterval 300" + notify: Restart SSH + + - name: 安装UFW(Debian) + apt: + name: ufw + state: present + when: ansible_os_family == "Debian" and enable_firewall + + - name: 配置UFW规则 + shell: | + ufw allow {{ ssh_port }}/tcp + ufw allow 80/tcp + ufw allow 443/tcp + ufw --force enable + when: ansible_os_family == "Debian" and enable_firewall + + - name: 安装fail2ban + package: + name: fail2ban + state: present + ignore_errors: yes + + - name: 启动fail2ban + service: + name: fail2ban + state: started + enabled: yes + ignore_errors: yes + + - name: 加固完成 + debug: + msg: "✅ 安全加固完成 - SSH端口: {{ ssh_port }}, Root登录: {{ '禁用' if disable_root_login else '允许' }}" + + handlers: + - name: Restart SSH + service: + name: "{{ 'ssh' if ansible_os_family == 'Debian' else 'sshd' }}" + state: restarted +`, + }, + { + ID: "ssl-cert", Name: "SSL证书部署", Category: "security", + Description: "使用Certbot自动申请Let's Encrypt证书", + Icon: "📜", Tags: []string{"ssl", "https", "certbot", "letsencrypt"}, + Content: `--- +- name: SSL证书部署 + hosts: "{{ target_hosts | default('all') }}" + become: yes + vars: + domain: example.com + email: admin@example.com + webroot: /var/www/html + + tasks: + - name: 安装Certbot + package: + name: + - certbot + - python3-certbot-nginx + state: present + + - name: 申请证书 + shell: | + certbot certonly --webroot -w {{ webroot }} \ + -d {{ domain }} \ + --email {{ email }} \ + --agree-tos --non-interactive + args: + creates: /etc/letsencrypt/live/{{ domain }}/fullchain.pem + + - name: 配置自动续期 + cron: + name: "certbot-renew" + hour: "3" + minute: "30" + job: "certbot renew --quiet --post-hook 'systemctl reload nginx'" + + - name: 证书信息 + shell: openssl x509 -in /etc/letsencrypt/live/{{ domain }}/fullchain.pem -noout -dates + register: cert_info + changed_when: false + ignore_errors: yes + + - name: 显示证书 + debug: + msg: "{{ cert_info.stdout_lines | default(['证书已申请']) }}" +`, + }, + + // ===== 监控告警 ===== + { + ID: "deploy-node-exporter", Name: "部署Node Exporter", Category: "monitoring", + Description: "部署Prometheus Node Exporter采集主机指标", + Icon: "📡", Tags: []string{"prometheus", "monitoring", "exporter"}, + Content: `--- +- name: 部署Node Exporter + hosts: "{{ target_hosts | default('all') }}" + become: yes + vars: + exporter_version: "1.7.0" + exporter_port: 9100 + + tasks: + - name: 创建用户 + user: + name: node_exporter + system: yes + shell: /usr/sbin/nologin + create_home: no + + - name: 下载Node Exporter + get_url: + url: "https://github.com/prometheus/node_exporter/releases/download/v{{ exporter_version }}/node_exporter-{{ exporter_version }}.linux-amd64.tar.gz" + dest: /tmp/node_exporter.tar.gz + + - name: 解压 + unarchive: + src: /tmp/node_exporter.tar.gz + dest: /tmp + remote_src: yes + + - name: 安装二进制 + copy: + src: "/tmp/node_exporter-{{ exporter_version }}.linux-amd64/node_exporter" + dest: /usr/local/bin/node_exporter + mode: '0755' + remote_src: yes + + - name: 创建Systemd服务 + copy: + content: | + [Unit] + Description=Node Exporter + After=network.target + + [Service] + User=node_exporter + ExecStart=/usr/local/bin/node_exporter --web.listen-address=:{{ exporter_port }} + Restart=always + + [Install] + WantedBy=multi-user.target + dest: /etc/systemd/system/node_exporter.service + + - name: 启动服务 + systemd: + name: node_exporter + state: started + enabled: yes + daemon_reload: yes + + - name: 验证 + shell: curl -s http://localhost:{{ exporter_port }}/metrics | head -5 + register: metrics + changed_when: false + + - name: 显示状态 + debug: + msg: "Node Exporter运行在端口 {{ exporter_port }}" +`, + }, + { + ID: "deploy-grafana", Name: "部署Grafana", Category: "monitoring", + Description: "部署Grafana可视化监控面板", + Icon: "📊", Tags: []string{"grafana", "dashboard", "visualization"}, + Content: `--- +- name: 部署Grafana + hosts: "{{ target_hosts | default('all') }}" + become: yes + vars: + grafana_port: 3000 + grafana_admin_password: "admin123" + + tasks: + - name: 安装依赖 + apt: + name: + - apt-transport-https + - software-properties-common + state: present + when: ansible_os_family == "Debian" + + - name: 添加Grafana仓库 + shell: | + curl -fsSL https://apt.grafana.com/gpg.key | gpg --dearmor -o /usr/share/keyrings/grafana.gpg + echo "deb [signed-by=/usr/share/keyrings/grafana.gpg] https://apt.grafana.com stable main" > /etc/apt/sources.list.d/grafana.list + when: ansible_os_family == "Debian" + + - name: 安装Grafana + apt: + name: grafana + state: present + update_cache: yes + when: ansible_os_family == "Debian" + + - name: 配置端口 + lineinfile: + path: /etc/grafana/grafana.ini + regexp: "^;?http_port" + line: "http_port = {{ grafana_port }}" + + - name: 启动Grafana + service: + name: grafana-server + state: started + enabled: yes + + - name: 显示访问信息 + debug: + msg: "Grafana: http://{{ ansible_host | default(inventory_hostname) }}:{{ grafana_port }} (admin/{{ grafana_admin_password }})" +`, + }, + + // ===== 系统管理(新增) ===== + { + ID: "host-connectivity", Name: "主机连通性测试", Category: "system", + Description: "批量检测主机SSH连通性、DNS解析、端口可达性", + Icon: "🔗", Tags: []string{"ping", "connectivity", "network", "check"}, + Content: `--- +- name: 主机连通性测试 + hosts: "{{ target_hosts | default('all') }}" + gather_facts: no + + tasks: + - name: Ansible Ping 测试 + ping: + register: ping_result + ignore_errors: yes + + - name: 检测SSH端口 + wait_for: + host: "{{ ansible_host | default(inventory_hostname) }}" + port: "{{ ansible_port | default(22) }}" + timeout: 5 + delegate_to: localhost + register: ssh_port + ignore_errors: yes + + - name: 检测DNS解析 + shell: nslookup {{ inventory_hostname }} 2>/dev/null || host {{ inventory_hostname }} 2>/dev/null || echo "DNS解析失败" + delegate_to: localhost + register: dns_result + changed_when: false + ignore_errors: yes + + - name: 检测常用端口 + wait_for: + host: "{{ ansible_host | default(inventory_hostname) }}" + port: "{{ item }}" + timeout: 3 + delegate_to: localhost + loop: [80, 443] + register: port_scan + ignore_errors: yes + + - name: 输出连通性报告 + debug: + msg: | + ═══════ {{ inventory_hostname }} 连通性报告 ═══════ + SSH Ping: {{ '✅ 成功' if ping_result is succeeded else '❌ 失败' }} + SSH 端口: {{ '✅ 开放' if ssh_port is succeeded else '❌ 不可达' }} + DNS 解析: {{ dns_result.stdout | default('N/A') }} + HTTP(80): {{ '✅ 开放' if port_scan.results[0] is succeeded else '❌ 关闭' }} + HTTPS(443): {{ '✅ 开放' if port_scan.results[1] is succeeded else '❌ 关闭' }} + ═══════════════════════════════════════ +`, + }, + { + ID: "ubuntu-multi-ip", Name: "Ubuntu 22.04 多IP配置", Category: "system", + Description: "通过Netplan为Ubuntu 22.04配置多IP地址(主IP+辅助IP)", + Icon: "🌐", Tags: []string{"ubuntu", "netplan", "network", "multi-ip"}, + Content: `--- +- name: Ubuntu 22.04 多IP地址配置 + hosts: "{{ target_hosts | default('all') }}" + become: yes + vars: + # 主网卡名称(根据实际修改) + primary_nic: ens192 + # 主IP配置 + primary_ip: "192.168.1.100/24" + primary_gateway: "192.168.1.1" + primary_dns: + - 223.5.5.5 + - 119.29.29.29 + # 辅助IP列表(可添加多个) + secondary_ips: + - "192.168.1.101/24" + - "10.0.0.100/16" + + tasks: + - name: 确认系统版本 + assert: + that: + - ansible_distribution == "Ubuntu" + - ansible_distribution_version is version('22.04', '>=') + fail_msg: "此模板仅适用于 Ubuntu 22.04+" + success_msg: "系统版本: {{ ansible_distribution }} {{ ansible_distribution_version }}" + + - name: 备份现有Netplan配置 + shell: | + mkdir -p /etc/netplan/backup + cp /etc/netplan/*.yaml /etc/netplan/backup/ 2>/dev/null || true + args: + creates: /etc/netplan/backup + + - name: 生成Netplan配置 + copy: + content: | + network: + version: 2 + ethernets: + {{ primary_nic }}: + addresses: + - {{ primary_ip }} + {% for ip in secondary_ips %} + - {{ ip }} + {% endfor %} + routes: + - to: default + via: {{ primary_gateway }} + nameservers: + addresses: + {% for dns in primary_dns %} + - {{ dns }} + {% endfor %} + dest: /etc/netplan/01-multi-ip.yaml + mode: '0600' + register: netplan_config + + - name: 验证Netplan配置语法 + shell: netplan try --timeout=10 + environment: + NETPLAN_TRY_TIMEOUT: "10" + ignore_errors: yes + register: netplan_try + + - name: 应用Netplan配置 + shell: netplan apply + when: netplan_config is changed + + - name: 等待网络恢复 + wait_for: + host: "{{ primary_ip | ansible.utils.ipaddr('address') }}" + port: 22 + timeout: 30 + delegate_to: localhost + when: netplan_config is changed + ignore_errors: yes + + - name: 验证IP配置 + shell: ip addr show {{ primary_nic }} | grep "inet " + register: ip_verify + changed_when: false + + - name: 显示配置结果 + debug: + msg: | + ═══════ 多IP配置完成 ═══════ + 网卡: {{ primary_nic }} + 主IP: {{ primary_ip }} + 网关: {{ primary_gateway }} + 辅助IP: + {% for ip in secondary_ips %} + - {{ ip }} + {% endfor %} + 当前IP列表: + {{ ip_verify.stdout }} + ═══════════════════════════ +`, + }, + { + ID: "ipmitool-bmc", Name: "IPMI配置BMC地址", Category: "system", + Description: "使用ipmitool配置服务器BMC/IPMI管理口IP地址", + Icon: "🔧", Tags: []string{"ipmi", "bmc", "ipmitool", "hardware"}, + Content: `--- +- name: IPMI配置BMC地址 + hosts: "{{ target_hosts | default('all') }}" + become: yes + vars: + # BMC网络配置 + bmc_ip: "10.10.10.100" + bmc_netmask: "255.255.255.0" + bmc_gateway: "10.10.10.1" + # BMC用户配置 + bmc_user: "admin" + bmc_password: "Admin@123" + bmc_channel: 1 + # 是否启用DHCP(设为true则忽略上面的静态IP配置) + bmc_dhcp: false + + tasks: + - name: 安装ipmitool + package: + name: ipmitool + state: present + + - name: 加载IPMI内核模块 + modprobe: + name: "{{ item }}" + state: present + loop: + - ipmi_devintf + - ipmi_si + ignore_errors: yes + + - name: 查看当前BMC配置 + shell: ipmitool lan print {{ bmc_channel }} + register: bmc_current + changed_when: false + ignore_errors: yes + + - name: 显示当前BMC信息 + debug: + msg: "{{ bmc_current.stdout_lines | default(['无法读取BMC信息']) }}" + + - name: 设置BMC为静态IP + shell: ipmitool lan set {{ bmc_channel }} ipsrc static + when: not bmc_dhcp + + - name: 配置BMC IP地址 + shell: ipmitool lan set {{ bmc_channel }} ipaddr {{ bmc_ip }} + when: not bmc_dhcp + + - name: 配置BMC子网掩码 + shell: ipmitool lan set {{ bmc_channel }} netmask {{ bmc_netmask }} + when: not bmc_dhcp + + - name: 配置BMC默认网关 + shell: ipmitool lan set {{ bmc_channel }} defgw ipaddr {{ bmc_gateway }} + when: not bmc_dhcp + + - name: 设置BMC为DHCP模式 + shell: ipmitool lan set {{ bmc_channel }} ipsrc dhcp + when: bmc_dhcp + + - name: 配置BMC用户密码 + shell: | + ipmitool user set name {{ bmc_channel }} 2 {{ bmc_user }} 2>/dev/null || true + ipmitool user set password 2 {{ bmc_password }} + ipmitool user enable 2 + ipmitool channel setaccess {{ bmc_channel }} 2 privilege=4 + no_log: true + ignore_errors: yes + + - name: 验证BMC配置 + shell: ipmitool lan print {{ bmc_channel }} + register: bmc_verify + changed_when: false + + - name: 显示最终配置 + debug: + msg: | + ═══════ BMC配置完成 ═══════ + 模式: {{ 'DHCP' if bmc_dhcp else '静态IP' }} + IP: {{ bmc_ip }} + 掩码: {{ bmc_netmask }} + 网关: {{ bmc_gateway }} + 用户: {{ bmc_user }} + ═══════════════════════════ + {{ bmc_verify.stdout }} +`, + }, + { + ID: "apt-install", Name: "APT软件安装", Category: "system", + Description: "通过apt批量安装/卸载软件包,支持指定版本和仓库", + Icon: "📦", Tags: []string{"apt", "install", "package", "debian"}, + Content: `--- +- name: APT软件安装 + hosts: "{{ target_hosts | default('all') }}" + become: yes + vars: + # 要安装的软件包列表 + packages_install: + - vim + - curl + - wget + - htop + - net-tools + - tree + - unzip + - git + # 要卸载的软件包列表 + packages_remove: [] + # 是否更新缓存 + update_cache: yes + # 是否自动清理 + autoremove: yes + # 安装推荐包 + install_recommends: no + # 指定版本(可选,格式: "package=version") + # 例: packages_install: ["nginx=1.18.0-0ubuntu1"] + + tasks: + - name: 确认Debian系统 + assert: + that: ansible_os_family == "Debian" + fail_msg: "此模板仅适用于 Debian/Ubuntu 系统" + + - name: 更新APT缓存 + apt: + update_cache: yes + cache_valid_time: 3600 + when: update_cache + + - name: 安装软件包 + apt: + name: "{{ packages_install }}" + state: present + install_recommends: "{{ install_recommends }}" + register: install_result + when: packages_install | length > 0 + + - name: 卸载软件包 + apt: + name: "{{ packages_remove }}" + state: absent + purge: yes + when: packages_remove | length > 0 + + - name: 自动清理 + apt: + autoremove: yes + autoclean: yes + when: autoremove + + - name: 验证安装结果 + shell: dpkg -l {{ packages_install | join(' ') }} 2>/dev/null | grep "^ii" | awk '{print $2, $3}' + register: verify_result + changed_when: false + when: packages_install | length > 0 + + - name: 显示安装报告 + debug: + msg: | + ═══════ APT安装报告 ═══════ + 已安装: {{ packages_install | length }} 个包 + 已卸载: {{ packages_remove | length }} 个包 + 变更: {{ install_result.changed | default(false) }} + {% if verify_result is defined and verify_result.stdout_lines is defined %} + 已确认: + {% for line in verify_result.stdout_lines %} + ✓ {{ line }} + {% endfor %} + {% endif %} + ═══════════════════════════ +`, + }, + { + ID: "dpkg-install", Name: "DPKG软件安装", Category: "system", + Description: "通过dpkg安装本地.deb包,支持URL下载和依赖修复", + Icon: "📥", Tags: []string{"dpkg", "deb", "install", "offline"}, + Content: `--- +- name: DPKG软件安装 + hosts: "{{ target_hosts | default('all') }}" + become: yes + vars: + # 本地deb包路径列表(服务器上的路径) + local_deb_files: [] + # 远程URL列表(会自动下载后安装) + remote_deb_urls: [] + # 下载临时目录 + download_dir: /tmp/dpkg_install + # 安装后是否修复依赖 + fix_dependencies: yes + # 是否保留下载的deb文件 + keep_deb_files: no + + tasks: + - name: 确认Debian系统 + assert: + that: ansible_os_family == "Debian" + fail_msg: "此模板仅适用于 Debian/Ubuntu 系统" + + - name: 创建下载目录 + file: + path: "{{ download_dir }}" + state: directory + mode: '0755' + when: remote_deb_urls | length > 0 + + - name: 下载远程deb包 + get_url: + url: "{{ item }}" + dest: "{{ download_dir }}/{{ item | basename }}" + mode: '0644' + loop: "{{ remote_deb_urls }}" + register: download_result + when: remote_deb_urls | length > 0 + + - name: 安装本地deb包 + apt: + deb: "{{ item }}" + state: present + loop: "{{ local_deb_files }}" + register: local_install + when: local_deb_files | length > 0 + + - name: 安装下载的deb包 + apt: + deb: "{{ download_dir }}/{{ item | basename }}" + state: present + loop: "{{ remote_deb_urls }}" + register: remote_install + when: remote_deb_urls | length > 0 + + - name: 修复依赖关系 + apt: + update_cache: yes + force_apt_get: yes + shell: apt-get install -f -y + when: fix_dependencies + ignore_errors: yes + + - name: 清理下载文件 + file: + path: "{{ download_dir }}" + state: absent + when: not keep_deb_files and remote_deb_urls | length > 0 + + - name: 显示安装报告 + debug: + msg: | + ═══════ DPKG安装报告 ═══════ + 本地包: {{ local_deb_files | length }} 个 + 远程包: {{ remote_deb_urls | length }} 个 + {% if local_deb_files | length > 0 %} + 本地安装: + {% for f in local_deb_files %} + ✓ {{ f }} + {% endfor %} + {% endif %} + {% if remote_deb_urls | length > 0 %} + 远程安装: + {% for u in remote_deb_urls %} + ✓ {{ u | basename }} + {% endfor %} + {% endif %} + 依赖修复: {{ '是' if fix_dependencies else '否' }} + ═══════════════════════════ +`, + }, + } +} diff --git a/inventory/groups.json b/inventory/groups.json deleted file mode 100644 index 67b2bc0..0000000 --- a/inventory/groups.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "all": { - "name": "all", - "description": "所有主机", - "hosts": null - }, - "ungrouped": { - "name": "ungrouped", - "description": "未分组主机", - "hosts": null - }, - "zebu_user01": { - "name": "zebu_user01", - "description": "", - "hosts": null - } -} \ No newline at end of file diff --git a/inventory/hosts b/inventory/hosts deleted file mode 100644 index 6e5bf4a..0000000 --- a/inventory/hosts +++ /dev/null @@ -1,10 +0,0 @@ -# Ansible Inventory File -# Generated by ansible-deploy - -[all] - nas ansible_host=10.168.1.209 ansible_user=root - -[zebu_user01] - scmp48 ansible_host=172.16.11.46 ansible_user=root - scmp46 ansible_host=172.16.11.42 ansible_user=root - scmp47 ansible_host=172.16.11.44 ansible_user=root diff --git a/inventory/hosts.json b/inventory/hosts.json deleted file mode 100644 index 79c6ec2..0000000 --- a/inventory/hosts.json +++ /dev/null @@ -1,62 +0,0 @@ -[ - { - "id": "706f8ce7", - "name": "nas", - "ip": "10.168.1.209", - "port": 22, - "username": "root", - "password": "WXJwxj91612!!", - "groups": [ - "all" - ], - "status": "online", - "last_check": "2026-05-13T17:34:10.808052527+08:00", - "created_at": "2026-05-13T16:03:45.265250935+08:00", - "updated_at": "2026-05-13T16:03:45.265251013+08:00" - }, - { - "id": "4d7a1f03", - "name": "scmp48", - "ip": "172.16.11.46", - "port": 22, - "username": "root", - "password": "STC#scmp%0818", - "groups": [ - "zebu_user01" - ], - "status": "online", - "last_check": "2026-05-13T18:27:19.272543838+08:00", - "created_at": "0001-01-01T00:00:00Z", - "updated_at": "2026-05-13T18:23:27.433461112+08:00" - }, - { - "id": "57884720", - "name": "scmp46", - "ip": "172.16.11.42", - "port": 22, - "username": "root", - "password": "STC#scmp%0818", - "groups": [ - "zebu_user01" - ], - "status": "online", - "last_check": "2026-05-13T18:30:38.103119534+08:00", - "created_at": "0001-01-01T00:00:00Z", - "updated_at": "2026-05-13T18:28:16.71131472+08:00" - }, - { - "id": "5150c740", - "name": "scmp47", - "ip": "172.16.11.44", - "port": 22, - "username": "root", - "password": "STC#scmp%0818", - "groups": [ - "zebu_user01" - ], - "status": "online", - "last_check": "2026-05-13T18:30:33.343216547+08:00", - "created_at": "0001-01-01T00:00:00Z", - "updated_at": "2026-05-13T18:28:08.656450224+08:00" - } -] \ No newline at end of file diff --git a/playbooks/deploy-node-exporter.yml b/playbooks/deploy-node-exporter.yml new file mode 100644 index 0000000..478f3c4 --- /dev/null +++ b/playbooks/deploy-node-exporter.yml @@ -0,0 +1,65 @@ +--- +- name: 部署Node Exporter + hosts: "{{ target_hosts | default('all') }}" + become: yes + vars: + exporter_version: "1.7.0" + exporter_port: 9100 + + tasks: + - name: 创建用户 + user: + name: node_exporter + system: yes + shell: /usr/sbin/nologin + create_home: no + + - name: 下载Node Exporter + get_url: + url: "https://github.com/prometheus/node_exporter/releases/download/v{{ exporter_version }}/node_exporter-{{ exporter_version }}.linux-amd64.tar.gz" + dest: /tmp/node_exporter.tar.gz + + - name: 解压 + unarchive: + src: /tmp/node_exporter.tar.gz + dest: /tmp + remote_src: yes + + - name: 安装二进制 + copy: + src: "/tmp/node_exporter-{{ exporter_version }}.linux-amd64/node_exporter" + dest: /usr/local/bin/node_exporter + mode: '0755' + remote_src: yes + + - name: 创建Systemd服务 + copy: + content: | + [Unit] + Description=Node Exporter + After=network.target + + [Service] + User=node_exporter + ExecStart=/usr/local/bin/node_exporter --web.listen-address=:{{ exporter_port }} + Restart=always + + [Install] + WantedBy=multi-user.target + dest: /etc/systemd/system/node_exporter.service + + - name: 启动服务 + systemd: + name: node_exporter + state: started + enabled: yes + daemon_reload: yes + + - name: 验证 + shell: curl -s http://localhost:{{ exporter_port }}/metrics | head -5 + register: metrics + changed_when: false + + - name: 显示状态 + debug: + msg: "Node Exporter运行在端口 {{ exporter_port }}" diff --git a/playbooks/deploy-python-app.yml b/playbooks/deploy-python-app.yml new file mode 100644 index 0000000..856850c --- /dev/null +++ b/playbooks/deploy-python-app.yml @@ -0,0 +1,70 @@ +--- +- name: 部署Python应用 + hosts: "{{ target_hosts | default('all') }}" + become: yes + vars: + app_name: myapp + app_dir: /opt/myapp + python_version: python3 + venv_dir: /opt/myapp/venv + gunicorn_port: 8000 + gunicorn_workers: 4 + app_user: www-data + + tasks: + - name: 安装Python和依赖 + package: + name: + - "{{ python_version }}" + - "{{ python_version }}-venv" + - python3-pip + state: present + + - name: 创建应用目录 + file: + path: "{{ app_dir }}" + state: directory + owner: "{{ app_user }}" + mode: '0755' + + - name: 创建虚拟环境 + shell: "{{ python_version }} -m venv {{ venv_dir }}" + args: + creates: "{{ venv_dir }}/bin/activate" + + - name: 安装Gunicorn + pip: + name: gunicorn + virtualenv: "{{ venv_dir }}" + + - name: 创建Systemd服务 + copy: + content: | + [Unit] + Description={{ app_name }} Gunicorn Service + After=network.target + + [Service] + User={{ app_user }} + WorkingDirectory={{ app_dir }} + ExecStart={{ venv_dir }}/bin/gunicorn --workers {{ gunicorn_workers }} --bind 0.0.0.0:{{ gunicorn_port }} app:app + Restart=always + + [Install] + WantedBy=multi-user.target + dest: /etc/systemd/system/{{ app_name }}.service + notify: Restart App + + - name: 启动服务 + systemd: + name: "{{ app_name }}" + state: started + enabled: yes + daemon_reload: yes + + handlers: + - name: Restart App + systemd: + name: "{{ app_name }}" + state: restarted + daemon_reload: yes diff --git a/web/dist/index.html b/web/dist/index.html index 9ea8b62..f590c86 100644 --- a/web/dist/index.html +++ b/web/dist/index.html @@ -1,1690 +1,1452 @@ - - - Ansible批量部署工具 - + + +Ansible Deploy Pro · 自动化部署平台 + + + + + -
-
-

⚡ Ansible批量部署工具

- -
-
-
- -
-
-
-

主机总数

-
0
-
-
-

在线主机

-
0
-
-
-

离线主机

-
0
-
-
-

运行中任务

-
0
-
-
+
-
-

📝 最近任务

- - - - - - - - - - - -
任务名称状态进度开始时间
-
-
+ + - - + + - - + +
+ +
- -