Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@
data/kleff.db
*.db

# Test binary
# Test binaries
/testprovision
/teststart
/teststop
/testdelete
/testrestart

# Build output
*.exe
Expand Down
154 changes: 151 additions & 3 deletions internal/adapters/out/runtime/kubernetes/agones.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package kubernetes

import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
Expand All @@ -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"
Expand All @@ -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",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions internal/application/ports/container_runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 11 additions & 5 deletions internal/workers/mocks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion internal/workers/payloads/server.go
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion internal/workers/provision_worker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading