added image pull feature to pull from dockerhub

This commit is contained in:
M1ngdaXie
2026-07-26 15:48:06 +08:00
parent c7bbc92dbe
commit 5dee3d3bcb
2 changed files with 388 additions and 34 deletions
+294
View File
@@ -0,0 +1,294 @@
package main
import (
"archive/tar"
"compress/gzip"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
)
// ——— 1. 数据结构:把 curl 看到的 JSON 翻译成 Go struct ———
//
// curl /manifests/latest 返回的 JSON
//
// { "manifests": [ { "digest": "sha256:xxx", "platform": {"architecture":"amd64","os":"linux"} }, ... ] }
//
// 这个叫 manifest list(多架构索引)。
type ManifestList struct {
SchemaVersion int `json:"schemaVersion"`
Manifests []ManifestEntry `json:"manifests"`
}
type ManifestEntry struct {
Digest string `json:"digest"` // 指向真正 manifest 的 sha256
Platform Platform `json:"platform"` // 比如 amd64/linux
}
type Platform struct {
Architecture string `json:"architecture"`
OS string `json:"os"`
}
// curl /manifests/sha256:79ff19... 返回的 JSON
//
// { "config": {"digest":"sha256:xxx"}, "layers": [{"digest":"sha256:yyy"}] }
//
// 这个是单架构的 manifest——里面有 config 和 layers。
type ImageManifest struct {
SchemaVersion int `json:"schemaVersion"`
Config BlobRef `json:"config"` // 指向 config blob
Layers []BlobRef `json:"layers"` // 文件系统层,索引 0 是最底层
}
// BlobRef 是个"指针"——用 sha256 digest 指向 registry 上的一个文件。
// config blob 和每一层 layer blob 都用这个表示。
type BlobRef struct {
Digest string `json:"digest"`
Size int64 `json:"size"`
}
// curl /blobs/sha256:d529dd0c...config blob)返回的 JSON
//
// { "config": { "Env": ["PATH=..."], "Cmd": ["/bin/sh"] } }
//
// 只有 Env 和 Cmd 是我们需要的。
// ImageConfig 是 config blob 的 Go 表示。
// Docker 里实际执行的命令 = Entrypoint(如果有)+ Cmd
// 比如 redis: Entrypoint=["docker-entrypoint.sh"], Cmd=["redis-server"]
//
// → 实际执行: docker-entrypoint.sh redis-server
type ImageConfig struct {
Config struct {
Env []string `json:"Env"`
Cmd []string `json:"Cmd"`
Entrypoint []string `json:"Entrypoint"`
} `json:"config"`
}
// PullResult 是 pullImage() 的返回值——run() 只需要这三样
type PullResult struct {
LowerDirs string // 冒号分隔:/tmp/layers/.../sha1:/tmp/layers/.../sha2
Env []string // PATH, PYTHON_VERSION... 直接拼进 cmd.Env
Cmd []string // 镜像默认命令,用户没指定时用
Entrypoint []string // 镜像入口脚本,如果有就排在 Cmd 前面
}
// ——— 2. 注册表 HTTP 客户端 ———
// getToken 向 Docker Hub 要一个临时 token。
// 免费公开镜像不需要登录——传 repo 名就行,返回一个 Bearer token。
// 所有后续请求都要带 Authorization: Bearer <token>
func getToken(repo string) (string, error) {
url := fmt.Sprintf(
"https://auth.docker.io/token?service=registry.docker.io&scope=repository:%s:pull",
repo,
)
resp, err := http.Get(url)
if err != nil {
return "", fmt.Errorf("token request: %w", err)
}
defer resp.Body.Close()
var out struct {
Token string `json:"token"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return "", fmt.Errorf("token decode: %w", err)
}
return out.Token, nil
}
// doRequest 封装了"带 token 和 Accept 头发一个 GET,把 JSON 解析到 target"。
// manifest list、manifest、config blob 共用这个函数——它们都是 JSON。
// layer blob 不走这个(layer 是 tar.gz 二进制)。
func doRequest(url, token, accept string, target interface{}) error {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+token)
if accept != "" {
req.Header.Set("Accept", accept)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("%s: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("%s: status %d", url, resp.StatusCode)
}
return json.NewDecoder(resp.Body).Decode(target)
}
// ——— 3. pullImage ——
// 输入 "alpine" + "latest" → 输出 PullResult{LowerDirs, Env, Cmd}
//
// 流程:
// 1. 解析镜像名("alpine" → "library/alpine"
// 2. 拿 token
// 3. GET /manifests/latest → ManifestList → 挑 amd64/linux
// 4. GET /manifests/sha256:xxx → ImageManifest → config.digest + layers[].digest
// 5. GET /blobs/config.digest → ImageConfig → Env + Cmd
// 6. 遍历 layers,每个 GET /blobs/digest → tar.gz → 解压到 /tmp/layers/
// 7. 返回
func pullImage(image, tag string) (*PullResult, error) {
if tag == "" {
tag = "latest"
}
repo := image
if !strings.Contains(image, "/") {
repo = "library/" + image // "alpine" → "library/alpine"
}
token, err := getToken(repo)
if err != nil {
return nil, err
}
baseURL := fmt.Sprintf("https://registry-1.docker.io/v2/%s", repo)
// 1. manifest list → 挑 amd64
var ml ManifestList
if err := doRequest(
fmt.Sprintf("%s/manifests/%s", baseURL, tag),
token,
"application/vnd.docker.distribution.manifest.v2+json",
&ml,
); err != nil {
return nil, fmt.Errorf("manifest list: %w", err)
}
var targetDigest string
for _, e := range ml.Manifests {
if e.Platform.Architecture == "amd64" && e.Platform.OS == "linux" {
targetDigest = e.Digest
break
}
}
if targetDigest == "" {
return nil, fmt.Errorf("no amd64/linux manifest for %s:%s", image, tag)
}
// 2. 真正的 manifest → config.digest + layers
var im ImageManifest
if err := doRequest(
fmt.Sprintf("%s/manifests/%s", baseURL, targetDigest),
token,
"application/vnd.oci.image.manifest.v1+json",
&im,
); err != nil {
return nil, fmt.Errorf("image manifest: %w", err)
}
// 3. config blob → Env + Cmd
var cfg ImageConfig
if err := doRequest(
fmt.Sprintf("%s/blobs/%s", baseURL, im.Config.Digest),
token,
"",
&cfg,
); err != nil {
return nil, fmt.Errorf("config blob: %w", err)
}
// 4. 逐层下载
layersBase := fmt.Sprintf("/tmp/layers/%s/%s", strings.ReplaceAll(repo, "/", "_"), tag)
var lowerDirs []string
for _, layer := range im.Layers {
safeName := strings.Replace(layer.Digest, ":", "_", 1)
layerDir := filepath.Join(layersBase, safeName)
// 目录已存在 = 之前下载过,跳过
if _, err := os.Stat(layerDir); os.IsNotExist(err) {
fmt.Printf("[pull] downloading %s (%d MB)...\n",
layer.Digest[:19], layer.Size/1024/1024)
if err := extractLayer(baseURL, layer.Digest, token, layerDir); err != nil {
fmt.Printf("[pull] WARNING: layer %s unavailable (%v), skipping\n", layer.Digest[:19], err)
continue // 404 或其他 registry 错误——跳过这一层
}
} else {
fmt.Printf("[pull] %s (cached)\n", layer.Digest[:19])
}
// OCI layers[0] = 基底层,overlay lowerdir 第一个 = 最上层
// 所以要反转——最新的层放在最前面
lowerDirs = append([]string{layerDir}, lowerDirs...)
}
return &PullResult{
LowerDirs: strings.Join(lowerDirs, ":"),
Env: cfg.Config.Env,
Cmd: cfg.Config.Cmd,
Entrypoint: cfg.Config.Entrypoint,
}, nil
}
// extractLayer 下载一个 blobtar.gz 格式)并解压到 destDir。
// 处理普通文件、目录、符号链接。不处理硬链接(容器镜像很少用)。
func extractLayer(baseURL, digest, token, destDir string) error {
url := fmt.Sprintf("%s/blobs/%s", baseURL, digest)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("status %d", resp.StatusCode)
}
// 第一层:gzip 解压
gz, err := gzip.NewReader(resp.Body)
if err != nil {
return fmt.Errorf("gzip: %w", err)
}
defer gz.Close()
// 第二层:tar 遍历
tr := tar.NewReader(gz)
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("tar: %w", err)
}
target := filepath.Join(destDir, hdr.Name)
switch hdr.Typeflag {
case tar.TypeDir:
os.MkdirAll(target, 0755)
os.Chmod(target, os.FileMode(hdr.Mode))
case tar.TypeReg:
os.MkdirAll(filepath.Dir(target), 0755)
f, err := os.Create(target)
if err != nil {
return fmt.Errorf("create %s: %w", target, err)
}
if _, err := io.Copy(f, tr); err != nil {
f.Close()
return err
}
f.Close()
os.Chmod(target, os.FileMode(hdr.Mode))
case tar.TypeSymlink:
os.Symlink(hdr.Linkname, target)
}
}
return nil
}