fix(images): 服务器镜像在创建时固定到仓库 digest,更换镜像需确认备份,安装器重建前先固定旧服并推送不可变版本标签
This commit is contained in:
29 files changed
+1139
-19
No files matched your search
@@ -17,6 +17,7 @@ import (
|
||||
"felis.lolicon.best/internal/build"
|
||||
"felis.lolicon.best/internal/config"
|
||||
"felis.lolicon.best/internal/fileedit"
|
||||
"felis.lolicon.best/internal/imagepin"
|
||||
"felis.lolicon.best/internal/mail"
|
||||
"felis.lolicon.best/internal/naming"
|
||||
"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),
|
||||
Internal: api.BearerTokenAuth{Token: token},
|
||||
Builder: builder,
|
||||
Images: imagePinner(cfg.Registry.URL),
|
||||
Restorer: restorer,
|
||||
Backuper: backuper,
|
||||
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}
|
||||
}
|
||||
|
||||
// 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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,7 @@ var commands = map[string]func(args []string, stdout, stderr io.Writer) int{
|
||||
"bootstrap-assets": cmdBootstrapAssets,
|
||||
"init-forwarding": cmdInitForwarding,
|
||||
"init-volume": cmdInitVolume,
|
||||
"pin-images": cmdPinImages,
|
||||
"version": cmdVersion,
|
||||
"update": cmdUpdate,
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ func TestRunUnknownCommand(t *testing.T) {
|
||||
// decision rather than an oversight.
|
||||
var undocumentedCommands = map[string]bool{
|
||||
"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.
|
||||
|
||||
@@ -1007,6 +1007,27 @@ pin_registry_images() {
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2584,9 +2605,30 @@ push_images_to_registry() {
|
||||
[ -n "$img" ] || continue
|
||||
push_image_to_registry "$img"
|
||||
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
|
||||
}
|
||||
|
||||
# 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
|
||||
# updates containerd, but an existing StatefulSet template is byte-for-byte
|
||||
# unchanged and Kubernetes will not roll it. Recreate only the two always-on
|
||||
@@ -3014,6 +3056,9 @@ main() {
|
||||
build_image
|
||||
# After build_image imported the felis image: the registry pod's gate runs it.
|
||||
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
|
||||
install_postgres
|
||||
configure_postgres
|
||||
|
||||
@@ -824,6 +824,7 @@ out="$(
|
||||
FELIS_IMAGE=a FELIS_LIMBO_IMAGE=b FELIS_LOBBY_IMAGE=c FELIS_PAPER_IMAGE=d bash -c '
|
||||
systemctl() { printf "SYSTEMCTL %s\n" "$*"; }
|
||||
push_image_to_registry() { printf "PUSH %s\n" "$1"; }
|
||||
push_version_tag() { printf "VERSION %s\n" "$1"; }
|
||||
registry_docker_login() { printf "LOGIN\n"; }
|
||||
'"$wiblock"'
|
||||
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 ] \
|
||||
&& 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)); }
|
||||
[ "$(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
|
||||
# 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)) ;;
|
||||
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 --------------------------
|
||||
iblock="$(awk '/^import_registry_image\(\) \{/,/^}/' "$BS")"
|
||||
[ -n "$iblock" ] || { echo "FAIL: no import_registry_image found in $BS"; exit 1; }
|
||||
|
||||
+23
-2
@@ -641,7 +641,12 @@ paths:
|
||||
tags: [admin-servers]
|
||||
operationId: createServer
|
||||
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-tier: admin
|
||||
security: [{ accessJWT: [] }]
|
||||
@@ -4564,7 +4569,19 @@ paths:
|
||||
properties:
|
||||
displayName: { 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 }
|
||||
storage:
|
||||
type: string
|
||||
@@ -4601,6 +4618,10 @@ paths:
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
'409':
|
||||
$ref: '#/components/responses/Conflict'
|
||||
'503':
|
||||
$ref: '#/components/responses/ServiceUnavailable'
|
||||
|
||||
/api/v1/images/build:
|
||||
post:
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
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 |
|
||||
| Which metric to scrape | §14 |
|
||||
| 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 |
|
||||
| `pre-migration backup failed, nothing applied` during an upgrade | §16 |
|
||||
| Undo a mistaken change / restore the control-plane database | §16 |
|
||||
|
||||
@@ -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,
|
||||
@@ -616,6 +623,11 @@ type patchServerRequest struct {
|
||||
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
|
||||
@@ -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
|
||||
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) {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -543,8 +543,12 @@ func (b *Builder) RemoveImage(ctx context.Context, imageRef string) error {
|
||||
// image). A disabled row never admits. A wildcard whitelist entry
|
||||
// ("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
|
||||
// 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) {
|
||||
imageRef, _, _ = strings.Cut(imageRef, "@")
|
||||
if strings.TrimSpace(imageRef) == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package build
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -548,6 +549,12 @@ func TestImageAdmitted(t *testing.T) {
|
||||
{"registry.felis.svc:5000/unknown:1", false}, // not on the list
|
||||
{"", false}, // empty 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 {
|
||||
got, err := b.ImageAdmitted(context.Background(), c.ref)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -234,11 +234,13 @@ func loginToInternalAPI(p Params) *networkingv1.NetworkPolicy {
|
||||
}
|
||||
}
|
||||
|
||||
// RegistryIngressPolicy fences the registry pod: only build pods reach its port.
|
||||
// Everything else that uses the registry runs on the node — containerd's pulls and
|
||||
// the installer's pushes both arrive through the loopback hostPort — and Kubernetes
|
||||
// never blocks resident-node traffic. Write authorization is the gate's job; this
|
||||
// policy keeps every other pod from even trying.
|
||||
// RegistryIngressPolicy fences the registry pod: only build pods and felis-api
|
||||
// reach its port. Build pods push what they build; felis-api reads a manifest
|
||||
// digest to pin a new server's image (internal/imagepin). Everything else that
|
||||
// uses the registry runs on the node — containerd's pulls and the installer's
|
||||
// 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 {
|
||||
p = p.withDefaults()
|
||||
tcp := corev1.ProtocolTCP
|
||||
@@ -246,11 +248,22 @@ func RegistryIngressPolicy(p Params) *networkingv1.NetworkPolicy {
|
||||
np := netpol("felis-registry-ingress", p.RegistryNamespace,
|
||||
metav1.LabelSelector{MatchLabels: registryLabels()},
|
||||
[]networkingv1.NetworkPolicyIngressRule{{
|
||||
From: []networkingv1.NetworkPolicyPeer{{
|
||||
From: []networkingv1.NetworkPolicyPeer{
|
||||
{
|
||||
NamespaceSelector: &metav1.LabelSelector{
|
||||
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}},
|
||||
}},
|
||||
)
|
||||
|
||||
@@ -307,7 +307,7 @@ func TestLoginToInternalAPI_SelectsOnlyTheSystemLoginPod(t *testing.T) {
|
||||
// TestRegistryIngress_BuildNamespaceOnly pins who may dial the registry pod: build
|
||||
// pods, on the registry port. Game servers and the control plane never pull
|
||||
// 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()
|
||||
np := RegistryIngressPolicy(p)
|
||||
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) {
|
||||
t.Error("registry ingress must not also fence the api pod")
|
||||
}
|
||||
if len(np.Spec.Ingress) != 1 || len(np.Spec.Ingress[0].From) != 1 {
|
||||
t.Fatalf("registry ingress shape = %+v, want one rule, one peer", np.Spec.Ingress)
|
||||
if len(np.Spec.Ingress) != 1 || len(np.Spec.Ingress[0].From) != 2 {
|
||||
t.Fatalf("registry ingress shape = %+v, want one rule, two peers", np.Spec.Ingress)
|
||||
}
|
||||
peer := np.Spec.Ingress[0].From[0]
|
||||
if peer.PodSelector != nil || peer.IPBlock != nil || peer.NamespaceSelector == nil ||
|
||||
peer.NamespaceSelector.MatchLabels["kubernetes.io/metadata.name"] != 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))
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api, humanizeError } from "@/lib/api";
|
||||
import { splitImageRef } from "@/lib/format";
|
||||
import { useAsync } from "@/lib/hooks";
|
||||
import type { AutostartPolicy } from "@/lib/types";
|
||||
|
||||
@@ -36,6 +37,22 @@ function idleLabel(t: (key: string, opts?: Record<string, unknown>) => string, s
|
||||
return t("idle_stop_seconds", { count: seconds });
|
||||
}
|
||||
|
||||
/** ImageLabel shows an image ref as the tag it was picked by, plus the short id of
|
||||
* the build a pinned ref is locked to. */
|
||||
function ImageLabel({ imageRef, t }: { imageRef: string; t: (key: string, opts?: Record<string, unknown>) => string }) {
|
||||
const { tag, short } = splitImageRef(imageRef);
|
||||
return (
|
||||
<>
|
||||
{tag}
|
||||
{short && (
|
||||
<span className="ml-2 font-mono text-xs text-muted-foreground">
|
||||
{t("edit_server_image_build", { id: short })}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function policyOptions(t: (key: string) => string): { value: AutostartPolicy; label: string }[] {
|
||||
return [
|
||||
{ value: "ownerOnly", label: t("create_server_policy_owner") },
|
||||
@@ -96,6 +113,9 @@ export function EditServerDialog({
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
// An image change moves the world to another build for good, so saving one waits
|
||||
// for this acknowledgement (the API refuses it with image_change_unconfirmed).
|
||||
const [imageConfirmed, setImageConfirmed] = useState(false);
|
||||
|
||||
// Sync form state when dialog opens or current values change from server status
|
||||
useEffect(() => {
|
||||
@@ -109,11 +129,13 @@ export function EditServerDialog({
|
||||
idleStop: currentIdleStop,
|
||||
});
|
||||
setError(null);
|
||||
setImageConfirmed(false);
|
||||
}
|
||||
}, [open, currentDisplayName, currentPolicy, currentImage, currentMemory, currentCpu, currentIdleStop]);
|
||||
|
||||
function set<K extends keyof EditServerForm>(k: K, v: EditServerForm[K]) {
|
||||
setForm((f) => ({ ...f, [k]: v }));
|
||||
if (k === "image") setImageConfirmed(false);
|
||||
}
|
||||
|
||||
const enabledImages = (images.data ?? []).filter((i) => i.enabled);
|
||||
@@ -127,7 +149,9 @@ export function EditServerDialog({
|
||||
form.cpu !== currentCpu ||
|
||||
form.idleStop !== currentIdleStop;
|
||||
|
||||
const canSubmit = hasChanges && !submitting;
|
||||
const imageChanged = form.image !== currentImage;
|
||||
const pinnedBuild = splitImageRef(currentImage).short;
|
||||
const canSubmit = hasChanges && !submitting && (!imageChanged || imageConfirmed);
|
||||
|
||||
async function submit() {
|
||||
setSubmitting(true);
|
||||
@@ -141,8 +165,9 @@ export function EditServerDialog({
|
||||
if (form.autostartPolicy !== currentPolicy) {
|
||||
payload.autostartPolicy = form.autostartPolicy;
|
||||
}
|
||||
if (form.image !== currentImage) {
|
||||
if (imageChanged) {
|
||||
payload.image = form.image;
|
||||
payload.confirmImageChange = imageConfirmed;
|
||||
}
|
||||
if (form.memory !== currentMemory) {
|
||||
payload.memory = form.memory;
|
||||
@@ -222,7 +247,15 @@ export function EditServerDialog({
|
||||
<SelectContent>
|
||||
{/* Fallback to display the current image even if not in the whitelist options list */}
|
||||
{form.image && !enabledImages.some((img) => img.image_ref === form.image) && (
|
||||
<SelectItem value={form.image}>{form.image}</SelectItem>
|
||||
<SelectItem value={form.image}>
|
||||
<ImageLabel imageRef={form.image} t={t} />
|
||||
</SelectItem>
|
||||
)}
|
||||
{currentImage && form.image !== currentImage &&
|
||||
!enabledImages.some((img) => img.image_ref === currentImage) && (
|
||||
<SelectItem value={currentImage}>
|
||||
<ImageLabel imageRef={currentImage} t={t} />
|
||||
</SelectItem>
|
||||
)}
|
||||
{enabledImages.map((img) => (
|
||||
<SelectItem key={img.image_ref} value={img.image_ref}>
|
||||
@@ -231,6 +264,28 @@ export function EditServerDialog({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{!imageChanged && pinnedBuild && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("edit_server_image_pinned_hint", { id: pinnedBuild })}
|
||||
</p>
|
||||
)}
|
||||
{imageChanged && (
|
||||
<div className="grid gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-xs">
|
||||
<p className="flex items-start gap-1.5 text-amber-700 dark:text-amber-300">
|
||||
<AlertTriangle className="mt-px h-3.5 w-3.5 shrink-0" />
|
||||
{t("edit_server_image_warning")}
|
||||
</p>
|
||||
<label className="flex cursor-pointer items-center gap-2 font-medium text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 shrink-0 cursor-pointer accent-primary"
|
||||
checked={imageConfirmed}
|
||||
onChange={(e) => setImageConfirmed(e.target.checked)}
|
||||
/>
|
||||
{t("edit_server_image_confirm")}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
"quota_exceeded": "You have reached your server quota.",
|
||||
"already_claimed": "Someone else just claimed this server.",
|
||||
"image_not_whitelisted": "That image is not on the whitelist.",
|
||||
"image_not_in_registry": "The internal registry doesn't hold that image tag — it was never pushed or has been deleted. Rebuild or push it, then try again.",
|
||||
"registry_unavailable": "Can't reach the internal registry to look up which build that image is — try again shortly.",
|
||||
"image_change_unconfirmed": "Changing the image opens the world with the new build's Minecraft version, and upgraded chunks can't be opened by the old one again. Back the world up, tick the confirmation, then save.",
|
||||
"subdomain_taken": "That subdomain is already in use.",
|
||||
"already_exists": "A server with that name already exists.",
|
||||
"cooldown": "Wake is cooling down — try again shortly.",
|
||||
|
||||
@@ -129,6 +129,10 @@
|
||||
"edit_server_desc": "Configure display name, autostart policy, image, memory, CPU, and idle stop",
|
||||
"edit_server_desc_long": "Updating server spec. Fields left unchanged will retain their current values.",
|
||||
"edit_server_submit": "Save Config",
|
||||
"edit_server_image_build": "build {{id}}",
|
||||
"edit_server_image_pinned_hint": "Locked to build {{id}}: the tag moving to a newer build (an installer re-run, say) leaves this server alone; only changing the image here moves it.",
|
||||
"edit_server_image_warning": "After saving, the server runs the build this tag points to right now — re-picking the current tag also gets its newest build. If the Minecraft version changes, the world is upgraded on its next start, and upgraded chunks can't be opened by the old version again.",
|
||||
"edit_server_image_confirm": "I've backed up the world — change the image",
|
||||
"luckperms_group_name": "Group Name",
|
||||
"luckperms_node": "Permission Node",
|
||||
"luckperms_action": "Action",
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
"quota_exceeded": "服务器数量已达配额上限。",
|
||||
"already_claimed": "该服务器已被他人抢先认领。",
|
||||
"image_not_whitelisted": "该镜像未在白名单中。",
|
||||
"image_not_in_registry": "内部镜像仓库里没有这个镜像标签,可能从未推送过,或已被删除。请重新构建或推送该镜像后再试。",
|
||||
"registry_unavailable": "暂时连不上内部镜像仓库,无法确定该镜像对应的构建,请稍后再试。",
|
||||
"image_change_unconfirmed": "更换镜像会让世界用新构建的 Minecraft 版本打开,区块升级后无法再用旧版本打开。请先备份世界,再勾选确认后保存。",
|
||||
"subdomain_taken": "该子域名已被占用。",
|
||||
"already_exists": "同名服务器已存在。",
|
||||
"cooldown": "启动冷却中——请稍后再试。",
|
||||
|
||||
@@ -129,6 +129,10 @@
|
||||
"edit_server_desc": "配置显示名、自启策略、镜像、内存、CPU 与空闲停服",
|
||||
"edit_server_desc_long": "正在修改服务器的 spec 配置。未修改的项将保持原样。",
|
||||
"edit_server_submit": "保存配置",
|
||||
"edit_server_image_build": "构建 {{id}}",
|
||||
"edit_server_image_pinned_hint": "已锁定在构建 {{id}}:镜像标签以后指向新构建(比如重跑安装器)不会影响这台服务器,只有在这里更换镜像才会。",
|
||||
"edit_server_image_warning": "保存后,服务器会换用这个镜像标签现在指向的构建;重新选择原来的标签,拿到的也是它最新的构建。如果 Minecraft 版本变了,世界会在下次启动时升级,升级过的区块无法再用旧版本打开。",
|
||||
"edit_server_image_confirm": "我已备份世界,确认更换镜像",
|
||||
"luckperms_group_name": "用户组名称",
|
||||
"luckperms_node": "权限节点",
|
||||
"luckperms_action": "操作类型",
|
||||
|
||||
@@ -322,6 +322,9 @@ export const api = {
|
||||
displayName?: string;
|
||||
autostartPolicy?: AutostartPolicy;
|
||||
image?: string;
|
||||
/** Required with an image that moves the server to another build: the world is
|
||||
* opened by that build's Minecraft version, which cannot be undone. */
|
||||
confirmImageChange?: boolean;
|
||||
memory?: string;
|
||||
/** Idle auto-stop: 0 turns it off, else seconds empty before the stop (60–86400). */
|
||||
idleStopSeconds?: number;
|
||||
@@ -701,6 +704,15 @@ export function humanizeError(e: unknown): string {
|
||||
return t("already_claimed");
|
||||
case "image_not_whitelisted":
|
||||
return t("image_not_whitelisted");
|
||||
// Image pinning (internal/imagepin): a server runs the exact build its tag
|
||||
// named when it was created or last changed, so the registry has to hold the
|
||||
// tag, and moving a world to another build needs an explicit confirmation.
|
||||
case "image_not_in_registry":
|
||||
return t("image_not_in_registry");
|
||||
case "registry_unavailable":
|
||||
return t("registry_unavailable");
|
||||
case "image_change_unconfirmed":
|
||||
return t("image_change_unconfirmed");
|
||||
case "subdomain_taken":
|
||||
return t("subdomain_taken");
|
||||
case "already_exists":
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { formatBytes, formatRelative, formatAbsolute, isExpired } from "./format";
|
||||
import { formatBytes, formatRelative, formatAbsolute, isExpired, splitImageRef } from "./format";
|
||||
|
||||
describe("formatBytes", () => {
|
||||
it("renders sub-KiB counts as plain bytes", () => {
|
||||
@@ -75,3 +75,23 @@ describe("isExpired", () => {
|
||||
expect(isExpired("nope", now)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitImageRef", () => {
|
||||
const hex = "0123456789abcdef".repeat(4);
|
||||
|
||||
it("splits a pinned ref into its tag and a short build id", () => {
|
||||
expect(splitImageRef(`registry.felis.svc:5000/felis/paper:demo@sha256:${hex}`)).toEqual({
|
||||
tag: "registry.felis.svc:5000/felis/paper:demo",
|
||||
digest: `sha256:${hex}`,
|
||||
short: "0123456789ab",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves an unpinned ref whole", () => {
|
||||
expect(splitImageRef("docker.io/itzg/minecraft-server:java21")).toEqual({
|
||||
tag: "docker.io/itzg/minecraft-server:java21",
|
||||
digest: "",
|
||||
short: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -63,3 +63,14 @@ export function isExpired(iso: string, now: number): boolean {
|
||||
const t = new Date(iso).getTime();
|
||||
return Number.isFinite(t) && t <= now;
|
||||
}
|
||||
|
||||
/** splitImageRef separates a server's image into the tag it was chosen by and the
|
||||
* build it is pinned to. felis-api stores `name:tag@sha256:<hex>`; the digest is
|
||||
* 64 hex characters no one reads, so `short` keeps the first 12, the length
|
||||
* `docker images` shows. A ref without a digest comes back with `short` empty. */
|
||||
export function splitImageRef(ref: string): { tag: string; digest: string; short: string } {
|
||||
const at = ref.indexOf("@");
|
||||
if (at < 0) return { tag: ref, digest: "", short: "" };
|
||||
const digest = ref.slice(at + 1);
|
||||
return { tag: ref.slice(0, at), digest, short: digest.replace(/^sha256:/, "").slice(0, 12) };
|
||||
}
|
||||
Reference in new issue
Block a user