Skip to content

Commit 5486d64

Browse files
committed
feat(server): add logging and timeout handling for image pull
1 parent 66c2bb7 commit 5486d64

File tree

9 files changed

+124
-4
lines changed

9 files changed

+124
-4
lines changed

components/PullPanel.vue

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,15 @@ const handleDownloadProgress = async (eventSource: EventSource, layers: any[]) =
209209
return new Promise((resolve, reject) => {
210210
eventSource.onmessage = async (event) => {
211211
const data = JSON.parse(event.data);
212+
if (data.error) {
213+
eventSource.close();
214+
layers.forEach((layer) => {
215+
activeDownloads.value.delete(layer.digest);
216+
});
217+
error.value = data.error;
218+
reject(new Error(data.error));
219+
return;
220+
}
212221
if (data.summary) {
213222
eventSource.close();
214223
layers.forEach((layer) => {

server/api/docker/assemble-image.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
getRepoTagName,
88
getSafeFileBaseName,
99
} from "~/server/utils/imageName";
10+
import { logger } from "~/server/utils/logger";
1011

1112
// 请求参数接口
1213
type AssembleParams = {
@@ -67,6 +68,12 @@ export default defineEventHandler(async (event) => {
6768
const repoTagName = getRepoTagName(rawImageName || imageName);
6869
const fileBaseName = getSafeFileBaseName(rawImageName || imageName);
6970
const manifest = JSON.parse(manifestJson) as DockerManifest;
71+
const startedAt = Date.now();
72+
logger.info("assemble start", {
73+
imageName,
74+
tag,
75+
layers: manifest.layers?.length || 0,
76+
});
7077

7178
// 创建临时目录
7279
const tmpDir = join(process.cwd(), "tmp", `${imageName}-${Date.now()}`);
@@ -197,6 +204,12 @@ export default defineEventHandler(async (event) => {
197204
// 清理临时目录
198205
await fs.rm(tmpDir, { recursive: true, force: true });
199206

207+
logger.info("assemble complete", {
208+
imageName,
209+
tag,
210+
bytes: buffer.length,
211+
elapsedMs: Date.now() - startedAt,
212+
});
200213
return buffer;
201214
} catch (error: any) {
202215
// 清理临时目录
@@ -206,6 +219,12 @@ export default defineEventHandler(async (event) => {
206219
console.warn("清理临时目录失败:", e);
207220
}
208221

222+
logger.error("assemble failed", {
223+
imageName,
224+
tag,
225+
message: error.message,
226+
elapsedMs: Date.now() - startedAt,
227+
});
209228
throw createError({
210229
statusCode: 500,
211230
message: error.message,

server/api/docker/manifest-detail.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// 请求参数接口
22
import axiosInstance from "~/server/config/axios";
33
import { normalizeImageName } from "~/server/utils/imageName";
4+
import { logger } from "~/server/utils/logger";
45

56
type QueryParams = {
67
imageName: string;
@@ -38,6 +39,8 @@ export default defineEventHandler(
3839
const imageName = normalizeImageName(rawImageName);
3940
const { digest, token, mediaType } = query;
4041

42+
logger.info("manifest detail request", { imageName, digest });
43+
4144
const fetchManifestDetail = async () => {
4245
const response = await axiosInstance.get<ManifestDetailResponse>(
4346
`https://registry-1.docker.io/v2/${imageName}/manifests/${digest}`,
@@ -52,8 +55,19 @@ export default defineEventHandler(
5255
};
5356

5457
try {
55-
return await fetchManifestDetail();
58+
const result = await fetchManifestDetail();
59+
logger.info("manifest detail success", {
60+
imageName,
61+
digest,
62+
layers: result.layers?.length || 0,
63+
});
64+
return result;
5665
} catch (error: any) {
66+
logger.error("manifest detail failed", {
67+
imageName,
68+
digest,
69+
message: error.message,
70+
});
5771
throw createError({
5872
statusCode: error.response?.status || 500,
5973
message: error.message,

server/api/docker/manifest.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// 请求参数接口
22
import axiosInstance from "~/server/config/axios";
33
import { normalizeImageName } from "~/server/utils/imageName";
4+
import { logger } from "~/server/utils/logger";
45

56
type QueryParams = {
67
imageName: string;
@@ -36,6 +37,8 @@ export default defineEventHandler(async (event): Promise<ManifestResponse> => {
3637
const imageName = normalizeImageName(rawImageName);
3738
const { tag, token } = query;
3839

40+
logger.info("manifest request", { imageName, tag });
41+
3942
const fetchManifest = async () => {
4043
const response = await axiosInstance.get<ManifestResponse>(
4144
`https://registry-1.docker.io/v2/${imageName}/manifests/${tag}`,
@@ -51,8 +54,11 @@ export default defineEventHandler(async (event): Promise<ManifestResponse> => {
5154
};
5255

5356
try {
54-
return await fetchManifest();
57+
const result = await fetchManifest();
58+
logger.info("manifest success", { imageName, tag, count: result.manifests?.length || 0 });
59+
return result;
5560
} catch (error: any) {
61+
logger.error("manifest failed", { imageName, tag, message: error.message });
5662
throw createError({
5763
statusCode: error.response?.status || 500,
5864
message: error.message,

server/api/docker/pull-image.ts

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createWriteStream, existsSync, mkdirSync, statSync } from "fs";
22
import { join } from "path";
33
import axiosInstance from "~/server/config/axios";
44
import { normalizeImageName } from "~/server/utils/imageName";
5+
import { logger } from "~/server/utils/logger";
56

67
// 请求参数接口
78
type QueryParams = {
@@ -42,6 +43,10 @@ type FileInfo = {
4243
};
4344

4445
const CONCURRENT_DOWNLOADS = 3;
46+
const LAYER_TIMEOUT_MS = Math.max(
47+
1000,
48+
parseInt(process.env.PULL_LAYER_TIMEOUT_MS || "600000", 10)
49+
);
4550

4651
export default defineEventHandler(async (event) => {
4752
// 设置SSE响应头
@@ -62,6 +67,7 @@ export default defineEventHandler(async (event) => {
6267
}
6368

6469
const layers = JSON.parse(layersJson) as DockerLayer[];
70+
logger.info("pull start", { imageName, layers: layers.length, concurrency: CONCURRENT_DOWNLOADS });
6571

6672
// 创建下载目录
6773
const downloadDir = join(process.cwd(), "downloads", imageName);
@@ -96,9 +102,22 @@ export default defineEventHandler(async (event) => {
96102
event.node.res.write(`data: ${JSON.stringify(progress)}\n\n`);
97103
(event.node.res as any).flush?.();
98104
};
105+
const sendError = (message: string, details?: string[]) => {
106+
event.node.res.write(
107+
`data: ${JSON.stringify({
108+
error: message,
109+
details,
110+
})}\n\n`
111+
);
112+
(event.node.res as any).flush?.();
113+
};
99114

100115
// 下载单个层
101116
const downloadLayer = async (layer: DockerLayer): Promise<LayerDownloadResult> => {
117+
const controller = new AbortController();
118+
const timeoutId = setTimeout(() => {
119+
controller.abort(new Error("timeout"));
120+
}, LAYER_TIMEOUT_MS);
102121
try {
103122
const fileInfo = checkLayerFile(layer);
104123

@@ -123,6 +142,7 @@ export default defineEventHandler(async (event) => {
123142
{
124143
headers: { Authorization: `Bearer ${token}` },
125144
responseType: "stream",
145+
signal: controller.signal,
126146
}
127147
);
128148

@@ -149,6 +169,8 @@ export default defineEventHandler(async (event) => {
149169
});
150170

151171
await new Promise((resolve, reject) => {
172+
const onAbort = () => reject(new Error("timeout"));
173+
controller.signal.addEventListener("abort", onAbort, { once: true });
152174
writeStream.on("finish", resolve);
153175
writeStream.on("error", reject);
154176
response.data.on("error", reject);
@@ -162,11 +184,20 @@ export default defineEventHandler(async (event) => {
162184
skipped: false,
163185
};
164186
} catch (error: any) {
187+
if (String(error?.message).toLowerCase().includes("timeout")) {
188+
return {
189+
success: false,
190+
digest: layer.digest,
191+
error: "timeout",
192+
};
193+
}
165194
return {
166195
success: false,
167196
digest: layer.digest,
168197
error: error.message,
169198
};
199+
} finally {
200+
clearTimeout(timeoutId);
170201
}
171202
};
172203

@@ -204,9 +235,27 @@ export default defineEventHandler(async (event) => {
204235
// 检查下载结果
205236
const failedDownloads = results.filter((result) => !result.success);
206237
if (failedDownloads.length > 0) {
207-
throw new Error(
208-
`部分层下载失败: ${failedDownloads.map((f) => f.digest).join(", ")}`
238+
const timedOut = failedDownloads.filter((f) => f.error === "timeout");
239+
if (timedOut.length > 0) {
240+
sendError(
241+
`部分层下载超时(${timedOut.length} 个),请重试`,
242+
timedOut.map((f) => f.digest)
243+
);
244+
logger.warn("pull timeout", {
245+
imageName,
246+
timedOut: timedOut.length,
247+
});
248+
return;
249+
}
250+
sendError(
251+
`部分层下载失败(${failedDownloads.length} 个),请重试`,
252+
failedDownloads.map((f) => f.digest)
209253
);
254+
logger.error("pull failed layers", {
255+
imageName,
256+
failed: failedDownloads.length,
257+
});
258+
return;
210259
}
211260

212261
// 发送下载汇总信息
@@ -223,10 +272,17 @@ export default defineEventHandler(async (event) => {
223272
})}\n\n`
224273
);
225274
(event.node.res as any).flush?.();
275+
logger.info("pull complete", {
276+
imageName,
277+
total: results.length,
278+
skipped: skippedLayers,
279+
downloaded: downloadedLayers,
280+
});
226281

227282

228283
return;
229284
} catch (error: any) {
285+
logger.error("pull failed", { imageName, message: error.message });
230286
throw createError({
231287
statusCode: 500,
232288
message: error.message,

server/api/docker/search.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import axiosInstance from "~/server/config/axios";
2+
import { logger } from "~/server/utils/logger";
23

34
type QueryParams = {
45
query?: string;
@@ -49,6 +50,8 @@ export default defineEventHandler(async (event): Promise<ApiSearchResponse> => {
4950
const page = Math.max(1, parseInt(query.page || "1", 10));
5051
const pageSize = Math.min(25, Math.max(1, parseInt(query.pageSize || "10", 10)));
5152

53+
logger.info("search request", { query: q, page, pageSize });
54+
5255
try {
5356
const response = await axiosInstance.get<HubSearchResponse>(
5457
"https://hub.docker.com/v2/search/repositories/",
@@ -80,6 +83,7 @@ export default defineEventHandler(async (event): Promise<ApiSearchResponse> => {
8083
results: sorted,
8184
};
8285
} catch (error: any) {
86+
logger.error("search failed", { query: q, page, pageSize, message: error.message });
8387
throw createError({
8488
statusCode: error.response?.status || 500,
8589
message: error.message,

server/api/docker/tags.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import axiosInstance from "~/server/config/axios";
22
import { normalizeImageName } from "~/server/utils/imageName";
3+
import { logger } from "~/server/utils/logger";
34

45
type QueryParams = {
56
imageName?: string;
@@ -50,6 +51,8 @@ export default defineEventHandler(async (event): Promise<ApiTagResponse> => {
5051
return { count: 0, results: [] };
5152
}
5253

54+
logger.info("tags request", { imageName, page, pageSize });
55+
5356
try {
5457
const response = await axiosInstance.get<HubTagResponse>(
5558
`https://hub.docker.com/v2/repositories/${namespace}/${repo}/tags`,
@@ -82,6 +85,7 @@ export default defineEventHandler(async (event): Promise<ApiTagResponse> => {
8285
if (statusCode === 404) {
8386
return { count: 0, results: [] };
8487
}
88+
logger.error("tags failed", { imageName, page, pageSize, message: error.message });
8589
throw createError({
8690
statusCode,
8791
message: error.message,

server/api/docker/token.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// 请求参数接口
22
import axiosInstance from "~/server/config/axios";
33
import { normalizeImageName } from "~/server/utils/imageName";
4+
import { logger } from "~/server/utils/logger";
45

56
type QueryParams = {
67
imageName?: string;
@@ -25,6 +26,8 @@ export default defineEventHandler(async (event): Promise<ApiResponse> => {
2526
const imageName = normalizeImageName(rawImageName);
2627
const scope = query.scope || "pull";
2728

29+
logger.info("token request", { imageName, scope });
30+
2831
const fetchToken = async () => {
2932
const response = await axiosInstance.get<DockerAuthResponse>(
3033
"https://auth.docker.io/token",
@@ -40,10 +43,12 @@ export default defineEventHandler(async (event): Promise<ApiResponse> => {
4043

4144
try {
4245
const authResponse = await fetchToken();
46+
logger.info("token success", { imageName, scope });
4347
return {
4448
token: authResponse.token,
4549
};
4650
} catch (error: any) {
51+
logger.error("token failed", { imageName, scope, message: error.message });
4752
throw createError({
4853
statusCode: error.response?.status || 500,
4954
message: error.message,

server/utils/logger.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { consola } from "consola";
2+
3+
export const logger = consola.withTag("docker-pull");

0 commit comments

Comments
 (0)