diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..323fd28 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,29 @@ +# Git +.git +.gitignore + +# 前端构建产物和依赖(后端构建不需要) +frontend/node_modules +frontend/dist +frontend/.cache + +# 日志 +**/*.log +backend/logs/ + +# 敏感配置 +backend/config.yaml +**/.env + +# Docker 自身 +docker-compose.yml +Dockerfile +**/Dockerfile + +# 文档 +*.md +docs/ + +# IDE +.idea +.vscode diff --git a/backend/Dockerfile b/backend/Dockerfile index 6ba8508..1a8329e 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,60 +1,82 @@ -# 构建阶段 +# ============================================================ +# AIOps Backend + Agent 多阶段 Dockerfile +# Stage 1: 编译后端 (Go 1.21) +# Stage 2: 交叉编译 Agent (Go 1.24, linux-amd64/arm64 + darwin-arm64) +# Stage 3: 运行时 (Alpine) +# 署名 Logan.Li +# ============================================================ + +# ==================== Stage 1: Backend Builder ==================== FROM golang:1.21-alpine AS builder -# 设置工作目录 WORKDIR /app -# 安装构建依赖 RUN apk add --no-cache git ca-certificates tzdata -# 设置时区 ENV TZ=Asia/Shanghai - -# 设置 Go 模块代理(国内加速) ENV GOPROXY=https://goproxy.cn,direct ENV GOSUMDB=off -# 复制 go.mod 和 go.sum -COPY go.mod go.sum ./ - -# 下载依赖 +# 复制后端 go.mod(利用 Docker 层缓存) +COPY backend/go.mod backend/go.sum ./ RUN go mod download -# 复制源代码 -COPY . . +# 复制后端源码 +COPY backend/ ./ -# 构建应用 +# 构建后端二进制 RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \ - -ldflags="-w -s \ - -X main.Version=$(git describe --tags --always --dirty 2>/dev/null || echo 'dev') \ - -X main.BuildTime=$(date +%Y-%m-%d_%H:%M:%S) \ - -X main.GitCommit=$(git rev-parse --short HEAD 2>/dev/null || echo 'unknown')" \ + -ldflags="-w -s" \ -o ezops ./cmd/ -# 运行阶段 +# ==================== Stage 2: Agent Builder ==================== +FROM golang:1.24-alpine AS agent-builder + +WORKDIR /agent + +ENV GOPROXY=https://goproxy.cn,direct +ENV GOSUMDB=off + +# 复制 agent go.mod +COPY agent/go.mod agent/go.sum ./ +RUN go mod download + +# 复制 agent 源码 +COPY agent/ ./ + +# 交叉编译三个平台的 agent 二进制 +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o bin/aiops-agent-linux-amd64 . && \ + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o bin/aiops-agent-linux-arm64 . && \ + CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w" -o bin/aiops-agent-darwin-arm64 . + +# ==================== Stage 3: Runtime ==================== FROM alpine:latest -# 安装运行时依赖 -RUN apk --no-cache add ca-certificates tzdata curl +# 安装运行时依赖(curl=健康检查,openssh-client=SSH远程安装agent) +RUN apk --no-cache add ca-certificates tzdata curl openssh-client -# 设置时区 ENV TZ=Asia/Shanghai -# 设置工作目录 WORKDIR /app -# 复制构建产物 +# 复制后端二进制 COPY --from=builder /app/ezops . -# 创建日志目录 -RUN mkdir -p /app/logs /app/uploads +# 创建运行时目录 +RUN mkdir -p /app/logs /app/uploads /app/agent/bin /app/agent/deploy + +# 复制 agent 二进制(三个平台) +COPY --from=agent-builder /agent/bin/aiops-agent-linux-amd64 /app/agent/bin/ +COPY --from=agent-builder /agent/bin/aiops-agent-linux-arm64 /app/agent/bin/ +COPY --from=agent-builder /agent/bin/aiops-agent-darwin-arm64 /app/agent/bin/ + +# 复制 agent 安装脚本和 systemd service 模板 +COPY agent/deploy/install.sh /app/agent/deploy/ +COPY agent/deploy/aiops-agent.service /app/agent/deploy/ -# 暴露端口 EXPOSE 8080 -# 健康检查 -HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ CMD curl -f http://localhost:8080/health || exit 1 -# 启动应用 CMD ["./ezops"] diff --git a/backend/internal/api/handlers/agent_install.go b/backend/internal/api/handlers/agent_install.go new file mode 100644 index 0000000..e822380 --- /dev/null +++ b/backend/internal/api/handlers/agent_install.go @@ -0,0 +1,146 @@ +package handlers + +import ( + "fmt" + "log" + "net/http" + "strconv" + "time" + + "ezops/internal/models" + + "github.com/gin-gonic/gin" + "golang.org/x/crypto/ssh" + "gorm.io/gorm" +) + +// AgentInstallHandler Agent 远程安装处理器 // Logan.Li +type AgentInstallHandler struct { + db *gorm.DB +} + +func NewAgentInstallHandler(db *gorm.DB) *AgentInstallHandler { + return &AgentInstallHandler{db: db} +} + +// sshDial 复用 webshell.go 的 SSH 连接逻辑 +func sshDial(server models.Server) (*ssh.Client, error) { + config := &ssh.ClientConfig{ + User: server.Username, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: 10 * time.Second, + } + + switch server.AuthType { + case "key": + signer, err := ssh.ParsePrivateKey([]byte(server.SSHKey)) + if err != nil { + config.Auth = []ssh.AuthMethod{ssh.Password(server.Password)} + } else { + config.Auth = []ssh.AuthMethod{ssh.PublicKeys(signer)} + } + default: + config.Auth = []ssh.AuthMethod{ssh.Password(server.Password)} + } + + addr := server.IP + ":" + strconv.Itoa(server.Port) + return ssh.Dial("tcp", addr, config) +} + +// runSSHCommand 在远程主机执行命令并返回输出 +func runSSHCommand(client *ssh.Client, cmd string) (string, error) { + session, err := client.NewSession() + if err != nil { + return "", err + } + defer session.Close() + + output, err := session.CombinedOutput(cmd) + return string(output), err +} + +// InstallAgent 远程安装 Agent 到指定服务器 +// POST /servers/:id/install-agent +func (h *AgentInstallHandler) InstallAgent(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + + var server models.Server + if err := h.db.First(&server, id).Error; err != nil { + c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "服务器不存在"}) + return + } + + if server.Status != "running" { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "服务器未运行,无法安装 Agent"}) + return + } + + // 1. SSH 连接目标主机 + client, err := sshDial(server) + if err != nil { + log.Printf("[AgentInstall] SSH连接失败 %s: %v", server.IP, err) + c.JSON(http.StatusOK, models.Response{ + Code: 500, + Message: "SSH 连接失败: " + formatSSHError(err), + }) + return + } + defer client.Close() + + // 2. 检测系统架构 + archOut, _ := runSSHCommand(client, "uname -m") + arch := "amd64" + if len(archOut) > 0 && (contains(archOut, "aarch64") || contains(archOut, "arm64")) { + arch = "arm64" + } + + // 3. 构造安装命令(使用 Agent API 服务地址) + // 从请求头获取后端可访问地址 + serverURL := fmt.Sprintf("http://%s", c.Request.Host) + token := c.Query("token") + + installCmd := fmt.Sprintf( + `curl -fsSL %s/agent/install.sh | sudo bash -s -- --server %s --token %s`, + serverURL, serverURL, token, + ) + + // 4. 执行安装 + installOut, err := runSSHCommand(client, installCmd) + if err != nil { + c.JSON(http.StatusOK, models.Response{ + Code: 500, + Message: "安装命令执行失败(可能需要 sudo 权限或 curl 未安装)", + Data: installOut, + }) + return + } + + // 5. 验证安装是否成功 + verifyOut, _ := runSSHCommand(client, "systemctl is-active aiops-agent 2>/dev/null || echo 'not_found'") + installed := contains(verifyOut, "active") || contains(verifyOut, "running") + + if installed { + // 更新服务器 Agent 状态 + h.db.Model(&server).Updates(map[string]interface{}{ + "agent_status": "online", + "agent_version": "installed", + "last_check_at": time.Now(), + }) + } + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: func() string { + if installed { + return "Agent 安装成功,已自动启动并设置开机自启" + } + return "安装命令已执行,但 Agent 服务未检测到运行(请查看日志排查)" + }(), + Data: gin.H{ + "installed": installed, + "arch": arch, + "output": installOut, + "verify": verifyOut, + }, + }) +} diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index e477c90..57cbb2b 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -4,6 +4,7 @@ import ( "ezops/internal/api/handlers" "ezops/internal/api/middleware" "ezops/internal/config" + "fmt" "github.com/gin-gonic/gin" "go.uber.org/zap" @@ -28,6 +29,19 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config) { r.StaticFile("/agent/aiops-agent-linux-arm64", "./agent/bin/aiops-agent-linux-arm64") r.StaticFile("/agent/aiops-agent-darwin-arm64", "./agent/bin/aiops-agent-darwin-arm64") // macOS Apple Silicon // Logan.Li r.GET("/agent/arch", func(c *gin.Context) { + // 动态构建安装命令 URL(根据请求来源,兼容 nginx 反代) + scheme := "http" + if c.Request.TLS != nil { + scheme = "https" + } + if proto := c.GetHeader("X-Forwarded-Proto"); proto != "" { + scheme = proto + } + host := c.Request.Host + if host == "" { + host = "localhost:8080" + } + base := fmt.Sprintf("%s://%s", scheme, host) c.JSON(200, gin.H{ "version": "1.0.0", "downloads": gin.H{ @@ -35,7 +49,7 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config) { "linux-arm64": "/agent/aiops-agent-linux-arm64", "darwin-arm64": "/agent/aiops-agent-darwin-arm64", }, - "install": "curl -fsSL http://:8080/agent/install.sh | sudo bash -s -- --server http://:8080 --token ", + "install": fmt.Sprintf("curl -fsSL %s/agent/install.sh | sudo bash -s -- --server %s --token ", base, base), }) }) @@ -122,6 +136,10 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config) { servers.DELETE("/:id", serverHandler.DeleteServer) servers.POST("/batch-delete", serverHandler.BatchDeleteServers) servers.POST("/batch", serverHandler.BatchCreateServers) + + // Agent 远程安装 // Logan.Li + agentInstallHandler := handlers.NewAgentInstallHandler(db) + servers.POST("/:id/install-agent", agentInstallHandler.InstallAgent) } // 云账号路由 diff --git a/docker-compose.yml b/docker-compose.yml index 85f38e7..b3213c0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,8 +50,8 @@ services: # ======================== Go 后端服务 ======================== backend: build: - context: ./backend - dockerfile: Dockerfile + context: . + dockerfile: backend/Dockerfile container_name: aiops-backend restart: unless-stopped ports: diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 8b0644f..fd5f532 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -48,6 +48,14 @@ server { proxy_read_timeout 86400; } + # Agent 安装脚本和二进制文件代理 → 后端 + location /agent/ { + proxy_pass http://backend:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + # 健康检查代理 location /health { proxy_pass http://backend:8080; diff --git a/frontend/src/components/agent/AgentWizard.tsx b/frontend/src/components/agent/AgentWizard.tsx index 4bf59b3..8971eac 100644 --- a/frontend/src/components/agent/AgentWizard.tsx +++ b/frontend/src/components/agent/AgentWizard.tsx @@ -17,11 +17,12 @@ interface AgentWizardProps { onOpenChange: (open: boolean) => void; } -/** 自动检测服务器地址:使用当前浏览器访问的主机名,端口固定 8080 */ +/** 自动检测服务器地址:使用当前浏览器访问的地址(协议+主机+端口) */ function detectServerAddress(): string { if (typeof window !== 'undefined' && window.location) { - const host = window.location.hostname; - return host ? `http://${host}:8080` : 'http://127.0.0.1:8080'; + // 直接用当前浏览器的 origin(如 http://10.10.10.150), + // Nginx 会将 /agent/ 请求代理到后端 + return window.location.origin; } return 'http://127.0.0.1:8080'; } @@ -35,12 +36,27 @@ export function AgentWizard({ open, onOpenChange }: AgentWizardProps) { const handleCopy = async () => { try { - await navigator.clipboard.writeText(installCommand); + // 优先使用现代 Clipboard API(需要 HTTPS 或 localhost) + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(installCommand); + } else { + // 降级方案:用隐藏 textarea + execCommand('copy'),兼容 HTTP 环境 + const textarea = document.createElement('textarea'); + textarea.value = installCommand; + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + textarea.style.pointerEvents = 'none'; + document.body.appendChild(textarea); + textarea.select(); + const ok = document.execCommand('copy'); + document.body.removeChild(textarea); + if (!ok) throw new Error('execCommand failed'); + } setCopied(true); toast.success('安装命令已复制'); setTimeout(() => setCopied(false), 2000); } catch { - toast.error('复制失败,请手动复制'); + toast.error('复制失败,请手动选中复制'); } }; diff --git a/frontend/src/components/server/ServerTableRow.tsx b/frontend/src/components/server/ServerTableRow.tsx index 7542e86..aac58c6 100644 --- a/frontend/src/components/server/ServerTableRow.tsx +++ b/frontend/src/components/server/ServerTableRow.tsx @@ -12,7 +12,7 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { TableCell, TableRow } from '@/components/ui/table'; -import { Edit, MoreHorizontal, Server as ServerIcon, Terminal, Trash2 } from 'lucide-react'; +import { Edit, MoreHorizontal, Server as ServerIcon, Terminal, Trash2, Download } from 'lucide-react'; import { getStatusBadge, getTags, getUsageColor } from './serverHelpers'; interface ServerTableRowProps { diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 5f4981d..0917d5e 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -143,6 +143,10 @@ export const serverApi = { // 后端暂无单机 metrics,用 dashboard metrics return http.get('/dashboard/metrics'); }, + // 远程安装 Agent(通过 SSH 执行安装脚本) + installAgent: async (id: number | string, token: string) => { + return http.post<{ installed: boolean; arch: string; output: string }>(`/servers/${id}/install-agent?token=${encodeURIComponent(token)}`); + }, }; // ==================== K8s 集群 API ====================