feat: customize login and lobby spaces from the panel

This commit is contained in:
Lemon-miaow committed 2026-10-04 12:38:39 +08:00
1 parent efbbe27629
commit 10bd2ddad0
54 files changed
+1146 -158

No files matched your search

+2 -1
View File
@@ -26,6 +26,7 @@ import (
"felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/fileedit"
"felis.lolicon.best/internal/naming"
)
// API holds the dependencies shared by every handler.
@@ -1197,7 +1198,7 @@ func (l *streamLimiter) release(key string) {
// reconcile is idempotent and the §18 reaper / §9.3 quota bound steady-state
// load; the cap exists to refuse an obvious flood, not to hold a hard ceiling.
func (a *API) withinRunningCap(ctx context.Context, info *ServerInfo) (bool, error) {
if a.MaxRunningServers <= 0 {
if a.MaxRunningServers <= 0 || naming.IsSystemServer(info.Name) {
return true, nil
}
if info.DesiredState == string(v1alpha1.DesiredRunning) {
+2 -3
View File
@@ -21,7 +21,6 @@ import (
"felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/fileedit"
"felis.lolicon.best/internal/maintenance"
"felis.lolicon.best/internal/naming"
"felis.lolicon.best/internal/worldexport"
)
@@ -605,11 +604,11 @@ func errExportUnavailable() error {
func (a *API) exportGate(w http.ResponseWriter, r *http.Request) (string, *ServerRecord, bool) {
p := principalFromContext(r.Context())
name := r.PathValue("name")
if err := naming.ValidateServerName(name); err != nil {
if err := validateManagedServerName(r, name); err != nil {
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
return "", nil, false
}
rec, err := a.Repo.ServerByName(r.Context(), name)
rec, err := a.managedServerRecord(r.Context(), name)
if err != nil {
a.writeLookupError(w, r, err)
return "", nil, false
+2 -4
View File
@@ -7,8 +7,6 @@ import (
"regexp"
"strconv"
"strings"
"felis.lolicon.best/internal/naming"
)
// Access / permissions domain (spec §7). These endpoints let an owner manage
@@ -101,12 +99,12 @@ func (a *API) issueLuckPermsCommand(w http.ResponseWriter, r *http.Request, name
// the path, not the body) is validated here.
func (a *API) issueAccessCommand(w http.ResponseWriter, r *http.Request, name, command string) (string, bool) {
p := principalFromContext(r.Context())
if err := naming.ValidateServerName(name); err != nil {
if err := validateManagedServerName(r, name); err != nil {
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
return "", false
}
rec, err := a.Repo.ServerByName(r.Context(), name)
rec, err := a.managedServerRecord(r.Context(), name)
if err != nil {
a.writeLookupError(w, r, err)
return "", false
+4 -4
View File
@@ -183,7 +183,7 @@ func restoreMayRead(jobs []AsyncJob, id string) bool {
func (a *API) handleRestoreBackup(w http.ResponseWriter, r *http.Request) {
p := principalFromContext(r.Context())
name := r.PathValue("name")
if err := naming.ValidateServerName(name); err != nil {
if err := validateManagedServerName(r, name); err != nil {
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
return
}
@@ -191,7 +191,7 @@ func (a *API) handleRestoreBackup(w http.ResponseWriter, r *http.Request) {
// Ownership: owner or admin, mirroring handleStop. An unknown server is 404; an
// unowned (released) server fails the owner check for everyone but admin, which
// is exactly the "must re-claim first" rule.
rec, err := a.Repo.ServerByName(r.Context(), name)
rec, err := a.managedServerRecord(r.Context(), name)
if err != nil {
a.writeLookupError(w, r, err)
return
@@ -367,12 +367,12 @@ func (a *API) handleRestoreBackup(w http.ResponseWriter, r *http.Request) {
func (a *API) handleBackupNow(w http.ResponseWriter, r *http.Request) {
p := principalFromContext(r.Context())
name := r.PathValue("name")
if err := naming.ValidateServerName(name); err != nil {
if err := validateManagedServerName(r, name); err != nil {
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
return
}
rec, err := a.Repo.ServerByName(r.Context(), name)
rec, err := a.managedServerRecord(r.Context(), name)
if err != nil {
a.writeLookupError(w, r, err)
return
+2 -4
View File
@@ -3,8 +3,6 @@ package api
import (
"errors"
"net/http"
"felis.lolicon.best/internal/naming"
)
// maxConsoleCommandLen caps the command body well under RCON's single-packet
@@ -39,7 +37,7 @@ type commandRequest struct {
func (a *API) handleCommand(w http.ResponseWriter, r *http.Request) {
p := principalFromContext(r.Context())
name := r.PathValue("name")
if err := naming.ValidateServerName(name); err != nil {
if err := validateManagedServerName(r, name); err != nil {
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
return
}
@@ -58,7 +56,7 @@ func (a *API) handleCommand(w http.ResponseWriter, r *http.Request) {
}
// Ownership: owner or admin, mirroring handleStop. An unknown server is 404.
rec, err := a.Repo.ServerByName(r.Context(), name)
rec, err := a.managedServerRecord(r.Context(), name)
if err != nil {
a.writeLookupError(w, r, err)
return
+2 -3
View File
@@ -11,7 +11,6 @@ import (
"felis.lolicon.best/internal/fileedit"
"felis.lolicon.best/internal/maintenance"
"felis.lolicon.best/internal/naming"
)
// A file too big for the one-request upload (handleUploadFile) arrives as an
@@ -430,11 +429,11 @@ func (a *API) requireFileStage(w http.ResponseWriter, r *http.Request) (string,
// staff. It returns the server name.
func (a *API) authorizeServerFiles(w http.ResponseWriter, r *http.Request) (string, bool) {
name := r.PathValue("name")
if err := naming.ValidateServerName(name); err != nil {
if err := validateManagedServerName(r, name); err != nil {
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
return "", false
}
rec, err := a.Repo.ServerByName(r.Context(), name)
rec, err := a.managedServerRecord(r.Context(), name)
if err != nil {
a.writeLookupError(w, r, err)
return "", false
+3 -5
View File
@@ -4,8 +4,6 @@ import (
"context"
"errors"
"net/http"
"felis.lolicon.best/internal/naming"
)
// handleServerConsole streams the caller's server console as Server-Sent Events
@@ -39,13 +37,13 @@ import (
func (a *API) handleServerConsole(w http.ResponseWriter, r *http.Request) {
p := principalFromContext(r.Context())
name := r.PathValue("name")
if err := naming.ValidateServerName(name); err != nil {
if err := validateManagedServerName(r, name); err != nil {
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
return
}
// Ownership: owner or admin, mirroring handleCommand. An unknown server is 404.
rec, err := a.Repo.ServerByName(r.Context(), name)
rec, err := a.managedServerRecord(r.Context(), name)
if err != nil {
a.writeLookupError(w, r, err)
return
@@ -105,7 +103,7 @@ func (a *API) handleServerConsole(w http.ResponseWriter, r *http.Request) {
a.audit(r, "console.attach", name)
relayLogStream(w, r, src, a.streamRecheck(r, func(ctx context.Context, p *Principal) error {
rec, err := a.Repo.ServerByName(ctx, name)
rec, err := a.managedServerRecord(ctx, name)
switch {
case errors.Is(err, ErrNotFound):
return errForbidden // the server is gone, and the grant with it
+13 -6
View File
@@ -20,7 +20,7 @@ import (
func (a *API) handleWake(w http.ResponseWriter, r *http.Request) {
p := principalFromContext(r.Context())
name := r.PathValue("name")
if err := naming.ValidateServerName(name); err != nil {
if err := validateManagedServerName(r, name); err != nil {
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
return
}
@@ -97,12 +97,12 @@ func (a *API) handleWake(w http.ResponseWriter, r *http.Request) {
func (a *API) handleStop(w http.ResponseWriter, r *http.Request) {
p := principalFromContext(r.Context())
name := r.PathValue("name")
if err := naming.ValidateServerName(name); err != nil {
if err := validateManagedServerName(r, name); err != nil {
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
return
}
rec, err := a.Repo.ServerByName(r.Context(), name)
rec, err := a.managedServerRecord(r.Context(), name)
if err != nil {
a.writeLookupError(w, r, err)
return
@@ -217,7 +217,7 @@ func (a *API) claimResources(ctx context.Context, name string) (ResourceSpec, er
// handleStatus returns the CRD status view (spec §7 GET /servers/{name}/status).
func (a *API) handleStatus(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if err := naming.ValidateServerName(name); err != nil {
if err := validateManagedServerName(r, name); err != nil {
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
return
}
@@ -899,7 +899,7 @@ const (
// the adminOnly wrapper in routing — every caller here is already an admin.
func (a *API) handlePatchServer(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if err := naming.ValidateServerName(name); err != nil {
if err := validateManagedServerName(r, name); err != nil {
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
return
}
@@ -910,6 +910,13 @@ func (a *API) handlePatchServer(w http.ResponseWriter, r *http.Request) {
return
}
if naming.IsSystemServer(name) &&
((body.AutostartPolicy != nil && *body.AutostartPolicy != string(v1alpha1.AutostartPublic)) ||
(body.IdleStopSeconds != nil && *body.IdleStopSeconds != 0)) {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "system services must remain public and exempt from idle stop"))
return
}
// An empty patch is a client mistake, not a no-op success.
if body.DisplayName == nil && body.AutostartPolicy == nil && body.Image == nil &&
body.Memory == nil && body.Resources == nil && body.Storage == nil && body.IdleStopSeconds == nil {
@@ -1073,7 +1080,7 @@ func (a *API) handlePatchServer(w http.ResponseWriter, r *http.Request) {
// fits (ResizeServer). Only growth is held to the caps: a change that grows
// neither CPU nor memory cannot push the owner past one, and it is how an admin
// brings a server back under a cap lowered below what the owner already uses.
if resUpdated {
if resUpdated && !naming.IsSystemServer(name) {
newCPU := quantityToMilli(newResources.Limits[corev1.ResourceCPU])
newMemMB := quantityToMB(newResources.Limits[corev1.ResourceMemory])
+2 -4
View File
@@ -4,8 +4,6 @@ import (
"context"
"net/http"
"time"
"felis.lolicon.best/internal/naming"
)
// AsyncJob is the observable outcome of one asynchronous world operation. The API
@@ -45,11 +43,11 @@ type JobStatusReader interface {
func (a *API) handleServerJobs(w http.ResponseWriter, r *http.Request) {
p := principalFromContext(r.Context())
name := r.PathValue("name")
if err := naming.ValidateServerName(name); err != nil {
if err := validateManagedServerName(r, name); err != nil {
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
return
}
rec, err := a.Repo.ServerByName(r.Context(), name)
rec, err := a.managedServerRecord(r.Context(), name)
if err != nil {
a.writeLookupError(w, r, err)
return
+28
View File
@@ -0,0 +1,28 @@
package api
import (
"context"
"net/http"
"felis.lolicon.best/internal/naming"
)
// System services have no player owner or business-layer row. Staff manage their
// existing cluster objects; creation and claiming keep the reserved-name gate.
func validateManagedServerName(r *http.Request, name string) error {
if p := principalFromContext(r.Context()); naming.IsSystemServer(name) && p != nil && p.IsAdmin() {
return naming.ValidateSystemServerName(name)
}
return naming.ValidateServerName(name)
}
func (a *API) managedServerRecord(ctx context.Context, name string) (*ServerRecord, error) {
if !naming.IsSystemServer(name) {
return a.Repo.ServerByName(ctx, name)
}
info, err := a.Cluster.GetServer(ctx, name)
if err != nil {
return nil, err
}
return &ServerRecord{Name: name, Subdomain: info.Subdomain}, nil
}
+123
View File
@@ -0,0 +1,123 @@
package api
import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
)
func TestSystemServerManagement(t *testing.T) {
for _, name := range []string{"login", "lobby"} {
for _, principal := range []*Principal{
{UserID: "staff", Role: "admin", ViaAdminAccess: true},
{UserID: "staff", Role: "admin"},
{UserID: "player", Role: "user"},
} {
t.Run(fmt.Sprintf("%s/%s/operator=%t", name, principal.Role, principal.ViaAdminAccess), func(t *testing.T) {
api, repo, cl, files := mkFiles(t)
api.External = staticExternal{p: principal}
cl.byName[name] = &ServerInfo{Name: name, Subdomain: name, Phase: "Stopped", DesiredState: "Stopped", ReaperExempt: true}
// These services are cluster-owned; there deliberately is no database row.
for _, route := range []struct {
method, suffix, body string
success int
}{
{"GET", "/status", "", 200},
{"GET", "/files", "", 200},
{"GET", "/file?path=felis-experience.json", "", 200},
{"PUT", "/file?path=felis-experience.json", `{"content":"aGk=","content_sha256":"` + hiSum + `"}`, 200},
{"PATCH", "", `{"displayName":"Custom Space"}`, 200},
{"POST", "/stop", "", 202},
} {
before := files.calls
result := do(api.ExternalHandler(), route.method, "/api/v1/servers/"+name+route.suffix, route.body, jsonHeader)
if principal.IsAdmin() {
if result.Code != route.success {
t.Fatalf("%s %s: %d %s", route.method, route.suffix, result.Code, result.Body.String())
}
} else {
if result.Code < 400 || files.calls != before {
t.Fatalf("player management admitted: %d %s", result.Code, result.Body.String())
}
}
}
result := do(api.ExternalHandler(), "POST", "/api/v1/servers/"+name+"/claim", "", nil)
if result.Code != http.StatusBadRequest {
t.Fatalf("system service claim: %d", result.Code)
}
if _, exists := repo.byName[name]; exists {
t.Fatal("management created a claimable business row")
}
})
}
}
}
func TestSystemServerResourcesAndInvariants(t *testing.T) {
api, repo, cl, _ := newPatchAPI()
cl.byName["lobby"] = &ServerInfo{Name: "lobby", ReaperExempt: true, Resources: corev1.ResourceRequirements{
Limits: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("1Gi")},
Requests: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("1Gi")},
}}
result := do(api.ExternalHandler(), "PATCH", "/api/v1/servers/lobby", `{"memory":"2Gi"}`, jsonHeader)
if result.Code != http.StatusOK || cl.patched["lobby"].JavaMemory == nil {
t.Fatalf("resource patch: %d %s", result.Code, result.Body.String())
}
if _, exists := repo.byName["lobby"]; exists {
t.Fatal("system resources added an ownership row")
}
for _, body := range []string{`{"autostartPolicy":"ownerOnly"}`, `{"idleStopSeconds":600}`} {
result := do(api.ExternalHandler(), "PATCH", "/api/v1/servers/lobby", body, jsonHeader)
if result.Code != http.StatusBadRequest {
t.Fatalf("system invariant changed: %d %s", result.Code, result.Body.String())
}
}
delete(cl.byName, "lobby")
result = do(api.ExternalHandler(), "POST", "/api/v1/servers/lobby/stop", "", nil)
if result.Code != http.StatusNotFound {
t.Fatalf("missing cluster service: %d %s", result.Code, result.Body.String())
}
}
func TestSystemLobbyBuilderAccess(t *testing.T) {
api, repo, cl, console := mkAccess(t)
api.External = staticExternal{p: &Principal{UserID: "staff", Role: "admin", ViaAdminAccess: true}}
cl.byName["lobby"] = &ServerInfo{Name: "lobby", Phase: "Running", Ready: true}
result := do(api.ExternalHandler(), "POST", "/api/v1/servers/lobby/access/permission",
`{"action":"set","player":"Steve","node":"felis.lobby.build","value":true}`, jsonHeader)
if result.Code != http.StatusOK || console.gotCommand != "lp user Steve permission set felis.lobby.build true" {
t.Fatalf("builder grant: %d %s; command %q", result.Code, result.Body.String(), console.gotCommand)
}
if _, exists := repo.byName["lobby"]; exists {
t.Fatal("builder access created an ownership row")
}
}
func TestSystemConsoleStreamRechecksWithoutOwnershipRow(t *testing.T) {
shrinkStreamTimers(t, 10*time.Millisecond, 80*time.Millisecond)
a, _, cl, _ := mkAccess(t)
a.External = staticExternal{p: &Principal{UserID: "staff", Role: "admin", ViaAdminAccess: true}}
cl.byName["lobby"] = &ServerInfo{Name: "lobby", Phase: "Running", Ready: true}
a.Logs = &fakeLogStreamer{srcFromCtx: func(ctx context.Context) io.ReadCloser {
return &ctxBlockingReadCloser{ctx: ctx, first: []byte("boot\n"), firstRead: make(chan struct{}), closed: make(chan struct{})}
}}
begun := time.Now()
done := make(chan *httptest.ResponseRecorder, 1)
go func() { done <- do(a.ExternalHandler(), "GET", "/api/v1/servers/lobby/console", "", nil) }()
select {
case result := <-done:
if result.Code != http.StatusOK || strings.Contains(result.Body.String(), "event: revoked") || time.Since(begun) < 80*time.Millisecond {
t.Fatalf("system logs ended before their lifetime: %d %s", result.Code, result.Body.String())
}
case <-time.After(2 * time.Second):
t.Fatal("system log stream outlived its lifetime")
}
}
+8 -3
View File
@@ -456,7 +456,7 @@ const (
redactedValue = "<redacted by felis>"
)
// redactSecretProps blanks the RCON password when server.properties is read.
// redactSecretProps hides RCON and Limbo forwarding secrets when server.properties is read.
//
// Unlike secretConfigPath this is a value redaction rather than a whole-file
// denial, because the file is not platform material that merely happens to sit in
@@ -468,6 +468,9 @@ const (
// regardless of blast radius, and because the console already gives an owner every
// capability the password would.
//
// Limbo stores its cluster forwarding key in this file too; that value is
// withheld by the same redaction and refreshed by the Limbo entrypoint.
//
// The write path is left alone on purpose, mirroring the reasoning at
// secretConfigPath: felis-lobby's entrypoint rewrites all three rcon keys from the
// injected Secret on every boot, so saving the placeholder back cannot lock the
@@ -481,8 +484,10 @@ func redactSecretProps(name string, content []byte) []byte {
for i, line := range lines {
// TrimSpace before matching: a properties key may be indented, and the
// trailing \r of a CRLF file would otherwise ride along into the value.
if bytes.HasPrefix(bytes.TrimSpace(line), []byte(rconPasswordKey+"=")) {
lines[i] = []byte(rconPasswordKey + "=" + redactedValue)
for _, key := range []string{rconPasswordKey, "forwarding-secrets"} {
if bytes.HasPrefix(bytes.TrimSpace(line), []byte(key+"=")) {
lines[i] = []byte(key + "=" + redactedValue)
}
}
}
return bytes.Join(lines, []byte("\n"))
+20
View File
@@ -678,3 +678,23 @@ func assertNoTemporaries(t *testing.T, dir string) {
}
}
}
func TestReadRedactsLimboForwardingSecret(t *testing.T) {
root := t.TempDir()
props := "spawn-x=8\nforwarding-secrets=shared-key\nvelocity-modern=true\n"
if err := os.WriteFile(filepath.Join(root, "server.properties"), []byte(props), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Link(filepath.Join(root, "server.properties"), filepath.Join(root, "copy.properties")); err != nil {
t.Fatal(err)
}
for _, path := range []string{"server.properties", "copy.properties"} {
res, err := run(root, OpRead, path, nil, "")
if err != nil || res.Code != "" {
t.Fatalf("read: %+v %v", res, err)
}
if strings.Contains(string(res.Content), "shared-key") || !strings.Contains(string(res.Content), "spawn-x=8") {
t.Fatalf("unsafe redaction: %s", res.Content)
}
}
}
+2 -2
View File
@@ -9,7 +9,7 @@ import (
// What leaves a world mount — a read, a download, a world export, a backup
// export — passes the same two guards: the forwarding-secret file
// (secretConfigPath) is withheld, and server.properties has its RCON password
// redacted (propsPath).
// redacted (propsPath), along with Limbo's forwarding-secrets.
//
// On a live mount both are matched by the file itself (os.SameFile), not by the
// name it was reached under. A plugin runs arbitrary code as the game uid and can
@@ -51,7 +51,7 @@ func ArchiveRule(name string) (withhold, redact bool) {
return name == secretConfigPath, name == propsPath
}
// RedactProps replaces the RCON password in server.properties content with
// RedactProps replaces RCON and Limbo forwarding secrets in server.properties with
// redactedValue (see redactSecretProps for why a placeholder and not a blank).
func RedactProps(content []byte) []byte {
return redactSecretProps(propsPath, content)
+2 -5
View File
@@ -48,11 +48,8 @@ const (
SystemLobbyServer = "lobby"
)
// IsSystemServer reports whether name is one of the platform-provisioned system
// services above. They carry reserved names on purpose, and the API's per-server
// routes reject those names outright (ValidateServerName) — so a caller that only
// DISPLAYS fleet rows uses this to mark them as not user-manageable instead of
// offering actions (claim/wake/stop/console) that would answer 400.
// IsSystemServer identifies platform services whose reserved names may be managed
// by staff, but never created or claimed through player-facing routes.
func IsSystemServer(name string) bool {
return name == SystemLoginServer || name == SystemLobbyServer
}
+9 -6
View File
@@ -269,15 +269,18 @@ func buildStatefulSet(server *v1alpha1.MinecraftServer, replicas int32, felisIma
// Every server first hands its world volume to the game uid (prepareDataInitContainer),
// since the pod runs as that uid and a world an older root-run release wrote would
// otherwise be read-only to it. An arbitrary user Paper image then gets the forwarding
// config written for it (it does not consume FELIS_FORWARDING_SECRET itself); system
// servers (login/lobby) are Felis-built and handle forwarding in their own
// entrypoints. Last, every server waits for its egress fence (egressGateInitContainer).
// config written for it (it does not consume FELIS_FORWARDING_SECRET itself).
// The lobby uses the same merge so custom settings survive; the login Limbo
// handles its own properties format. Every server then waits for its egress fence.
// Without a felis image name there is nothing to run any step with.
var initContainers []corev1.Container
if felisImage != "" {
initContainers = append(initContainers, prepareDataInitContainer(felisImage))
if server.Labels[v1alpha1.LabelSystemRole] == "" {
if server.Labels[v1alpha1.LabelSystemRole] != naming.SystemLoginServer {
initContainers = append(initContainers, forwardingInitContainer(felisImage))
if server.Labels[v1alpha1.LabelSystemRole] == naming.SystemLobbyServer {
container.Env = append(container.Env, corev1.EnvVar{Name: "FELIS_MANAGED_FORWARDING", Value: "true"})
}
}
gate := egressGateInitContainer(felisImage)
if server.Spec.NodeName != "" || (len(gateProbe) > 0 && gateProbe[0] != "") {
@@ -447,8 +450,8 @@ func forwardingSecretEnvVar() corev1.EnvVar {
// at all: no capability, a read-only root filesystem, and the files it writes are
// owned by the very uid that rewrites them on boot.
//
// Only user servers get it: the Felis-built system images (login limbo, lobby) already
// consume the secret in their own entrypoints, and the login limbo is not Paper at all.
// User Paper servers and the lobby share this merge. The login Limbo handles
// its own properties format in its entrypoint.
func forwardingInitContainer(felisImage string) corev1.Container {
return corev1.Container{
Name: "init-forwarding",
+14 -5
View File
@@ -89,7 +89,7 @@ func TestReadinessProbeHTTPCustomPath(t *testing.T) {
// A user server (no system-role label) gets the forwarding-config initContainer after
// prepare-data, running the felis image and mounting the world volume. A system
// server gets no forwarding step, and a build with no felis image name gets no step.
// login gate handles its own properties; a build with no felis image name gets no step.
func TestBuildStatefulSetForwardingInitContainer(t *testing.T) {
user := &v1alpha1.MinecraftServer{}
user.Spec.Storage.Size = "1Gi"
@@ -143,14 +143,23 @@ func TestBuildStatefulSetForwardingInitContainer(t *testing.T) {
t.Error("no felis image must yield no initContainer")
}
// System server handles forwarding in its own entrypoint, but its world still
// needs handing to the game uid and its image waits for the fence all the same.
// The lobby shares the forwarding merge, preserving its custom Paper globals.
sys := &v1alpha1.MinecraftServer{}
sys.Spec.Storage.Size = "1Gi"
sys.Labels = map[string]string{v1alpha1.LabelSystemRole: "lobby"}
sysSts, _ := buildStatefulSet(sys, 1, "felis:demo")
if got := sysSts.Spec.Template.Spec.InitContainers; len(got) != 2 || got[0].Name != "prepare-data" || got[1].Name != "egress-gate" {
t.Errorf("system server must get [prepare-data egress-gate], got %+v", got)
if got := sysSts.Spec.Template.Spec.InitContainers; len(got) != 3 || got[0].Name != "prepare-data" || got[1].Name != "init-forwarding" || got[2].Name != "egress-gate" {
t.Errorf("lobby must get [prepare-data init-forwarding egress-gate], got %+v", got)
}
managed := false
for _, env := range sysSts.Spec.Template.Spec.Containers[0].Env {
if env.Name == "FELIS_MANAGED_FORWARDING" && env.Value == "true" {
managed = true
}
}
if !managed {
t.Fatal("lobby entrypoint would overwrite the merged forwarding config")
}
}