fix(image): 区分「连不上仓库」与「版本不存在」,别再回退到过期镜像 (issue #114)

用户面板 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 才退而求其次,并明确告知
  可能较旧、网络恢复后重新升级。
This commit is contained in:
Gloridust
2026-07-16 20:58:15 +08:00
parent 3ce8065f56
commit 896a788a54

View File

@@ -24,9 +24,15 @@ function resolveWechatImage(): string {
let WECHAT_IMAGE = resolveWechatImage();
// 版本耦合的安全兜底P0issue #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<void> {
if (imageResolved) return;
@@ -41,12 +47,20 @@ export async function resolveInstanceImage(): Promise<void> {
/* 本地没有,继续查 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<void> {
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<boolean | null> {
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<string, string>
}
}
// registry 上该 tag manifest digest(多架构 index 的 digest与本地 RepoDigests 同层级)
async function fetchManifestDigest(ref: { registry: string; repo: string; tag: string }): Promise<string | null> {
// 探测 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<ManifestProbe> {
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";其它非 2xx401/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' };
}
}