fix(images): 服务器镜像在创建时固定到仓库 digest,更换镜像需确认备份,安装器重建前先固定旧服并推送不可变版本标签

This commit is contained in:
Lemon-miaow committed 2026-09-24 16:57:38 +08:00
1 parent 857b6da886
commit 215bfd78d7
29 files changed
+1149 -29

No files matched your search

+50 -9
View File
@@ -375,6 +375,13 @@ func (a *API) handleCreateServer(w http.ResponseWriter, r *http.Request) {
"image %q is not on the whitelist", body.Image))
return
}
// The spec keeps the digest the tag names now, not the tag: the world is
// created on this build and stays on it until an admin changes the image.
image, err := a.pinImage(r.Context(), body.Image)
if err != nil {
writeError(w, r, err)
return
}
// Quota is intentionally NOT enforced here. §15 creates an UNOWNED server
// (owner_id NULL); the per-user quota is charged at claim time (spec §9.3 /
@@ -432,7 +439,7 @@ func (a *API) handleCreateServer(w http.ResponseWriter, r *http.Request) {
Name: body.Name,
Subdomain: body.Subdomain,
DisplayName: body.DisplayName,
Image: body.Image,
Image: image,
JavaMemory: javaMemory,
StorageSize: storage,
AutostartPolicy: policy,
@@ -613,11 +620,16 @@ func parsePositiveQuantity(s, field string) (resource.Quantity, error) {
// the dual-write routing identity (name is the immutable object key; subdomain
// would desync the Postgres alias) nor for the world PVC size (see below).
type patchServerRequest struct {
DisplayName *string `json:"displayName,omitempty"`
AutostartPolicy *string `json:"autostartPolicy,omitempty"`
Image *string `json:"image,omitempty"`
Memory *string `json:"memory,omitempty"`
Resources *resourceRequest `json:"resources,omitempty"`
DisplayName *string `json:"displayName,omitempty"`
AutostartPolicy *string `json:"autostartPolicy,omitempty"`
Image *string `json:"image,omitempty"`
// ConfirmImageChange acknowledges that a new image opens the world with
// whatever Minecraft version it carries. Chunks a newer version has upgraded
// cannot be read by the older one again, so without it an image change that
// would actually move the server is refused (image_change_unconfirmed).
ConfirmImageChange bool `json:"confirmImageChange,omitempty"`
Memory *string `json:"memory,omitempty"`
Resources *resourceRequest `json:"resources,omitempty"`
// IdleStopSeconds sets idle auto-stop: 0 turns it off, otherwise the server
// stops after that many seconds with nobody online (60 to 86400).
IdleStopSeconds *int32 `json:"idleStopSeconds,omitempty"`
@@ -672,6 +684,8 @@ func (a *API) handlePatchServer(w http.ResponseWriter, r *http.Request) {
// so the response and audit name the real mutation.
var patch ServerSpecPatch
var changed []string
// imageFrom is the image a confirmed image change replaced, for the audit row.
var imageFrom string
if body.DisplayName != nil {
patch.DisplayName = body.DisplayName
@@ -730,8 +744,31 @@ func (a *API) handlePatchServer(w http.ResponseWriter, r *http.Request) {
"image %q is not on the whitelist", *body.Image))
return
}
patch.Image = body.Image
changed = append(changed, "image")
image, err := a.pinImage(r.Context(), *body.Image)
if err != nil {
writeError(w, r, err)
return
}
info, err := a.Cluster.GetServer(r.Context(), name)
if err != nil {
a.writeLookupError(w, r, err)
return
}
// Re-picking the tag a server was created from resolves to that tag's
// newest build, which is as much a version move as picking another image.
// Only a pin that lands on exactly the current image is no change at all.
if image != info.Image {
if !body.ConfirmImageChange {
writeError(w, r, newError(http.StatusConflict, "image_change_unconfirmed",
"changing the image from %q to %q opens this world with the new image's Minecraft version, "+
"and chunks it upgrades cannot be opened by the old one again; back the world up first, "+
"then resend with confirmImageChange", info.Image, image))
return
}
patch.Image = &image
changed = append(changed, "image")
imageFrom = info.Image
}
}
// Memory and the resource overrides move together: resolveResources derives the
@@ -814,7 +851,11 @@ func (a *API) handlePatchServer(w http.ResponseWriter, r *http.Request) {
}
}
a.audit(r, "server.patch", name)
if patch.Image != nil {
a.auditImageChange(r, name, imageFrom, *patch.Image)
} else {
a.audit(r, "server.patch", name)
}
writeJSON(w, http.StatusOK, map[string]any{
"name": name,
"patched": changed,