From 896a788a54fb4b20fa34783f778354f6bf5ac2b2 Mon Sep 17 00:00:00 2001 From: Gloridust Date: Thu, 16 Jul 2026 20:58:15 +0800 Subject: [PATCH] =?UTF-8?q?fix(image):=20=E5=8C=BA=E5=88=86=E3=80=8C?= =?UTF-8?q?=E8=BF=9E=E4=B8=8D=E4=B8=8A=E4=BB=93=E5=BA=93=E3=80=8D=E4=B8=8E?= =?UTF-8?q?=E3=80=8C=E7=89=88=E6=9C=AC=E4=B8=8D=E5=AD=98=E5=9C=A8=E3=80=8D?= =?UTF-8?q?=EF=BC=8C=E5=88=AB=E5=86=8D=E5=9B=9E=E9=80=80=E5=88=B0=E8=BF=87?= =?UTF-8?q?=E6=9C=9F=E9=95=9C=E5=83=8F=20(issue=20#114)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户面板 v1.4.6 却报「wechat-on-cloud:1.4.6 在本地与镜像仓库均不存在」——但该 tag 在 ghcr 与 Docker Hub 都在(实测 HTTP 200)。#107 同理:面板报 1.2.7 不存在,实测它也在(200)。 根因:probe 拿不到就返回 null,代码把「网络不通」与「tag 不存在」当同一回事 → 回退到本地可能几周前的 :latest → 用户面板是新版、实例却永远停在老镜像,还打出「很可能该版本未成功发布」的误导文案。 国内 NAS 极常见:docker pull 走加速镜像能通,但面板进程直连 registry API 不通。 修: - probeManifest 返回判别式结果:404=missing(仓库确认没有)/ 其它非2xx与网络错误=unreachable(未知)。 已实测两个仓库对不存在的 tag 均回 404(非 401),该判据成立。 - resolveInstanceImage:仅 missing 才回退 :latest;unreachable 时保持版本 tag(乐观),把判决权交给 真正的 docker pull(它有镜像加速配置,多半能拉到)。文案改为网络提示,不再误导。 - ensureImage:真兜底改为基于【真实拉取失败】——拉不到且本地有 :latest 才退而求其次,并明确告知 可能较旧、网络恢复后重新升级。 --- panel/server/src/docker.ts | 79 ++++++++++++++++++++++++++++++-------- 1 file changed, 62 insertions(+), 17 deletions(-) diff --git a/panel/server/src/docker.ts b/panel/server/src/docker.ts index d11ae06..2dc58e3 100644 --- a/panel/server/src/docker.ts +++ b/panel/server/src/docker.ts @@ -24,9 +24,15 @@ function resolveWechatImage(): string { let WECHAT_IMAGE = resolveWechatImage(); // 版本耦合的安全兜底(P0:issue #112 后续)。版本耦合让面板偏好「与自身同版本」的实例镜像 tag, -// 但若该 tag 在本地和 registry 都不存在——典型如某次 CI 只发布了面板、实例镜像构建失败—— -// 面板就会指向一个不存在的 tag,导致:可升级检测恒空(指示器消失)、创建/升级实例拉取失败。 -// 故启动时校验一次:偏好的具体版本 tag 不可达 → 回退 :latest,保证功能永不因版本错配而瘫痪。 +// 但若该 tag 确实没发布(典型如某次 CI 只发布了面板、实例镜像构建失败),面板会指向一个不存在的 +// tag,导致可升级检测恒空、创建/升级实例拉取失败。故启动时校验一次。 +// +// ⚠️ 关键(issue #114):必须区分「仓库明确说没有(404)」和「压根连不上仓库」。 +// 国内 NAS 极常见的情形是:`docker pull` 走加速镜像【能通】,但面板进程直连 registry API 的 fetch +// 【不通】。旧实现把两者都当成"不存在"→ 回退到本地那份可能几周前的 `:latest` → 用户面板明明是新版、 +// 实例却永远停在老镜像,还打出"很可能该版本未成功发布"的误导文案。 +// 现在:只有仓库【确认 404】才回退;连不上时保持版本 tag(乐观),把判决权交给真正的 `docker pull` +// (它有镜像加速配置,多半能拉到);真拉不到时再由 ensureImage 兜底回退(见那里)。 let imageResolved = false; export async function resolveInstanceImage(): Promise { if (imageResolved) return; @@ -41,12 +47,20 @@ export async function resolveInstanceImage(): Promise { /* 本地没有,继续查 registry */ } const ref = parseImageRef(preferred); - const remote = ref ? await fetchManifestDigest(ref) : null; - if (remote) return; // registry 上存在该版本 → 用它(ensureImage 会拉) + const probe = ref ? await probeManifest(ref) : ({ ok: false, reason: 'unreachable' } as const); + if (probe.ok) return; // 仓库上存在该版本 → 用它(ensureImage 会拉) + if (probe.reason === 'unreachable') { + // 连不上仓库 ≠ 版本不存在。保持版本 tag,让 docker pull(可能走加速镜像)去试。 + appendPanelLog( + 'WARN', + `无法连接镜像仓库校验 ${preferred}(网络受限?),仍按该版本拉取;若拉取失败会自动回退 :latest`, + ); + return; + } const fallback = preferred.replace(/:[^/:]+$/, ':latest'); appendPanelLog( 'WARN', - `实例镜像 ${preferred} 在本地与镜像仓库均不存在,回退使用 ${fallback}(很可能该版本的实例镜像未成功发布;升级/检测将基于 :latest)`, + `镜像仓库确认不存在 ${preferred}(该版本的实例镜像可能未成功发布),回退使用 ${fallback}`, ); WECHAT_IMAGE = fallback; } @@ -239,12 +253,34 @@ async function ensureImage(): Promise { try { await pullImage(); appendPanelLog('INFO', `实例镜像拉取完成 ${WECHAT_IMAGE}(耗时 ${Math.round((Date.now() - t0) / 1000)}s)`); + return; } catch (e: any) { appendPanelLog('ERROR', `实例镜像拉取失败 ${WECHAT_IMAGE}(耗时 ${Math.round((Date.now() - t0) / 1000)}s):${e?.message || e}`); + // 真兜底(issue #114):版本 tag 拉不到时,若本地已有 :latest 就退而求其次用它,别让用户彻底不能用。 + // 这里基于【真实拉取失败】而非探测猜测——docker pull 有镜像加速配置,探测不通不代表拉不到。 + const fb = fallbackLatestRef(); + if (fb) { + try { + await docker.getImage(fb).inspect(); + appendPanelLog('WARN', `改用本地已有的 ${fb} 重建(注意:它可能是较旧的镜像;网络恢复后请重新「升级实例」拿到 ${WECHAT_IMAGE})`); + WECHAT_IMAGE = fb; + return; + } catch { + /* 本地也没有 :latest → 无可兜底,抛出原错误 */ + } + } throw e; } } +// 当前镜像引用对应的 :latest 形式;已是 latest / 无 tag 则返回 null(无可回退)。 +function fallbackLatestRef(): string | null { + const noDigest = WECHAT_IMAGE.split('@')[0]; + const m = noDigest.match(/^(.*):([^/:]+)$/); + if (!m || m[2] === 'latest') return null; + return `${m[1]}:latest`; +} + // 创建并启动一个微信实例容器。若同名容器已存在则先移除(仅容器,不动卷)。 // keepImage(稳定性关键):重启/自愈必须幂等——沿用该实例当前正在跑的镜像重建, // 绝不因"本地 :latest 恰好被某次拉取更新过"就悄悄换镜像(那等于一次没人要求的隐式升级; @@ -713,15 +749,16 @@ async function checkRemoteImageNewer(): Promise { local = await docker.getImage(WECHAT_IMAGE).inspect(); } catch { // 本地没有该镜像。版本耦合后这是常态:面板刚自更新到 vX,本地还只有旧版镜像、没有 :vX 的 tag。 - // 只要远端确实存在该镜像(digest 可取)就视为「有新版可拉取」,让升级引导亮起(一键升级会先拉取)。 - const remote = await fetchManifestDigest(ref); - return remote ? true : null; + // 远端确实存在该镜像 → 视为「有新版可拉取」,让升级引导亮起(一键升级会先拉取)。 + // 连不上(unreachable)→ null=未知,不打扰;确认不存在(missing)→ 也没得升,null。 + const probe = await probeManifest(ref); + return probe.ok ? true : null; } const repoDigests: string[] = local.RepoDigests || []; if (!repoDigests.length) return null; // 本地自构建(无 registry 来源)→ 无从比较,不打扰 - const remote = await fetchManifestDigest(ref); - if (!remote) return null; - return !repoDigests.some((d) => d.endsWith('@' + remote)); + const probe = await probeManifest(ref); + if (!probe.ok) return null; // 未知/不存在 → 不打扰 + return !repoDigests.some((d) => d.endsWith('@' + probe.digest)); } // 解析镜像引用 → { registry, repo, tag }。例:docker.io/gloridust/wechat-on-cloud:latest。 @@ -753,8 +790,12 @@ async function fetchJsonWithTimeout(url: string, headers: Record } } -// 取 registry 上该 tag 的 manifest digest(多架构 index 的 digest,与本地 RepoDigests 同层级)。 -async function fetchManifestDigest(ref: { registry: string; repo: string; tag: string }): Promise { +// 探测 registry 上该 tag 是否存在及其 manifest digest。 +// 关键(issue #114):必须把「仓库确认没有(404)」与「连不上仓库(网络/超时/鉴权失败)」分开—— +// 前者才能据以回退,后者只是未知,不能当作"不存在"(否则受限网络用户会被误判、回退到过期的 :latest)。 +type ManifestProbe = { ok: true; digest: string } | { ok: false; reason: 'missing' | 'unreachable' }; + +async function probeManifest(ref: { registry: string; repo: string; tag: string }): Promise { const accept = 'application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json'; let host = ref.registry; @@ -780,13 +821,17 @@ async function fetchManifestDigest(ref: { registry: string; repo: string; tag: s headers: { accept, ...(token ? { authorization: `Bearer ${token}` } : {}) }, signal: ctrl.signal, }); - if (!res.ok) return null; - return res.headers.get('docker-content-digest'); + // 404 = 仓库明确回答"没有这个 tag";其它非 2xx(401/5xx…)当作不可达,不敢断言不存在。 + if (res.status === 404) return { ok: false, reason: 'missing' }; + if (!res.ok) return { ok: false, reason: 'unreachable' }; + const digest = res.headers.get('docker-content-digest'); + return digest ? { ok: true, digest } : { ok: false, reason: 'unreachable' }; } finally { clearTimeout(t); } } catch { - return null; + // 网络错误 / 超时 / 取 token 失败 → 未知,不是"不存在" + return { ok: false, reason: 'unreachable' }; } }