|
1 | 1 | import { writeFileSync, promises as fs } from "fs"; |
2 | 2 | import { join } from "path"; |
3 | | -import * as tar from "tar"; |
| 3 | +import { Readable } from "node:stream"; |
| 4 | +import type { H3Event } from "h3"; |
4 | 5 | import { sendStream } from "h3"; |
| 6 | +import * as tar from "tar"; |
5 | 7 | import axiosInstance from "~/server/config/axios"; |
| 8 | +import { getErrorMessage } from "~/server/utils/http-error"; |
6 | 9 | import { |
7 | | - normalizeImageName, |
8 | 10 | getRepoTagName, |
9 | 11 | getSafeFileBaseName, |
| 12 | + normalizeImageName, |
10 | 13 | } from "~/server/utils/imageName"; |
11 | | -import { getErrorMessage } from "~/server/utils/http-error"; |
12 | 14 | import { logger } from "~/server/utils/logger"; |
13 | 15 |
|
14 | | -// 请求参数接口 |
15 | 16 | type AssembleParams = { |
16 | 17 | imageName: string; |
17 | 18 | tag: string; |
18 | 19 | token: string; |
19 | 20 | manifest: string; |
20 | 21 | }; |
21 | 22 |
|
22 | | -// Docker配置接口 |
23 | 23 | type DockerConfig = { |
24 | 24 | digest: string; |
25 | 25 | mediaType: string; |
26 | 26 | size: number; |
27 | 27 | }; |
28 | 28 |
|
29 | | -// Docker层接口 |
30 | 29 | type DockerLayer = { |
31 | 30 | digest: string; |
32 | 31 | mediaType: string; |
33 | 32 | size: number; |
34 | 33 | }; |
35 | 34 |
|
36 | | -// Docker平台接口 |
37 | 35 | type DockerPlatform = { |
38 | 36 | architecture: string; |
39 | 37 | os: string; |
40 | 38 | }; |
41 | 39 |
|
42 | | -// Docker清单接口 |
43 | 40 | type DockerManifest = { |
44 | 41 | config: DockerConfig; |
45 | 42 | layers: DockerLayer[]; |
46 | 43 | platform: DockerPlatform; |
47 | 44 | }; |
48 | 45 |
|
49 | | -// 层JSON配置接口 |
50 | 46 | type LayerJson = { |
51 | 47 | id: string; |
52 | 48 | parent?: string; |
53 | 49 | created: string; |
54 | | - container_config: { |
55 | | - Cmd: string[]; |
56 | | - }; |
| 50 | + container_config: { Cmd: string[] }; |
57 | 51 | architecture: string; |
58 | 52 | os: string; |
59 | 53 | }; |
60 | 54 |
|
| 55 | +type AssembleContext = { |
| 56 | + imageName: string; |
| 57 | + repoTagName: string; |
| 58 | + fileBaseName: string; |
| 59 | + tag: string; |
| 60 | + token: string; |
| 61 | + manifest: DockerManifest; |
| 62 | +}; |
| 63 | + |
| 64 | +const stripSha256 = (digest: string) => digest.replace("sha256:", ""); |
| 65 | +const TAR_OPTIONS = { |
| 66 | + preservePaths: true, |
| 67 | + follow: true, |
| 68 | + noPax: true, |
| 69 | + noMtime: true, |
| 70 | + gzip: false, |
| 71 | +} as const; |
| 72 | + |
| 73 | +const parseAssembleContext = (event: H3Event): AssembleContext => { |
| 74 | + const query = getQuery(event) as unknown as AssembleParams; |
| 75 | + const imageName = normalizeImageName(query.imageName || ""); |
| 76 | + if (!query.tag || !query.token || !query.manifest) { |
| 77 | + throw createError({ statusCode: 400, message: "缺少必要参数" }); |
| 78 | + } |
| 79 | + return { |
| 80 | + imageName, |
| 81 | + repoTagName: getRepoTagName(query.imageName || imageName), |
| 82 | + fileBaseName: getSafeFileBaseName(query.imageName || imageName), |
| 83 | + tag: query.tag, |
| 84 | + token: query.token, |
| 85 | + manifest: JSON.parse(query.manifest) as DockerManifest, |
| 86 | + }; |
| 87 | +}; |
| 88 | + |
| 89 | +const fetchConfigJson = async (imageName: string, token: string, digest: string) => { |
| 90 | + const response = await axiosInstance.get( |
| 91 | + `https://registry-1.docker.io/v2/${imageName}/blobs/${digest}`, |
| 92 | + { headers: { Authorization: `Bearer ${token}` } } |
| 93 | + ); |
| 94 | + return response.data; |
| 95 | +}; |
| 96 | + |
| 97 | +const processLayer = async ( |
| 98 | + tmpDir: string, |
| 99 | + imageName: string, |
| 100 | + manifest: DockerManifest, |
| 101 | + layer: DockerLayer, |
| 102 | + index: number |
| 103 | +) => { |
| 104 | + const layerId = stripSha256(layer.digest); |
| 105 | + const parentLayer = index > 0 ? manifest.layers[index - 1] : undefined; |
| 106 | + const layerDir = join(tmpDir, layerId); |
| 107 | + |
| 108 | + await fs.mkdir(layerDir, { recursive: true }); |
| 109 | + writeFileSync(join(layerDir, "VERSION"), "1.0"); |
| 110 | + |
| 111 | + const layerJson: LayerJson = { |
| 112 | + id: layerId, |
| 113 | + parent: parentLayer ? stripSha256(parentLayer.digest) : undefined, |
| 114 | + created: new Date().toISOString(), |
| 115 | + container_config: { Cmd: ["baselayer"] }, |
| 116 | + architecture: manifest.platform.architecture, |
| 117 | + os: manifest.platform.os, |
| 118 | + }; |
| 119 | + |
| 120 | + writeFileSync(join(layerDir, "json"), JSON.stringify(layerJson, null, 2)); |
| 121 | + |
| 122 | + const sourceFile = join( |
| 123 | + process.cwd(), |
| 124 | + "downloads", |
| 125 | + imageName, |
| 126 | + `${layer.digest.replace(":", "_")}.tar` |
| 127 | + ); |
| 128 | + await fs.copyFile(sourceFile, join(layerDir, "layer.tar")); |
| 129 | +}; |
| 130 | + |
| 131 | +const writeMetadataFiles = ( |
| 132 | + tmpDir: string, |
| 133 | + manifest: DockerManifest, |
| 134 | + repoTagName: string, |
| 135 | + tag: string, |
| 136 | + configFileName: string, |
| 137 | + configJson: unknown |
| 138 | +) => { |
| 139 | + const dockerManifest = [ |
| 140 | + { |
| 141 | + Config: `${configFileName}.json`, |
| 142 | + RepoTags: [`${repoTagName}:${tag}`], |
| 143 | + Layers: manifest.layers.map((layer) => `${stripSha256(layer.digest)}/layer.tar`), |
| 144 | + }, |
| 145 | + ]; |
| 146 | + |
| 147 | + const repositories = { [repoTagName]: { [tag]: configFileName } }; |
| 148 | + |
| 149 | + writeFileSync(join(tmpDir, "manifest.json"), JSON.stringify(dockerManifest, null, 2)); |
| 150 | + writeFileSync(join(tmpDir, `${configFileName}.json`), JSON.stringify(configJson, null, 2)); |
| 151 | + writeFileSync(join(tmpDir, "repositories"), JSON.stringify(repositories, null, 2)); |
| 152 | +}; |
| 153 | + |
61 | 154 | export default defineEventHandler(async (event) => { |
62 | | - const query = getQuery(event); |
63 | | - const { |
64 | | - imageName: rawImageName, |
65 | | - tag, |
66 | | - token, |
67 | | - manifest: manifestJson, |
68 | | - } = query as unknown as AssembleParams; |
69 | | - const imageName = normalizeImageName(rawImageName || ""); |
70 | | - const repoTagName = getRepoTagName(rawImageName || imageName); |
71 | | - const fileBaseName = getSafeFileBaseName(rawImageName || imageName); |
72 | | - const manifest = JSON.parse(manifestJson) as DockerManifest; |
| 155 | + const ctx = parseAssembleContext(event); |
| 156 | + const { imageName, repoTagName, fileBaseName, tag, token, manifest } = ctx; |
73 | 157 | const startedAt = Date.now(); |
74 | | - logger.info("assemble start", { |
75 | | - imageName, |
76 | | - tag, |
77 | | - layers: manifest.layers?.length || 0, |
78 | | - }); |
79 | 158 |
|
80 | | - // 创建临时目录 |
| 159 | + logger.info("assemble start", { imageName, tag, layers: manifest.layers.length }); |
| 160 | + |
81 | 161 | const tmpDir = join(process.cwd(), "tmp", `${imageName}-${Date.now()}`); |
82 | 162 | await fs.mkdir(tmpDir, { recursive: true }); |
83 | 163 |
|
84 | | - // 获取配置文件 |
85 | | - const fetchConfig = async () => { |
86 | | - const response = await axiosInstance.get( |
87 | | - `https://registry-1.docker.io/v2/${imageName}/blobs/${manifest.config.digest}`, |
88 | | - { |
89 | | - headers: { Authorization: `Bearer ${token}` }, |
90 | | - } |
91 | | - ); |
92 | | - return response.data; |
93 | | - }; |
94 | | - |
95 | | - // 处理单个层 |
96 | | - const processLayer = async (layer: DockerLayer, index: number) => { |
97 | | - const layerId = layer.digest.replace("sha256:", ""); |
98 | | - const layerDir = join(tmpDir, layerId); |
99 | | - |
100 | | - await fs.mkdir(layerDir, { recursive: true }); |
101 | | - |
102 | | - // 写入版本文件 |
103 | | - writeFileSync(join(layerDir, "VERSION"), "1.0"); |
104 | | - |
105 | | - // 创建层配置 |
106 | | - const layerJson: LayerJson = { |
107 | | - id: layerId, |
108 | | - parent: |
109 | | - index > 0 |
110 | | - ? manifest.layers[index - 1].digest.replace("sha256:", "") |
111 | | - : undefined, |
112 | | - created: new Date().toISOString(), |
113 | | - container_config: { |
114 | | - Cmd: ["baselayer"], |
115 | | - }, |
116 | | - architecture: manifest.platform.architecture, |
117 | | - os: manifest.platform.os, |
118 | | - }; |
119 | | - |
120 | | - // 写入层配置 |
121 | | - writeFileSync(join(layerDir, "json"), JSON.stringify(layerJson, null, 2)); |
122 | | - |
123 | | - // 复制层文件 |
124 | | - const sourceFile = join( |
125 | | - process.cwd(), |
126 | | - "downloads", |
127 | | - imageName, |
128 | | - `${layer.digest.replace(":", "_")}.tar` |
129 | | - ); |
130 | | - await fs.copyFile(sourceFile, join(layerDir, "layer.tar")); |
| 164 | + const cleanup = async () => { |
| 165 | + await fs.rm(tmpDir, { recursive: true, force: true }); |
131 | 166 | }; |
132 | 167 |
|
133 | 168 | try { |
134 | | - // 获取配置 |
135 | | - const configJson = await fetchConfig(); |
136 | | - const configFileName = manifest.config.digest.replace("sha256:", ""); |
| 169 | + const configJson = await fetchConfigJson(imageName, token, manifest.config.digest); |
| 170 | + const configFileName = stripSha256(manifest.config.digest); |
137 | 171 |
|
138 | | - // 处理所有层 |
139 | 172 | await Promise.all( |
140 | | - manifest.layers.map((layer, index) => processLayer(layer, index)) |
| 173 | + manifest.layers.map((layer, index) => processLayer(tmpDir, imageName, manifest, layer, index)) |
141 | 174 | ); |
142 | 175 |
|
143 | | - // 写入manifest.json |
144 | | - const manifestJson = [ |
145 | | - { |
146 | | - Config: `${configFileName}.json`, |
147 | | - RepoTags: [`${repoTagName}:${tag}`], |
148 | | - Layers: manifest.layers.map( |
149 | | - (layer) => `${layer.digest.replace("sha256:", "")}/layer.tar` |
150 | | - ), |
151 | | - }, |
152 | | - ]; |
153 | | - writeFileSync( |
154 | | - join(tmpDir, "manifest.json"), |
155 | | - JSON.stringify(manifestJson, null, 2) |
156 | | - ); |
157 | | - |
158 | | - // 写入配置文件 |
159 | | - writeFileSync( |
160 | | - join(tmpDir, `${configFileName}.json`), |
161 | | - JSON.stringify(configJson, null, 2) |
162 | | - ); |
| 176 | + writeMetadataFiles(tmpDir, manifest, repoTagName, tag, configFileName, configJson); |
163 | 177 |
|
164 | | - // 写入repositories文件 |
165 | | - const repositoriesJson = { |
166 | | - [repoTagName]: { |
167 | | - [tag]: configFileName, |
168 | | - }, |
169 | | - }; |
170 | | - writeFileSync( |
171 | | - join(tmpDir, "repositories"), |
172 | | - JSON.stringify(repositoriesJson, null, 2) |
173 | | - ); |
174 | | - |
175 | | - // 设置响应头 |
176 | 178 | setHeader(event, "Content-Type", "application/x-tar"); |
177 | 179 | setHeader( |
178 | 180 | event, |
179 | 181 | "Content-Disposition", |
180 | 182 | `attachment; filename="${fileBaseName}-${tag}.tar"` |
181 | 183 | ); |
182 | 184 |
|
183 | | - const tarStream = tar.create( |
184 | | - { |
185 | | - cwd: tmpDir, |
186 | | - preservePaths: true, |
187 | | - follow: true, |
188 | | - noPax: true, |
189 | | - noMtime: true, |
190 | | - gzip: false, |
191 | | - }, |
192 | | - ["."] |
193 | | - ); |
194 | | - |
195 | | - let cleaned = false; |
196 | | - const cleanup = async () => { |
197 | | - if (cleaned) return; |
198 | | - cleaned = true; |
199 | | - await fs.rm(tmpDir, { recursive: true, force: true }); |
200 | | - }; |
| 185 | + const tarStream = tar.create({ cwd: tmpDir, ...TAR_OPTIONS }, ["."]); |
201 | 186 |
|
202 | 187 | tarStream.on("close", async () => { |
203 | 188 | await cleanup(); |
204 | | - logger.info("assemble complete", { |
205 | | - imageName, |
206 | | - tag, |
207 | | - elapsedMs: Date.now() - startedAt, |
208 | | - }); |
| 189 | + logger.info("assemble complete", { imageName, tag, elapsedMs: Date.now() - startedAt }); |
209 | 190 | }); |
210 | | - tarStream.on("error", async (err) => { |
| 191 | + tarStream.on("error", async (error: unknown) => { |
211 | 192 | await cleanup(); |
212 | 193 | logger.error("assemble stream error", { |
213 | 194 | imageName, |
214 | 195 | tag, |
215 | | - message: String(err?.message || err), |
| 196 | + message: getErrorMessage(error), |
216 | 197 | }); |
217 | 198 | }); |
218 | 199 |
|
219 | | - return sendStream(event, tarStream); |
| 200 | + return sendStream(event, tarStream as unknown as Readable); |
220 | 201 | } catch (error: unknown) { |
221 | | - const message = getErrorMessage(error); |
222 | | - // 清理临时目录 |
223 | 202 | try { |
224 | | - await fs.rm(tmpDir, { recursive: true, force: true }); |
225 | | - } catch (e) { |
226 | | - console.warn("清理临时目录失败:", e); |
| 203 | + await cleanup(); |
| 204 | + } catch (cleanupError: unknown) { |
| 205 | + logger.warn("assemble cleanup failed", { |
| 206 | + imageName, |
| 207 | + tag, |
| 208 | + message: getErrorMessage(cleanupError), |
| 209 | + }); |
227 | 210 | } |
228 | 211 |
|
229 | | - logger.error("assemble failed", { |
230 | | - imageName, |
231 | | - tag, |
232 | | - message, |
233 | | - elapsedMs: Date.now() - startedAt, |
234 | | - }); |
235 | | - throw createError({ |
236 | | - statusCode: 500, |
237 | | - message, |
238 | | - }); |
| 212 | + const message = getErrorMessage(error); |
| 213 | + logger.error("assemble failed", { imageName, tag, message, elapsedMs: Date.now() - startedAt }); |
| 214 | + throw createError({ statusCode: 500, message }); |
239 | 215 | } |
240 | 216 | }); |
0 commit comments