Loading cmd/felis/api.go +12 −0 Changes for cmd/felis/api.go: 12 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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" Loading Loading @@ -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), Loading Loading @@ -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 } cmd/felis/pinimages_test.go 0 → 100644 +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) } } } cmd/felis/run.go +1 −0 Changes for cmd/felis/run.go: 1 added line, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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, } Loading cmd/felis/run_test.go +1 −0 Changes for cmd/felis/run_test.go: 1 added line, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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 Loading
cmd/felis/api.go +12 −0 Changes for cmd/felis/api.go: 12 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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" Loading Loading @@ -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), Loading Loading @@ -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 }
cmd/felis/pinimages_test.go 0 → 100644 +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) } } }
cmd/felis/run.go +1 −0 Changes for cmd/felis/run.go: 1 added line, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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, } Loading
cmd/felis/run_test.go +1 −0 Changes for cmd/felis/run_test.go: 1 added line, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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