Unverified Commit 215bfd78 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

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

parent 857b6da8
Loading
Loading
Loading
Loading
+12 −0
Changes for cmd/felis/api.go: 12 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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}
}

cmd/felis/pinimages.go

0 → 100644
+126 −0
Changes for cmd/felis/pinimages.go: 126 added lines, 0 removed lines.
Original line number Diff line number Diff line
package main

import (
	"context"
	"errors"
	"flag"
	"fmt"
	"io"
	"strings"
	"time"

	"felis.lolicon.best/internal/apis/felis/v1alpha1"
	"felis.lolicon.best/internal/imagepin"
	"felis.lolicon.best/internal/platform"
	"k8s.io/apimachinery/pkg/api/meta"
	"sigs.k8s.io/controller-runtime/pkg/client"
)

// defaultRegistryURL is the [registry] url every install uses; deploy/bootstrap.sh
// spells the same value as REGISTRY_URL.
const defaultRegistryURL = "registry.felis.svc:5000"

// cmdPinImages pins every user server whose spec.image still names a mutable tag
// in the platform registry to the digest that tag names now (internal/imagepin).
// felis-api pins on create, so this covers the servers created before it did.
//
// deploy/bootstrap.sh runs it before it rebuilds the game images and pushes them
// over the same tags: run after the push, it would pin those servers to the new
// build, which is exactly the silent Minecraft upgrade pinning exists to stop.
// It reaches the registry through the node's loopback hostPort, the same way the
// installer pushes.
func cmdPinImages(args []string, stdout, stderr io.Writer) int {
	fs := flag.NewFlagSet("pin-images", flag.ContinueOnError)
	fs.SetOutput(stderr)
	namespace := fs.String("namespace", platform.DefaultMinecraftNamespace, "namespace the MinecraftServers live in")
	registry := fs.String("registry", defaultRegistryURL, "registry host[:port] the image refs spell")
	endpoint := fs.String("endpoint", "", "host[:port] to reach the registry at (default: 127.0.0.1 on the registry's port, its hostPort on this node)")
	if err := fs.Parse(args); err != nil {
		if errors.Is(err, flag.ErrHelp) {
			return 0
		}
		return 2
	}
	if *endpoint == "" {
		*endpoint = loopbackEndpoint(*registry)
	}
	cl, err := buildSystemServerClient()
	if err != nil {
		fmt.Fprintf(stderr, "felis pin-images: %v\n", err)
		return 1
	}
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
	defer cancel()
	outcomes, err := pinUserServerImages(ctx, cl, *namespace, imagepin.Resolver{Registry: *registry, Endpoint: *endpoint})
	if meta.IsNoMatchError(err) {
		fmt.Fprintln(stdout, "felis pin-images: no MinecraftServer CRD yet, so no server to pin")
		return 0
	}
	if err != nil {
		fmt.Fprintf(stderr, "felis pin-images: %v\n", err)
		return 1
	}
	if len(outcomes) == 0 {
		fmt.Fprintln(stdout, "felis pin-images: every user server already runs a pinned image")
		return 0
	}
	fmt.Fprintln(stdout, "felis pin-images: pinning user servers to the build their tag names now:")
	exit := 0
	for _, o := range outcomes {
		if o.err != nil {
			fmt.Fprintf(stdout, "  - %s: ERROR %v\n", o.name, o.err)
			exit = 1
			continue
		}
		fmt.Fprintf(stdout, "  - %s: %s\n", o.name, strings.Join(o.changes, ", "))
	}
	return exit
}

// loopbackEndpoint is the registry's port on 127.0.0.1: the registry Deployment
// binds it as a hostPort, and containerd's mirror and the installer's pushes use
// the same address.
func loopbackEndpoint(registry string) string {
	if i := strings.LastIndex(registry, ":"); i >= 0 {
		return "127.0.0.1" + registry[i:]
	}
	return "127.0.0.1"
}

// pinUserServerImages patches spec.image of every user server whose image the
// resolver covers and is not yet pinned. System servers are left on their tags:
// the installer rebuilds and restarts them on purpose (restart_existing_system_servers).
// A server that is already pinned, or runs an image from elsewhere, produces no
// outcome, so a pinned fleet reports nothing. A running server restarts once as
// the operator rolls its StatefulSet onto the pinned ref, which is the build it
// already runs.
func pinUserServerImages(ctx context.Context, cl client.Client, namespace string, r imagepin.Resolver) ([]systemServerOutcome, error) {
	var list v1alpha1.MinecraftServerList
	if err := cl.List(ctx, &list, client.InNamespace(namespace)); err != nil {
		return nil, fmt.Errorf("list servers: %w", err)
	}
	var out []systemServerOutcome
	for i := range list.Items {
		ms := &list.Items[i]
		if ms.Labels[v1alpha1.LabelSystemRole] != "" || imagepin.Pinned(ms.Spec.Image) || !r.Covers(ms.Spec.Image) {
			continue
		}
		pinned, err := r.Pin(ctx, ms.Spec.Image)
		if errors.Is(err, imagepin.ErrNotFound) {
			err = fmt.Errorf("%s is not in the registry, so there is no build to pin it to; left unpinned: %w", ms.Spec.Image, err)
		}
		if err != nil {
			out = append(out, systemServerOutcome{name: ms.Name, err: err})
			continue
		}
		patch := client.MergeFrom(ms.DeepCopy())
		ms.Spec.Image = pinned
		if err := cl.Patch(ctx, ms, patch); err != nil {
			out = append(out, systemServerOutcome{name: ms.Name, err: fmt.Errorf("patch %s: %w", ms.Name, err)})
			continue
		}
		out = append(out, systemServerOutcome{name: ms.Name, available: true, updated: true,
			changes: []string{"spec.image pinned to " + pinned}})
	}
	return out, nil
}
+112 −0
Changes for cmd/felis/pinimages_test.go: 112 added lines, 0 removed lines.
Original line number Diff line number Diff line
package main

