package console import ( "bytes" "encoding/json" "fmt" "strings" "sync" "time" "github.com/gin-gonic/gin" "golang.org/x/crypto/ssh" ) // ============================ 部署执行域 (deploy) ============================ // // 「升级」= 把一个已有部署更新到新镜像并重启:SSH 到该部署绑定的部署环境(app_environment) → // 进部署目录(app_registry.deploy_dir) → docker login → docker compose pull → up -d 重启 → 清理旧镜像。 // 与本地 prod-deploy.sh 的远程块同一套动作,只是凭据从环境节点解密取、输出 NDJSON 流式回显。 // // 前提:服务器上的 docker-compose.yml / .env / confs 由用户自行维护(console 不上传真实配置, // 与 deploy.sh 约定一致)。tag 取前端传入 > 部署行 image_tag > latest;前端显式传了则回写部署行。 // appsUpgrade 远程升级一个部署(NDJSON 流:type=log 每行日志 / done 完成 / error 失败)。 func (this *serverComp) appsUpgrade(c *gin.Context) { var req struct { Id uint32 `json:"id"` Tag string `json:"tag"` } _ = c.ShouldBindJSON(&req) c.Header("Content-Type", "application/x-ndjson; charset=utf-8") c.Header("Cache-Control", "no-cache") c.Header("X-Accel-Buffering", "no") enc := json.NewEncoder(c.Writer) var emitMu sync.Mutex emit := func(v interface{}) { emitMu.Lock() _ = enc.Encode(v) // Encode 自带换行 → NDJSON c.Writer.Flush() emitMu.Unlock() } logln := func(s string) { emit(gin.H{"type": "log", "line": s}) } fail := func(msg string) { emit(gin.H{"type": "error", "msg": msg}) } if req.Id == 0 { fail("id 必填") return } // 并发保护:同一部署同一时刻只允许一个升级在跑(docker compose up 并发会互相拉起/删容器)。 if _, busy := this.deploying.LoadOrStore(req.Id, true); busy { fail("该部署正在升级中,请等当前升级完成后再试") return } defer this.deploying.Delete(req.Id) dep, err := this.module.model.getApp(req.Id) if err != nil { fail("部署不存在: " + err.Error()) return } if dep.EnvId == 0 { fail("该部署未绑定部署环境,请先在「编辑部署」里选择环境节点") return } deployDir := strings.TrimSpace(dep.DeployDir) if deployDir == "" { fail("该部署未填「部署目录」,请先在「编辑部署」里填写远端目录(deploy_dir)") return } env, err := this.module.model.getEnv(dep.EnvId) if err != nil { fail("部署环境不存在: " + err.Error()) return } key := this.options.EncryptKey sshKey, _ := svcDecrypt(key, env.SshPrivateKey) regPass, _ := svcDecrypt(key, env.RegistryPass) if strings.TrimSpace(sshKey) == "" { fail("部署环境未配置 SSH 私钥,无法登录目标服务器") return } // tag:前端传入 > 部署行 image_tag > latest;前端显式传了新值则回写部署行(best-effort)。 tag := strings.TrimSpace(req.Tag) if tag == "" { tag = strings.TrimSpace(dep.ImageTag) } if tag == "" { tag = "latest" } if strings.TrimSpace(req.Tag) != "" && strings.TrimSpace(req.Tag) != dep.ImageTag { dep.ImageTag = strings.TrimSpace(req.Tag) dep.Updatetime = time.Now().Unix() _ = this.module.model.saveApp(dep) } sshPort := env.SshPort if sshPort == 0 { sshPort = 22 } // 容器名候选(up 冲突时删同名容器重试用):优先镜像名、其次部署名。best-effort,对不上则忽略。 container := strings.TrimSpace(dep.ImageName) if container == "" { container = strings.TrimSpace(dep.Name) } logln(fmt.Sprintf("目标:%s@%s:%d 目录:%s", env.SshUser, env.SshHost, sshPort, deployDir)) if strings.TrimSpace(env.Registry) != "" { logln(fmt.Sprintf("镜像仓库:%s TAG:%s", env.Registry, tag)) } else { logln(fmt.Sprintf("TAG:%s", tag)) } logln("─────────── 开始远程部署 ───────────") args := []string{deployDir, tag, env.Registry, env.RegistryUser, regPass, env.DockerNetwork, container} if err := sshRunStreaming(env.SshHost, sshPort, env.SshUser, sshKey, deployScript, args, logln); err != nil { fail("部署失败:" + err.Error()) return } emit(gin.H{"type": "done", "msg": "部署完成"}) } // deployScript 远端执行的 bash(bash -s 从 stdin 读、位置参数 $1..$7 = DIR TAG REG REG_USER REG_PASS NET CONTAINER)。 // 与 prod-deploy.sh 远程块一致:进目录→login→pull→up -d(冲突删同名容器重试)→prune。 const deployScript = `set -e DIR="$1"; TAG="$2"; REG="$3"; REG_USER="$4"; REG_PASS="$5"; NET="$6"; CONTAINER="$7" [ -n "$DIR" ] || { echo "✗ 部署目录为空"; exit 1; } cd "$DIR" 2>/dev/null || { echo "✗ 无法进入部署目录: $DIR"; exit 1; } [ -f docker-compose.yml ] || { echo "✗ 目录下缺少 docker-compose.yml(请先在服务器维护好 compose/.env/confs)"; exit 1; } [ -f .env ] || echo "⚠ 目录下未发现 .env(若 compose 依赖 .env,请先在服务器创建)" if docker compose version >/dev/null 2>&1; then DC="docker compose"; else DC="docker-compose"; fi echo "compose 命令: $DC" if [ -n "$REG" ] && [ -n "$REG_USER" ]; then echo ">>> docker login $REG" echo "$REG_PASS" | docker login "$REG" -u "$REG_USER" --password-stdin fi if [ -n "$NET" ]; then docker network inspect "$NET" >/dev/null 2>&1 || { echo ">>> 创建 docker 网络 $NET"; docker network create "$NET"; }; fi echo ">>> 拉取镜像 (REGISTRY=$REG TAG=$TAG $DC pull)" REGISTRY="$REG" TAG="$TAG" $DC pull echo ">>> 重启容器 (REGISTRY=$REG TAG=$TAG $DC up -d --force-recreate)" # --force-recreate:即使镜像/compose 定义没变也强制重建容器,确保重新注入 .env(env_file) 并重启进程重读 confs; # 否则镜像 digest 相同时 compose 只显示 Running、不 recreate,改过的环境/配置不会生效。 REGISTRY="$REG" TAG="$TAG" $DC up -d --force-recreate || { if [ -n "$CONTAINER" ]; then echo ">>> up 失败,尝试删除同名容器 $CONTAINER 后重试" docker rm -f "$CONTAINER" 2>/dev/null || true REGISTRY="$REG" TAG="$TAG" $DC up -d --force-recreate else exit 1 fi } docker image prune -f >/dev/null 2>&1 || true echo "✓ 部署完成: $DIR (TAG=$TAG)" ` // sshRunStreaming SSH 到目标机执行脚本,stdout/stderr 合并按行实时回调 logln。返回远端退出错误(非 0 即失败)。 func sshRunStreaming(host string, port int, user, privateKeyPEM, script string, args []string, logln func(string)) error { signer, err := ssh.ParsePrivateKey([]byte(privateKeyPEM)) if err != nil { return fmt.Errorf("SSH 私钥解析失败: %w", err) } cfg := &ssh.ClientConfig{ User: user, Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)}, HostKeyCallback: ssh.InsecureIgnoreHostKey(), // 内网部署目标,不校验 host key(与 envTest 一致) Timeout: 15 * time.Second, } cli, err := ssh.Dial("tcp", fmt.Sprintf("%s:%d", host, port), cfg) if err != nil { return fmt.Errorf("SSH 连接失败: %w", err) } defer cli.Close() session, err := cli.NewSession() if err != nil { return fmt.Errorf("SSH 会话创建失败: %w", err) } defer session.Close() w := &sshLineWriter{emit: logln} session.Stdout = w session.Stderr = w session.Stdin = strings.NewReader(script) quoted := make([]string, len(args)) for i, a := range args { quoted[i] = shQuote(a) } runErr := session.Run("bash -s -- " + strings.Join(quoted, " ")) w.flush() return runErr } // shQuote 单引号包裹并转义,安全地把参数拼进远端 shell 命令行。 func shQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" } // sshLineWriter 把 SSH 输出按行切分实时回调(stdout/stderr 共用一个 writer,用锁串行化)。 type sshLineWriter struct { mu sync.Mutex buf []byte emit func(string) } func (w *sshLineWriter) Write(p []byte) (int, error) { w.mu.Lock() defer w.mu.Unlock() w.buf = append(w.buf, p...) for { i := bytes.IndexByte(w.buf, '\n') if i < 0 { break } w.emit(strings.TrimRight(string(w.buf[:i]), "\r")) w.buf = w.buf[i+1:] } return len(p), nil } func (w *sshLineWriter) flush() { w.mu.Lock() defer w.mu.Unlock() if len(w.buf) > 0 { w.emit(strings.TrimRight(string(w.buf), "\r")) w.buf = nil } }