A server whose world PVC does not exist yet (never started) or no longer exists (the world was already reaped) accepted the backup/restore POST, answered 202, and the Job sat Pending on the missing claim until its deadline with nothing recorded anywhere — a silent no-op from the operator's seat. The live drill on the reaped `resolvecheck` world reproduced exactly that. Both handlers now read the world PVC (Cluster.WorldVolumeExists, over the same naming.WorldPVCName the Jobs mount) and answer a specific 409 no_world_volume with "start it once to create it, then retry". The felis-api Role gains the matching get-only PVC grant — the first live run surfaced the missing RBAC as a 403 behind a 500, so the fix ships with it. Live (auditfix38): resolvecheck -> 409 no_world_volume on both faces; test-one (which has a world) still backs up through the new gate end to end.
218 lines
7.9 KiB
Go
218 lines
7.9 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
|
|
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
|
"felis.lolicon.best/internal/naming"
|
|
corev1 "k8s.io/api/core/v1"
|
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/types"
|
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
|
)
|
|
|
|
// K8sCluster is the production Cluster backed by a controller-runtime client
|
|
// (spec §4). It reads the MinecraftServer CRD and performs the API's spec writes
|
|
// — the app-tier desiredState lever, the admin-tier create, and the admin-tier
|
|
// spec patch — each via a merge patch. It is integration-tested against a live
|
|
// cluster, not the hermetic api_test.go suite.
|
|
type K8sCluster struct {
|
|
c client.Client
|
|
namespace string
|
|
}
|
|
|
|
// NewK8sCluster builds a Cluster over c, scoped to namespace.
|
|
func NewK8sCluster(c client.Client, namespace string) *K8sCluster {
|
|
return &K8sCluster{c: c, namespace: namespace}
|
|
}
|
|
|
|
func (k *K8sCluster) Ping(ctx context.Context) error {
|
|
var list v1alpha1.MinecraftServerList
|
|
return k.c.List(ctx, &list, client.InNamespace(k.namespace), client.Limit(1))
|
|
}
|
|
|
|
func (k *K8sCluster) GetServer(ctx context.Context, name string) (*ServerInfo, error) {
|
|
var ms v1alpha1.MinecraftServer
|
|
if err := k.c.Get(ctx, types.NamespacedName{Namespace: k.namespace, Name: name}, &ms); err != nil {
|
|
if apierrors.IsNotFound(err) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
return serverInfo(&ms), nil
|
|
}
|
|
|
|
// WorldVolumeExists reads the world PVC the operator's StatefulSet
|
|
// volumeClaimTemplate creates (naming.WorldPVCName — the same name the backup
|
|
// and restore Jobs mount), so existence here is exactly existence at Job mount
|
|
// time. NotFound is (false, nil): the caller refuses with a specific 409.
|
|
func (k *K8sCluster) WorldVolumeExists(ctx context.Context, name string) (bool, error) {
|
|
var pvc corev1.PersistentVolumeClaim
|
|
err := k.c.Get(ctx, types.NamespacedName{Namespace: k.namespace, Name: naming.WorldPVCName(name)}, &pvc)
|
|
if apierrors.IsNotFound(err) {
|
|
return false, nil
|
|
}
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func (k *K8sCluster) GetBySubdomain(ctx context.Context, subdomain string) (*ServerInfo, error) {
|
|
var list v1alpha1.MinecraftServerList
|
|
if err := k.c.List(ctx, &list, client.InNamespace(k.namespace)); err != nil {
|
|
return nil, err
|
|
}
|
|
for i := range list.Items {
|
|
if list.Items[i].Spec.Subdomain == subdomain {
|
|
return serverInfo(&list.Items[i]), nil
|
|
}
|
|
}
|
|
return nil, ErrNotFound
|
|
}
|
|
|
|
func (k *K8sCluster) ListServers(ctx context.Context) ([]ServerInfo, error) {
|
|
var list v1alpha1.MinecraftServerList
|
|
if err := k.c.List(ctx, &list, client.InNamespace(k.namespace)); err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]ServerInfo, 0, len(list.Items))
|
|
for i := range list.Items {
|
|
out = append(out, *serverInfo(&list.Items[i]))
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// CreateServer creates a MinecraftServer CRD from the validated §15 form. The
|
|
// server starts DesiredState=Stopped (created cold, woken later) and unowned —
|
|
// ownership is established by a later claim (spec §9.3). felis-api has already
|
|
// guaranteed the §22 memory ceiling lives in in.Resources, so the operator
|
|
// never has to derive a cgroup limit from JavaMemory. An existing name maps to
|
|
// ErrConflict so the handler returns 409.
|
|
//
|
|
// Every user server falls back to the login gate while it is stopped or starting
|
|
// — never to the lobby. Routing a fresh connection to the lobby would drop the
|
|
// player past authentication; falling back to login keeps the gate in front of
|
|
// them (and if login itself is down the proxy refuses, which is the intended
|
|
// "rather unreachable than unauthenticated" trade-off).
|
|
func (k *K8sCluster) CreateServer(ctx context.Context, in CreateServerInput) error {
|
|
ms := &v1alpha1.MinecraftServer{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: in.Name,
|
|
Namespace: k.namespace,
|
|
},
|
|
Spec: v1alpha1.MinecraftServerSpec{
|
|
Subdomain: in.Subdomain,
|
|
DisplayName: in.DisplayName,
|
|
Image: in.Image,
|
|
JavaMemory: in.JavaMemory,
|
|
DesiredState: v1alpha1.DesiredStopped,
|
|
AutostartPolicy: in.AutostartPolicy,
|
|
FallbackServer: naming.SystemLoginServer,
|
|
Storage: v1alpha1.StorageSpec{Size: in.StorageSize},
|
|
Resources: in.Resources,
|
|
// RCON is what makes a server manageable at all: the operator gates
|
|
// phase=Running on the probe and samples the player tally from it (spec
|
|
// §5), and every write — console commands, the LuckPerms grants behind the
|
|
// permissions UI — travels over it (spec §8 写=RCON). Leaving it unset
|
|
// produced a server that looked Running, reported nobody online, and
|
|
// answered the console with 503; enabling it here is the fix for all
|
|
// three. Port stays 0 so the operator applies its own default rather than
|
|
// this package pinning a second copy of it. The password is not set (and
|
|
// felis-api could not set it — it holds secrets:get, not create): the
|
|
// operator mints it into this Secret on first reconcile, and felis-api
|
|
// only ever reads it back at command time.
|
|
Rcon: v1alpha1.RconSpec{
|
|
Enabled: true,
|
|
SecretRef: v1alpha1.SecretKeyRef{
|
|
Name: naming.RconSecretName(in.Name),
|
|
Key: naming.RconSecretKey,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
if err := k.c.Create(ctx, ms); err != nil {
|
|
if apierrors.IsAlreadyExists(err) {
|
|
return ErrConflict
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SetDesiredState patches spec.desiredState with a merge patch so concurrent
|
|
// status writes by the operator are never clobbered (spec §9.1).
|
|
func (k *K8sCluster) SetDesiredState(ctx context.Context, name string, state v1alpha1.DesiredState) error {
|
|
var ms v1alpha1.MinecraftServer
|
|
if err := k.c.Get(ctx, types.NamespacedName{Namespace: k.namespace, Name: name}, &ms); err != nil {
|
|
if apierrors.IsNotFound(err) {
|
|
return ErrNotFound
|
|
}
|
|
return err
|
|
}
|
|
patch := client.MergeFrom(ms.DeepCopy())
|
|
ms.Spec.DesiredState = state
|
|
return k.c.Patch(ctx, &ms, patch)
|
|
}
|
|
|
|
// PatchServerSpec applies the admin-tier spec mutation (spec §7) with the same
|
|
// merge-patch discipline as SetDesiredState: read, copy, mutate only the fields
|
|
// the admin set, patch — so an operator status write racing in parallel survives.
|
|
// felis-api has already validated every field and resolved the §22 ceiling, so
|
|
// here we only translate the non-nil patch fields onto the live spec.
|
|
func (k *K8sCluster) PatchServerSpec(ctx context.Context, name string, p ServerSpecPatch) error {
|
|
var ms v1alpha1.MinecraftServer
|
|
if err := k.c.Get(ctx, types.NamespacedName{Namespace: k.namespace, Name: name}, &ms); err != nil {
|
|
if apierrors.IsNotFound(err) {
|
|
return ErrNotFound
|
|
}
|
|
return err
|
|
}
|
|
patch := client.MergeFrom(ms.DeepCopy())
|
|
if p.DisplayName != nil {
|
|
ms.Spec.DisplayName = *p.DisplayName
|
|
}
|
|
if p.AutostartPolicy != nil {
|
|
ms.Spec.AutostartPolicy = *p.AutostartPolicy
|
|
}
|
|
if p.Image != nil {
|
|
ms.Spec.Image = *p.Image
|
|
}
|
|
if p.JavaMemory != nil {
|
|
ms.Spec.JavaMemory = *p.JavaMemory
|
|
}
|
|
if p.Resources != nil {
|
|
ms.Spec.Resources = *p.Resources
|
|
}
|
|
return k.c.Patch(ctx, &ms, patch)
|
|
}
|
|
|
|
// serverInfo projects a MinecraftServer onto the API's lifecycle view.
|
|
func serverInfo(ms *v1alpha1.MinecraftServer) *ServerInfo {
|
|
var cpuStr string
|
|
if ms.Spec.Resources.Limits != nil {
|
|
if limit, ok := ms.Spec.Resources.Limits[corev1.ResourceCPU]; ok {
|
|
cpuStr = limit.String()
|
|
}
|
|
}
|
|
|
|
return &ServerInfo{
|
|
Name: ms.Name,
|
|
Subdomain: ms.Spec.Subdomain,
|
|
Phase: string(ms.Status.Phase),
|
|
Ready: ms.Status.Ready,
|
|
AutostartPolicy: string(ms.Spec.AutostartPolicy),
|
|
DesiredState: string(ms.Spec.DesiredState),
|
|
EndpointMode: string(ms.Status.Endpoint.Mode),
|
|
EndpointAddress: ms.Status.Endpoint.Address,
|
|
PlayersOnline: ms.Status.Players.Online,
|
|
PlayersMax: ms.Status.Players.Max,
|
|
DisplayName: ms.Spec.DisplayName,
|
|
Image: ms.Spec.Image,
|
|
JavaMemory: ms.Spec.JavaMemory,
|
|
StorageSize: ms.Spec.Storage.Size,
|
|
CPU: cpuStr,
|
|
}
|
|
}
|