import (
	"context"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"

	"felis.lolicon.best/internal/apis/felis/v1alpha1"
	"felis.lolicon.best/internal/imagepin"
	"felis.lolicon.best/internal/naming"
	"k8s.io/apimachinery/pkg/api/meta"
	"k8s.io/apimachinery/pkg/runtime/schema"
	"sigs.k8s.io/controller-runtime/pkg/client"
	"sigs.k8s.io/controller-runtime/pkg/client/fake"
	"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
)

const pinTestDigest = "sha256:2222222222222222222222222222222222222222222222222222222222222222"

// TestPinUserServerImages pins exactly the user servers still on a platform tag,
// reports a tag the registry lost as an error without touching that server, and
// has nothing left to do on a second pass.
func TestPinUserServerImages(t *testing.T) {
	reg := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path != "/v2/felis/paper/manifests/demo" {
			http.NotFound(w, r)
			return
		}
		w.Header().Set("Docker-Content-Digest", pinTestDigest)
	}))
	defer reg.Close()
	res := imagepin.Resolver{Registry: defaultRegistryURL, Endpoint: strings.TrimPrefix(reg.URL, "http://")}

	paper := defaultRegistryURL + "/felis/paper:demo"
	mk := func(name, image, role string) *v1alpha1.MinecraftServer {
		ms := &v1alpha1.MinecraftServer{}
		ms.Name, ms.Namespace = name, "minecraft"
		ms.Spec.Image = image
		if role != "" {
			ms.Labels = map[string]string{v1alpha1.LabelSystemRole: role}
		}
		return ms
	}
	cl := fake.NewClientBuilder().WithScheme(newSystemServerScheme(t)).WithObjects(
		mk("legacy", paper, ""),
		mk("pinned", paper+"@sha256:"+strings.Repeat("3", 64), ""),
		mk("external", "docker.io/itzg/minecraft-server:java21", ""),
		mk("gone", defaultRegistryURL+"/felis/paper:old", ""),
		mk(naming.SystemLobbyServer, defaultRegistryURL+"/felis/felis-lobby:demo", naming.SystemLobbyServer),
	).Build()
	ctx := context.Background()

	outcomes, err := pinUserServerImages(ctx, cl, "minecraft", res)
	if err != nil {
		t.Fatalf("pinUserServerImages: %v", err)
	}
	byName := map[string]systemServerOutcome{}
	for _, o := range outcomes {
		byName[o.name] = o
	}
	if len(outcomes) != 2 || byName["legacy"].err != nil || byName["gone"].err == nil {
		t.Fatalf("outcomes = %+v, want legacy pinned and gone reported", outcomes)
	}

	want := map[string]string{
		"legacy":                 paper + "@" + pinTestDigest,
		"pinned":                 paper + "@sha256:" + strings.Repeat("3", 64),
		"external":               "docker.io/itzg/minecraft-server:java21",
		"gone":                   defaultRegistryURL + "/felis/paper:old",
		naming.SystemLobbyServer: defaultRegistryURL + "/felis/felis-lobby:demo",
	}
	for name, image := range want {
		var ms v1alpha1.MinecraftServer
		if err := cl.Get(ctx, client.ObjectKey{Namespace: "minecraft", Name: name}, &ms); err != nil {
			t.Fatalf("get %s: %v", name, err)
		}
		if ms.Spec.Image != image {
			t.Errorf("%s image = %q, want %q", name, ms.Spec.Image, image)
		}
	}

	again, err := pinUserServerImages(ctx, cl, "minecraft", res)
	if err != nil || len(again) != 1 || again[0].name != "gone" {
		t.Fatalf("second pass = %+v, %v; want only the unresolvable server again", again, err)
	}
}

// A fresh install has no CRD yet; the command must read that as nothing to pin.
func TestPinUserServerImagesNoCRD(t *testing.T) {
	cl := fake.NewClientBuilder().WithScheme(newSystemServerScheme(t)).WithInterceptorFuncs(interceptor.Funcs{
		List: func(context.Context, client.WithWatch, client.ObjectList, ...client.ListOption) error {
			return &meta.NoKindMatchError{GroupKind: schema.GroupKind{Group: "felis.lolicon.best", Kind: "MinecraftServer"}}
		},
	}).Build()
	_, err := pinUserServerImages(context.Background(), cl, "minecraft", imagepin.Resolver{Registry: defaultRegistryURL})
	if !meta.IsNoMatchError(err) {
		t.Fatalf("err = %v, want a NoMatch error the command can recognise", err)
	}
}

func TestLoopbackEndpoint(t *testing.T) {
	for in, want := range map[string]string{
		"registry.felis.svc:5000": "127.0.0.1:5000",
		"registry.example":        "127.0.0.1",
	} {
		if got := loopbackEndpoint(in); got != want {
			t.Errorf("loopbackEndpoint(%q) = %q, want %q", in, got, want)
		}
	}
}
+1 −0
Changes for cmd/felis/run.go: 1 added line, 0 removed lines.
Original line number Diff line number Diff line
@@ -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,
}
+1 −0
Changes for cmd/felis/run_test.go: 1 added line, 0 removed lines.
Original line number Diff line number Diff line
@@ -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.
Loading