mirror of
https://github.com/Gloridust/WechatOnCloud.git
synced 2026-09-20 11:33:32 +08:00
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:
@@ -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<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";其它非 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' };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user