Files
mini-docker/pull.go
T

295 lines
8.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}