From 603dc890731a25eaad22845fbb38639699fbd553 Mon Sep 17 00:00:00 2001 From: liwei1dao Date: Mon, 6 Jul 2026 12:02:02 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E4=BC=98=E5=8C=96=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E9=83=A8=E7=BD=B2=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin/app/pages/apps.vue | 69 ++++- .../services/modules/console/registry_tags.go | 282 ++++++++++++++++++ apps/services/modules/console/server.go | 1 + 3 files changed, 348 insertions(+), 4 deletions(-) create mode 100644 apps/services/modules/console/registry_tags.go diff --git a/apps/admin/app/pages/apps.vue b/apps/admin/app/pages/apps.vue index b8d0feb9..7350b331 100644 --- a/apps/admin/app/pages/apps.vue +++ b/apps/admin/app/pages/apps.vue @@ -52,7 +52,7 @@ - + @@ -73,6 +73,10 @@ 待部署已接入 +
部署区域环境状态业务库启用操作部署区域环境状态当前版本业务库启用操作
+ {{ a.image_tag }} + {{ a.image_tag || 'latest' }} ⚠ + {{ dbName(a.service_dsn) }} 是 @@ -327,8 +331,26 @@
部署目录{{ up.deployDir || '未填 —— 请先在「编辑」里填写远端部署目录' }}
- - +
+ + +
+ +
+ + +
+ + +

拉取仓库版本失败:{{ up.tagsError }}(可点上方「手动输入」直接填版本号,或点「刷新」重试)

+

⚠ 未固定版本,升级后列表无法体现具体版本号,建议选明确版本(如 0.1.1)。

@@ -458,6 +480,12 @@ function dbName(dsn?: string) { return m ? m[1] : '-' } +// 是否已固定到明确版本(非空、非 latest)→ 列表用绿色 chip 显示;否则标黄提示。 +function isPinnedVer(tag?: string) { + const t = (tag || '').trim() + return !!t && t !== 'latest' +} + async function loadAll() { loading.value = true try { @@ -855,6 +883,10 @@ const up = reactive({ envName: '', deployDir: '', tag: '', + tags: [] as string[], // 从仓库拉取的可用版本 + tagMode: 'list' as 'list' | 'custom', + tagsLoading: false, + tagsError: '', log: '', show: false, running: false, @@ -869,6 +901,9 @@ function openUpgrade(a: Deployment) { up.envName = a.env_id ? envName(a.env_id) : '' up.deployDir = a.deploy_dir || '' up.tag = a.image_tag || '' + up.tags = [] + up.tagMode = 'list' + up.tagsError = '' up.log = '' up.show = false up.running = false @@ -876,6 +911,28 @@ function openUpgrade(a: Deployment) { up.canRun = !!(a.env_id && (a.deploy_dir || '').trim()) up.btnText = '开始升级' 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() { @@ -892,7 +949,11 @@ async function runUpgrade() { window.alert(`无法升级:请先关闭本弹窗,点该部署的「编辑」完成 —— ${miss.join('、')},保存后再来升级。`) 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.show = true diff --git a/apps/services/modules/console/registry_tags.go b/apps/services/modules/console/registry_tags.go new file mode 100644 index 00000000..046f351d --- /dev/null +++ b/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//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 vb { + return 1 + } + return -1 + } + } + return 0 +} diff --git a/apps/services/modules/console/server.go b/apps/services/modules/console/server.go index 883c726e..37eb0c85 100644 --- a/apps/services/modules/console/server.go +++ b/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/analyze", this.appsAnalyze) 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/update", this.appsUpdate) apps.POST("/apps/del", this.appsDel)