339 lines
12 KiB
Go
339 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"strings"
|
|
|
|
"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"
|
|
"k8s.io/apimachinery/pkg/api/resource"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
|
|
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
|
|
ctrl "sigs.k8s.io/controller-runtime"
|
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
|
)
|
|
|
|
// cmdApply creates a MinecraftServer CRD from a JSON form.
|
|
//
|
|
// It is an operator-only direct CRD create path — it does NOT seed Postgres
|
|
// business rows (ownership, alias, image whitelist, audit), so the resulting
|
|
// server is claimable only if those rows are seeded separately. For normal
|
|
// provisioning prefer the Web form or felis-api.
|
|
//
|
|
// Usage: felis apply -f server.json [-n minecraft]
|
|
//
|
|
// This is a DIRECT Kubernetes write — it does not go through felis-api. It
|
|
// requires a kubeconfig or in-cluster identity with "create minecraftservers"
|
|
// permission in the target namespace. Subdomain duplicates are checked against
|
|
// existing CRDs in the target namespace (same check as the web form's
|
|
// Cluster.GetBySubdomain).
|
|
|
|
// applyRequest is the CLI-facing server creation form. It mirrors the Web
|
|
// form's field shape (createServerRequest) so the two provisioners stay
|
|
// structurally aligned, but validation differs: here we validate the CRD
|
|
// resource shape only — image whitelist admission, PG alias seeding, quota, and
|
|
// audit are the API's business layer and are NOT performed.
|
|
type applyRequest struct {
|
|
Name string `json:"name"`
|
|
Subdomain string `json:"subdomain"`
|
|
DisplayName string `json:"displayName,omitempty"`
|
|
Image string `json:"image"`
|
|
Memory string `json:"memory"`
|
|
Storage string `json:"storage"`
|
|
AutostartPolicy string `json:"autostartPolicy,omitempty"`
|
|
Resources *resourceRequest `json:"resources,omitempty"`
|
|
}
|
|
|
|
// resourceRequest mirrors the API's resourceRequest.
|
|
type resourceRequest struct {
|
|
CPU string `json:"cpu,omitempty"`
|
|
CPURequest string `json:"cpuRequest,omitempty"`
|
|
Memory string `json:"memory,omitempty"`
|
|
MemoryRequest string `json:"memoryRequest,omitempty"`
|
|
}
|
|
|
|
func cmdApply(args []string, stdout, stderr io.Writer) int {
|
|
fs := flag.NewFlagSet("apply", flag.ContinueOnError)
|
|
fs.SetOutput(stderr)
|
|
file := fs.String("f", "", "path to JSON server form (required; use - for stdin)")
|
|
namespace := fs.String("n", "minecraft", "Kubernetes namespace")
|
|
if err := fs.Parse(args); err != nil {
|
|
return 2
|
|
}
|
|
if *file == "" {
|
|
fmt.Fprintln(stderr, "felis apply: missing required flag -f; use -f server.json or -f - for stdin")
|
|
return 2
|
|
}
|
|
|
|
var raw []byte
|
|
var err error
|
|
if *file == "-" {
|
|
raw, err = io.ReadAll(os.Stdin)
|
|
} else {
|
|
raw, err = os.ReadFile(*file)
|
|
}
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "felis apply: read: %v\n", err)
|
|
return 1
|
|
}
|
|
|
|
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
|
dec.DisallowUnknownFields()
|
|
var req applyRequest
|
|
if err := dec.Decode(&req); err != nil {
|
|
fmt.Fprintf(stderr, "felis apply: invalid JSON: %v\n", err)
|
|
return 1
|
|
}
|
|
// Drain the decoder: a second Decode must hit io.EOF — anything else
|
|
// (another value, trailing garbage like ] or }) means the input is not
|
|
// exactly one valid form.
|
|
if err := dec.Decode(&struct{}{}); err == nil || !errors.Is(err, io.EOF) {
|
|
fmt.Fprintln(stderr, "felis apply: invalid JSON: unexpected data after the server form")
|
|
return 1
|
|
}
|
|
|
|
// Build the CRD from the form. This validates the resource shape but does
|
|
// NOT perform image whitelist admission or PG seeding (API business layer).
|
|
ms, err := buildMinecraftServerFromApplyRequest(req, *namespace)
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "felis apply: %v\n", err)
|
|
return 1
|
|
}
|
|
|
|
// ------- K8s client (one context, one client) -------
|
|
// SetupSignalHandler must be called exactly once per process —
|
|
// controller-runtime panics on a second call. We create ctx and the
|
|
// K8s client here and thread both through every downstream call so no
|
|
// callee ever needs to call SetupSignalHandler again.
|
|
ctx := ctrl.SetupSignalHandler()
|
|
|
|
scheme := runtime.NewScheme()
|
|
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
|
|
utilruntime.Must(v1alpha1.AddToScheme(scheme))
|
|
|
|
cfg := ctrl.GetConfigOrDie()
|
|
cl, err := client.New(cfg, client.Options{Scheme: scheme})
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "felis apply: build client: %v\n", err)
|
|
return 1
|
|
}
|
|
|
|
// Subdomain duplicate check: the web form queries GetBySubdomain before
|
|
// creating; we list all CRDs in the namespace and check spec.subdomain.
|
|
// metadata.name already receives K8s AlreadyExists enforcement on create,
|
|
// so a name collision surfaces cleanly — but subdomain has no such native
|
|
// uniqueness, so it must be checked explicitly.
|
|
if err := checkSubdomainUnique(ctx, cl, *namespace, req.Subdomain); err != nil {
|
|
fmt.Fprintf(stderr, "felis apply: %v\n", err)
|
|
return 1
|
|
}
|
|
|
|
if err := cl.Create(ctx, ms); err != nil {
|
|
if apierrors.IsAlreadyExists(err) {
|
|
fmt.Fprintf(stderr, "felis apply: server %q already exists\n", req.Name)
|
|
return 1
|
|
}
|
|
fmt.Fprintf(stderr, "felis apply: create: %v\n", err)
|
|
return 1
|
|
}
|
|
|
|
fmt.Fprintf(stdout, "created MinecraftServer %s/%s (subdomain=%s, image=%s, memory=%s, storage=%s, policy=%s)\n",
|
|
*namespace, req.Name, req.Subdomain, req.Image, resourcesMemoryString(ms.Spec.Resources), ms.Spec.Storage.Size, ms.Spec.AutostartPolicy)
|
|
fmt.Fprintln(stderr, "note: Postgres business rows (ownership / alias / whitelist / audit) were NOT seeded — prefer Web/API for normal provisioning")
|
|
return 0
|
|
}
|
|
|
|
// buildMinecraftServerFromApplyRequest validates the request and constructs a
|
|
// MinecraftServer CRD. It is a pure function (no K8s, no I/O) so it can be
|
|
// tested without a cluster. It validates: name/subdomain format, required
|
|
// fields, policy enum, positive K8s quantities, request ≤ limit, and the §22
|
|
// memory ceiling. The returned CRD is always DesiredState=Stopped and unowned
|
|
// (ownership is established by a later claim).
|
|
func buildMinecraftServerFromApplyRequest(req applyRequest, namespace string) (*v1alpha1.MinecraftServer, error) {
|
|
// ---- name & subdomain ----
|
|
if err := naming.ValidateServerName(req.Name); err != nil {
|
|
return nil, fmt.Errorf("invalid name: %w", err)
|
|
}
|
|
if err := naming.ValidateServerName(req.Subdomain); err != nil {
|
|
return nil, fmt.Errorf("invalid subdomain: %w", err)
|
|
}
|
|
if strings.TrimSpace(req.Image) == "" {
|
|
return nil, fmt.Errorf("image is required")
|
|
}
|
|
|
|
// ---- autostart policy ----
|
|
policy, err := parseApplyAutostartPolicy(req.AutostartPolicy)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// ---- resources (§22 ceiling) ----
|
|
memQ, err := parseApplyPositiveQuantity(req.Memory, "memory")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
limits := corev1.ResourceList{corev1.ResourceMemory: memQ}
|
|
requests := corev1.ResourceList{corev1.ResourceMemory: memQ}
|
|
|
|
if req.Resources != nil {
|
|
if req.Resources.Memory != "" {
|
|
q, err := parseApplyPositiveQuantity(req.Resources.Memory, "resources.memory")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
limits[corev1.ResourceMemory] = q
|
|
}
|
|
if req.Resources.MemoryRequest != "" {
|
|
q, err := parseApplyPositiveQuantity(req.Resources.MemoryRequest, "resources.memoryRequest")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
requests[corev1.ResourceMemory] = q
|
|
}
|
|
if req.Resources.CPU != "" {
|
|
q, err := parseApplyPositiveQuantity(req.Resources.CPU, "resources.cpu")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
limits[corev1.ResourceCPU] = q
|
|
}
|
|
if req.Resources.CPURequest != "" {
|
|
q, err := parseApplyPositiveQuantity(req.Resources.CPURequest, "resources.cpuRequest")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
requests[corev1.ResourceCPU] = q
|
|
}
|
|
}
|
|
if err := validateResourceCeilings(requests, limits); err != nil {
|
|
return nil, err
|
|
}
|
|
memLim, ok := limits[corev1.ResourceMemory]
|
|
if !ok || memLim.IsZero() {
|
|
return nil, fmt.Errorf("internal error: refusing to create a server without a memory ceiling (§22)")
|
|
}
|
|
|
|
// ---- storage ----
|
|
storageQ, err := parseApplyPositiveQuantity(req.Storage, "storage")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &v1alpha1.MinecraftServer{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: req.Name,
|
|
Namespace: namespace,
|
|
},
|
|
Spec: v1alpha1.MinecraftServerSpec{
|
|
Subdomain: req.Subdomain,
|
|
DisplayName: req.DisplayName,
|
|
Image: req.Image,
|
|
JavaMemory: deriveApplyJavaHeap(memLim),
|
|
DesiredState: v1alpha1.DesiredStopped,
|
|
AutostartPolicy: policy,
|
|
Storage: v1alpha1.StorageSpec{Size: storageQ.String()},
|
|
Resources: corev1.ResourceRequirements{Limits: limits, Requests: requests},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// checkSubdomainUnique lists all MinecraftServers in namespace and rejects the
|
|
// request if any CRD already carries the given spec.subdomain. metadata.name
|
|
// uniqueness is enforced by K8s on Create, but spec.subdomain must be checked
|
|
// here because two CRDs with different names could otherwise share a subdomain.
|
|
// It reuses the caller's context and K8s client — it never calls
|
|
// SetupSignalHandler or builds its own client.
|
|
func checkSubdomainUnique(ctx context.Context, cl client.Client, namespace, subdomain string) error {
|
|
var list v1alpha1.MinecraftServerList
|
|
if err := cl.List(ctx, &list, client.InNamespace(namespace)); err != nil {
|
|
return fmt.Errorf("list servers: %w", err)
|
|
}
|
|
for i := range list.Items {
|
|
if list.Items[i].Spec.Subdomain == subdomain {
|
|
return fmt.Errorf("subdomain %q is already in use by server %q", subdomain, list.Items[i].Name)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateResourceCeilings checks that every resource request is ≤ its limit;
|
|
// Kubernetes would reject the CRD anyway, but we fail fast with a clear message.
|
|
func validateResourceCeilings(requests, limits corev1.ResourceList) error {
|
|
for name, lim := range limits {
|
|
req, ok := requests[name]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if req.Cmp(lim) > 0 {
|
|
return fmt.Errorf("%s request %s exceeds limit %s", name, req.String(), lim.String())
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// resourcesMemoryString returns the memory limit as a human-readable string
|
|
// for the success log.
|
|
func resourcesMemoryString(rr corev1.ResourceRequirements) string {
|
|
if m, ok := rr.Limits[corev1.ResourceMemory]; ok {
|
|
return m.String()
|
|
}
|
|
return "?"
|
|
}
|
|
|
|
// ---- pure helpers (K8s-free, testable) ----
|
|
|
|
func parseApplyAutostartPolicy(s string) (v1alpha1.AutostartPolicy, error) {
|
|
switch s {
|
|
case "":
|
|
return v1alpha1.AutostartOwnerOnly, nil
|
|
case string(v1alpha1.AutostartOwnerOnly):
|
|
return v1alpha1.AutostartOwnerOnly, nil
|
|
case string(v1alpha1.AutostartPublic):
|
|
return v1alpha1.AutostartPublic, nil
|
|
case string(v1alpha1.AutostartAllowlist):
|
|
return v1alpha1.AutostartAllowlist, nil
|
|
default:
|
|
return "", fmt.Errorf("invalid autostartPolicy %q (want ownerOnly, public, or allowlist)", s)
|
|
}
|
|
}
|
|
|
|
func parseApplyPositiveQuantity(s, field string) (resource.Quantity, error) {
|
|
q, err := resource.ParseQuantity(s)
|
|
if err != nil {
|
|
return resource.Quantity{}, fmt.Errorf("invalid %s quantity %q: %v", field, s, err)
|
|
}
|
|
if q.Sign() <= 0 {
|
|
return resource.Quantity{}, fmt.Errorf("%s must be a positive quantity", field)
|
|
}
|
|
return q, nil
|
|
}
|
|
|
|
func deriveApplyJavaHeap(limit resource.Quantity) string {
|
|
const mib = int64(1024 * 1024)
|
|
bytes := limit.Value()
|
|
|
|
reserve := bytes / 4
|
|
if floor := 512 * mib; reserve < floor {
|
|
reserve = floor
|
|
}
|
|
if half := bytes / 2; reserve > half {
|
|
reserve = half
|
|
}
|
|
|
|
heapMiB := (bytes - reserve) / mib
|
|
if heapMiB < 1 {
|
|
heapMiB = 1
|
|
}
|
|
return fmt.Sprintf("%dM", heapMiB)
|
|
}
|