3 changed files with 348 additions and 4 deletions
@ -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 |
||||
|
} |
||||
Loading…
Reference in new issue