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