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
+1139 -19

No files matched your search

+12
View File
@@ -17,6 +17,7 @@ import (
"felis.lolicon.best/internal/build" "felis.lolicon.best/internal/build"
"felis.lolicon.best/internal/config" "felis.lolicon.best/internal/config"
"felis.lolicon.best/internal/fileedit" "felis.lolicon.best/internal/fileedit"
"felis.lolicon.best/internal/imagepin"
"felis.lolicon.best/internal/mail" "felis.lolicon.best/internal/mail"
"felis.lolicon.best/internal/naming" "felis.lolicon.best/internal/naming"
"felis.lolicon.best/internal/panel" "felis.lolicon.best/internal/panel"
@@ -268,6 +269,7 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
BuildLogs: api.NewK8sBuildLogStreamer(clientset, cfg.Registry.BuildNamespace), BuildLogs: api.NewK8sBuildLogStreamer(clientset, cfg.Registry.BuildNamespace),
Internal: api.BearerTokenAuth{Token: token}, Internal: api.BearerTokenAuth{Token: token},
Builder: builder, Builder: builder,
Images: imagePinner(cfg.Registry.URL),
Restorer: restorer, Restorer: restorer,
Backuper: backuper, Backuper: backuper,
JobStatus: api.NewK8sJobStatus(cl, cfg.K8s.Namespace), JobStatus: api.NewK8sJobStatus(cl, cfg.K8s.Namespace),
@@ -568,3 +570,13 @@ func mailLimit(perHour int) api.RateLimit {
} }
return api.RateLimit{Burst: max(perHour/4, 5), PerMinute: float64(perHour) / 60} return api.RateLimit{Burst: max(perHour/4, 5), PerMinute: float64(perHour) / 60}
} }
// imagePinner resolves a new server's image against the platform registry
// through its in-cluster Service, the address its refs already spell. An install
// without a registry has no platform-built images to pin.
func imagePinner(registry string) api.ImagePinner {
if registry == "" {
return nil
}
return imagepin.Resolver{Registry: registry}
}
+126
View File
@@ -0,0 +1,126 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"strings"
"time"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/imagepin"
"felis.lolicon.best/internal/platform"
"k8s.io/apimachinery/pkg/api/meta"
"sigs.k8s.io/controller-runtime/pkg/client"
)
// defaultRegistryURL is the [registry] url every install uses; deploy/bootstrap.sh
// spells the same value as REGISTRY_URL.
const defaultRegistryURL = "registry.felis.svc:5000"
// cmdPinImages pins every user server whose spec.image still names a mutable tag
// in the platform registry to the digest that tag names now (internal/imagepin).
// felis-api pins on create, so this covers the servers created before it did.
//
// deploy/bootstrap.sh runs it before it rebuilds the game images and pushes them
// over the same tags: run after the push, it would pin those servers to the new
// build, which is exactly the silent Minecraft upgrade pinning exists to stop.
// It reaches the registry through the node's loopback hostPort, the same way the
// installer pushes.
func cmdPinImages(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("pin-images", flag.ContinueOnError)
fs.SetOutput(stderr)
namespace := fs.String("namespace", platform.DefaultMinecraftNamespace, "namespace the MinecraftServers live in")
registry := fs.String("registry", defaultRegistryURL, "registry host[:port] the image refs spell")
endpoint := fs.String("endpoint", "", "host[:port] to reach the registry at (default: 127.0.0.1 on the registry's port, its hostPort on this node)")
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return 0
}
return 2
}
if *endpoint == "" {
*endpoint = loopbackEndpoint(*registry)
}
cl, err := buildSystemServerClient()
if err != nil {
fmt.Fprintf(stderr, "felis pin-images: %v\n", err)
return 1
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
outcomes, err := pinUserServerImages(ctx, cl, *namespace, imagepin.Resolver{Registry: *registry, Endpoint: *endpoint})
if meta.IsNoMatchError(err) {
fmt.Fprintln(stdout, "felis pin-images: no MinecraftServer CRD yet, so no server to pin")
return 0
}
if err != nil {
fmt.Fprintf(stderr, "felis pin-images: %v\n", err)
return 1
}
if len(outcomes) == 0 {
fmt.Fprintln(stdout, "felis pin-images: every user server already runs a pinned image")
return 0
}
fmt.Fprintln(stdout, "felis pin-images: pinning user servers to the build their tag names now:")
exit := 0
for _, o := range outcomes {
if o.err != nil {
fmt.Fprintf(stdout, " - %s: ERROR %v\n", o.name, o.err)
exit = 1
continue
}
fmt.Fprintf(stdout, " - %s: %s\n", o.name, strings.Join(o.changes, ", "))
}
return exit
}
// loopbackEndpoint is the registry's port on 127.0.0.1: the registry Deployment
// binds it as a hostPort, and containerd's mirror and the installer's pushes use
// the same address.
func loopbackEndpoint(registry string) string {
if i := strings.LastIndex(registry, ":"); i >= 0 {
return "127.0.0.1" + registry[i:]
}
return "127.0.0.1"
}
// pinUserServerImages patches spec.image of every user server whose image the
// resolver covers and is not yet pinned. System servers are left on their tags:
// the installer rebuilds and restarts them on purpose (restart_existing_system_servers).
// A server that is already pinned, or runs an image from elsewhere, produces no
// outcome, so a pinned fleet reports nothing. A running server restarts once as
// the operator rolls its StatefulSet onto the pinned ref, which is the build it
// already runs.
func pinUserServerImages(ctx context.Context, cl client.Client, namespace string, r imagepin.Resolver) ([]systemServerOutcome, error) {
var list v1alpha1.MinecraftServerList
if err := cl.List(ctx, &list, client.InNamespace(namespace)); err != nil {
return nil, fmt.Errorf("list servers: %w", err)
}
var out []systemServerOutcome
for i := range list.Items {
ms := &list.Items[i]
if ms.Labels[v1alpha1.LabelSystemRole] != "" || imagepin.Pinned(ms.Spec.Image) || !r.Covers(ms.Spec.Image) {
continue
}
pinned, err := r.Pin(ctx, ms.Spec.Image)
if errors.Is(err, imagepin.ErrNotFound) {
err = fmt.Errorf("%s is not in the registry, so there is no build to pin it to; left unpinned: %w", ms.Spec.Image, err)
}
if err != nil {
out = append(out, systemServerOutcome{name: ms.Name, err: err})
continue
}
patch := client.MergeFrom(ms.DeepCopy())
ms.Spec.Image = pinned
if err := cl.Patch(ctx, ms, patch); err != nil {
out = append(out, systemServerOutcome{name: ms.Name, err: fmt.Errorf("patch %s: %w", ms.Name, err)})
continue
}
out = append(out, systemServerOutcome{name: ms.Name, available: true, updated: true,
changes: []string{"spec.image pinned to " + pinned}})
}
return out, nil
}
+112
View File
@@ -0,0 +1,112 @@
package main
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/imagepin"
"felis.lolicon.best/internal/naming"
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/runtime/schema"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
)
const pinTestDigest = "sha256:2222222222222222222222222222222222222222222222222222222222222222"
// TestPinUserServerImages pins exactly the user servers still on a platform tag,
// reports a tag the registry lost as an error without touching that server, and
// has nothing left to do on a second pass.
func TestPinUserServerImages(t *testing.T) {
reg := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v2/felis/paper/manifests/demo" {
http.NotFound(w, r)
return
}
w.Header().Set("Docker-Content-Digest", pinTestDigest)
}))
defer reg.Close()
res := imagepin.Resolver{Registry: defaultRegistryURL, Endpoint: strings.TrimPrefix(reg.URL, "http://")}
paper := defaultRegistryURL + "/felis/paper:demo"
mk := func(name, image, role string) *v1alpha1.MinecraftServer {
ms := &v1alpha1.MinecraftServer{}
ms.Name, ms.Namespace = name, "minecraft"
ms.Spec.Image = image
if role != "" {
ms.Labels = map[string]string{v1alpha1.LabelSystemRole: role}
}
return ms
}
cl := fake.NewClientBuilder().WithScheme(newSystemServerScheme(t)).WithObjects(
mk("legacy", paper, ""),
mk("pinned", paper+"@sha256:"+strings.Repeat("3", 64), ""),
mk("external", "docker.io/itzg/minecraft-server:java21", ""),
mk("gone", defaultRegistryURL+"/felis/paper:old", ""),
mk(naming.SystemLobbyServer, defaultRegistryURL+"/felis/felis-lobby:demo", naming.SystemLobbyServer),
).Build()
ctx := context.Background()
outcomes, err := pinUserServerImages(ctx, cl, "minecraft", res)
if err != nil {
t.Fatalf("pinUserServerImages: %v", err)
}
byName := map[string]systemServerOutcome{}
for _, o := range outcomes {
byName[o.name] = o
}
if len(outcomes) != 2 || byName["legacy"].err != nil || byName["gone"].err == nil {
t.Fatalf("outcomes = %+v, want legacy pinned and gone reported", outcomes)
}
want := map[string]string{
"legacy": paper + "@" + pinTestDigest,
"pinned": paper + "@sha256:" + strings.Repeat("3", 64),
"external": "docker.io/itzg/minecraft-server:java21",
"gone": defaultRegistryURL + "/felis/paper:old",
naming.SystemLobbyServer: defaultRegistryURL + "/felis/felis-lobby:demo",
}
for name, image := range want {
var ms v1alpha1.MinecraftServer
if err := cl.Get(ctx, client.ObjectKey{Namespace: "minecraft", Name: name}, &ms); err != nil {
t.Fatalf("get %s: %v", name, err)
}
if ms.Spec.Image != image {
t.Errorf("%s image = %q, want %q", name, ms.Spec.Image, image)
}
}
again, err := pinUserServerImages(ctx, cl, "minecraft", res)
if err != nil || len(again) != 1 || again[0].name != "gone" {
t.Fatalf("second pass = %+v, %v; want only the unresolvable server again", again, err)
}
}
// A fresh install has no CRD yet; the command must read that as nothing to pin.
func TestPinUserServerImagesNoCRD(t *testing.T) {
cl := fake.NewClientBuilder().WithScheme(newSystemServerScheme(t)).WithInterceptorFuncs(interceptor.Funcs{
List: func(context.Context, client.WithWatch, client.ObjectList, ...client.ListOption) error {
return &meta.NoKindMatchError{GroupKind: schema.GroupKind{Group: "felis.lolicon.best", Kind: "MinecraftServer"}}
},
}).Build()
_, err := pinUserServerImages(context.Background(), cl, "minecraft", imagepin.Resolver{Registry: defaultRegistryURL})
if !meta.IsNoMatchError(err) {
t.Fatalf("err = %v, want a NoMatch error the command can recognise", err)
}
}
func TestLoopbackEndpoint(t *testing.T) {
for in, want := range map[string]string{
"registry.felis.svc:5000": "127.0.0.1:5000",
"registry.example": "127.0.0.1",
} {
if got := loopbackEndpoint(in); got != want {
t.Errorf("loopbackEndpoint(%q) = %q, want %q", in, got, want)
}
}
}
+1
View File
@@ -64,6 +64,7 @@ var commands = map[string]func(args []string, stdout, stderr io.Writer) int{
"bootstrap-assets": cmdBootstrapAssets, "bootstrap-assets": cmdBootstrapAssets,
"init-forwarding": cmdInitForwarding, "init-forwarding": cmdInitForwarding,
"init-volume": cmdInitVolume, "init-volume": cmdInitVolume,
"pin-images": cmdPinImages,
"version": cmdVersion, "version": cmdVersion,
"update": cmdUpdate, "update": cmdUpdate,
} }
+1
View File
@@ -43,6 +43,7 @@ func TestRunUnknownCommand(t *testing.T) {
// decision rather than an oversight. // decision rather than an oversight.
var undocumentedCommands = map[string]bool{ var undocumentedCommands = map[string]bool{
"bootstrap-assets": true, "init-forwarding": true, "init-volume": true, "bootstrap-assets": true, "init-forwarding": true, "init-volume": true,
"pin-images": true,
} }
// The usage text and the dispatch table must describe the same set of commands. // The usage text and the dispatch table must describe the same set of commands.
+45
View File
@@ -1007,6 +1007,27 @@ pin_registry_images() {
done done
} }
# pin_user_server_images fixes every user server still naming a tag in the platform
# registry (felis/paper:demo) to the digest that tag names now. It has to run before
# build_game_stack and push_images_to_registry put new builds under those tags: a
# server left on the bare tag would boot the new build on its next wake and open its
# world with a newer Minecraft version, and chunk upgrades cannot be undone. felis-api
# pins every server it creates; this catches the ones created before it did. A fresh
# install has no CRD, so nothing to pin; a running server restarts once onto the
# build it already runs.
pin_user_server_images() {
kube get crd minecraftservers.felis.lolicon.best >/dev/null 2>&1 || return 0
# The registry answers the lookups, and a k3s restart above may have left its pod
# still starting. A registry that never comes up fails the lookups below, loudly.
kube -n "$CONTROL_NS" rollout status deployment/registry --timeout=180s >/dev/null 2>&1 || true
if "$HOST_BIN" pin-images --namespace "$MINECRAFT_NS" \
--registry "$REGISTRY_URL" --endpoint "$REGISTRY_PUSH_HOST"; then
ok "user servers pinned to the builds they run"
else
warn "could not pin every user server listed above to its current build: each one still names a tag this run is about to point at a new build, so its next start may open its world with a newer Minecraft version. Pin it before starting it again: sudo felis pin-images (docs/troubleshooting.md §15b)"
fi
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 5. Source/binary + image build + containerd import # 5. Source/binary + image build + containerd import
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -2584,9 +2605,30 @@ push_images_to_registry() {
[ -n "$img" ] || continue [ -n "$img" ] || continue
push_image_to_registry "$img" push_image_to_registry "$img"
done done
for img in "$FELIS_LIMBO_IMAGE" "$FELIS_LOBBY_IMAGE" "$FELIS_PAPER_IMAGE"; do
[ -n "$img" ] || continue
push_version_tag "$img"
done
systemctl stop docker docker.socket 2>/dev/null || true systemctl stop docker docker.socket 2>/dev/null || true
} }
# push_version_tag mirrors a game image a second time under a tag no later run
# rewrites: <Minecraft version>-<first 12 hex of the image id>, e.g.
# felis/paper:26.2-3f9c0a1b2c4d. The :demo tag moves with every run, and servers are
# pinned to the digest it named when they were created, so this is the readable name
# for each build: an admin can whitelist it to create servers on that exact
# Minecraft version long after :demo has moved on.
push_version_tag() {
local ref="$1" id versioned
[ -n "${MC_VERSION:-}" ] || return 0
id="$(docker image inspect -f '{{.Id}}' "$ref" 2>/dev/null)" || return 0
id="${id#sha256:}"
versioned="${ref%:*}:${MC_VERSION}-${id:0:12}"
docker tag "$ref" "$versioned" || die "could not tag ${ref} as ${versioned} — is docker healthy?"
push_image_to_registry "$versioned"
docker rmi "$versioned" >/dev/null 2>&1 || true
}
# The login/lobby images use mutable :demo tags. Importing/pushing a replacement # The login/lobby images use mutable :demo tags. Importing/pushing a replacement
# updates containerd, but an existing StatefulSet template is byte-for-byte # updates containerd, but an existing StatefulSet template is byte-for-byte
# unchanged and Kubernetes will not roll it. Recreate only the two always-on # unchanged and Kubernetes will not roll it. Recreate only the two always-on
@@ -3014,6 +3056,9 @@ main() {
build_image build_image
# After build_image imported the felis image: the registry pod's gate runs it. # After build_image imported the felis image: the registry pod's gate runs it.
pin_registry_images pin_registry_images
# Before build_game_stack: the builds user servers run must be read off the
# registry's tags before new ones replace them.
pin_user_server_images
build_game_stack build_game_stack
install_postgres install_postgres
configure_postgres configure_postgres
+67
View File
@@ -824,6 +824,7 @@ out="$(
FELIS_IMAGE=a FELIS_LIMBO_IMAGE=b FELIS_LOBBY_IMAGE=c FELIS_PAPER_IMAGE=d bash -c ' FELIS_IMAGE=a FELIS_LIMBO_IMAGE=b FELIS_LOBBY_IMAGE=c FELIS_PAPER_IMAGE=d bash -c '
systemctl() { printf "SYSTEMCTL %s\n" "$*"; } systemctl() { printf "SYSTEMCTL %s\n" "$*"; }
push_image_to_registry() { printf "PUSH %s\n" "$1"; } push_image_to_registry() { printf "PUSH %s\n" "$1"; }
push_version_tag() { printf "VERSION %s\n" "$1"; }
registry_docker_login() { printf "LOGIN\n"; } registry_docker_login() { printf "LOGIN\n"; }
'"$wiblock"' '"$wiblock"'
push_images_to_registry' push_images_to_registry'
@@ -836,6 +837,34 @@ stops="$(printf '%s\n' "$out" | grep -c 'SYSTEMCTL stop docker')"
[ "$starts" = 1 ] && [ "$stops" = 1 ] && [ "$(printf '%s\n' "$out" | grep -c '^PUSH')" = 4 ] \ [ "$starts" = 1 ] && [ "$stops" = 1 ] && [ "$(printf '%s\n' "$out" | grep -c '^PUSH')" = 4 ] \
&& echo "PASS the batch wraps all four pushes in ONE docker start/stop" \ && echo "PASS the batch wraps all four pushes in ONE docker start/stop" \
|| { echo "FAIL: expected 1 start / 1 stop / 4 pushes, got:"; printf '%s\n' "$out"; fails=$((fails + 1)); } || { echo "FAIL: expected 1 start / 1 stop / 4 pushes, got:"; printf '%s\n' "$out"; fails=$((fails + 1)); }
[ "$(printf '%s\n' "$out" | grep '^VERSION' | tr '\n' ' ')" = "VERSION b VERSION c VERSION d " ] \
&& echo "PASS the three game images, and only they, also get a version tag" \
|| { echo "FAIL: expected version tags for b c d only, got:"; printf '%s\n' "$out"; fails=$((fails + 1)); }
# Each game build is also mirrored under <MC version>-<image id>, a tag no later run
# rewrites, so an admin can still name that exact build after :demo moves on.
vtblock="$(awk '/^push_version_tag\(\) \{/,/^}/' "$BS")"
[ -n "$vtblock" ] || { echo "FAIL: no push_version_tag found in $BS"; exit 1; }
run_version_tag() { # MC_VERSION
MC_VERSION="$1" bash -c '
die() { printf "DIE: %s\n" "$*"; exit 1; }
docker() {
case "$1" in
image) printf "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\n" ;;
*) printf "DOCKER %s\n" "$*" ;;
esac
}
push_image_to_registry() { printf "PUSH %s\n" "$1"; }
'"$vtblock"'
push_version_tag registry.felis.svc:5000/felis/paper:demo'
}
out="$(run_version_tag 26.2)"
expect "a game build is pushed under its version tag" "PUSH registry.felis.svc:5000/felis/paper:26.2-0123456789ab" "$out"
expect "the version tag is created from the built image" "DOCKER tag registry.felis.svc:5000/felis/paper:demo registry.felis.svc:5000/felis/paper:26.2-0123456789ab" "$out"
case "$(run_version_tag '')" in
*PUSH*) echo "FAIL: no Minecraft version, no version tag"; fails=$((fails + 1)) ;;
*) echo "PASS without a resolved Minecraft version no version tag is pushed" ;;
esac
# The registry refuses anonymous writes, and the platform token must never reach # The registry refuses anonymous writes, and the platform token must never reach
# docker's argv (ps) or root's ~/.docker: stdin into a throwaway --config dir. # docker's argv (ps) or root's ~/.docker: stdin into a throwaway --config dir.
@@ -888,6 +917,44 @@ case "$out" in
*"limbo:demo io.cri"*) echo "FAIL: only the registry pod's images may be pinned or unpinned"; fails=$((fails + 1)) ;; *"limbo:demo io.cri"*) echo "FAIL: only the registry pod's images may be pinned or unpinned"; fails=$((fails + 1)) ;;
esac esac
# User servers still on a bare registry tag are pinned to the build it names BEFORE
# this run builds and pushes new ones over it; a fresh install has nothing to pin.
puiblock="$(awk '/^pin_user_server_images\(\) \{/,/^}/' "$BS")"
[ -n "$puiblock" ] || { echo "FAIL: no pin_user_server_images found in $BS"; exit 1; }
run_pin_user() { # crd-present(0/1) pin-exit
CALLS="$calls" CRD="$1" PIN_EXIT="$2" HOST_BIN=felis CONTROL_NS=felis MINECRAFT_NS=minecraft \
REGISTRY_URL=registry.felis.svc:5000 REGISTRY_PUSH_HOST=127.0.0.1:5000 bash -c '
ok() { printf "OK: %s\n" "$*"; }
warn() { printf "WARN: %s\n" "$*"; }
kube() {
case "$*" in
"get crd"*) [ "$CRD" = 1 ] ;;
*) printf "KUBE %s\n" "$*" >>"$CALLS" ;;
esac
}
felis() { printf "FELIS %s\n" "$*"; return "$PIN_EXIT"; }
'"$puiblock"'
pin_user_server_images'
}
calls="$(mktemp)"
case "$(run_pin_user 0 0)" in
*FELIS*) echo "FAIL: without the CRD there is nothing to pin"; fails=$((fails + 1)) ;;
*) echo "PASS a fresh install skips pinning" ;;
esac
out="$(run_pin_user 1 0)$(printf '\n'; cat "$calls")"
expect "pinning reaches the registry through its loopback hostPort" "FELIS pin-images --namespace minecraft --registry registry.felis.svc:5000 --endpoint 127.0.0.1:5000" "$out"
expect "pinning waits for the registry first" "KUBE -n felis rollout status deployment/registry" "$out"
out="$(run_pin_user 1 1)"
expect "a failed pin warns with the consequence" "WARN: could not pin every user server" "$out"
rm -f "$calls"
mainblock="$(awk '/^main\(\) \{/,/^}/' "$BS")"
line_of() { printf '%s\n' "$mainblock" | grep -n "^ $1\$" | head -n 1 | cut -d: -f1; }
p="$(line_of pin_user_server_images)"; b="$(line_of build_game_stack)"; u="$(line_of push_images_to_registry)"
[ -n "$p" ] && [ -n "$b" ] && [ -n "$u" ] && [ "$p" -lt "$b" ] && [ "$b" -lt "$u" ] \
&& echo "PASS user servers are pinned before the game images are rebuilt and pushed" \
|| { echo "FAIL: main must run pin_user_server_images before build_game_stack and push_images_to_registry (lines: $p $b $u)"; fails=$((fails + 1)); }
# --- the registry's own image must not be re-pulled on every run -------------------------- # --- the registry's own image must not be re-pulled on every run --------------------------
iblock="$(awk '/^import_registry_image\(\) \{/,/^}/' "$BS")" iblock="$(awk '/^import_registry_image\(\) \{/,/^}/' "$BS")"
[ -n "$iblock" ] || { echo "FAIL: no import_registry_image found in $BS"; exit 1; } [ -n "$iblock" ] || { echo "FAIL: no import_registry_image found in $BS"; exit 1; }
+23 -2
View File
@@ -641,7 +641,12 @@ paths:
tags: [admin-servers] tags: [admin-servers]
operationId: createServer operationId: createServer
summary: Create a server (admin). summary: Create a server (admin).
description: Requires the admin Access path; the image must be whitelisted. description: >-
Requires the admin Access path; the image must be whitelisted. An image in the
platform registry is stored pinned to the digest its tag names at creation
(name:tag@sha256:…), so a later push over the tag never moves the server;
400 image_not_in_registry when the registry lacks the tag, 503
registry_unavailable when it cannot be asked.
x-felis-face: [external] x-felis-face: [external]
x-felis-tier: admin x-felis-tier: admin
security: [{ accessJWT: [] }] security: [{ accessJWT: [] }]
@@ -4564,7 +4569,19 @@ paths:
properties: properties:
displayName: { type: string } displayName: { type: string }
autostartPolicy: { type: string } autostartPolicy: { type: string }
image: { type: string } image:
type: string
description: >-
Re-admitted against the whitelist (a pinned name:tag@sha256:… ref is
admitted by its name:tag) and pinned like create does. A pin equal to
the current image is no change; any other needs confirmImageChange.
confirmImageChange:
type: boolean
description: >-
Acknowledges that the new image opens the world with its Minecraft
version, whose chunk upgrades the old one cannot read. Without it an
image that would move the server is refused with 409
image_change_unconfirmed. The audit row records image_from/image_to.
memory: { type: string } memory: { type: string }
storage: storage:
type: string type: string
@@ -4601,6 +4618,10 @@ paths:
$ref: '#/components/responses/Forbidden' $ref: '#/components/responses/Forbidden'
'404': '404':
$ref: '#/components/responses/NotFound' $ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/Conflict'
'503':
$ref: '#/components/responses/ServiceUnavailable'
/api/v1/images/build: /api/v1/images/build:
post: post:
+35
View File
@@ -1048,6 +1048,40 @@ installer built — only hand-built tags need a manual re-mirror.
applied; when they are the problem, restore the `pre-migrate` bundle the upgrade applied; when they are the problem, restore the `pre-migrate` bundle the upgrade
took (§16, "Roll back an upgrade that broke the database"). took (§16, "Roll back an upgrade that broke the database").
## 15b. Game images, pinned builds, and moving a world to a newer Minecraft
The platform's game images live under mutable tags
(`registry.felis.svc:5000/felis/paper:demo`): every installer run resolves the
newest Paper/Limbo release and pushes the new build over the same tag. A server
never follows that tag on its own. felis-api stores the image a server is
created with pinned to the digest the tag named at that moment
(`…/felis/paper:demo@sha256:…`), and the installer's `pin_user_server_images`
step pins any older server still on a bare tag *before* it pushes the new
builds. Kubernetes pulls a digest-qualified ref by the digest, so a pinned
server wakes on exactly the build it was created on, however often the tag moves.
Each installer run also pushes every game build under a tag no later run
rewrites, `<Minecraft version>-<12 hex of the image id>`
(`felis/paper:26.2-3f9c0a1b2c4d`). Whitelist one of those to create servers on a
specific Minecraft version after `:demo` has moved on.
Moving an existing world to a newer build is an explicit step: pick the image in
the panel's **Edit server** dialog (re-picking the current tag moves it to that
tag's newest build) and tick the backup confirmation. Over the API the same
PATCH needs `"confirmImageChange": true`, or it is refused with `409
image_change_unconfirmed`. Back the world up first: Minecraft upgrades chunks
as it loads them, and the old version cannot open them again. The audit log keeps
`image_from`/`image_to` for every change, so the exact previous build can be set
back (it is admitted by its tag) together with a restore of the pre-upgrade
backup.
| Symptom | Cause | Fix |
|---|---|---|
| Create/edit refused with `image_not_in_registry` | the whitelisted tag was never pushed to the internal registry, or was deleted | push or rebuild the image, then retry |
| Create/edit refused with `registry_unavailable` | felis-api could not reach `registry.felis.svc:5000` | `kubectl -n felis get pods -l app.kubernetes.io/component=registry`; check the `felis-registry-ingress` NetworkPolicy still admits felis-api |
| Installer warns `could not pin every user server` | the registry was down, or a server names a tag the registry lost | fix the registry, then `sudo felis pin-images` before starting those servers; a server whose tag is gone keeps its bare tag until an admin picks a new image |
| A running server restarted during an installer re-run | it was pinned in place: the operator rolled it onto the pinned ref, the build it already ran | nothing; it happens once per server |
## 16. Control-plane database backups and disaster recovery ## 16. Control-plane database backups and disaster recovery
The PostgreSQL database behind felis-api holds everything that is not a world: The PostgreSQL database behind felis-api holds everything that is not a world:
@@ -1347,6 +1381,7 @@ for 10 seconds (the Free plan's limits).
| Node out of disk; pods evicted / ImagePullBackOff | §13b | | Node out of disk; pods evicted / ImagePullBackOff | §13b |
| Which metric to scrape | §14 | | Which metric to scrape | §14 |
| Upgrade / roll back a bad control-plane image | §15 | | Upgrade / roll back a bad control-plane image | §15 |
| `image_change_unconfirmed` / `image_not_in_registry` / `registry_unavailable`; move a world to a newer Minecraft | §15b |
| Database backup overdue / `FelisDBBackupStale` / panel shows 从未备份 | §16 | | Database backup overdue / `FelisDBBackupStale` / panel shows 从未备份 | §16 |
| `pre-migration backup failed, nothing applied` during an upgrade | §16 | | `pre-migration backup failed, nothing applied` during an upgrade | §16 |
| Undo a mistaken change / restore the control-plane database | §16 | | Undo a mistaken change / restore the control-plane database | §16 |
+5
View File
@@ -33,6 +33,11 @@ type API struct {
// still exercised even before the subsystem is wired in. // still exercised even before the subsystem is wired in.
Builder ImageBuilder 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 // 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 // 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. // 503 rather than panic, so the ownership boundary is still exercised in tests.
+13
View File
@@ -64,6 +64,19 @@ func (a *API) audit(r *http.Request, action, target string) {
a.auditEntry(r, e) 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 // 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 // resolved (u nil: none was). The username is the actor: the door has not yet
// proven anything about the address. // proven anything about the address.
+1 -1
View File
@@ -75,7 +75,7 @@ func TestPatchServerAutostartPolicy(t *testing.T) {
func TestPatchServerImageReAdmitted(t *testing.T) { func TestPatchServerImageReAdmitted(t *testing.T) {
api, _, cl, _ := newPatchAPI() api, _, cl, _ := newPatchAPI()
w := patchSurvival(api, `{"image":"`+admittedImage+`"}`) w := patchSurvival(api, `{"image":"`+admittedImage+`","confirmImageChange":true}`)
if w.Code != http.StatusOK { if w.Code != http.StatusOK {
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String()) t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
} }
+43 -2
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)) "image %q is not on the whitelist", body.Image))
return 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 // 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 / // (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, Name: body.Name,
Subdomain: body.Subdomain, Subdomain: body.Subdomain,
DisplayName: body.DisplayName, DisplayName: body.DisplayName,
Image: body.Image, Image: image,
JavaMemory: javaMemory, JavaMemory: javaMemory,
StorageSize: storage, StorageSize: storage,
AutostartPolicy: policy, AutostartPolicy: policy,
@@ -616,6 +623,11 @@ type patchServerRequest struct {
DisplayName *string `json:"displayName,omitempty"` DisplayName *string `json:"displayName,omitempty"`
AutostartPolicy *string `json:"autostartPolicy,omitempty"` AutostartPolicy *string `json:"autostartPolicy,omitempty"`
Image *string `json:"image,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"` Memory *string `json:"memory,omitempty"`
Resources *resourceRequest `json:"resources,omitempty"` Resources *resourceRequest `json:"resources,omitempty"`
// IdleStopSeconds sets idle auto-stop: 0 turns it off, otherwise the server // IdleStopSeconds sets idle auto-stop: 0 turns it off, otherwise the server
@@ -672,6 +684,8 @@ func (a *API) handlePatchServer(w http.ResponseWriter, r *http.Request) {
// so the response and audit name the real mutation. // so the response and audit name the real mutation.
var patch ServerSpecPatch var patch ServerSpecPatch
var changed []string var changed []string
// imageFrom is the image a confirmed image change replaced, for the audit row.
var imageFrom string
if body.DisplayName != nil { if body.DisplayName != nil {
patch.DisplayName = body.DisplayName 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)) "image %q is not on the whitelist", *body.Image))
return return
} }
patch.Image = body.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") changed = append(changed, "image")
imageFrom = info.Image
}
} }
// Memory and the resource overrides move together: resolveResources derives the // Memory and the resource overrides move together: resolveResources derives the
@@ -814,7 +851,11 @@ func (a *API) handlePatchServer(w http.ResponseWriter, r *http.Request) {
} }
} }
if patch.Image != nil {
a.auditImageChange(r, name, imageFrom, *patch.Image)
} else {
a.audit(r, "server.patch", name) a.audit(r, "server.patch", name)
}
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"name": name, "name": name,
"patched": changed, "patched": changed,
+27
View File
@@ -6,6 +6,7 @@ import (
"net/http" "net/http"
"felis.lolicon.best/internal/build" "felis.lolicon.best/internal/build"
"felis.lolicon.best/internal/imagepin"
"k8s.io/apimachinery/pkg/util/validation" "k8s.io/apimachinery/pkg/util/validation"
) )
@@ -252,3 +253,29 @@ func writeBuildError(w http.ResponseWriter, r *http.Request, err error) {
writeError(w, r, err) 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
}
+162
View File
@@ -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)
}
}
+5 -1
View File
@@ -543,8 +543,12 @@ func (b *Builder) RemoveImage(ctx context.Context, imageRef string) error {
// image). A disabled row never admits. A wildcard whitelist entry // image). A disabled row never admits. A wildcard whitelist entry
// ("registry/foo:*") admits any concrete tag on that repo (imageMatches); the // ("registry/foo:*") admits any concrete tag on that repo (imageMatches); the
// caller always passes a concrete ref, never a wildcard. An empty ref is never // caller always passes a concrete ref, never a wildcard. An empty ref is never
// admitted. // admitted. A ref pinned to a digest (name:tag@sha256:…, what a server's spec
// carries once created) is admitted by its name:tag: the digest only fixes which
// build of that tag runs, so an admin can set a server back to the exact build an
// earlier image change replaced.
func (b *Builder) ImageAdmitted(ctx context.Context, imageRef string) (bool, error) { func (b *Builder) ImageAdmitted(ctx context.Context, imageRef string) (bool, error) {
imageRef, _, _ = strings.Cut(imageRef, "@")
if strings.TrimSpace(imageRef) == "" { if strings.TrimSpace(imageRef) == "" {
return false, nil return false, nil
} }
+7
View File
@@ -3,6 +3,7 @@ package build
import ( import (
"context" "context"
"errors" "errors"
"strings"
"testing" "testing"
"time" "time"
@@ -548,6 +549,12 @@ func TestImageAdmitted(t *testing.T) {
{"registry.felis.svc:5000/unknown:1", false}, // not on the list {"registry.felis.svc:5000/unknown:1", false}, // not on the list
{"", false}, // empty ref {"", false}, // empty ref
{" ", false}, // blank ref {" ", false}, // blank ref
// A pinned ref is admitted by its name:tag, wildcard or exact.
{"registry.felis.svc:5000/exact:1@sha256:" + strings.Repeat("a", 64), true},
{"registry.felis.svc:5000/wild:99@sha256:" + strings.Repeat("b", 64), true},
{"registry.felis.svc:5000/exact:2@sha256:" + strings.Repeat("a", 64), false},
{"registry.felis.svc:5000/off:1@sha256:" + strings.Repeat("a", 64), false},
{"@sha256:" + strings.Repeat("a", 64), false},
} }
for _, c := range cases { for _, c := range cases {
got, err := b.ImageAdmitted(context.Background(), c.ref) got, err := b.ImageAdmitted(context.Background(), c.ref)
+167
View File
@@ -0,0 +1,167 @@
// Package imagepin fixes a server's image to the exact build it was created
// with. The platform's own game images are published under mutable tags
// (registry.felis.svc:5000/felis/paper:demo): every installer run rebuilds them
// against the newest Paper/Limbo release and pushes over the same tag. A server
// whose spec.image names that tag would boot whatever the tag points at on its
// next wake, so re-running the installer would silently move a sleeping world to
// a newer Minecraft version. Chunk upgrades are one-way, so that move can never
// be taken back.
//
// Pin resolves such a tag to the manifest digest it names right now and appends
// it (name:tag@sha256:…). Kubernetes pulls a reference carrying a digest by the
// digest alone, so the tag stays only as a readable label of where the build
// came from. A pinned server changes image only when an admin changes
// spec.image, which the API makes an explicit, confirmed step.
//
// Only refs in the platform registry are resolved. That registry is reachable
// anonymously for reads from inside the cluster; a public registry would need a
// token exchange per vendor and egress felis-api does not otherwise have, and an
// external image is one an admin whitelisted by an exact tag of their choosing.
package imagepin
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"time"
)
// ErrNotFound means the registry answered and does not hold the tag: the image
// was whitelisted but never pushed, or has been deleted since.
var ErrNotFound = errors.New("imagepin: tag not found in the registry")
// manifestAccept lists every manifest shape the registry may hold for a tag. A
// registry asked without an Accept it can satisfy answers with a converted
// schema-1 manifest, whose digest is not the one kubelet would pull.
var manifestAccept = strings.Join([]string{
"application/vnd.oci.image.index.v1+json",
"application/vnd.docker.distribution.manifest.list.v2+json",
"application/vnd.oci.image.manifest.v1+json",
"application/vnd.docker.distribution.manifest.v2+json",
}, ", ")
var digestRE = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
// maxManifestBytes bounds the body read when the registry sends no digest
// header; a manifest or index is a few KiB.
const maxManifestBytes = 4 << 20
// Pinned reports whether ref already names a digest.
func Pinned(ref string) bool { return strings.Contains(ref, "@") }
// Resolver pins refs that live in one registry.
type Resolver struct {
// Registry is the host[:port] the refs spell, the [registry] url
// (registry.felis.svc:5000). Refs under any other host are left as they are.
Registry string
// Endpoint is the host[:port] to dial for it. Empty means Registry, which is
// right inside the cluster; a command on the node reaches the same registry
// through its loopback hostPort instead.
Endpoint string
// Client makes the request. Nil uses a client with a 10s timeout.
Client *http.Client
}
// Covers reports whether ref lives in the resolver's registry.
func (r Resolver) Covers(ref string) bool {
return r.Registry != "" && strings.HasPrefix(ref, r.Registry+"/")
}
// Pin returns ref with the digest its tag names now appended. A ref that already
// carries a digest, or lives outside the registry, comes back unchanged.
func (r Resolver) Pin(ctx context.Context, ref string) (string, error) {
if Pinned(ref) || !r.Covers(ref) {
return ref, nil
}
repo, tag := splitTag(strings.TrimPrefix(ref, r.Registry+"/"))
if repo == "" {
return "", fmt.Errorf("imagepin: %q names no repository", ref)
}
digest, err := r.digest(ctx, repo, tag)
if err != nil {
return "", fmt.Errorf("imagepin: resolve %s: %w", ref, err)
}
if !strings.Contains(ref[strings.LastIndex(ref, "/")+1:], ":") {
ref += ":" + tag // spell the implied tag out, so the label reads as what was pinned
}
return ref + "@" + digest, nil
}
// splitTag splits "felis/paper:demo" into ("felis/paper", "demo"); a path with no
// tag means "latest", as it does for every image client.
func splitTag(path string) (repo, tag string) {
slash := strings.LastIndex(path, "/")
if colon := strings.LastIndex(path, ":"); colon > slash {
return path[:colon], path[colon+1:]
}
return path, "latest"
}
func (r Resolver) digest(ctx context.Context, repo, tag string) (string, error) {
endpoint := r.Endpoint
if endpoint == "" {
endpoint = r.Registry
}
// Plain HTTP: the platform registry is an in-cluster Service and the node's
// loopback hostPort, and containerd's mirror for it is configured the same way.
url := "http://" + endpoint + "/v2/" + repo + "/manifests/" + tag
client := r.Client
if client == nil {
client = &http.Client{Timeout: 10 * time.Second}
}
// HEAD first: the registry answers it with Docker-Content-Digest and no body.
// GET covers a registry that leaves the header off, by hashing the manifest.
for _, method := range []string{http.MethodHead, http.MethodGet} {
req, err := http.NewRequestWithContext(ctx, method, url, nil)
if err != nil {
return "", err
}
req.Header.Set("Accept", manifestAccept)
resp, err := client.Do(req)
if err != nil {
return "", err
}
d, err := readDigest(resp, method == http.MethodGet)
resp.Body.Close()
if err != nil || d != "" {
return d, err
}
}
return "", errors.New("registry sent neither a digest header nor a manifest")
}
// readDigest takes the digest from a manifest response, hashing the body when the
// header is missing and hash is set. An empty digest with a nil error means "try
// the next method".
func readDigest(resp *http.Response, hash bool) (string, error) {
switch {
case resp.StatusCode == http.StatusNotFound:
return "", ErrNotFound
case resp.StatusCode != http.StatusOK:
return "", fmt.Errorf("registry answered %s", resp.Status)
}
if d := resp.Header.Get("Docker-Content-Digest"); d != "" {
if !digestRE.MatchString(d) {
return "", fmt.Errorf("registry sent a malformed digest %q", d)
}
return d, nil
}
if !hash {
return "", nil
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxManifestBytes+1))
if err != nil {
return "", err
}
if len(body) > maxManifestBytes {
return "", errors.New("manifest is implausibly large")
}
sum := sha256.Sum256(body)
return "sha256:" + hex.EncodeToString(sum[:]), nil
}
+136
View File
@@ -0,0 +1,136 @@
package imagepin
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
const testDigest = "sha256:d2fcc09d2caa108678c540c99703db96d63038a5fc9e366402d7ef1712ec4d95"
// fakeRegistry serves /v2/felis/paper/manifests/demo and 404s everything else.
func fakeRegistry(t *testing.T, header bool) (*httptest.Server, *[]string) {
t.Helper()
var seen []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = append(seen, r.Method+" "+r.URL.Path)
if !strings.Contains(r.Header.Get("Accept"), "application/vnd.oci.image.index.v1+json") {
t.Errorf("request without an OCI index Accept: %q", r.Header.Get("Accept"))
}
if r.URL.Path != "/v2/felis/paper/manifests/demo" {
http.NotFound(w, r)
return
}
if header {
w.Header().Set("Docker-Content-Digest", testDigest)
}
if r.Method == http.MethodGet {
w.Write([]byte(`{"schemaVersion":2}`))
}
}))
t.Cleanup(srv.Close)
return srv, &seen
}
func resolverFor(srv *httptest.Server) Resolver {
return Resolver{
Registry: "registry.felis.svc:5000",
Endpoint: strings.TrimPrefix(srv.URL, "http://"),
Client: srv.Client(),
}
}
func TestPinResolvesPlatformTag(t *testing.T) {
srv, seen := fakeRegistry(t, true)
got, err := resolverFor(srv).Pin(context.Background(), "registry.felis.svc:5000/felis/paper:demo")
if err != nil {
t.Fatalf("Pin: %v", err)
}
if want := "registry.felis.svc:5000/felis/paper:demo@" + testDigest; got != want {
t.Errorf("Pin = %q, want %q", got, want)
}
if len(*seen) != 1 || (*seen)[0] != "HEAD /v2/felis/paper/manifests/demo" {
t.Errorf("requests = %v, want a single HEAD", *seen)
}
}
func TestPinHashesManifestWithoutDigestHeader(t *testing.T) {
srv, seen := fakeRegistry(t, false)
got, err := resolverFor(srv).Pin(context.Background(), "registry.felis.svc:5000/felis/paper:demo")
if err != nil {
t.Fatalf("Pin: %v", err)
}
sum := sha256.Sum256([]byte(`{"schemaVersion":2}`))
if want := "registry.felis.svc:5000/felis/paper:demo@sha256:" + hex.EncodeToString(sum[:]); got != want {
t.Errorf("Pin = %q, want %q", got, want)
}
if len(*seen) != 2 {
t.Errorf("requests = %v, want HEAD then GET", *seen)
}
}
func TestPinLeavesOtherRefsAlone(t *testing.T) {
srv, seen := fakeRegistry(t, true)
r := resolverFor(srv)
for _, ref := range []string{
"registry.felis.svc:5000/felis/paper:demo@" + testDigest, // already pinned
"docker.io/itzg/minecraft-server:java21", // external
"registry.felis.svc:50000/felis/paper:demo", // a different port is a different registry
} {
got, err := r.Pin(context.Background(), ref)
if err != nil || got != ref {
t.Errorf("Pin(%q) = %q, %v; want it unchanged", ref, got, err)
}
}
if len(*seen) != 0 {
t.Errorf("requests = %v, want none", *seen)
}
}
func TestPinImpliedLatest(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v2/felis/paper/manifests/latest" {
http.NotFound(w, r)
return
}
w.Header().Set("Docker-Content-Digest", testDigest)
}))
defer srv.Close()
got, err := resolverFor(srv).Pin(context.Background(), "registry.felis.svc:5000/felis/paper")
if err != nil {
t.Fatalf("Pin: %v", err)
}
if want := "registry.felis.svc:5000/felis/paper:latest@" + testDigest; got != want {
t.Errorf("Pin = %q, want %q", got, want)
}
}
func TestPinErrors(t *testing.T) {
srv, _ := fakeRegistry(t, true)
_, err := resolverFor(srv).Pin(context.Background(), "registry.felis.svc:5000/felis/paper:gone")
if !errors.Is(err, ErrNotFound) {
t.Errorf("missing tag: err = %v, want ErrNotFound", err)
}
bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Docker-Content-Digest", "sha256:nothex")
}))
defer bad.Close()
if _, err := resolverFor(bad).Pin(context.Background(), "registry.felis.svc:5000/felis/paper:demo"); err == nil {
t.Error("malformed digest accepted")
}
down := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
}))
defer down.Close()
_, err = resolverFor(down).Pin(context.Background(), "registry.felis.svc:5000/felis/paper:demo")
if err == nil || errors.Is(err, ErrNotFound) {
t.Errorf("503: err = %v, want a non-NotFound error", err)
}
}
+19 -6
View File
@@ -234,11 +234,13 @@ func loginToInternalAPI(p Params) *networkingv1.NetworkPolicy {
} }
} }
// RegistryIngressPolicy fences the registry pod: only build pods reach its port. // RegistryIngressPolicy fences the registry pod: only build pods and felis-api
// Everything else that uses the registry runs on the node — containerd's pulls and // reach its port. Build pods push what they build; felis-api reads a manifest
// the installer's pushes both arrive through the loopback hostPort — and Kubernetes // digest to pin a new server's image (internal/imagepin). Everything else that
// never blocks resident-node traffic. Write authorization is the gate's job; this // uses the registry runs on the node — containerd's pulls and the installer's
// policy keeps every other pod from even trying. // pushes both arrive through the loopback hostPort — and Kubernetes never blocks
// resident-node traffic. Write authorization is the gate's job (felis-api holds no
// registry credential); this policy keeps every other pod from even trying.
func RegistryIngressPolicy(p Params) *networkingv1.NetworkPolicy { func RegistryIngressPolicy(p Params) *networkingv1.NetworkPolicy {
p = p.withDefaults() p = p.withDefaults()
tcp := corev1.ProtocolTCP tcp := corev1.ProtocolTCP
@@ -246,11 +248,22 @@ func RegistryIngressPolicy(p Params) *networkingv1.NetworkPolicy {
np := netpol("felis-registry-ingress", p.RegistryNamespace, np := netpol("felis-registry-ingress", p.RegistryNamespace,
metav1.LabelSelector{MatchLabels: registryLabels()}, metav1.LabelSelector{MatchLabels: registryLabels()},
[]networkingv1.NetworkPolicyIngressRule{{ []networkingv1.NetworkPolicyIngressRule{{
From: []networkingv1.NetworkPolicyPeer{{ From: []networkingv1.NetworkPolicyPeer{
{
NamespaceSelector: &metav1.LabelSelector{ NamespaceSelector: &metav1.LabelSelector{
MatchLabels: map[string]string{"kubernetes.io/metadata.name": p.BuildNamespace}, MatchLabels: map[string]string{"kubernetes.io/metadata.name": p.BuildNamespace},
}, },
},
{
NamespaceSelector: &metav1.LabelSelector{
MatchLabels: map[string]string{"kubernetes.io/metadata.name": p.ControlNamespace},
},
PodSelector: &metav1.LabelSelector{MatchLabels: map[string]string{
LabelPartOf: controlPlanePartOf,
LabelComponent: ComponentAPI,
}}, }},
},
},
Ports: []networkingv1.NetworkPolicyPort{{Protocol: &tcp, Port: &port}}, Ports: []networkingv1.NetworkPolicyPort{{Protocol: &tcp, Port: &port}},
}}, }},
) )
+16 -3
View File
@@ -307,7 +307,7 @@ func TestLoginToInternalAPI_SelectsOnlyTheSystemLoginPod(t *testing.T) {
// TestRegistryIngress_BuildNamespaceOnly pins who may dial the registry pod: build // TestRegistryIngress_BuildNamespaceOnly pins who may dial the registry pod: build
// pods, on the registry port. Game servers and the control plane never pull // pods, on the registry port. Game servers and the control plane never pull
// through the Service — containerd pulls over the node's loopback hostPort. // through the Service — containerd pulls over the node's loopback hostPort.
func TestRegistryIngress_BuildNamespaceOnly(t *testing.T) { func TestRegistryIngress_BuildNamespaceAndAPI(t *testing.T) {
p := testParams().withDefaults() p := testParams().withDefaults()
np := RegistryIngressPolicy(p) np := RegistryIngressPolicy(p)
if np.Namespace != p.RegistryNamespace { if np.Namespace != p.RegistryNamespace {
@@ -319,14 +319,27 @@ func TestRegistryIngress_BuildNamespaceOnly(t *testing.T) {
if mapSelectorMatches(np.Spec.PodSelector.MatchLabels, APIDeployment(p).Spec.Template.Labels) { if mapSelectorMatches(np.Spec.PodSelector.MatchLabels, APIDeployment(p).Spec.Template.Labels) {
t.Error("registry ingress must not also fence the api pod") t.Error("registry ingress must not also fence the api pod")
} }
if len(np.Spec.Ingress) != 1 || len(np.Spec.Ingress[0].From) != 1 { if len(np.Spec.Ingress) != 1 || len(np.Spec.Ingress[0].From) != 2 {
t.Fatalf("registry ingress shape = %+v, want one rule, one peer", np.Spec.Ingress) t.Fatalf("registry ingress shape = %+v, want one rule, two peers", np.Spec.Ingress)
} }
peer := np.Spec.Ingress[0].From[0] peer := np.Spec.Ingress[0].From[0]
if peer.PodSelector != nil || peer.IPBlock != nil || peer.NamespaceSelector == nil || if peer.PodSelector != nil || peer.IPBlock != nil || peer.NamespaceSelector == nil ||
peer.NamespaceSelector.MatchLabels["kubernetes.io/metadata.name"] != p.BuildNamespace { peer.NamespaceSelector.MatchLabels["kubernetes.io/metadata.name"] != p.BuildNamespace {
t.Errorf("registry ingress peer = %+v, want the whole %s namespace", peer, p.BuildNamespace) t.Errorf("registry ingress peer = %+v, want the whole %s namespace", peer, p.BuildNamespace)
} }
// The second peer is felis-api alone: it selects the api pod and not the
// operator's, both of which live in the control namespace.
api := np.Spec.Ingress[0].From[1]
if api.IPBlock != nil || api.NamespaceSelector == nil || api.PodSelector == nil ||
api.NamespaceSelector.MatchLabels["kubernetes.io/metadata.name"] != p.ControlNamespace {
t.Fatalf("registry ingress api peer = %+v, want pods in %s", api, p.ControlNamespace)
}
if !mapSelectorMatches(api.PodSelector.MatchLabels, APIDeployment(p).Spec.Template.Labels) {
t.Errorf("registry ingress api peer %v does not select the api pod", api.PodSelector)
}
if mapSelectorMatches(api.PodSelector.MatchLabels, OperatorDeployment(p).Spec.Template.Labels) {
t.Errorf("registry ingress api peer %v also selects the operator pod", api.PodSelector)
}
assertSinglePort(t, np.Spec.Ingress[0].Ports, int(p.RegistryPort)) assertSinglePort(t, np.Spec.Ingress[0].Ports, int(p.RegistryPort))
} }
+58 -3
View File
@@ -20,6 +20,7 @@ import {
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { api, humanizeError } from "@/lib/api"; import { api, humanizeError } from "@/lib/api";
import { splitImageRef } from "@/lib/format";
import { useAsync } from "@/lib/hooks"; import { useAsync } from "@/lib/hooks";
import type { AutostartPolicy } from "@/lib/types"; 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 }); 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 }[] { function policyOptions(t: (key: string) => string): { value: AutostartPolicy; label: string }[] {
return [ return [
{ value: "ownerOnly", label: t("create_server_policy_owner") }, { value: "ownerOnly", label: t("create_server_policy_owner") },
@@ -96,6 +113,9 @@ export function EditServerDialog({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false); 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 // Sync form state when dialog opens or current values change from server status
useEffect(() => { useEffect(() => {
@@ -109,11 +129,13 @@ export function EditServerDialog({
idleStop: currentIdleStop, idleStop: currentIdleStop,
}); });
setError(null); setError(null);
setImageConfirmed(false);
} }
}, [open, currentDisplayName, currentPolicy, currentImage, currentMemory, currentCpu, currentIdleStop]); }, [open, currentDisplayName, currentPolicy, currentImage, currentMemory, currentCpu, currentIdleStop]);
function set<K extends keyof EditServerForm>(k: K, v: EditServerForm[K]) { function set<K extends keyof EditServerForm>(k: K, v: EditServerForm[K]) {
setForm((f) => ({ ...f, [k]: v })); setForm((f) => ({ ...f, [k]: v }));
if (k === "image") setImageConfirmed(false);
} }
const enabledImages = (images.data ?? []).filter((i) => i.enabled); const enabledImages = (images.data ?? []).filter((i) => i.enabled);
@@ -127,7 +149,9 @@ export function EditServerDialog({
form.cpu !== currentCpu || form.cpu !== currentCpu ||
form.idleStop !== currentIdleStop; 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() { async function submit() {
setSubmitting(true); setSubmitting(true);
@@ -141,8 +165,9 @@ export function EditServerDialog({
if (form.autostartPolicy !== currentPolicy) { if (form.autostartPolicy !== currentPolicy) {
payload.autostartPolicy = form.autostartPolicy; payload.autostartPolicy = form.autostartPolicy;
} }
if (form.image !== currentImage) { if (imageChanged) {
payload.image = form.image; payload.image = form.image;
payload.confirmImageChange = imageConfirmed;
} }
if (form.memory !== currentMemory) { if (form.memory !== currentMemory) {
payload.memory = form.memory; payload.memory = form.memory;
@@ -222,7 +247,15 @@ export function EditServerDialog({
<SelectContent> <SelectContent>
{/* Fallback to display the current image even if not in the whitelist options list */} {/* Fallback to display the current image even if not in the whitelist options list */}
{form.image && !enabledImages.some((img) => img.image_ref === form.image) && ( {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) => ( {enabledImages.map((img) => (
<SelectItem key={img.image_ref} value={img.image_ref}> <SelectItem key={img.image_ref} value={img.image_ref}>
@@ -231,6 +264,28 @@ export function EditServerDialog({
))} ))}
</SelectContent> </SelectContent>
</Select> </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>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
@@ -7,6 +7,9 @@
"quota_exceeded": "You have reached your server quota.", "quota_exceeded": "You have reached your server quota.",
"already_claimed": "Someone else just claimed this server.", "already_claimed": "Someone else just claimed this server.",
"image_not_whitelisted": "That image is not on the whitelist.", "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.", "subdomain_taken": "That subdomain is already in use.",
"already_exists": "A server with that name already exists.", "already_exists": "A server with that name already exists.",
"cooldown": "Wake is cooling down — try again shortly.", "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": "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_desc_long": "Updating server spec. Fields left unchanged will retain their current values.",
"edit_server_submit": "Save Config", "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_group_name": "Group Name",
"luckperms_node": "Permission Node", "luckperms_node": "Permission Node",
"luckperms_action": "Action", "luckperms_action": "Action",
@@ -7,6 +7,9 @@
"quota_exceeded": "服务器数量已达配额上限。", "quota_exceeded": "服务器数量已达配额上限。",
"already_claimed": "该服务器已被他人抢先认领。", "already_claimed": "该服务器已被他人抢先认领。",
"image_not_whitelisted": "该镜像未在白名单中。", "image_not_whitelisted": "该镜像未在白名单中。",
"image_not_in_registry": "内部镜像仓库里没有这个镜像标签,可能从未推送过,或已被删除。请重新构建或推送该镜像后再试。",
"registry_unavailable": "暂时连不上内部镜像仓库,无法确定该镜像对应的构建,请稍后再试。",
"image_change_unconfirmed": "更换镜像会让世界用新构建的 Minecraft 版本打开,区块升级后无法再用旧版本打开。请先备份世界,再勾选确认后保存。",
"subdomain_taken": "该子域名已被占用。", "subdomain_taken": "该子域名已被占用。",
"already_exists": "同名服务器已存在。", "already_exists": "同名服务器已存在。",
"cooldown": "启动冷却中——请稍后再试。", "cooldown": "启动冷却中——请稍后再试。",
@@ -129,6 +129,10 @@
"edit_server_desc": "配置显示名、自启策略、镜像、内存、CPU 与空闲停服", "edit_server_desc": "配置显示名、自启策略、镜像、内存、CPU 与空闲停服",
"edit_server_desc_long": "正在修改服务器的 spec 配置。未修改的项将保持原样。", "edit_server_desc_long": "正在修改服务器的 spec 配置。未修改的项将保持原样。",
"edit_server_submit": "保存配置", "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_group_name": "用户组名称",
"luckperms_node": "权限节点", "luckperms_node": "权限节点",
"luckperms_action": "操作类型", "luckperms_action": "操作类型",
+12
View File
@@ -322,6 +322,9 @@ export const api = {
displayName?: string; displayName?: string;
autostartPolicy?: AutostartPolicy; autostartPolicy?: AutostartPolicy;
image?: string; 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; memory?: string;
/** Idle auto-stop: 0 turns it off, else seconds empty before the stop (60–86400). */ /** Idle auto-stop: 0 turns it off, else seconds empty before the stop (60–86400). */
idleStopSeconds?: number; idleStopSeconds?: number;
@@ -701,6 +704,15 @@ export function humanizeError(e: unknown): string {
return t("already_claimed"); return t("already_claimed");
case "image_not_whitelisted": case "image_not_whitelisted":
return t("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": case "subdomain_taken":
return t("subdomain_taken"); return t("subdomain_taken");
case "already_exists": case "already_exists":
+21 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { formatBytes, formatRelative, formatAbsolute, isExpired } from "./format"; import { formatBytes, formatRelative, formatAbsolute, isExpired, splitImageRef } from "./format";
describe("formatBytes", () => { describe("formatBytes", () => {
it("renders sub-KiB counts as plain bytes", () => { it("renders sub-KiB counts as plain bytes", () => {
@@ -75,3 +75,23 @@ describe("isExpired", () => {
expect(isExpired("nope", now)).toBe(false); 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: "",
});
});
});
+11
View File
@@ -63,3 +63,14 @@ export function isExpired(iso: string, now: number): boolean {
const t = new Date(iso).getTime(); const t = new Date(iso).getTime();
return Number.isFinite(t) && t <= now; 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) };
}