fix(setup): 系统服与副本 Secret 改用带乐观锁的 merge-patch 并冲突重试,安装器在构建变化时把登录与大厅固定到 digest
This commit is contained in:
7 files changed
+577
-64
No files matched your search
@@ -0,0 +1,358 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
||||
"felis.lolicon.best/internal/imagepin"
|
||||
"felis.lolicon.best/internal/naming"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
|
||||
)
|
||||
|
||||
// racingClient lands one concurrent write (race) on the stored object just before
|
||||
// setup's first write of it goes out, the way the operator's status update or
|
||||
// felis-api's idle patch can, and counts setup's writes.
|
||||
func racingClient(t *testing.T, race func(ctx context.Context, c client.WithWatch), objs ...client.Object) (client.Client, *int) {
|
||||
t.Helper()
|
||||
writes := 0
|
||||
before := func(ctx context.Context, c client.WithWatch) {
|
||||
writes++
|
||||
if writes == 1 {
|
||||
race(ctx, c)
|
||||
}
|
||||
}
|
||||
cl := fake.NewClientBuilder().WithScheme(newSystemServerScheme(t)).WithObjects(objs...).
|
||||
WithStatusSubresource(&v1alpha1.MinecraftServer{}).
|
||||
WithInterceptorFuncs(interceptor.Funcs{
|
||||
Update: func(ctx context.Context, c client.WithWatch, obj client.Object, opts ...client.UpdateOption) error {
|
||||
before(ctx, c)
|
||||
return c.Update(ctx, obj, opts...)
|
||||
},
|
||||
Patch: func(ctx context.Context, c client.WithWatch, obj client.Object, patch client.Patch, opts ...client.PatchOption) error {
|
||||
before(ctx, c)
|
||||
return c.Patch(ctx, obj, patch, opts...)
|
||||
},
|
||||
}).Build()
|
||||
return cl, &writes
|
||||
}
|
||||
|
||||
func staleLoginGate(t *testing.T) *v1alpha1.MinecraftServer {
|
||||
t.Helper()
|
||||
ms, err := loginSystemServer("felis-limbo:demo", "minecraft",
|
||||
"http://old.internal:8081", "203.0.113.10.nip.io", "console.203.0.113.10.nip.io")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
|
||||
func getLogin(t *testing.T, cl client.Client) *v1alpha1.MinecraftServer {
|
||||
t.Helper()
|
||||
var ms v1alpha1.MinecraftServer
|
||||
if err := cl.Get(context.Background(), client.ObjectKey{Namespace: "minecraft", Name: naming.SystemLoginServer}, &ms); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &ms
|
||||
}
|
||||
|
||||
func envMap(ms *v1alpha1.MinecraftServer) map[string]string {
|
||||
m := map[string]string{}
|
||||
for _, e := range ms.Spec.Env {
|
||||
m[e.Name] = e.Value
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// A hand edit that lands while setup refreshes the console hostnames costs setup a
|
||||
// re-read and a second write; both the edit and the refresh survive.
|
||||
func TestRefreshDerivedEnvRetriesAConcurrentEdit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cl, writes := racingClient(t, func(ctx context.Context, c client.WithWatch) {
|
||||
ms := getLogin(t, c)
|
||||
ms.Spec.Env = append(ms.Spec.Env, v1alpha1.EnvVar{Name: "HAND_TUNED", Value: "1"})
|
||||
if err := c.Update(ctx, ms); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}, staleLoginGate(t))
|
||||
|
||||
desired, err := loginSystemServer("felis-limbo:demo", "minecraft",
|
||||
"http://felis-api-internal.felis.svc.cluster.local:8081", "mc.example.net", "console.mc.example.net")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
refreshed, err := refreshDerivedEnv(ctx, cl, getLogin(t, cl), desired)
|
||||
if err != nil || !refreshed {
|
||||
t.Fatalf("refreshed=%v err=%v, want a refresh after the retry", refreshed, err)
|
||||
}
|
||||
env := envMap(getLogin(t, cl))
|
||||
if env[envPanelHostname] != "console.mc.example.net" || env[envRootDomain] != "mc.example.net" {
|
||||
t.Errorf("env = %v, want the new hostnames", env)
|
||||
}
|
||||
if env["HAND_TUNED"] != "1" {
|
||||
t.Errorf("env = %v: the concurrent hand edit was dropped", env)
|
||||
}
|
||||
if *writes != 2 {
|
||||
t.Errorf("writes = %d, want 2 (one conflict, one retry)", *writes)
|
||||
}
|
||||
}
|
||||
|
||||
// converge reports each fill once even when a status write forced a retry.
|
||||
func TestConvergeRetriesAConcurrentStatusWrite(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
lobby, err := lobbySystemServer("reg/lobby:1", "minecraft")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lobby.Spec.Rcon = v1alpha1.RconSpec{}
|
||||
cl, writes := racingClient(t, func(ctx context.Context, c client.WithWatch) {
|
||||
var ms v1alpha1.MinecraftServer
|
||||
if err := c.Get(ctx, client.ObjectKey{Namespace: "minecraft", Name: naming.SystemLobbyServer}, &ms); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ms.Status.Phase = v1alpha1.PhaseRunning
|
||||
if err := c.Status().Update(ctx, &ms); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}, lobby)
|
||||
|
||||
var got systemServerOutcome
|
||||
for _, o := range convergeSystemServers(ctx, cl, "minecraft", "", "reg/lobby:1",
|
||||
"http://felis-api-internal.felis.svc.cluster.local:8081", "mc.example.net", "console.mc.example.net") {
|
||||
if o.name == naming.SystemLobbyServer {
|
||||
got = o
|
||||
}
|
||||
}
|
||||
if got.err != nil || !got.updated || !slices.Equal(got.changes, []string{"spec.rcon"}) {
|
||||
t.Fatalf("lobby outcome = %+v, want spec.rcon filled once", got)
|
||||
}
|
||||
var ms v1alpha1.MinecraftServer
|
||||
if err := cl.Get(ctx, client.ObjectKey{Namespace: "minecraft", Name: naming.SystemLobbyServer}, &ms); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ms.Spec.Rcon.Enabled || ms.Status.Phase != v1alpha1.PhaseRunning {
|
||||
t.Errorf("rcon.enabled=%v phase=%q, want the fill and the status write both kept", ms.Spec.Rcon.Enabled, ms.Status.Phase)
|
||||
}
|
||||
if *writes != 2 {
|
||||
t.Errorf("writes = %d, want 2", *writes)
|
||||
}
|
||||
}
|
||||
|
||||
// A replica refresh racing another writer keeps that writer's key.
|
||||
func TestSecretReplicaRefreshRetriesAConcurrentWrite(t *testing.T) {
|
||||
secret := func(ns, body string) *corev1.Secret {
|
||||
return &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "felis-config", Namespace: ns},
|
||||
Data: map[string][]byte{"felis.toml": []byte(body)}}
|
||||
}
|
||||
cl, writes := racingClient(t, func(ctx context.Context, c client.WithWatch) {
|
||||
var s corev1.Secret
|
||||
if err := c.Get(ctx, client.ObjectKey{Namespace: "minecraft", Name: "felis-config"}, &s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.Data["extra"] = []byte("x")
|
||||
if err := c.Update(ctx, &s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}, secret("felis", "current"), secret("minecraft", "stale"))
|
||||
|
||||
out := ensureSecretReplica(context.Background(), cl, "felis", "minecraft",
|
||||
"felis-config", "felis.toml", "config", "minecraft ns", true)
|
||||
if out.err != nil || !out.updated {
|
||||
t.Fatalf("outcome = %+v, want refreshed", out)
|
||||
}
|
||||
var s corev1.Secret
|
||||
if err := cl.Get(context.Background(), client.ObjectKey{Namespace: "minecraft", Name: "felis-config"}, &s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(s.Data["felis.toml"]) != "current" || string(s.Data["extra"]) != "x" {
|
||||
t.Errorf("replica data = %q, want felis.toml=current and extra=x", s.Data)
|
||||
}
|
||||
if *writes != 2 {
|
||||
t.Errorf("writes = %d, want 2", *writes)
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
sysOldDigest = "sha256:1111111111111111111111111111111111111111111111111111111111111111"
|
||||
sysNewDigest = "sha256:4444444444444444444444444444444444444444444444444444444444444444"
|
||||
)
|
||||
|
||||
func systemPinRegistry(t *testing.T) imagepin.Resolver {
|
||||
t.Helper()
|
||||
reg := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v2/felis/limbo/manifests/demo", "/v2/felis/lobby/manifests/demo":
|
||||
w.Header().Set("Docker-Content-Digest", sysNewDigest)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(reg.Close)
|
||||
return imagepin.Resolver{Registry: defaultRegistryURL, Endpoint: strings.TrimPrefix(reg.URL, "http://")}
|
||||
}
|
||||
|
||||
func systemServer(name, image string) *v1alpha1.MinecraftServer {
|
||||
ms := &v1alpha1.MinecraftServer{}
|
||||
ms.Name, ms.Namespace = name, "minecraft"
|
||||
ms.Labels = map[string]string{v1alpha1.LabelSystemRole: name}
|
||||
ms.Spec.Image = image
|
||||
return ms
|
||||
}
|
||||
|
||||
// The installer's --system pin moves a system server onto the build its tag names
|
||||
// now, from the bare tag or from an earlier digest, and is a no-op the second time.
|
||||
func TestPinSystemServerImage(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
res := systemPinRegistry(t)
|
||||
limbo := defaultRegistryURL + "/felis/limbo:demo"
|
||||
lobby := defaultRegistryURL + "/felis/lobby:demo"
|
||||
cl := fake.NewClientBuilder().WithScheme(newSystemServerScheme(t)).WithObjects(
|
||||
systemServer(naming.SystemLoginServer, limbo),
|
||||
systemServer(naming.SystemLobbyServer, lobby+"@"+sysOldDigest),
|
||||
).Build()
|
||||
image := func(name string) string {
|
||||
var ms v1alpha1.MinecraftServer
|
||||
if err := cl.Get(ctx, client.ObjectKey{Namespace: "minecraft", Name: name}, &ms); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ms.Spec.Image
|
||||
}
|
||||
|
||||
for name, want := range map[string]string{
|
||||
naming.SystemLoginServer: limbo + "@" + sysNewDigest,
|
||||
naming.SystemLobbyServer: lobby + "@" + sysNewDigest,
|
||||
} {
|
||||
o, err := pinSystemServerImage(ctx, cl, "minecraft", name, res)
|
||||
if err != nil || o.err != nil || !o.updated {
|
||||
t.Fatalf("%s: outcome=%+v err=%v, want pinned", name, o, err)
|
||||
}
|
||||
if got := image(name); got != want {
|
||||
t.Errorf("%s image = %q, want %q", name, got, want)
|
||||
}
|
||||
again, err := pinSystemServerImage(ctx, cl, "minecraft", name, res)
|
||||
if err != nil || again.updated || again.skipped != "already runs "+want {
|
||||
t.Errorf("%s second pass = %+v, %v; want already runs %s", name, again, err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinSystemServerImageLeavesOthersAlone(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
res := systemPinRegistry(t)
|
||||
pin := func(t *testing.T, ms *v1alpha1.MinecraftServer) (systemServerOutcome, string) {
|
||||
t.Helper()
|
||||
cl := fake.NewClientBuilder().WithScheme(newSystemServerScheme(t)).WithObjects(ms).Build()
|
||||
o, err := pinSystemServerImage(ctx, cl, "minecraft", naming.SystemLoginServer, res)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got v1alpha1.MinecraftServer
|
||||
if err := cl.Get(ctx, client.ObjectKeyFromObject(ms), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return o, got.Spec.Image
|
||||
}
|
||||
|
||||
t.Run("external image", func(t *testing.T) {
|
||||
o, img := pin(t, systemServer(naming.SystemLoginServer, "docker.io/example/limbo:1.2"))
|
||||
if o.err != nil || o.updated || img != "docker.io/example/limbo:1.2" ||
|
||||
o.skipped != "runs docker.io/example/limbo:1.2, which names no platform registry tag to follow; left alone" {
|
||||
t.Fatalf("outcome=%+v image=%q, want left alone", o, img)
|
||||
}
|
||||
})
|
||||
t.Run("digest without a tag", func(t *testing.T) {
|
||||
ref := defaultRegistryURL + "/felis/limbo@" + sysOldDigest
|
||||
o, img := pin(t, systemServer(naming.SystemLoginServer, ref))
|
||||
if o.err != nil || o.updated || img != ref {
|
||||
t.Fatalf("outcome=%+v image=%q, want left alone", o, img)
|
||||
}
|
||||
})
|
||||
t.Run("not a system server", func(t *testing.T) {
|
||||
ms := systemServer(naming.SystemLoginServer, defaultRegistryURL+"/felis/limbo:demo")
|
||||
ms.Labels = nil
|
||||
o, img := pin(t, ms)
|
||||
if o.err == nil || img != defaultRegistryURL+"/felis/limbo:demo" {
|
||||
t.Fatalf("outcome=%+v image=%q, want refused", o, img)
|
||||
}
|
||||
})
|
||||
t.Run("tag the registry lost", func(t *testing.T) {
|
||||
o, img := pin(t, systemServer(naming.SystemLoginServer, defaultRegistryURL+"/felis/limbo:gone"))
|
||||
if o.err == nil || img != defaultRegistryURL+"/felis/limbo:gone" {
|
||||
t.Fatalf("outcome=%+v image=%q, want an error the installer falls back on", o, img)
|
||||
}
|
||||
})
|
||||
t.Run("absent", func(t *testing.T) {
|
||||
cl := fake.NewClientBuilder().WithScheme(newSystemServerScheme(t)).Build()
|
||||
o, err := pinSystemServerImage(ctx, cl, "minecraft", naming.SystemLoginServer, res)
|
||||
if err != nil || o.err != nil || o.skipped != "not present yet; sudo felis setup creates it" {
|
||||
t.Fatalf("outcome=%+v err=%v, want a skip", o, err)
|
||||
}
|
||||
})
|
||||
t.Run("retargeted while pinning", func(t *testing.T) {
|
||||
cl, _ := racingClient(t, func(ctx context.Context, c client.WithWatch) {
|
||||
ms := getLogin(t, c)
|
||||
ms.Spec.Image = "docker.io/example/limbo:1.2"
|
||||
if err := c.Update(ctx, ms); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}, systemServer(naming.SystemLoginServer, defaultRegistryURL+"/felis/limbo:demo"))
|
||||
o, err := pinSystemServerImage(ctx, cl, "minecraft", naming.SystemLoginServer, res)
|
||||
if err != nil || o.err != nil || o.updated {
|
||||
t.Fatalf("outcome=%+v err=%v, want nothing written", o, err)
|
||||
}
|
||||
if img := getLogin(t, cl).Spec.Image; img != "docker.io/example/limbo:1.2" {
|
||||
t.Errorf("image = %q, want the admin's retarget kept", img)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --system names one of the two system servers; anything else is a usage error
|
||||
// before any cluster is touched.
|
||||
func TestPinImagesSystemFlagTakesOnlySystemServers(t *testing.T) {
|
||||
var stdout, stderr strings.Builder
|
||||
if code := cmdPinImages([]string{"--system", "survival"}, &stdout, &stderr); code != 2 {
|
||||
t.Fatalf("exit = %d, want 2", code)
|
||||
}
|
||||
if got := stderr.String(); got != "felis pin-images: --system takes login or lobby, not \"survival\"\n" {
|
||||
t.Errorf("stderr = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// An idle setting the panel saves while converge fills the default is kept.
|
||||
func TestConvergeIdleKeepsAConcurrentPanelEdit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
srv := &v1alpha1.MinecraftServer{}
|
||||
srv.Name, srv.Namespace = "survival", "minecraft"
|
||||
cl, writes := racingClient(t, func(ctx context.Context, c client.WithWatch) {
|
||||
var ms v1alpha1.MinecraftServer
|
||||
if err := c.Get(ctx, client.ObjectKey{Namespace: "minecraft", Name: "survival"}, &ms); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ms.Spec.Idle = v1alpha1.IdleSpec{AutoStopEnabled: false, EmptySecondsBeforeStop: 1800}
|
||||
if err := c.Update(ctx, &ms); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}, srv)
|
||||
|
||||
if out := convergeUserServerIdle(ctx, cl, "minecraft"); len(out) != 0 {
|
||||
t.Fatalf("outcomes = %+v, want none: the server has a setting by the time converge writes", out)
|
||||
}
|
||||
var ms v1alpha1.MinecraftServer
|
||||
if err := cl.Get(ctx, client.ObjectKey{Namespace: "minecraft", Name: "survival"}, &ms); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := (v1alpha1.IdleSpec{AutoStopEnabled: false, EmptySecondsBeforeStop: 1800}); ms.Spec.Idle != want {
|
||||
t.Errorf("idle = %+v, want the panel's %+v", ms.Spec.Idle, want)
|
||||
}
|
||||
if *writes != 1 {
|
||||
t.Errorf("writes = %d, want 1 (the conflicted attempt; the retry sends nothing)", *writes)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user