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.
227 lines
7.8 KiB
227 lines
7.8 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)"
|
|
REGISTRY="$REG" TAG="$TAG" $DC up -d || {
|
|
if [ -n "$CONTAINER" ]; then
|
|
echo ">>> up 失败,尝试删除同名容器 $CONTAINER 后重试"
|
|
docker rm -f "$CONTAINER" 2>/dev/null || true
|
|
REGISTRY="$REG" TAG="$TAG" $DC up -d
|
|
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
|
|
}
|
|
}
|
|
|