Skip to content

Commit 89895e8

Browse files
committed
feat: split image assemble and download APIs; switch assemble-image to POST
1 parent 4aef2d3 commit 89895e8

File tree

4 files changed

+124
-96
lines changed

4 files changed

+124
-96
lines changed

components/PullPanel.vue

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -113,9 +113,12 @@
113113
<script setup lang="ts">
114114
import { ref, computed, watch, onBeforeUnmount } from "vue";
115115
import type {
116+
AssembleResponse,
116117
TokenResponse,
117118
ManifestResponse,
118119
ManifestDetailResponse,
120+
DockerManifest,
121+
DockerLayer,
119122
DockerPlatform,
120123
DownloadProgress,
121124
} from "~/types/docker";
@@ -130,7 +133,7 @@ const selectedPlatform = ref<DockerPlatform | null>(null);
130133
const downloadProgress = ref<Record<string, DownloadProgress>>({});
131134
const activeDownloads = ref(new Set<string>());
132135
const currentToken = ref("");
133-
const currentManifest = ref<any>(null);
136+
const currentManifest = ref<{ platform: DockerManifest; detail: ManifestDetailResponse } | null>(null);
134137
const downloadComplete = ref(false);
135138
const downloadSummary = ref<{
136139
total: number;
@@ -214,7 +217,7 @@ const fetchManifests = async () => {
214217
return response;
215218
};
216219
217-
const fetchManifestDetail = async (targetManifest: any) => {
220+
const fetchManifestDetail = async (targetManifest: DockerManifest) => {
218221
return await $fetch<ManifestDetailResponse>("/api/docker/manifest-detail", {
219222
params: {
220223
imageName: imageName.value,
@@ -227,7 +230,7 @@ const fetchManifestDetail = async (targetManifest: any) => {
227230
// #endregion
228231
229232
// #region Download
230-
const handleDownloadProgress = async (eventSource: EventSource, layers: any[]) => {
233+
const handleDownloadProgress = async (eventSource: EventSource, layers: DockerLayer[]) => {
231234
return new Promise((resolve, reject) => {
232235
eventSource.onmessage = async (event) => {
233236
const data = JSON.parse(event.data);
@@ -273,7 +276,7 @@ const handleDownloadProgress = async (eventSource: EventSource, layers: any[]) =
273276
});
274277
};
275278
276-
const downloadAllLayers = async (layers: any[]) => {
279+
const downloadAllLayers = async (layers: DockerLayer[]) => {
277280
if (activeDownloads.value.size > 0) return;
278281
279282
const eventSource = new EventSource(
@@ -302,31 +305,31 @@ const downloadAllLayers = async (layers: any[]) => {
302305
// #region Assemble
303306
const assembleImage = async () => {
304307
try {
305-
const response = await $fetch("/api/docker/assemble-image", {
306-
params: {
308+
if (!currentManifest.value) {
309+
throw new Error("manifest 信息缺失");
310+
}
311+
312+
const response = await $fetch<AssembleResponse>("/api/docker/assemble-image", {
313+
method: "POST",
314+
body: {
307315
imageName: imageName.value,
308316
tag: tag.value,
309317
token: currentToken.value,
310-
manifest: JSON.stringify({
318+
manifest: {
311319
config: currentManifest.value.detail.config,
312320
layers: currentManifest.value.detail.layers,
313321
platform: currentManifest.value.platform.platform,
314-
}),
322+
},
315323
},
316-
responseType: "blob",
317324
});
318325
319-
const buffer = await (response as unknown as Response).arrayBuffer();
320-
const blob = new Blob([buffer], { type: "application/x-tar" });
321-
322-
const url = window.URL.createObjectURL(blob);
323326
const a = document.createElement("a");
324-
a.href = url;
325-
const safeName = imageName.value.replaceAll("/", "_");
326-
a.download = `${safeName}-${tag.value}.tar`;
327+
a.href = `/api/docker/download-image?${new URLSearchParams({
328+
downloadId: response.downloadId,
329+
}).toString()}`;
330+
a.download = response.fileName;
327331
document.body.appendChild(a);
328332
a.click();
329-
window.URL.revokeObjectURL(url);
330333
document.body.removeChild(a);
331334
} catch (e) {
332335
console.error("组装镜像时出错:", e);
Lines changed: 47 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
1-
import { writeFileSync, promises as fs } from "fs";
2-
import { join } from "path";
3-
import { Readable } from "node:stream";
4-
import type { H3Event } from "h3";
5-
import { sendStream } from "h3";
1+
import { randomUUID } from "node:crypto";
2+
import { writeFileSync, promises as fs } from "node:fs";
3+
import { join } from "node:path";
64
import * as tar from "tar";
75
import axiosInstance from "~/server/config/axios";
86
import { getErrorMessage } from "~/server/utils/http-error";
@@ -13,13 +11,6 @@ import {
1311
} from "~/server/utils/imageName";
1412
import { logger } from "~/server/utils/logger";
1513

16-
type AssembleParams = {
17-
imageName: string;
18-
tag: string;
19-
token: string;
20-
manifest: string;
21-
};
22-
2314
type DockerConfig = {
2415
digest: string;
2516
mediaType: string;
@@ -43,6 +34,18 @@ type DockerManifest = {
4334
platform: DockerPlatform;
4435
};
4536

37+
type AssembleBody = {
38+
imageName: string;
39+
tag: string;
40+
token: string;
41+
manifest: DockerManifest;
42+
};
43+
44+
type AssembleResponse = {
45+
downloadId: string;
46+
fileName: string;
47+
};
48+
4649
type LayerJson = {
4750
id: string;
4851
parent?: string;
@@ -52,15 +55,6 @@ type LayerJson = {
5255
os: string;
5356
};
5457

55-
type AssembleContext = {
56-
imageName: string;
57-
repoTagName: string;
58-
fileBaseName: string;
59-
tag: string;
60-
token: string;
61-
manifest: DockerManifest;
62-
};
63-
6458
const stripSha256 = (digest: string) => digest.replace("sha256:", "");
6559
const TAR_OPTIONS = {
6660
preservePaths: true,
@@ -70,19 +64,20 @@ const TAR_OPTIONS = {
7064
gzip: false,
7165
} as const;
7266

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) {
67+
const parseAssembleBody = (body: Partial<AssembleBody>) => {
68+
const imageName = normalizeImageName((body.imageName || "").trim());
69+
const tag = (body.tag || "").trim();
70+
const token = (body.token || "").trim();
71+
if (!imageName || !tag || !token || !body.manifest) {
7772
throw createError({ statusCode: 400, message: "缺少必要参数" });
7873
}
7974
return {
8075
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,
76+
tag,
77+
token,
78+
manifest: body.manifest,
79+
repoTagName: getRepoTagName(body.imageName || imageName),
80+
fileBaseName: getSafeFileBaseName(body.imageName || imageName),
8681
};
8782
};
8883

@@ -104,10 +99,8 @@ const processLayer = async (
10499
const layerId = stripSha256(layer.digest);
105100
const parentLayer = index > 0 ? manifest.layers[index - 1] : undefined;
106101
const layerDir = join(tmpDir, layerId);
107-
108102
await fs.mkdir(layerDir, { recursive: true });
109103
writeFileSync(join(layerDir, "VERSION"), "1.0");
110-
111104
const layerJson: LayerJson = {
112105
id: layerId,
113106
parent: parentLayer ? stripSha256(parentLayer.digest) : undefined,
@@ -116,9 +109,7 @@ const processLayer = async (
116109
architecture: manifest.platform.architecture,
117110
os: manifest.platform.os,
118111
};
119-
120112
writeFileSync(join(layerDir, "json"), JSON.stringify(layerJson, null, 2));
121-
122113
const sourceFile = join(
123114
process.cwd(),
124115
"downloads",
@@ -143,72 +134,49 @@ const writeMetadataFiles = (
143134
Layers: manifest.layers.map((layer) => `${stripSha256(layer.digest)}/layer.tar`),
144135
},
145136
];
146-
147137
const repositories = { [repoTagName]: { [tag]: configFileName } };
148-
149138
writeFileSync(join(tmpDir, "manifest.json"), JSON.stringify(dockerManifest, null, 2));
150139
writeFileSync(join(tmpDir, `${configFileName}.json`), JSON.stringify(configJson, null, 2));
151140
writeFileSync(join(tmpDir, "repositories"), JSON.stringify(repositories, null, 2));
152141
};
153142

154-
export default defineEventHandler(async (event) => {
155-
const ctx = parseAssembleContext(event);
156-
const { imageName, repoTagName, fileBaseName, tag, token, manifest } = ctx;
143+
export default defineEventHandler(async (event): Promise<AssembleResponse> => {
144+
const body = await readBody<Partial<AssembleBody>>(event);
145+
const { imageName, tag, token, manifest, repoTagName, fileBaseName } = parseAssembleBody(body);
157146
const startedAt = Date.now();
147+
const tempId = randomUUID();
148+
const tmpDir = join(process.cwd(), "tmp", `${imageName}-${Date.now()}`);
149+
const assembleDir = join(process.cwd(), "tmp", "assembled");
150+
const tarPath = join(assembleDir, `${tempId}.tar`);
151+
const metaPath = join(assembleDir, `${tempId}.json`);
152+
const fileName = `${fileBaseName}-${tag}.tar`;
158153

159154
logger.info("assemble start", { imageName, tag, layers: manifest.layers.length });
160-
161-
const tmpDir = join(process.cwd(), "tmp", `${imageName}-${Date.now()}`);
162155
await fs.mkdir(tmpDir, { recursive: true });
163-
164-
const cleanup = async () => {
165-
await fs.rm(tmpDir, { recursive: true, force: true });
166-
};
156+
await fs.mkdir(assembleDir, { recursive: true });
167157

168158
try {
169159
const configJson = await fetchConfigJson(imageName, token, manifest.config.digest);
170160
const configFileName = stripSha256(manifest.config.digest);
171-
172161
await Promise.all(
173162
manifest.layers.map((layer, index) => processLayer(tmpDir, imageName, manifest, layer, index))
174163
);
175-
176164
writeMetadataFiles(tmpDir, manifest, repoTagName, tag, configFileName, configJson);
165+
await tar.create({ cwd: tmpDir, file: tarPath, ...TAR_OPTIONS }, ["."]);
166+
await fs.writeFile(metaPath, JSON.stringify({ fileName }, null, 2), "utf-8");
167+
await fs.rm(tmpDir, { recursive: true, force: true });
177168

178-
setHeader(event, "Content-Type", "application/x-tar");
179-
setHeader(
180-
event,
181-
"Content-Disposition",
182-
`attachment; filename="${fileBaseName}-${tag}.tar"`
183-
);
184-
185-
const tarStream = tar.create({ cwd: tmpDir, ...TAR_OPTIONS }, ["."]);
186-
187-
tarStream.on("close", async () => {
188-
await cleanup();
189-
logger.info("assemble complete", { imageName, tag, elapsedMs: Date.now() - startedAt });
190-
});
191-
tarStream.on("error", async (error: unknown) => {
192-
await cleanup();
193-
logger.error("assemble stream error", {
194-
imageName,
195-
tag,
196-
message: getErrorMessage(error),
197-
});
169+
logger.info("assemble complete", {
170+
imageName,
171+
tag,
172+
downloadId: tempId,
173+
elapsedMs: Date.now() - startedAt,
198174
});
199-
200-
return sendStream(event, tarStream as unknown as Readable);
175+
return { downloadId: tempId, fileName };
201176
} catch (error: unknown) {
202-
try {
203-
await cleanup();
204-
} catch (cleanupError: unknown) {
205-
logger.warn("assemble cleanup failed", {
206-
imageName,
207-
tag,
208-
message: getErrorMessage(cleanupError),
209-
});
210-
}
211-
177+
await fs.rm(tmpDir, { recursive: true, force: true });
178+
await fs.rm(tarPath, { force: true });
179+
await fs.rm(metaPath, { force: true });
212180
const message = getErrorMessage(error);
213181
logger.error("assemble failed", { imageName, tag, message, elapsedMs: Date.now() - startedAt });
214182
throw createError({ statusCode: 500, message });
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { createReadStream } from "node:fs";
2+
import { promises as fs } from "node:fs";
3+
import { join } from "node:path";
4+
import { sendStream } from "h3";
5+
import { getErrorMessage } from "~/server/utils/http-error";
6+
import { logger } from "~/server/utils/logger";
7+
8+
type DownloadQuery = {
9+
downloadId?: string;
10+
};
11+
12+
type DownloadMeta = {
13+
fileName: string;
14+
};
15+
16+
const DOWNLOAD_ID_PATTERN = /^[a-f0-9-]{36}$/i;
17+
18+
export default defineEventHandler(async (event) => {
19+
const query = getQuery(event) as DownloadQuery;
20+
const downloadId = (query.downloadId || "").trim();
21+
if (!DOWNLOAD_ID_PATTERN.test(downloadId)) {
22+
throw createError({ statusCode: 400, message: "无效下载ID" });
23+
}
24+
25+
const assembleDir = join(process.cwd(), "tmp", "assembled");
26+
const tarPath = join(assembleDir, `${downloadId}.tar`);
27+
const metaPath = join(assembleDir, `${downloadId}.json`);
28+
29+
try {
30+
const metaRaw = await fs.readFile(metaPath, "utf-8");
31+
const meta = JSON.parse(metaRaw) as DownloadMeta;
32+
33+
setHeader(event, "Content-Type", "application/x-tar");
34+
setHeader(event, "Content-Disposition", `attachment; filename="${meta.fileName}"`);
35+
36+
const tarStream = createReadStream(tarPath);
37+
tarStream.on("close", async () => {
38+
await fs.rm(tarPath, { force: true });
39+
await fs.rm(metaPath, { force: true });
40+
});
41+
tarStream.on("error", async (error: unknown) => {
42+
logger.error("download stream failed", { downloadId, message: getErrorMessage(error) });
43+
await fs.rm(tarPath, { force: true });
44+
await fs.rm(metaPath, { force: true });
45+
});
46+
47+
return sendStream(event, tarStream);
48+
} catch (error: unknown) {
49+
logger.error("download failed", { downloadId, message: getErrorMessage(error) });
50+
throw createError({ statusCode: 404, message: "文件不存在或已失效" });
51+
}
52+
});

types/docker.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ export type ManifestDetailResponse = {
5252
schemaVersion: number;
5353
};
5454

55+
export type AssembleResponse = {
56+
downloadId: string;
57+
fileName: string;
58+
};
59+
5560
export type DownloadSummary = {
5661
total: number;
5762
skipped: number;

0 commit comments

Comments
 (0)