fix(images): 服务器镜像在创建时固定到仓库 digest,更换镜像需确认备份,安装器重建前先固定旧服并推送不可变版本标签
This commit is contained in:
29 files changed
+1149
-29
No files matched your search
@@ -33,6 +33,11 @@ type API struct {
|
||||
// still exercised even before the subsystem is wired in.
|
||||
Builder ImageBuilder
|
||||
|
||||
// Images pins a whitelisted image ref to the digest it names when a server is
|
||||
// created or its image is changed (internal/imagepin), so a later push over
|
||||
// the same tag never reaches an existing world. Nil stores refs as given.
|
||||
Images ImagePinner
|
||||
|
||||
// Console is the synchronous RCON write channel (spec §8 写=RCON). It is
|
||||
// wired in production (cmd/felis); a nil Console makes the command route report
|
||||
// 503 rather than panic, so the ownership boundary is still exercised in tests.
|
||||
|
||||
@@ -64,6 +64,19 @@ func (a *API) audit(r *http.Request, action, target string) {
|
||||
a.auditEntry(r, e)
|
||||
}
|
||||
|
||||
// auditImageChange records a confirmed image change as server.patch with the
|
||||
// image it replaced and the one it set, so the audit log alone can say which
|
||||
// build a world ran before it was moved.
|
||||
func (a *API) auditImageChange(r *http.Request, server, from, to string) {
|
||||
p := principalFromContext(r.Context())
|
||||
e := AuditEntry{Actor: auditActor(p), Action: "server.patch", ServerName: server}
|
||||
if p != nil {
|
||||
e.ActorUserID = p.UserID
|
||||
}
|
||||
e.Payload = auditPayload(map[string]any{"image_from": from, "image_to": to})
|
||||
a.auditEntry(r, e)
|
||||
}
|
||||
|
||||
// auditAccount records an action a pre-session door took for the account it
|
||||
// resolved (u nil: none was). The username is the actor: the door has not yet
|
||||
// proven anything about the address.
|
||||
|
||||
@@ -75,7 +75,7 @@ func TestPatchServerAutostartPolicy(t *testing.T) {
|
||||
func TestPatchServerImageReAdmitted(t *testing.T) {
|
||||
api, _, cl, _ := newPatchAPI()
|
||||
|
||||
w := patchSurvival(api, `{"image":"`+admittedImage+`"}`)
|
||||
w := patchSurvival(api, `{"image":"`+admittedImage+`","confirmImageChange":true}`)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"felis.lolicon.best/internal/build"
|
||||
"felis.lolicon.best/internal/imagepin"
|
||||
"k8s.io/apimachinery/pkg/util/validation"
|
||||
)
|
||||
|
||||
@@ -252,3 +253,29 @@ func writeBuildError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
writeError(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ImagePinner resolves an image ref to the immutable form a server's spec keeps
|
||||
// (imagepin.Resolver). A ref it does not manage comes back unchanged.
|
||||
type ImagePinner interface {
|
||||
Pin(ctx context.Context, ref string) (string, error)
|
||||
}
|
||||
|
||||
// pinImage pins an admitted ref for a server spec. A tag the registry does not
|
||||
// hold is the caller's to fix (build or push it first); any other failure is the
|
||||
// registry being unreachable, and the server is not created or changed without a
|
||||
// pin, since an unpinned ref is exactly what lets a later push move its world.
|
||||
func (a *API) pinImage(ctx context.Context, ref string) (string, error) {
|
||||
if a.Images == nil {
|
||||
return ref, nil
|
||||
}
|
||||
pinned, err := a.Images.Pin(ctx, ref)
|
||||
switch {
|
||||
case errors.Is(err, imagepin.ErrNotFound):
|
||||
return "", newError(http.StatusBadRequest, "image_not_in_registry",
|
||||
"image %q is whitelisted but the registry does not hold it; build or push it first", ref)
|
||||
case err != nil:
|
||||
return "", newError(http.StatusServiceUnavailable, "registry_unavailable",
|
||||
"could not resolve image %q to a digest: %v", ref, err)
|
||||
}
|
||||
return pinned, nil
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"felis.lolicon.best/internal/imagepin"
|
||||
)
|
||||
|
||||
const pinnedDigest = "sha256:1111111111111111111111111111111111111111111111111111111111111111"
|
||||
|
||||
// fakePinner pins every unpinned ref to pinnedDigest, or fails with err. A ref
|
||||
// that already names a digest comes back as is, like imagepin.Resolver.
|
||||
type fakePinner struct {
|
||||
err error
|
||||
seen []string
|
||||
}
|
||||
|
||||
func (f *fakePinner) Pin(_ context.Context, ref string) (string, error) {
|
||||
f.seen = append(f.seen, ref)
|
||||
if f.err != nil {
|
||||
return "", f.err
|
||||
}
|
||||
if imagepin.Pinned(ref) {
|
||||
return ref, nil
|
||||
}
|
||||
return ref + "@" + pinnedDigest, nil
|
||||
}
|
||||
|
||||
// TestCreateServerStoresPinnedImage: the spec a server is created with names the
|
||||
// digest its tag resolved to, so a later push over the tag cannot move it.
|
||||
func TestCreateServerStoresPinnedImage(t *testing.T) {
|
||||
api, _, cl, _ := newCreateAPI()
|
||||
pin := &fakePinner{}
|
||||
api.Images = pin
|
||||
|
||||
w := do(api.ExternalHandler(), "POST", "/api/v1/servers", validCreateBody, nil)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("code = %d, want 201 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if got, want := cl.created["survival"].Image, admittedImage+"@"+pinnedDigest; got != want {
|
||||
t.Errorf("created image = %q, want %q", got, want)
|
||||
}
|
||||
if len(pin.seen) != 1 || pin.seen[0] != admittedImage {
|
||||
t.Errorf("pinned refs = %v, want the admitted ref once", pin.seen)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateServerPinErrors: a tag the registry does not hold is the caller's
|
||||
// problem (400); a registry that cannot answer is the platform's (503). Neither
|
||||
// creates anything.
|
||||
func TestCreateServerPinErrors(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
err error
|
||||
code int
|
||||
wantCode string
|
||||
}{
|
||||
{fmt.Errorf("resolve: %w", imagepin.ErrNotFound), http.StatusBadRequest, "image_not_in_registry"},
|
||||
{errors.New("dial tcp: connection refused"), http.StatusServiceUnavailable, "registry_unavailable"},
|
||||
} {
|
||||
api, _, cl, _ := newCreateAPI()
|
||||
api.Images = &fakePinner{err: tc.err}
|
||||
|
||||
w := do(api.ExternalHandler(), "POST", "/api/v1/servers", validCreateBody, nil)
|
||||
if w.Code != tc.code || decodeErr(t, w) != tc.wantCode {
|
||||
t.Errorf("%v: got %d %s, want %d %s", tc.err, w.Code, w.Body.String(), tc.code, tc.wantCode)
|
||||
}
|
||||
if _, ok := cl.created["survival"]; ok {
|
||||
t.Errorf("%v: server created despite the pin failure", tc.err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPatchServerImageChangeUnconfirmed: moving a world to another build is
|
||||
// refused until the caller acknowledges the chunk upgrade cannot be undone.
|
||||
func TestPatchServerImageChangeUnconfirmed(t *testing.T) {
|
||||
api, repo, cl, _ := newPatchAPI()
|
||||
cl.byName["survival"].Image = "registry.felis.svc:5000/mc:0@" + pinnedDigest
|
||||
api.Images = &fakePinner{}
|
||||
|
||||
w := patchSurvival(api, `{"image":"`+admittedImage+`"}`)
|
||||
if w.Code != http.StatusConflict || decodeErr(t, w) != "image_change_unconfirmed" {
|
||||
t.Fatalf("got %d %s, want 409 image_change_unconfirmed", w.Code, w.Body.String())
|
||||
}
|
||||
if _, ok := cl.patched["survival"]; ok {
|
||||
t.Error("spec patched without confirmation")
|
||||
}
|
||||
if len(repo.audits) != 0 {
|
||||
t.Errorf("refused change audited: %+v", repo.audits)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPatchServerImageConfirmedAudited: a confirmed change stores the pinned ref
|
||||
// and the audit row names both builds.
|
||||
func TestPatchServerImageConfirmedAudited(t *testing.T) {
|
||||
api, repo, cl, _ := newPatchAPI()
|
||||
from := "registry.felis.svc:5000/mc:0@" + pinnedDigest
|
||||
cl.byName["survival"].Image = from
|
||||
api.Images = &fakePinner{}
|
||||
|
||||
w := patchSurvival(api, `{"image":"`+admittedImage+`","confirmImageChange":true}`)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
to := admittedImage + "@" + pinnedDigest
|
||||
if p := cl.patched["survival"]; p.Image == nil || *p.Image != to {
|
||||
t.Fatalf("patched image = %v, want %q", p.Image, to)
|
||||
}
|
||||
if len(repo.audits) != 1 || repo.audits[0].Action != "server.patch" {
|
||||
t.Fatalf("audits = %+v, want one server.patch", repo.audits)
|
||||
}
|
||||
var payload map[string]string
|
||||
if err := json.Unmarshal(repo.audits[0].Payload, &payload); err != nil {
|
||||
t.Fatalf("audit payload %q: %v", repo.audits[0].Payload, err)
|
||||
}
|
||||
if payload["image_from"] != from || payload["image_to"] != to {
|
||||
t.Errorf("audit payload = %v, want image_from %q image_to %q", payload, from, to)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPatchServerImageSamePinIsNoChange: re-picking the tag a server runs, while
|
||||
// the tag still names the same build, changes nothing and needs no confirmation.
|
||||
func TestPatchServerImageSamePinIsNoChange(t *testing.T) {
|
||||
api, repo, cl, _ := newPatchAPI()
|
||||
cl.byName["survival"].Image = admittedImage + "@" + pinnedDigest
|
||||
api.Images = &fakePinner{}
|
||||
|
||||
w := patchSurvival(api, `{"image":"`+admittedImage+`"}`)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if p := cl.patched["survival"]; p.Image != nil {
|
||||
t.Errorf("patched image = %q, want no image change", *p.Image)
|
||||
}
|
||||
if len(repo.audits) != 1 || repo.audits[0].Payload != nil {
|
||||
t.Errorf("audits = %+v, want a plain server.patch", repo.audits)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPatchServerImageRestoresPinnedBuild: the exact build an earlier change
|
||||
// replaced is admitted by its tag and set as is, so a world restored from a
|
||||
// backup can go back to the build that wrote it.
|
||||
func TestPatchServerImageRestoresPinnedBuild(t *testing.T) {
|
||||
api, _, cl, fb := newPatchAPI()
|
||||
cl.byName["survival"].Image = admittedImage + "@" + pinnedDigest
|
||||
pin := &fakePinner{}
|
||||
api.Images = pin
|
||||
old := admittedImage + "@sha256:" + fmt.Sprintf("%064d", 0)
|
||||
fb.admitted[old] = true
|
||||
|
||||
w := patchSurvival(api, `{"image":"`+old+`","confirmImageChange":true}`)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if p := cl.patched["survival"]; p.Image == nil || *p.Image != old {
|
||||
t.Errorf("patched image = %v, want %q", p.Image, old)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user