fix(images): 服务器镜像在创建时固定到仓库 digest,更换镜像需确认备份,安装器重建前先固定旧服并推送不可变版本标签
This commit is contained in:
29 files changed
+1149
-29
No files matched your search
@@ -20,6 +20,7 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api, humanizeError } from "@/lib/api";
|
||||
import { splitImageRef } from "@/lib/format";
|
||||
import { useAsync } from "@/lib/hooks";
|
||||
import type { AutostartPolicy } from "@/lib/types";
|
||||
|
||||
@@ -36,6 +37,22 @@ function idleLabel(t: (key: string, opts?: Record<string, unknown>) => string, s
|
||||
return t("idle_stop_seconds", { count: seconds });
|
||||
}
|
||||
|
||||
/** ImageLabel shows an image ref as the tag it was picked by, plus the short id of
|
||||
* the build a pinned ref is locked to. */
|
||||
function ImageLabel({ imageRef, t }: { imageRef: string; t: (key: string, opts?: Record<string, unknown>) => string }) {
|
||||
const { tag, short } = splitImageRef(imageRef);
|
||||
return (
|
||||
<>
|
||||
{tag}
|
||||
{short && (
|
||||
<span className="ml-2 font-mono text-xs text-muted-foreground">
|
||||
{t("edit_server_image_build", { id: short })}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function policyOptions(t: (key: string) => string): { value: AutostartPolicy; label: string }[] {
|
||||
return [
|
||||
{ value: "ownerOnly", label: t("create_server_policy_owner") },
|
||||
@@ -96,6 +113,9 @@ export function EditServerDialog({
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
// An image change moves the world to another build for good, so saving one waits
|
||||
// for this acknowledgement (the API refuses it with image_change_unconfirmed).
|
||||
const [imageConfirmed, setImageConfirmed] = useState(false);
|
||||
|
||||
// Sync form state when dialog opens or current values change from server status
|
||||
useEffect(() => {
|
||||
@@ -109,11 +129,13 @@ export function EditServerDialog({
|
||||
idleStop: currentIdleStop,
|
||||
});
|
||||
setError(null);
|
||||
setImageConfirmed(false);
|
||||
}
|
||||
}, [open, currentDisplayName, currentPolicy, currentImage, currentMemory, currentCpu, currentIdleStop]);
|
||||
|
||||
function set<K extends keyof EditServerForm>(k: K, v: EditServerForm[K]) {
|
||||
setForm((f) => ({ ...f, [k]: v }));
|
||||
if (k === "image") setImageConfirmed(false);
|
||||
}
|
||||
|
||||
const enabledImages = (images.data ?? []).filter((i) => i.enabled);
|
||||
@@ -127,7 +149,9 @@ export function EditServerDialog({
|
||||
form.cpu !== currentCpu ||
|
||||
form.idleStop !== currentIdleStop;
|
||||
|
||||
const canSubmit = hasChanges && !submitting;
|
||||
const imageChanged = form.image !== currentImage;
|
||||
const pinnedBuild = splitImageRef(currentImage).short;
|
||||
const canSubmit = hasChanges && !submitting && (!imageChanged || imageConfirmed);
|
||||
|
||||
async function submit() {
|
||||
setSubmitting(true);
|
||||
@@ -141,8 +165,9 @@ export function EditServerDialog({
|
||||
if (form.autostartPolicy !== currentPolicy) {
|
||||
payload.autostartPolicy = form.autostartPolicy;
|
||||
}
|
||||
if (form.image !== currentImage) {
|
||||
if (imageChanged) {
|
||||
payload.image = form.image;
|
||||
payload.confirmImageChange = imageConfirmed;
|
||||
}
|
||||
if (form.memory !== currentMemory) {
|
||||
payload.memory = form.memory;
|
||||
@@ -222,7 +247,15 @@ export function EditServerDialog({
|
||||
<SelectContent>
|
||||
{/* Fallback to display the current image even if not in the whitelist options list */}
|
||||
{form.image && !enabledImages.some((img) => img.image_ref === form.image) && (
|
||||
<SelectItem value={form.image}>{form.image}</SelectItem>
|
||||
<SelectItem value={form.image}>
|
||||
<ImageLabel imageRef={form.image} t={t} />
|
||||
</SelectItem>
|
||||
)}
|
||||
{currentImage && form.image !== currentImage &&
|
||||
!enabledImages.some((img) => img.image_ref === currentImage) && (
|
||||
<SelectItem value={currentImage}>
|
||||
<ImageLabel imageRef={currentImage} t={t} />
|
||||
</SelectItem>
|
||||
)}
|
||||
{enabledImages.map((img) => (
|
||||
<SelectItem key={img.image_ref} value={img.image_ref}>
|
||||
@@ -231,6 +264,28 @@ export function EditServerDialog({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{!imageChanged && pinnedBuild && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("edit_server_image_pinned_hint", { id: pinnedBuild })}
|
||||
</p>
|
||||
)}
|
||||
{imageChanged && (
|
||||
<div className="grid gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-xs">
|
||||
<p className="flex items-start gap-1.5 text-amber-700 dark:text-amber-300">
|
||||
<AlertTriangle className="mt-px h-3.5 w-3.5 shrink-0" />
|
||||
{t("edit_server_image_warning")}
|
||||
</p>
|
||||
<label className="flex cursor-pointer items-center gap-2 font-medium text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 shrink-0 cursor-pointer accent-primary"
|
||||
checked={imageConfirmed}
|
||||
onChange={(e) => setImageConfirmed(e.target.checked)}
|
||||
/>
|
||||
{t("edit_server_image_confirm")}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
"quota_exceeded": "You have reached your server quota.",
|
||||
"already_claimed": "Someone else just claimed this server.",
|
||||
"image_not_whitelisted": "That image is not on the whitelist.",
|
||||
"image_not_in_registry": "The internal registry doesn't hold that image tag — it was never pushed or has been deleted. Rebuild or push it, then try again.",
|
||||
"registry_unavailable": "Can't reach the internal registry to look up which build that image is — try again shortly.",
|
||||
"image_change_unconfirmed": "Changing the image opens the world with the new build's Minecraft version, and upgraded chunks can't be opened by the old one again. Back the world up, tick the confirmation, then save.",
|
||||
"subdomain_taken": "That subdomain is already in use.",
|
||||
"already_exists": "A server with that name already exists.",
|
||||
"cooldown": "Wake is cooling down — try again shortly.",
|
||||
|
||||
@@ -129,6 +129,10 @@
|
||||
"edit_server_desc": "Configure display name, autostart policy, image, memory, CPU, and idle stop",
|
||||
"edit_server_desc_long": "Updating server spec. Fields left unchanged will retain their current values.",
|
||||
"edit_server_submit": "Save Config",
|
||||
"edit_server_image_build": "build {{id}}",
|
||||
"edit_server_image_pinned_hint": "Locked to build {{id}}: the tag moving to a newer build (an installer re-run, say) leaves this server alone; only changing the image here moves it.",
|
||||
"edit_server_image_warning": "After saving, the server runs the build this tag points to right now — re-picking the current tag also gets its newest build. If the Minecraft version changes, the world is upgraded on its next start, and upgraded chunks can't be opened by the old version again.",
|
||||
"edit_server_image_confirm": "I've backed up the world — change the image",
|
||||
"luckperms_group_name": "Group Name",
|
||||
"luckperms_node": "Permission Node",
|
||||
"luckperms_action": "Action",
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
"quota_exceeded": "服务器数量已达配额上限。",
|
||||
"already_claimed": "该服务器已被他人抢先认领。",
|
||||
"image_not_whitelisted": "该镜像未在白名单中。",
|
||||
"image_not_in_registry": "内部镜像仓库里没有这个镜像标签,可能从未推送过,或已被删除。请重新构建或推送该镜像后再试。",
|
||||
"registry_unavailable": "暂时连不上内部镜像仓库,无法确定该镜像对应的构建,请稍后再试。",
|
||||
"image_change_unconfirmed": "更换镜像会让世界用新构建的 Minecraft 版本打开,区块升级后无法再用旧版本打开。请先备份世界,再勾选确认后保存。",
|
||||
"subdomain_taken": "该子域名已被占用。",
|
||||
"already_exists": "同名服务器已存在。",
|
||||
"cooldown": "启动冷却中——请稍后再试。",
|
||||
|
||||
@@ -129,6 +129,10 @@
|
||||
"edit_server_desc": "配置显示名、自启策略、镜像、内存、CPU 与空闲停服",
|
||||
"edit_server_desc_long": "正在修改服务器的 spec 配置。未修改的项将保持原样。",
|
||||
"edit_server_submit": "保存配置",
|
||||
"edit_server_image_build": "构建 {{id}}",
|
||||
"edit_server_image_pinned_hint": "已锁定在构建 {{id}}:镜像标签以后指向新构建(比如重跑安装器)不会影响这台服务器,只有在这里更换镜像才会。",
|
||||
"edit_server_image_warning": "保存后,服务器会换用这个镜像标签现在指向的构建;重新选择原来的标签,拿到的也是它最新的构建。如果 Minecraft 版本变了,世界会在下次启动时升级,升级过的区块无法再用旧版本打开。",
|
||||
"edit_server_image_confirm": "我已备份世界,确认更换镜像",
|
||||
"luckperms_group_name": "用户组名称",
|
||||
"luckperms_node": "权限节点",
|
||||
"luckperms_action": "操作类型",
|
||||
|
||||
@@ -322,6 +322,9 @@ export const api = {
|
||||
displayName?: string;
|
||||
autostartPolicy?: AutostartPolicy;
|
||||
image?: string;
|
||||
/** Required with an image that moves the server to another build: the world is
|
||||
* opened by that build's Minecraft version, which cannot be undone. */
|
||||
confirmImageChange?: boolean;
|
||||
memory?: string;
|
||||
/** Idle auto-stop: 0 turns it off, else seconds empty before the stop (60–86400). */
|
||||
idleStopSeconds?: number;
|
||||
@@ -701,6 +704,15 @@ export function humanizeError(e: unknown): string {
|
||||
return t("already_claimed");
|
||||
case "image_not_whitelisted":
|
||||
return t("image_not_whitelisted");
|
||||
// Image pinning (internal/imagepin): a server runs the exact build its tag
|
||||
// named when it was created or last changed, so the registry has to hold the
|
||||
// tag, and moving a world to another build needs an explicit confirmation.
|
||||
case "image_not_in_registry":
|
||||
return t("image_not_in_registry");
|
||||
case "registry_unavailable":
|
||||
return t("registry_unavailable");
|
||||
case "image_change_unconfirmed":
|
||||
return t("image_change_unconfirmed");
|
||||
case "subdomain_taken":
|
||||
return t("subdomain_taken");
|
||||
case "already_exists":
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { formatBytes, formatRelative, formatAbsolute, isExpired } from "./format";
|
||||
import { formatBytes, formatRelative, formatAbsolute, isExpired, splitImageRef } from "./format";
|
||||
|
||||
describe("formatBytes", () => {
|
||||
it("renders sub-KiB counts as plain bytes", () => {
|
||||
@@ -75,3 +75,23 @@ describe("isExpired", () => {
|
||||
expect(isExpired("nope", now)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitImageRef", () => {
|
||||
const hex = "0123456789abcdef".repeat(4);
|
||||
|
||||
it("splits a pinned ref into its tag and a short build id", () => {
|
||||
expect(splitImageRef(`registry.felis.svc:5000/felis/paper:demo@sha256:${hex}`)).toEqual({
|
||||
tag: "registry.felis.svc:5000/felis/paper:demo",
|
||||
digest: `sha256:${hex}`,
|
||||
short: "0123456789ab",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves an unpinned ref whole", () => {
|
||||
expect(splitImageRef("docker.io/itzg/minecraft-server:java21")).toEqual({
|
||||
tag: "docker.io/itzg/minecraft-server:java21",
|
||||
digest: "",
|
||||
short: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -63,3 +63,14 @@ export function isExpired(iso: string, now: number): boolean {
|
||||
const t = new Date(iso).getTime();
|
||||
return Number.isFinite(t) && t <= now;
|
||||
}
|
||||
|
||||
/** splitImageRef separates a server's image into the tag it was chosen by and the
|
||||
* build it is pinned to. felis-api stores `name:tag@sha256:<hex>`; the digest is
|
||||
* 64 hex characters no one reads, so `short` keeps the first 12, the length
|
||||
* `docker images` shows. A ref without a digest comes back with `short` empty. */
|
||||
export function splitImageRef(ref: string): { tag: string; digest: string; short: string } {
|
||||
const at = ref.indexOf("@");
|
||||
if (at < 0) return { tag: ref, digest: "", short: "" };
|
||||
const digest = ref.slice(at + 1);
|
||||
return { tag: ref.slice(0, at), digest, short: digest.replace(/^sha256:/, "").slice(0, 12) };
|
||||
}
|
||||
Reference in new issue
Block a user