feat: customize login and lobby spaces from the panel
This commit is contained in:
54 files changed
+1146
-158
No files matched your search
+2
-1
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user