Browse Source

上传优化节点部署代码

main
liwei1dao 3 months ago
parent
commit
603dc89073
  1. 69
      apps/admin/app/pages/apps.vue
  2. 282
      apps/services/modules/console/registry_tags.go
  3. 1
      apps/services/modules/console/server.go

69
apps/admin/app/pages/apps.vue

@ -52,7 +52,7 @@
<table class="table table-sm text-sm"> <table class="table table-sm text-sm">
<thead> <thead>
<tr class="text-slate-400"> <tr class="text-slate-400">
<th>部署</th><th>区域</th><th>环境</th><th>状态</th><th>业务库</th><th>启用</th><th class="text-right">操作</th> <th>部署</th><th>区域</th><th>环境</th><th>状态</th><th>当前版本</th><th>业务库</th><th>启用</th><th class="text-right">操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -73,6 +73,10 @@
<span v-if="a.status === 'pending'" class="badge badge-warning badge-sm text-amber-900">待部署</span> <span v-if="a.status === 'pending'" class="badge badge-warning badge-sm text-amber-900">待部署</span>
<span v-else class="badge badge-success badge-sm text-white">已接入</span> <span v-else class="badge badge-success badge-sm text-white">已接入</span>
</td> </td>
<td>
<span v-if="isPinnedVer(a.image_tag)" class="chip-mono text-emerald-700">{{ a.image_tag }}</span>
<span v-else class="text-amber-600 text-xs font-mono" title="未固定版本,升级时建议选明确版本号(如 0.1.1)">{{ a.image_tag || 'latest' }} ⚠</span>
</td>
<td class="font-mono text-xs">{{ dbName(a.service_dsn) }}</td> <td class="font-mono text-xs">{{ dbName(a.service_dsn) }}</td>
<td> <td>
<span v-if="a.enabled" class="text-green-600">是</span> <span v-if="a.enabled" class="text-green-600">是</span>
@ -327,8 +331,26 @@
<div class="flex"><span class="w-20 text-slate-400 shrink-0">部署目录</span><span :class="up.deployDir ? 'font-mono text-slate-700' : 'font-medium text-red-500'">{{ up.deployDir || '未填 —— 请先在「编辑」里填写远端部署目录' }}</span></div> <div class="flex"><span class="w-20 text-slate-400 shrink-0">部署目录</span><span :class="up.deployDir ? 'font-mono text-slate-700' : 'font-medium text-red-500'">{{ up.deployDir || '未填 —— 请先在「编辑」里填写远端部署目录' }}</span></div>
</div> </div>
<div class="mt-3"> <div class="mt-3">
<label class="text-xs text-slate-500">镜像 Tag(留空 = latest)</label> <div class="flex items-center justify-between mb-1">
<input v-model="up.tag" type="text" class="input input-bordered input-sm w-full font-mono" placeholder="latest" :disabled="up.running" /> <label class="text-xs text-slate-500">镜像版本 Tag</label>
<button type="button" class="text-xs text-indigo-500 hover:underline" :disabled="up.running"
@click="up.tagMode = up.tagMode === 'list' ? 'custom' : 'list'">
{{ up.tagMode === 'list' ? '手动输入…' : '从仓库选择' }}
</button>
</div>
<!-- 从镜像仓库拉取的可用版本,下拉选择 -->
<div v-if="up.tagMode === 'list'" class="flex gap-2">
<select v-model="up.tag" class="select select-bordered select-sm flex-1 font-mono" :disabled="up.running || up.tagsLoading">
<option value="">{{ up.tagsLoading ? '加载可用版本中…' : '请选择版本' }}</option>
<option v-if="up.tag && !up.tags.includes(up.tag)" :value="up.tag">{{ up.tag }}(当前记录)</option>
<option v-for="t in up.tags" :key="t" :value="t">{{ t }}{{ t === 'latest' ? ' · 不固定,不推荐' : '' }}</option>
</select>
<button type="button" class="btn btn-ghost btn-sm" :disabled="up.running || up.tagsLoading" @click="loadUpgradeTags">刷新</button>
</div>
<!-- 手动输入兜底(仓库拉取失败或想指定任意 tag 时用) -->
<input v-else v-model="up.tag" type="text" class="input input-bordered input-sm w-full font-mono" placeholder="0.1.1" :disabled="up.running" />
<p v-if="up.tagsError" class="text-xs text-amber-600 mt-1">拉取仓库版本失败:{{ up.tagsError }}(可点上方「手动输入」直接填版本号,或点「刷新」重试)</p>
<p v-else-if="up.tag === 'latest' || up.tag === ''" class="text-xs text-amber-600 mt-1">⚠ 未固定版本,升级后列表无法体现具体版本号,建议选明确版本(如 0.1.1)。</p>
</div> </div>
<!-- 日志流 --> <!-- 日志流 -->
@ -458,6 +480,12 @@ function dbName(dsn?: string) {
return m ? m[1] : '-' return m ? m[1] : '-'
} }
// 是否已固定到明确版本(非空、非 latest)→ 列表用绿色 chip 显示;否则标黄提示。
function isPinnedVer(tag?: string) {
const t = (tag || '').trim()
return !!t && t !== 'latest'
}
async function loadAll() { async function loadAll() {
loading.value = true loading.value = true
try { try {
@ -855,6 +883,10 @@ const up = reactive({
envName: '', envName: '',
deployDir: '', deployDir: '',
tag: '', tag: '',
tags: [] as string[], // 从仓库拉取的可用版本
tagMode: 'list' as 'list' | 'custom',
tagsLoading: false,
tagsError: '',
log: '', log: '',
show: false, show: false,
running: false, running: false,
@ -869,6 +901,9 @@ function openUpgrade(a: Deployment) {
up.envName = a.env_id ? envName(a.env_id) : '' up.envName = a.env_id ? envName(a.env_id) : ''
up.deployDir = a.deploy_dir || '' up.deployDir = a.deploy_dir || ''
up.tag = a.image_tag || '' up.tag = a.image_tag || ''
up.tags = []
up.tagMode = 'list'
up.tagsError = ''
up.log = '' up.log = ''
up.show = false up.show = false
up.running = false up.running = false
@ -876,6 +911,28 @@ function openUpgrade(a: Deployment) {
up.canRun = !!(a.env_id && (a.deploy_dir || '').trim()) up.canRun = !!(a.env_id && (a.deploy_dir || '').trim())
up.btnText = '开始升级' up.btnText = '开始升级'
upgradeModal.value?.showModal() upgradeModal.value?.showModal()
if (up.canRun) loadUpgradeTags() // 弹窗弹出后异步拉可用版本;缺环境/目录时无从查,跳过
}
// 从镜像仓库拉取该部署镜像的可用 tag,填充升级弹窗下拉。失败则切到手动输入。
async function loadUpgradeTags() {
if (!up.id) return
up.tagsLoading = true
up.tagsError = ''
try {
const r = await consoleApi<{ repo: string; current: string; tags: string[] }>('apps/tags', { id: up.id })
up.tags = r.tags || []
up.tagMode = 'list'
// 预选:优先当前记录的版本;否则第一个非 latest 的明确版本,引导选版本号而非 latest
if (!up.tag || !up.tags.includes(up.tag)) {
up.tag = (up.tag && up.tags.includes(up.tag)) ? up.tag : (up.tags.find((t) => t !== 'latest') || up.tag || '')
}
} catch (e: any) {
// 失败不清空 tags、不强制切模式:保留「当前记录版本」仍可选,只提示错误,避免看着像数据全没了
up.tagsError = e?.message ?? '拉取失败'
} finally {
up.tagsLoading = false
}
} }
function closeUpgrade() { function closeUpgrade() {
@ -892,7 +949,11 @@ async function runUpgrade() {
window.alert(`无法升级:请先关闭本弹窗,点该部署的「编辑」完成 —— ${miss.join('、')},保存后再来升级。`) window.alert(`无法升级:请先关闭本弹窗,点该部署的「编辑」完成 —— ${miss.join('、')},保存后再来升级。`)
return return
} }
if (!window.confirm(`确认升级【${up.label}】?\n将 SSH 连到「${up.envName}」,在 ${up.deployDir} 执行 docker compose 拉新镜像并重启容器。`)) return const tag = (up.tag || '').trim()
if (!tag || tag === 'latest') {
if (!window.confirm(`未选择明确版本号(当前 = ${tag || '空 → 后端回退 latest'})。\nlatest 是可变 tag,升级后列表无法体现具体版本号,确定继续?`)) return
}
if (!window.confirm(`确认升级【${up.label}】?\n目标版本:${tag || 'latest'}\n将 SSH 连到「${up.envName}」,在 ${up.deployDir} 执行 docker compose 拉新镜像并重启容器。`)) return
up.running = true up.running = true
up.show = true up.show = true

282
apps/services/modules/console/registry_tags.go

@ -0,0 +1,282 @@
package console
import (
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
"yunyan/pb"
"github.com/gin-gonic/gin"
)
// ============================ 镜像仓库 tag 查询域 (registry) ============================
//
// 升级弹窗「选版本」用:列出某部署对应镜像在私有仓库里的可用 tag,供下拉选择明确版本号(如 0.1.1)。
// 走 Docker Registry HTTP API v2:GET /v2/<repo>/tags/list。
// · repo(镜像名) = 部署行 image_name(空则回退应用 image_name),与各 docker-compose.yml 的 image 段一致。
// · 仓库地址/账号密码取自部署绑定的「环境节点」(app_environment.Registry/RegistryUser/RegistryPass),与升级 login 同一份凭据。
// · 认证:先 Basic;遇 Bearer 挑战(Harbor/带 token auth 的 registry)再换 token 重试,兼容两类仓库。
// registryHTTP 仓库查询 client:15s 超时;跳过证书校验(私有/自签仓库,取舍与部署 SSH 的 InsecureIgnoreHostKey 一致)。
var registryHTTP = &http.Client{
Timeout: 15 * time.Second,
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}},
}
// appsTags 列出某部署镜像在仓库里的可用 tag(已排序:latest 置顶 → 语义版本降序 → 其它降序)。
func (this *serverComp) appsTags(c *gin.Context) {
var req struct {
Id uint32 `json:"id"`
}
_ = c.ShouldBindJSON(&req)
if req.Id == 0 {
writeErr(c, pb.ErrorCode_ReqParameterError, "id 必填")
return
}
dep, err := this.module.model.getApp(req.Id)
if err != nil {
writeErr(c, pb.ErrorCode_DBError, "部署不存在: "+err.Error())
return
}
// repo = 部署行镜像名,空则回退应用(产品)镜像名
repo := strings.TrimSpace(dep.ImageName)
if repo == "" {
if p, _ := this.module.model.getProductByName(dep.AppName); p != nil {
repo = strings.TrimSpace(p.ImageName)
}
}
if repo == "" {
writeErr(c, pb.ErrorCode_ReqParameterError, "该部署未配置镜像名(image_name),请先在「编辑部署」里填写,如 deepglass-a11")
return
}
if dep.EnvId == 0 {
writeErr(c, pb.ErrorCode_ReqParameterError, "该部署未绑定环境节点,无法确定镜像仓库地址")
return
}
env, err := this.module.model.getEnv(dep.EnvId)
if err != nil {
writeErr(c, pb.ErrorCode_DBError, "环境节点不存在: "+err.Error())
return
}
registry := strings.TrimSpace(env.Registry)
if registry == "" {
writeErr(c, pb.ErrorCode_ReqParameterError, "环境节点未配置镜像仓库地址(registry)")
return
}
pass, _ := svcDecrypt(this.options.EncryptKey, env.RegistryPass)
tags, err := fetchRegistryTags(registry, repo, env.RegistryUser, pass)
if err != nil {
writeErr(c, pb.ErrorCode_SystemError, "拉取仓库 tag 失败: "+err.Error())
return
}
writeOK(c, gin.H{"repo": repo, "current": strings.TrimSpace(dep.ImageTag), "tags": sortImageTags(tags)})
}
// fetchRegistryTags 调 Registry v2 /tags/list:先 Basic,遇 Bearer 挑战换 token 重试。
func fetchRegistryTags(registry, repo, user, pass string) ([]string, error) {
url := registryBaseURL(registry) + "/v2/" + repo + "/tags/list"
data, challenge, err := registryGet(url, user, pass, "")
if err != nil && strings.HasPrefix(strings.ToLower(strings.TrimSpace(challenge)), "bearer") {
token, terr := fetchRegistryToken(challenge, user, pass)
if terr != nil {
return nil, terr
}
data, _, err = registryGet(url, user, pass, token)
}
if err != nil {
return nil, err
}
var out struct {
Tags []string `json:"tags"`
}
if e := json.Unmarshal(data, &out); e != nil {
return nil, fmt.Errorf("解析 tag 列表失败: %w", e)
}
return out.Tags, nil
}
// registryGet 发一次 GET;401 时把 WWW-Authenticate 头作为 challenge 回传(供上层决定是否走 token)。
func registryGet(u, user, pass, bearer string) (body []byte, challenge string, err error) {
reqx, err := http.NewRequest(http.MethodGet, u, nil)
if err != nil {
return nil, "", err
}
reqx.Header.Set("Accept", "application/json")
if bearer != "" {
reqx.Header.Set("Authorization", "Bearer "+bearer)
} else if user != "" {
reqx.SetBasicAuth(user, pass)
}
resp, err := registryHTTP.Do(reqx)
if err != nil {
return nil, "", err
}
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
switch {
case resp.StatusCode == http.StatusUnauthorized:
return nil, resp.Header.Get("WWW-Authenticate"), fmt.Errorf("仓库鉴权失败(401):账号密码不对或无该 repo 权限")
case resp.StatusCode == http.StatusNotFound:
return nil, "", fmt.Errorf("仓库中不存在该镜像(404):请核对镜像名 image_name 是否与仓库 repo 一致")
case resp.StatusCode >= 300:
return nil, "", fmt.Errorf("仓库返回 %d: %s", resp.StatusCode, strings.TrimSpace(string(data)))
}
return data, "", nil
}
// fetchRegistryToken 按 Bearer 挑战(realm/service/scope)换取访问 token。
func fetchRegistryToken(challenge, user, pass string) (string, error) {
p := parseAuthChallenge(challenge)
realm := p["realm"]
if realm == "" {
return "", fmt.Errorf("Bearer 挑战缺少 realm")
}
u, err := url.Parse(realm)
if err != nil {
return "", err
}
q := u.Query()
if p["service"] != "" {
q.Set("service", p["service"])
}
if p["scope"] != "" {
q.Set("scope", p["scope"])
}
u.RawQuery = q.Encode()
reqx, _ := http.NewRequest(http.MethodGet, u.String(), nil)
if user != "" {
reqx.SetBasicAuth(user, pass)
}
resp, err := registryHTTP.Do(reqx)
if err != nil {
return "", err
}
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 300 {
return "", fmt.Errorf("获取仓库 token 失败(%d): %s", resp.StatusCode, strings.TrimSpace(string(data)))
}
var tok struct {
Token string `json:"token"`
AccessToken string `json:"access_token"`
}
_ = json.Unmarshal(data, &tok)
if tok.Token != "" {
return tok.Token, nil
}
if tok.AccessToken != "" {
return tok.AccessToken, nil
}
return "", fmt.Errorf("token 响应为空")
}
// parseAuthChallenge 解析 `Bearer realm="..",service="..",scope=".."` → map。
func parseAuthChallenge(h string) map[string]string {
out := map[string]string{}
h = strings.TrimSpace(h)
if i := strings.IndexByte(h, ' '); i >= 0 {
h = h[i+1:] // 去掉前导 "Bearer"
}
for _, part := range strings.Split(h, ",") {
kv := strings.SplitN(part, "=", 2)
if len(kv) != 2 {
continue
}
out[strings.TrimSpace(kv[0])] = strings.Trim(strings.TrimSpace(kv[1]), `"`)
}
return out
}
// registryBaseURL 补全 scheme:显式带 http(s):// 则原样,否则默认 https://。
func registryBaseURL(registry string) string {
registry = strings.TrimRight(strings.TrimSpace(registry), "/")
if strings.HasPrefix(registry, "http://") || strings.HasPrefix(registry, "https://") {
return registry
}
return "https://" + registry
}
// sortImageTags 展示排序:latest 置顶 → 语义版本降序(新→旧) → 其它字符串降序。
func sortImageTags(tags []string) []string {
var semver, others []string
hasLatest := false
for _, t := range tags {
switch {
case t == "latest":
hasLatest = true
case looksSemver(t):
semver = append(semver, t)
default:
others = append(others, t)
}
}
sort.Slice(semver, func(i, j int) bool { return compareSemver(semver[i], semver[j]) > 0 })
sort.Slice(others, func(i, j int) bool { return others[i] > others[j] })
out := make([]string, 0, len(tags))
if hasLatest {
out = append(out, "latest")
}
out = append(out, semver...)
return append(out, others...)
}
// parseVer 把 "v0.1.1" / "0.1.1-rc1" 解析成数字段([]int),取每段前导数字;无法解析返回 false。
func parseVer(s string) ([]int, bool) {
s = strings.TrimPrefix(strings.TrimSpace(s), "v")
if s == "" {
return nil, false
}
nums := make([]int, 0, 4)
for _, seg := range strings.Split(s, ".") {
digits := seg
if i := strings.IndexFunc(seg, func(r rune) bool { return r < '0' || r > '9' }); i >= 0 {
digits = seg[:i] // 截断预发布后缀,如 "1-rc1" → "1"
}
if digits == "" {
return nil, false
}
n, err := strconv.Atoi(digits)
if err != nil {
return nil, false
}
nums = append(nums, n)
}
if len(nums) == 0 {
return nil, false
}
return nums, true
}
func looksSemver(s string) bool { _, ok := parseVer(s); return ok }
// compareSemver 语义版本比较:a>b 返回 1,a<b 返回 -1,相等 0(缺失段按 0 补齐)。
func compareSemver(a, b string) int {
na, _ := parseVer(a)
nb, _ := parseVer(b)
for i := 0; i < len(na) || i < len(nb); i++ {
va, vb := 0, 0
if i < len(na) {
va = na[i]
}
if i < len(nb) {
vb = nb[i]
}
if va != vb {
if va > vb {
return 1
}
return -1
}
}
return 0
}

1
apps/services/modules/console/server.go

@ -100,6 +100,7 @@ func (this *serverComp) routes(eng *gin.Engine) {
apps.POST("/apps/test", this.appsTest) apps.POST("/apps/test", this.appsTest)
apps.POST("/apps/analyze", this.appsAnalyze) apps.POST("/apps/analyze", this.appsAnalyze)
apps.POST("/apps/upgrade", this.appsUpgrade) // 远程升级:SSH 到部署环境 → docker login/pull/up 重启(NDJSON 流式回显) apps.POST("/apps/upgrade", this.appsUpgrade) // 远程升级:SSH 到部署环境 → docker login/pull/up 重启(NDJSON 流式回显)
apps.POST("/apps/tags", this.appsTags) // 列镜像仓库可用 tag(升级弹窗下拉选版本)
apps.POST("/apps/add", this.appsAdd) apps.POST("/apps/add", this.appsAdd)
apps.POST("/apps/update", this.appsUpdate) apps.POST("/apps/update", this.appsUpdate)
apps.POST("/apps/del", this.appsDel) apps.POST("/apps/del", this.appsDel)

Loading…
Cancel
Save