You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

229 lines
8.1 KiB

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
}
}