diff --git a/.gitignore b/.gitignore index 13e8c3f..ec005f9 100644 --- a/.gitignore +++ b/.gitignore @@ -2,8 +2,12 @@ data/kleff.db *.db -# Test binary +# Test binaries /testprovision +/teststart +/teststop +/testdelete +/testrestart # Build output *.exe diff --git a/internal/adapters/out/runtime/kubernetes/agones.go b/internal/adapters/out/runtime/kubernetes/agones.go index 5e3160b..5ac2745 100644 --- a/internal/adapters/out/runtime/kubernetes/agones.go +++ b/internal/adapters/out/runtime/kubernetes/agones.go @@ -2,6 +2,7 @@ package kubernetes import ( "context" + "encoding/json" "fmt" "strconv" "strings" @@ -10,9 +11,11 @@ import ( "github.com/kleffio/kleff-daemon/internal/application/ports" "github.com/kleffio/kleff-daemon/internal/workers/payloads" "github.com/kleffio/kleff-daemon/pkg/labels" + "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/wait" "k8s.io/client-go/dynamic" "k8s.io/client-go/rest" @@ -25,6 +28,12 @@ var minecraftServerGVR = schema.GroupVersionResource{ Resource: "minecraftservers", } +var xMinecraftServerGVR = schema.GroupVersionResource{ + Group: "kleff.io", + Version: "v1alpha1", + Resource: "xminecraftservers", +} + var gameServerGVR = schema.GroupVersionResource{ Group: "agones.dev", Version: "v1", @@ -63,7 +72,7 @@ func New(kubeconfig, namespace, nodeID string) (*KubernetesRuntime, error) { return &KubernetesRuntime{client: client, namespace: namespace, nodeID: nodeID}, nil } -func (k *KubernetesRuntime) Start(ctx context.Context, payload payloads.ServerOperationPayload) (*ports.RunningServer, error) { +func (k *KubernetesRuntime) Provision(ctx context.Context, payload payloads.ServerOperationPayload) (*ports.RunningServer, error) { serverLabels := labels.WorkloadLabels{ OwnerID: payload.OwnerID, ServerID: payload.ServerID, @@ -120,12 +129,137 @@ func (k *KubernetesRuntime) Start(ctx context.Context, payload payloads.ServerOp return server, nil } +func (k *KubernetesRuntime) Start(ctx context.Context, payload payloads.ServerOperationPayload) (*ports.RunningServer, error) { + serverLabels := labels.WorkloadLabels{ + OwnerID: payload.OwnerID, + ServerID: payload.ServerID, + BlueprintID: payload.BlueprintID, + NodeID: k.nodeID, + } + + compositeName, err := k.getCompositeName(ctx, payload.ServerID) + if err != nil { + return nil, err + } + + patch := []byte(`{"metadata":{"annotations":{"crossplane.io/paused":null}}}`) + _, err = k.client.Resource(xMinecraftServerGVR).Patch(ctx, compositeName, types.MergePatchType, patch, metav1.PatchOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to unpause composite: %w", err) + } + + // Delete stale GameServer if it exists so Agones doesn't panic on stale state + _ = k.client.Resource(gameServerGVR).Namespace(k.namespace).Delete(ctx, payload.ServerID, metav1.DeleteOptions{}) + + env := payload.EnvOverrides + memoryQty := resource.NewQuantity(4*1024*1024*1024, resource.BinarySI) + if payload.MemoryBytes > 0 { + memoryQty = resource.NewQuantity(payload.MemoryBytes, resource.BinarySI) + } + memory := memoryQty.String() + + gs := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "agones.dev/v1", + "kind": "GameServer", + "metadata": map[string]interface{}{ + "name": payload.ServerID, + "namespace": k.namespace, + }, + "spec": map[string]interface{}{ + "container": "minecraft", + "health": map[string]interface{}{ + "disabled": true, + }, + "ports": []interface{}{ + map[string]interface{}{ + "name": "minecraft", + "portPolicy": "Dynamic", + "containerPort": int64(25565), + "protocol": "TCP", + }, + }, + "template": map[string]interface{}{ + "spec": map[string]interface{}{ + "containers": []interface{}{ + map[string]interface{}{ + "name": "minecraft", + "image": "itzg/minecraft-server:latest", + "env": []interface{}{ + map[string]interface{}{"name": "EULA", "value": "TRUE"}, + map[string]interface{}{"name": "TYPE", "value": env["TYPE"]}, + map[string]interface{}{"name": "VERSION", "value": env["VERSION"]}, + map[string]interface{}{"name": "MAX_PLAYERS", "value": env["MAX_PLAYERS"]}, + map[string]interface{}{"name": "DIFFICULTY", "value": env["DIFFICULTY"]}, + map[string]interface{}{"name": "MODE", "value": env["MODE"]}, + map[string]interface{}{"name": "VIEW_DISTANCE", "value": env["VIEW_DISTANCE"]}, + map[string]interface{}{"name": "LEVEL_SEED", "value": env["LEVEL_SEED"]}, + map[string]interface{}{"name": "ONLINE_MODE", "value": env["ONLINE_MODE"]}, + }, + "resources": map[string]interface{}{ + "requests": map[string]interface{}{"memory": memory}, + "limits": map[string]interface{}{"memory": memory}, + }, + "volumeMounts": []interface{}{ + map[string]interface{}{ + "name": "world", + "mountPath": "/data", + }, + }, + }, + }, + "volumes": []interface{}{ + map[string]interface{}{ + "name": "world", + "persistentVolumeClaim": map[string]interface{}{ + "claimName": payload.ServerID, + }, + }, + }, + }, + }, + }, + }, + } + + if b, jerr := json.MarshalIndent(gs.Object, "", " "); jerr == nil { + fmt.Printf("[DEBUG] GameServer spec being submitted:\n%s\n", string(b)) + } + + _, err = k.client.Resource(gameServerGVR).Namespace(k.namespace).Create(ctx, gs, metav1.CreateOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to create game server: %w", err) + } + + server, err := k.waitForReady(ctx, payload.ServerID, serverLabels) + if err != nil { + return nil, fmt.Errorf("server did not reach ready state: %w", err) + } + + return server, nil +} + func (k *KubernetesRuntime) Stop(ctx context.Context, serverID string) error { - return k.client.Resource(minecraftServerGVR).Namespace(k.namespace).Delete(ctx, serverID, metav1.DeleteOptions{}) + compositeName, err := k.getCompositeName(ctx, serverID) + if err != nil { + return err + } + + patch := []byte(`{"metadata":{"annotations":{"crossplane.io/paused":"true"}}}`) + _, err = k.client.Resource(xMinecraftServerGVR).Patch(ctx, compositeName, types.MergePatchType, patch, metav1.PatchOptions{}) + if err != nil { + return fmt.Errorf("failed to pause composite: %w", err) + } + + if err := k.client.Resource(gameServerGVR).Namespace(k.namespace).Delete(ctx, serverID, metav1.DeleteOptions{}); err != nil { + return fmt.Errorf("failed to delete game server: %w", err) + } + + return nil } func (k *KubernetesRuntime) Delete(ctx context.Context, serverID string) error { - return k.Stop(ctx, serverID) + return k.client.Resource(minecraftServerGVR).Namespace(k.namespace).Delete(ctx, serverID, metav1.DeleteOptions{}) } func (k *KubernetesRuntime) GetByID(ctx context.Context, serverID string) (*ports.RunningServer, error) { @@ -170,6 +304,20 @@ func (k *KubernetesRuntime) Stats(ctx context.Context, serverID string) (*ports. return &ports.RawStats{}, nil } +func (k *KubernetesRuntime) getCompositeName(ctx context.Context, serverID string) (string, error) { + claim, err := k.client.Resource(minecraftServerGVR).Namespace(k.namespace).Get(ctx, serverID, metav1.GetOptions{}) + if err != nil { + return "", fmt.Errorf("failed to get MinecraftServer claim: %w", err) + } + + compositeName, _, err := unstructured.NestedString(claim.Object, "spec", "resourceRef", "name") + if err != nil || compositeName == "" { + return "", fmt.Errorf("composite name not found on claim %s", serverID) + } + + return compositeName, nil +} + func (k *KubernetesRuntime) waitForReady(ctx context.Context, name string, serverLabels labels.WorkloadLabels) (*ports.RunningServer, error) { var server *ports.RunningServer diff --git a/internal/application/ports/container_runtime.go b/internal/application/ports/container_runtime.go index 8ef65cb..14278f2 100644 --- a/internal/application/ports/container_runtime.go +++ b/internal/application/ports/container_runtime.go @@ -21,6 +21,7 @@ type RawStats struct { } type ContainerRuntime interface { + Provision(ctx context.Context, payload WorkloadSpec) (*RunningServer, error) Start(ctx context.Context, payload WorkloadSpec) (*RunningServer, error) Stop(ctx context.Context, serverID string) error Delete(ctx context.Context, serverID string) error diff --git a/internal/workers/mocks_test.go b/internal/workers/mocks_test.go index e1adae6..6b1d827 100644 --- a/internal/workers/mocks_test.go +++ b/internal/workers/mocks_test.go @@ -8,11 +8,17 @@ import ( ) type mockRuntime struct { - startCalled bool - returnServer *ports.RunningServer - returnErr error - deleteErr error - stopErr error + provisionCalled bool + startCalled bool + returnServer *ports.RunningServer + returnErr error + deleteErr error + stopErr error +} + +func (m *mockRuntime) Provision(ctx context.Context, payload payloads.ServerOperationPayload) (*ports.RunningServer, error) { + m.provisionCalled = true + return m.returnServer, m.returnErr } func (m *mockRuntime) Start(ctx context.Context, payload payloads.ServerOperationPayload) (*ports.RunningServer, error) { diff --git a/internal/workers/payloads/server.go b/internal/workers/payloads/server.go index 14f3b88..65e9a3c 100644 --- a/internal/workers/payloads/server.go +++ b/internal/workers/payloads/server.go @@ -1,6 +1,6 @@ package payloads -import "github.com/kleffio/gameserver-daemon/internal/application/ports" +import "github.com/kleffio/kleff-daemon/internal/application/ports" // ServerOperationPayload is deprecated: use ports.WorkloadSpec directly. // Kept as a type alias so existing worker code compiles without changes during transition. diff --git a/internal/workers/provision_worker.go b/internal/workers/provision_worker.go index 42c7af0..bcb310f 100644 --- a/internal/workers/provision_worker.go +++ b/internal/workers/provision_worker.go @@ -36,7 +36,7 @@ func (w *ProvisionWorker) Handle(ctx context.Context, job *jobs.Job) error { log.Info("Provisioning server", ports.LogKeyServerID, payload.ServerID) - server, err := w.runtime.Start(ctx, payload) + server, err := w.runtime.Provision(ctx, payload) if err != nil { log.Error("Failed to provision server", err) return fmt.Errorf("provision failed: %w", err)