From 215bfd78d7a716685a6b4c5d1fda9d6fccbc571f Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Thu, 24 Sep 2026 16:57:38 +0800 Subject: [PATCH] =?UTF-8?q?fix(images):=20=E6=9C=8D=E5=8A=A1=E5=99=A8?= =?UTF-8?q?=E9=95=9C=E5=83=8F=E5=9C=A8=E5=88=9B=E5=BB=BA=E6=97=B6=E5=9B=BA?= =?UTF-8?q?=E5=AE=9A=E5=88=B0=E4=BB=93=E5=BA=93=20digest=EF=BC=8C=E6=9B=B4?= =?UTF-8?q?=E6=8D=A2=E9=95=9C=E5=83=8F=E9=9C=80=E7=A1=AE=E8=AE=A4=E5=A4=87?= =?UTF-8?q?=E4=BB=BD=EF=BC=8C=E5=AE=89=E8=A3=85=E5=99=A8=E9=87=8D=E5=BB=BA?= =?UTF-8?q?=E5=89=8D=E5=85=88=E5=9B=BA=E5=AE=9A=E6=97=A7=E6=9C=8D=E5=B9=B6?= =?UTF-8?q?=E6=8E=A8=E9=80=81=E4=B8=8D=E5=8F=AF=E5=8F=98=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E6=A0=87=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/felis/api.go | 12 ++ cmd/felis/pinimages.go | 126 +++++++++++++++ cmd/felis/pinimages_test.go | 112 +++++++++++++ cmd/felis/run.go | 1 + cmd/felis/run_test.go | 1 + deploy/bootstrap.sh | 45 ++++++ deploy/bootstrap_test.sh | 67 ++++++++ docs/openapi.yaml | 25 ++- docs/troubleshooting.md | 35 ++++ internal/api/api.go | 5 + internal/api/audit.go | 13 ++ internal/api/handlers_patch_test.go | 2 +- internal/api/handlers_user.go | 59 +++++-- internal/api/images.go | 27 ++++ internal/api/images_pin_test.go | 162 +++++++++++++++++++ internal/build/build.go | 6 +- internal/build/build_test.go | 7 + internal/imagepin/imagepin.go | 167 ++++++++++++++++++++ internal/imagepin/imagepin_test.go | 136 ++++++++++++++++ internal/platform/netpol.go | 31 ++-- internal/platform/netpol_test.go | 19 ++- panel/src/components/EditServerDialog.tsx | 61 ++++++- panel/src/i18n/resources/en-US/errors.json | 3 + panel/src/i18n/resources/en-US/servers.json | 4 + panel/src/i18n/resources/zh-CN/errors.json | 3 + panel/src/i18n/resources/zh-CN/servers.json | 4 + panel/src/lib/api.ts | 12 ++ panel/src/lib/format.test.ts | 22 ++- panel/src/lib/format.ts | 11 ++ 29 files changed, 1149 insertions(+), 29 deletions(-) create mode 100644 cmd/felis/pinimages.go create mode 100644 cmd/felis/pinimages_test.go create mode 100644 internal/api/images_pin_test.go create mode 100644 internal/imagepin/imagepin.go create mode 100644 internal/imagepin/imagepin_test.go diff --git a/cmd/felis/api.go b/cmd/felis/api.go index f281b18..d7358a7 100644 --- a/cmd/felis/api.go +++ b/cmd/felis/api.go @@ -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} +} diff --git a/cmd/felis/pinimages.go b/cmd/felis/pinimages.go new file mode 100644 index 0000000..827c3d5 --- /dev/null +++ b/cmd/felis/pinimages.go @@ -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 +} diff --git a/cmd/felis/pinimages_test.go b/cmd/felis/pinimages_test.go new file mode 100644 index 0000000..ad2dc43 --- /dev/null +++ b/cmd/felis/pinimages_test.go @@ -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) + } + } +} diff --git a/cmd/felis/run.go b/cmd/felis/run.go index 5ecc186..4a8bcc6 100644 --- a/cmd/felis/run.go +++ b/cmd/felis/run.go @@ -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, } diff --git a/cmd/felis/run_test.go b/cmd/felis/run_test.go index 7552490..e5319b3 100644 --- a/cmd/felis/run_test.go +++ b/cmd/felis/run_test.go @@ -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. diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh index 550a814..62f5eb2 100644 --- a/deploy/bootstrap.sh +++ b/deploy/bootstrap.sh @@ -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: -, 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 diff --git a/deploy/bootstrap_test.sh b/deploy/bootstrap_test.sh index 295b9b2..f42a930 100644 --- a/deploy/bootstrap_test.sh +++ b/deploy/bootstrap_test.sh @@ -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 -, 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; } diff --git a/docs/openapi.yaml b/docs/openapi.yaml index e87f045..e4beaf7 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -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: diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index a952623..b08558a 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -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, `-<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 | diff --git a/internal/api/api.go b/internal/api/api.go index 6699e7c..7efb7c7 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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. diff --git a/internal/api/audit.go b/internal/api/audit.go index db08bf3..bb7e50c 100644 --- a/internal/api/audit.go +++ b/internal/api/audit.go @@ -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. diff --git a/internal/api/handlers_patch_test.go b/internal/api/handlers_patch_test.go index a71caa2..73a9746 100644 --- a/internal/api/handlers_patch_test.go +++ b/internal/api/handlers_patch_test.go @@ -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()) } diff --git a/internal/api/handlers_user.go b/internal/api/handlers_user.go index fa47930..7103c61 100644 --- a/internal/api/handlers_user.go +++ b/internal/api/handlers_user.go @@ -375,6 +375,13 @@ func (a *API) handleCreateServer(w http.ResponseWriter, r *http.Request) { "image %q is not on the whitelist", body.Image)) return } + // The spec keeps the digest the tag names now, not the tag: the world is + // created on this build and stays on it until an admin changes the image. + image, err := a.pinImage(r.Context(), body.Image) + if err != nil { + writeError(w, r, err) + return + } // Quota is intentionally NOT enforced here. §15 creates an UNOWNED server // (owner_id NULL); the per-user quota is charged at claim time (spec §9.3 / @@ -432,7 +439,7 @@ func (a *API) handleCreateServer(w http.ResponseWriter, r *http.Request) { Name: body.Name, Subdomain: body.Subdomain, DisplayName: body.DisplayName, - Image: body.Image, + Image: image, JavaMemory: javaMemory, StorageSize: storage, AutostartPolicy: policy, @@ -613,11 +620,16 @@ func parsePositiveQuantity(s, field string) (resource.Quantity, error) { // the dual-write routing identity (name is the immutable object key; subdomain // would desync the Postgres alias) nor for the world PVC size (see below). type patchServerRequest struct { - DisplayName *string `json:"displayName,omitempty"` - AutostartPolicy *string `json:"autostartPolicy,omitempty"` - Image *string `json:"image,omitempty"` - Memory *string `json:"memory,omitempty"` - Resources *resourceRequest `json:"resources,omitempty"` + DisplayName *string `json:"displayName,omitempty"` + AutostartPolicy *string `json:"autostartPolicy,omitempty"` + Image *string `json:"image,omitempty"` + // ConfirmImageChange acknowledges that a new image opens the world with + // whatever Minecraft version it carries. Chunks a newer version has upgraded + // cannot be read by the older one again, so without it an image change that + // would actually move the server is refused (image_change_unconfirmed). + ConfirmImageChange bool `json:"confirmImageChange,omitempty"` + Memory *string `json:"memory,omitempty"` + Resources *resourceRequest `json:"resources,omitempty"` // IdleStopSeconds sets idle auto-stop: 0 turns it off, otherwise the server // stops after that many seconds with nobody online (60 to 86400). IdleStopSeconds *int32 `json:"idleStopSeconds,omitempty"` @@ -672,6 +684,8 @@ func (a *API) handlePatchServer(w http.ResponseWriter, r *http.Request) { // so the response and audit name the real mutation. var patch ServerSpecPatch var changed []string + // imageFrom is the image a confirmed image change replaced, for the audit row. + var imageFrom string if body.DisplayName != nil { patch.DisplayName = body.DisplayName @@ -730,8 +744,31 @@ func (a *API) handlePatchServer(w http.ResponseWriter, r *http.Request) { "image %q is not on the whitelist", *body.Image)) return } - patch.Image = body.Image - changed = append(changed, "image") + image, err := a.pinImage(r.Context(), *body.Image) + if err != nil { + writeError(w, r, err) + return + } + info, err := a.Cluster.GetServer(r.Context(), name) + if err != nil { + a.writeLookupError(w, r, err) + return + } + // Re-picking the tag a server was created from resolves to that tag's + // newest build, which is as much a version move as picking another image. + // Only a pin that lands on exactly the current image is no change at all. + if image != info.Image { + if !body.ConfirmImageChange { + writeError(w, r, newError(http.StatusConflict, "image_change_unconfirmed", + "changing the image from %q to %q opens this world with the new image's Minecraft version, "+ + "and chunks it upgrades cannot be opened by the old one again; back the world up first, "+ + "then resend with confirmImageChange", info.Image, image)) + return + } + patch.Image = &image + changed = append(changed, "image") + imageFrom = info.Image + } } // Memory and the resource overrides move together: resolveResources derives the @@ -814,7 +851,11 @@ func (a *API) handlePatchServer(w http.ResponseWriter, r *http.Request) { } } - a.audit(r, "server.patch", name) + if patch.Image != nil { + a.auditImageChange(r, name, imageFrom, *patch.Image) + } else { + a.audit(r, "server.patch", name) + } writeJSON(w, http.StatusOK, map[string]any{ "name": name, "patched": changed, diff --git a/internal/api/images.go b/internal/api/images.go index d1274d7..6cc4555 100644 --- a/internal/api/images.go +++ b/internal/api/images.go @@ -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 +} diff --git a/internal/api/images_pin_test.go b/internal/api/images_pin_test.go new file mode 100644 index 0000000..cfba1a1 --- /dev/null +++ b/internal/api/images_pin_test.go @@ -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) + } +} diff --git a/internal/build/build.go b/internal/build/build.go index cca9586..69f55e3 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -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 } diff --git a/internal/build/build_test.go b/internal/build/build_test.go index ca3e53b..3465ff4 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -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) diff --git a/internal/imagepin/imagepin.go b/internal/imagepin/imagepin.go new file mode 100644 index 0000000..9b8663b --- /dev/null +++ b/internal/imagepin/imagepin.go @@ -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 +} diff --git a/internal/imagepin/imagepin_test.go b/internal/imagepin/imagepin_test.go new file mode 100644 index 0000000..f7a0f34 --- /dev/null +++ b/internal/imagepin/imagepin_test.go @@ -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) + } +} diff --git a/internal/platform/netpol.go b/internal/platform/netpol.go index 0b33d7b..d4a5cc1 100644 --- a/internal/platform/netpol.go +++ b/internal/platform/netpol.go @@ -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{{ - NamespaceSelector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"kubernetes.io/metadata.name": p.BuildNamespace}, + 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}}, }}, ) diff --git a/internal/platform/netpol_test.go b/internal/platform/netpol_test.go index 5cb4a38..91d667f 100644 --- a/internal/platform/netpol_test.go +++ b/internal/platform/netpol_test.go @@ -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)) } diff --git a/panel/src/components/EditServerDialog.tsx b/panel/src/components/EditServerDialog.tsx index 915eda3..409bcfc 100644 --- a/panel/src/components/EditServerDialog.tsx +++ b/panel/src/components/EditServerDialog.tsx @@ -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, 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 }) { + const { tag, short } = splitImageRef(imageRef); + return ( + <> + {tag} + {short && ( + + {t("edit_server_image_build", { id: short })} + + )} + + ); +} + 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(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: 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({ {/* 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} + + + + )} + {currentImage && form.image !== currentImage && + !enabledImages.some((img) => img.image_ref === currentImage) && ( + + + )} {enabledImages.map((img) => ( @@ -231,6 +264,28 @@ export function EditServerDialog({ ))} + {!imageChanged && pinnedBuild && ( +

+ {t("edit_server_image_pinned_hint", { id: pinnedBuild })} +

+ )} + {imageChanged && ( +
+

+ + {t("edit_server_image_warning")} +

+ +
+ )}
diff --git a/panel/src/i18n/resources/en-US/errors.json b/panel/src/i18n/resources/en-US/errors.json index 0f7961d..4f18177 100644 --- a/panel/src/i18n/resources/en-US/errors.json +++ b/panel/src/i18n/resources/en-US/errors.json @@ -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.", diff --git a/panel/src/i18n/resources/en-US/servers.json b/panel/src/i18n/resources/en-US/servers.json index 1f576bc..65720c3 100644 --- a/panel/src/i18n/resources/en-US/servers.json +++ b/panel/src/i18n/resources/en-US/servers.json @@ -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", diff --git a/panel/src/i18n/resources/zh-CN/errors.json b/panel/src/i18n/resources/zh-CN/errors.json index c439a85..fe78f58 100644 --- a/panel/src/i18n/resources/zh-CN/errors.json +++ b/panel/src/i18n/resources/zh-CN/errors.json @@ -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": "启动冷却中——请稍后再试。", diff --git a/panel/src/i18n/resources/zh-CN/servers.json b/panel/src/i18n/resources/zh-CN/servers.json index 3316e50..83f75d6 100644 --- a/panel/src/i18n/resources/zh-CN/servers.json +++ b/panel/src/i18n/resources/zh-CN/servers.json @@ -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": "操作类型", diff --git a/panel/src/lib/api.ts b/panel/src/lib/api.ts index c53f90d..73f98a9 100644 --- a/panel/src/lib/api.ts +++ b/panel/src/lib/api.ts @@ -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": diff --git a/panel/src/lib/format.test.ts b/panel/src/lib/format.test.ts index bf5a293..7ec0e44 100644 --- a/panel/src/lib/format.test.ts +++ b/panel/src/lib/format.test.ts @@ -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: "", + }); + }); +}); diff --git a/panel/src/lib/format.ts b/panel/src/lib/format.ts index 78be562..253e6bb 100644 --- a/panel/src/lib/format.ts +++ b/panel/src/lib/format.ts @@ -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:`; 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) }; +}