From 29312ee762ad0e0dffaadf534bc8b7f9a8890918 Mon Sep 17 00:00:00 2001 From: Jeefos Date: Fri, 10 Apr 2026 10:10:23 -0400 Subject: [PATCH 1/3] removed harcoding to only minecraft and kubernetes --- cmd/kleffd/main.go | 87 ++- cmd/testprovision/main.go | 113 ++++ go.mod | 48 +- go.sum | 134 ++++- .../adapters/out/runtime/docker/docker.go | 216 +++++++ .../adapters/out/runtime/kubernetes/agones.go | 348 ------------ .../out/runtime/kubernetes/kubernetes.go | 530 ++++++++++++++++++ internal/app/config/config.go | 60 +- internal/app/config/config_test.go | 50 +- .../application/ports/container_runtime.go | 31 - internal/application/ports/runtime_adapter.go | 15 +- internal/application/ports/workload.go | 9 + internal/workers/delete_worker.go | 30 +- internal/workers/delete_worker_test.go | 12 +- internal/workers/mocks_test.go | 34 +- internal/workers/payloads/server.go | 10 - internal/workers/payloads/server_test.go | 42 -- internal/workers/provision_worker.go | 29 +- internal/workers/provision_worker_test.go | 15 +- internal/workers/restart_worker.go | 32 +- internal/workers/restart_worker_test.go | 17 +- internal/workers/start_worker.go | 30 +- internal/workers/start_worker_test.go | 9 +- internal/workers/stop_worker.go | 30 +- internal/workers/stop_worker_test.go | 12 +- 25 files changed, 1253 insertions(+), 690 deletions(-) create mode 100644 cmd/testprovision/main.go create mode 100644 internal/adapters/out/runtime/docker/docker.go delete mode 100644 internal/adapters/out/runtime/kubernetes/agones.go create mode 100644 internal/adapters/out/runtime/kubernetes/kubernetes.go delete mode 100644 internal/application/ports/container_runtime.go delete mode 100644 internal/workers/payloads/server.go delete mode 100644 internal/workers/payloads/server_test.go diff --git a/cmd/kleffd/main.go b/cmd/kleffd/main.go index fec6594..d78d0f6 100644 --- a/cmd/kleffd/main.go +++ b/cmd/kleffd/main.go @@ -1,13 +1,23 @@ package main import ( + "context" "log" "os" + "os/signal" + "syscall" "github.com/kleffio/kleff-daemon/internal/adapters/out/db" "github.com/kleffio/kleff-daemon/internal/adapters/out/observability/logging" + queueadapter "github.com/kleffio/kleff-daemon/internal/adapters/out/queue" + memrepo "github.com/kleffio/kleff-daemon/internal/adapters/out/repository/memory" + dockeradapter "github.com/kleffio/kleff-daemon/internal/adapters/out/runtime/docker" + k8sadapter "github.com/kleffio/kleff-daemon/internal/adapters/out/runtime/kubernetes" "github.com/kleffio/kleff-daemon/internal/app/config" "github.com/kleffio/kleff-daemon/internal/application/ports" + "github.com/kleffio/kleff-daemon/internal/workers" + "github.com/kleffio/kleff-daemon/internal/workers/jobs" + "k8s.io/client-go/rest" ) func main() { @@ -17,17 +27,86 @@ func main() { } baseLogger := logging.NewSlogAdapter() - daemonLog := baseLogger.With(ports.LogKeyNodeID, cfg.NodeID) - daemonLog.Info("Daemon starting", "runtime_mode", cfg.RuntimeMode) + // --- Runtime adapter: auto-detect Docker vs Kubernetes --- + runtime, err := detectRuntime(cfg, daemonLog) + if err != nil { + daemonLog.Error("Failed to initialize runtime adapter", err) + os.Exit(1) + } + // --- Database --- sqliteDB, err := db.InitDB(cfg.DatabasePath) if err != nil { daemonLog.Error("Failed to initialize database", err, "path", cfg.DatabasePath) os.Exit(1) } defer sqliteDB.Close() - daemonLog.Info("Database initialized successfully", "path", cfg.DatabasePath) - + daemonLog.Info("Database initialized", "path", cfg.DatabasePath) + + // --- Queue --- + var q ports.Queue + switch cfg.QueueBackend { + case config.QueueBackendRedis: + rq, err := queueadapter.NewRedisQueue(cfg.RedisURL, cfg.RedisPassword, cfg.RedisTLS) + if err != nil { + daemonLog.Error("Failed to initialize Redis queue", err) + os.Exit(1) + } + q = rq + daemonLog.Info("Queue backend: Redis", "url", cfg.RedisURL) + default: + q = queueadapter.NewMemoryQueue() + daemonLog.Info("Queue backend: in-memory") + } + + // --- Repository --- + repo := memrepo.NewServerRepository() + + // --- Dispatcher + workers --- + dispatcher := workers.NewDispatcher(q, 4) + dispatcher.Register(jobs.JobTypeServerProvision, workers.NewProvisionWorker(runtime, repo, daemonLog).Handle) + dispatcher.Register(jobs.JobTypeServerStart, workers.NewStartWorker(runtime, repo, daemonLog).Handle) + dispatcher.Register(jobs.JobTypeServerStop, workers.NewStopWorker(runtime, repo, daemonLog).Handle) + dispatcher.Register(jobs.JobTypeServerDelete, workers.NewDeleteWorker(runtime, repo, daemonLog).Handle) + dispatcher.Register(jobs.JobTypeServerRestart, workers.NewRestartWorker(runtime, repo, daemonLog).Handle) + + daemonLog.Info("Daemon started", "node_id", cfg.NodeID, "grpc_port", cfg.GRPCPort) + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + dispatcher.Run(ctx) + daemonLog.Info("Daemon shutdown complete") +} + +func detectRuntime(cfg *config.Config, logger ports.Logger) (ports.RuntimeAdapter, error) { + // Explicit kubeconfig → always Kubernetes. + if cfg.Kubeconfig != "" { + adapter, err := k8sadapter.New(cfg.Kubeconfig, cfg.KubeNamespace, cfg.NodeID) + if err != nil { + return nil, err + } + logger.Info("Runtime: Kubernetes", "kubeconfig", cfg.Kubeconfig) + return adapter, nil + } + + // In-cluster environment detected → Kubernetes. + if _, err := rest.InClusterConfig(); err == nil { + adapter, err := k8sadapter.New("", cfg.KubeNamespace, cfg.NodeID) + if err != nil { + return nil, err + } + logger.Info("Runtime: Kubernetes (in-cluster)") + return adapter, nil + } + + // Fall back to Docker. + adapter, err := dockeradapter.New(cfg.NodeID) + if err != nil { + return nil, err + } + logger.Info("Runtime: Docker") + return adapter, nil } diff --git a/cmd/testprovision/main.go b/cmd/testprovision/main.go new file mode 100644 index 0000000..8d04b63 --- /dev/null +++ b/cmd/testprovision/main.go @@ -0,0 +1,113 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "time" + + k8sadapter "github.com/kleffio/kleff-daemon/internal/adapters/out/runtime/kubernetes" + "github.com/kleffio/kleff-daemon/internal/application/ports" +) + +// testprovision provisions a PaperMC Minecraft server via the Kubernetes adapter +// and prints the result. It expects kubectl proxy running on localhost:8888. +// +// Usage (on the cluster node): +// +// kubectl proxy --port=8888 & +// ./testprovision +// +// To clean up afterwards: +// +// ./testprovision cleanup +func main() { + const ( + proxyURL = "http://localhost:8888" + namespace = "default" + nodeID = "test-node" + serverID = "kleff-test-papermc" + ownerID = "test-owner" + blueprintID = "minecraft-vanilla" + ) + + adapter, err := k8sadapter.New(proxyURL, namespace, nodeID) + if err != nil { + log.Fatalf("failed to create kubernetes adapter: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + // Cleanup mode — remove the GameServer if it exists. + if len(os.Args) > 1 && os.Args[1] == "cleanup" { + fmt.Printf("Removing %s...\n", serverID) + if err := adapter.Remove(ctx, serverID); err != nil { + log.Fatalf("remove failed: %v", err) + } + fmt.Println("Removed.") + return + } + + // Build the WorkloadSpec from the minecraft-papermc blueprint/construct. + spec := ports.WorkloadSpec{ + OwnerID: ownerID, + ServerID: serverID, + BlueprintID: blueprintID, + Image: "itzg/minecraft-server", + + // From blueprint.json resources + MemoryBytes: 2048 * 1024 * 1024, // 2048 MB + CPUMillicores: 500, // 0.5 vCPU (test only) + + // From construct.json env + user config defaults + EnvOverrides: map[string]string{ + "EULA": "TRUE", + "TYPE": "VANILLA", + "VERSION": "LATEST", + "DIFFICULTY": "normal", + "MAX_PLAYERS": "20", + "MEMORY": "2G", + }, + + // From construct.json ports + PortRequirements: []ports.PortRequirement{ + {TargetPort: 25565, Protocol: "tcp"}, + {TargetPort: 25565, Protocol: "udp"}, + {TargetPort: 25575, Protocol: "tcp"}, // RCON + }, + + // From construct.json runtime_hints + RuntimeHints: ports.RuntimeHints{ + KubernetesStrategy: "agones", + ExposeUDP: true, + }, + } + + fmt.Printf("Provisioning %s (%s) via Agones...\n", serverID, spec.Image) + fmt.Printf(" Memory: %d MB CPU: %dm\n", spec.MemoryBytes/1024/1024, spec.CPUMillicores) + fmt.Printf(" Ports: 25565/tcp, 25565/udp, 25575/tcp\n") + fmt.Println(" Waiting for GameServer to become Ready (up to 10 min)...") + + start := time.Now() + server, err := adapter.Deploy(ctx, spec) + if err != nil { + log.Fatalf("deploy failed: %v", err) + } + + fmt.Printf("\nServer is ready! (took %s)\n", time.Since(start).Round(time.Second)) + fmt.Printf(" RuntimeRef : %s\n", server.RuntimeRef) + fmt.Printf(" State : %s\n", server.State) + fmt.Printf(" NodeID : %s\n", server.Labels.NodeID) + fmt.Printf(" ServerID : %s\n", server.Labels.ServerID) + + endpoint, err := adapter.Endpoint(ctx, serverID) + if err != nil { + fmt.Printf(" Endpoint : (could not resolve: %v)\n", err) + } else { + fmt.Printf(" Endpoint : %s\n", endpoint) + } + + fmt.Printf("\nTo clean up: ./testprovision cleanup\n") +} diff --git a/go.mod b/go.mod index c439a13..54c57f4 100644 --- a/go.mod +++ b/go.mod @@ -4,33 +4,59 @@ go 1.25.5 require ( github.com/alicebob/miniredis/v2 v2.37.0 + github.com/docker/docker v28.5.2+incompatible + github.com/docker/go-connections v0.6.0 github.com/redis/go-redis/v9 v9.18.0 github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 + k8s.io/api v0.29.0 k8s.io/apimachinery v0.29.0 k8s.io/client-go v0.29.0 modernc.org/sqlite v1.46.1 ) require ( + github.com/Microsoft/go-winio v0.4.21 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/emicklei/go-restful/v3 v3.11.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/jsonpointer v0.19.6 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.22.3 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/protobuf v1.5.4 // indirect + github.com/google/gnostic-models v0.6.8 // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/imdario/mergo v0.3.6 // indirect + github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/sys/atomicwriter v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/morikuni/aec v1.1.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect @@ -38,20 +64,28 @@ require ( github.com/spf13/cast v1.10.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/yuin/gopher-lua v1.1.1 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect - golang.org/x/net v0.46.0 // indirect + golang.org/x/net v0.52.0 // indirect golang.org/x/oauth2 v0.20.0 // indirect - golang.org/x/sys v0.37.0 // indirect - golang.org/x/term v0.36.0 // indirect - golang.org/x/text v0.30.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/term v0.41.0 // indirect + golang.org/x/text v0.35.0 // indirect golang.org/x/time v0.3.0 // indirect - google.golang.org/protobuf v1.34.2 // indirect - gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + gotest.tools/v3 v3.5.2 // indirect k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00 // indirect k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect modernc.org/libc v1.67.6 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index 82f2cdc..e2045cb 100644 --- a/go.sum +++ b/go.sum @@ -1,32 +1,60 @@ +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.4.21 h1:+6mVbXh4wPzUrl1COX9A+ZCvEpYsOBZ6/+kwDnvLyro= +github.com/Microsoft/go-winio v0.4.21/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68= github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= +github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE= github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g= github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= @@ -36,8 +64,8 @@ github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6 github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -45,6 +73,8 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28= @@ -68,27 +98,50 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0 github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ= +github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/onsi/ginkgo/v2 v2.13.0 h1:0jY9lJquiL8fcf3M4LAXN5aMlS/b2BV86HFFPCPMgE4= +github.com/onsi/ginkgo/v2 v2.13.0/go.mod h1:TE309ZR8s5FsKKpuB1YAQYBzCaAfUgatB/xlT/ETL/o= +github.com/onsi/gomega v1.29.0 h1:KIA/t2t5UBzoirT4H9tsML45GEbo3ouUnBHsCfD2tVg= +github.com/onsi/gomega v1.29.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= -github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= @@ -100,7 +153,13 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= @@ -111,6 +170,26 @@ github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= @@ -122,47 +201,55 @@ golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2 golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= -golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= -golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/oauth2 v0.20.0 h1:4mQdhULixXKP1rwYBW0vAijoXnkTG0BLCDRzfe1idMo= golang.org/x/oauth2 v0.20.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= -golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= -golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= -golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= -google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= @@ -171,8 +258,11 @@ gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= k8s.io/api v0.29.0 h1:NiCdQMY1QOp1H8lfRyeEf8eOwV6+0xA6XEE44ohDX2A= k8s.io/api v0.29.0/go.mod h1:sdVmXoz2Bo/cb77Pxi71IPTSErEW32xa4aXwKH7gfBA= k8s.io/apimachinery v0.29.0 h1:+ACVktwyicPz0oc6MTMLwa2Pw3ouLAfAon1wPLtG48o= diff --git a/internal/adapters/out/runtime/docker/docker.go b/internal/adapters/out/runtime/docker/docker.go new file mode 100644 index 0000000..1e6f820 --- /dev/null +++ b/internal/adapters/out/runtime/docker/docker.go @@ -0,0 +1,216 @@ +package docker + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/filters" + "github.com/docker/docker/api/types/image" + "github.com/docker/docker/api/types/network" + "github.com/docker/docker/client" + "github.com/docker/go-connections/nat" + "github.com/kleffio/kleff-daemon/internal/application/ports" + "github.com/kleffio/kleff-daemon/pkg/labels" +) + +const labelPrefix = "kleff." + +// Adapter is a Docker RuntimeAdapter. +// All three strategies (agones, statefulset, deployment) map to the same +// Docker container lifecycle — the strategy hint is ignored here. +type Adapter struct { + client *client.Client + nodeID string +} + +func New(nodeID string) (*Adapter, error) { + c, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) + if err != nil { + return nil, fmt.Errorf("failed to create docker client: %w", err) + } + return &Adapter{client: c, nodeID: nodeID}, nil +} + +// Deploy pulls the image and starts a new container. +func (a *Adapter) Deploy(ctx context.Context, spec ports.WorkloadSpec) (*ports.RunningServer, error) { + // Pull image. + rc, err := a.client.ImagePull(ctx, spec.Image, image.PullOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to pull image %s: %w", spec.Image, err) + } + rc.Close() + + containerID, err := a.createContainer(ctx, spec) + if err != nil { + return nil, err + } + + if err := a.client.ContainerStart(ctx, containerID, container.StartOptions{}); err != nil { + return nil, fmt.Errorf("failed to start container: %w", err) + } + + return &ports.RunningServer{ + Labels: labels.WorkloadLabels{ + OwnerID: spec.OwnerID, ServerID: spec.ServerID, + BlueprintID: spec.BlueprintID, NodeID: a.nodeID, + }, + RuntimeRef: containerID, + State: "Running", + }, nil +} + +// Start restarts a stopped container. If it no longer exists, re-creates it. +func (a *Adapter) Start(ctx context.Context, spec ports.WorkloadSpec) (*ports.RunningServer, error) { + containerID, err := a.findContainer(ctx, spec.ServerID) + if err != nil { + // Container gone — re-create it. + return a.Deploy(ctx, spec) + } + + if err := a.client.ContainerStart(ctx, containerID, container.StartOptions{}); err != nil { + return nil, fmt.Errorf("failed to start container: %w", err) + } + + return &ports.RunningServer{RuntimeRef: containerID, State: "Running"}, nil +} + +// Stop stops the container without removing it. +func (a *Adapter) Stop(ctx context.Context, workloadID string) error { + containerID, err := a.findContainer(ctx, workloadID) + if err != nil { + return err + } + timeout := 10 + if err := a.client.ContainerStop(ctx, containerID, container.StopOptions{Timeout: &timeout}); err != nil { + return fmt.Errorf("failed to stop container: %w", err) + } + return nil +} + +// Remove stops and removes the container. +func (a *Adapter) Remove(ctx context.Context, workloadID string) error { + containerID, err := a.findContainer(ctx, workloadID) + if err != nil { + return err + } + timeout := 10 + _ = a.client.ContainerStop(ctx, containerID, container.StopOptions{Timeout: &timeout}) + if err := a.client.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: true}); err != nil { + return fmt.Errorf("failed to remove container: %w", err) + } + return nil +} + +// Status returns the current state of the container. +func (a *Adapter) Status(ctx context.Context, workloadID string) (*ports.WorkloadHealth, error) { + containerID, err := a.findContainer(ctx, workloadID) + if err != nil { + return nil, err + } + info, err := a.client.ContainerInspect(ctx, containerID) + if err != nil { + return nil, fmt.Errorf("failed to inspect container: %w", err) + } + state := strings.ToLower(info.State.Status) + return &ports.WorkloadHealth{WorkloadID: workloadID, State: state}, nil +} + +// Endpoint returns the first exposed host port. +func (a *Adapter) Endpoint(ctx context.Context, workloadID string) (string, error) { + containerID, err := a.findContainer(ctx, workloadID) + if err != nil { + return "", err + } + info, err := a.client.ContainerInspect(ctx, containerID) + if err != nil { + return "", fmt.Errorf("failed to inspect container: %w", err) + } + for _, bindings := range info.NetworkSettings.Ports { + if len(bindings) > 0 { + return fmt.Sprintf("127.0.0.1:%s", bindings[0].HostPort), nil + } + } + return "", fmt.Errorf("no exposed ports found for workload %s", workloadID) +} + +// Logs streams the container's stdout/stderr. +func (a *Adapter) Logs(ctx context.Context, workloadID string, follow bool) (io.ReadCloser, error) { + containerID, err := a.findContainer(ctx, workloadID) + if err != nil { + return nil, err + } + rc, err := a.client.ContainerLogs(ctx, containerID, container.LogsOptions{ + ShowStdout: true, + ShowStderr: true, + Follow: follow, + }) + if err != nil { + return nil, fmt.Errorf("failed to get logs: %w", err) + } + return rc, nil +} + +// --- Helpers --- + +func (a *Adapter) createContainer(ctx context.Context, spec ports.WorkloadSpec) (string, error) { + env := make([]string, 0, len(spec.EnvOverrides)) + for k, v := range spec.EnvOverrides { + env = append(env, fmt.Sprintf("%s=%s", k, v)) + } + + exposedPorts := nat.PortSet{} + portBindings := nat.PortMap{} + for _, p := range spec.PortRequirements { + proto := strings.ToLower(p.Protocol) + if proto == "" { + proto = "tcp" + } + natPort := nat.Port(fmt.Sprintf("%d/%s", p.TargetPort, proto)) + exposedPorts[natPort] = struct{}{} + portBindings[natPort] = []nat.PortBinding{{HostIP: "0.0.0.0", HostPort: "0"}} // 0 = random host port + } + + containerLabels := map[string]string{ + labelPrefix + "server_id": spec.ServerID, + labelPrefix + "owner_id": spec.OwnerID, + labelPrefix + "blueprint_id": spec.BlueprintID, + labelPrefix + "node_id": a.nodeID, + } + + resp, err := a.client.ContainerCreate(ctx, + &container.Config{ + Image: spec.Image, + Env: env, + ExposedPorts: exposedPorts, + Labels: containerLabels, + }, + &container.HostConfig{ + PortBindings: portBindings, + }, + &network.NetworkingConfig{}, + nil, + spec.ServerID, + ) + if err != nil { + return "", fmt.Errorf("failed to create container: %w", err) + } + return resp.ID, nil +} + +// findContainer looks up a container by the kleff server_id label. +func (a *Adapter) findContainer(ctx context.Context, serverID string) (string, error) { + containers, err := a.client.ContainerList(ctx, container.ListOptions{ + All: true, + Filters: filters.NewArgs(filters.Arg("label", labelPrefix+"server_id="+serverID)), + }) + if err != nil { + return "", fmt.Errorf("failed to list containers: %w", err) + } + if len(containers) == 0 { + return "", fmt.Errorf("container not found for server %s", serverID) + } + return containers[0].ID, nil +} diff --git a/internal/adapters/out/runtime/kubernetes/agones.go b/internal/adapters/out/runtime/kubernetes/agones.go deleted file mode 100644 index 5ac2745..0000000 --- a/internal/adapters/out/runtime/kubernetes/agones.go +++ /dev/null @@ -1,348 +0,0 @@ -package kubernetes - -import ( - "context" - "encoding/json" - "fmt" - "strconv" - "strings" - "time" - - "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" - "k8s.io/client-go/tools/clientcmd" -) - -var minecraftServerGVR = schema.GroupVersionResource{ - Group: "kleff.io", - Version: "v1alpha1", - Resource: "minecraftservers", -} - -var xMinecraftServerGVR = schema.GroupVersionResource{ - Group: "kleff.io", - Version: "v1alpha1", - Resource: "xminecraftservers", -} - -var gameServerGVR = schema.GroupVersionResource{ - Group: "agones.dev", - Version: "v1", - Resource: "gameservers", -} - -type KubernetesRuntime struct { - client dynamic.Interface - namespace string - nodeID string -} - -func New(kubeconfig, namespace, nodeID string) (*KubernetesRuntime, error) { - var cfg *rest.Config - var err error - - if kubeconfig == "" { - cfg, err = rest.InClusterConfig() - if err != nil { - return nil, fmt.Errorf("failed to build kubeconfig: %w", err) - } - } else if strings.HasPrefix(kubeconfig, "http") { - cfg = &rest.Config{Host: kubeconfig} - } else { - cfg, err = clientcmd.BuildConfigFromFlags("", kubeconfig) - if err != nil { - return nil, fmt.Errorf("failed to build kubeconfig: %w", err) - } - } - - client, err := dynamic.NewForConfig(cfg) - if err != nil { - return nil, fmt.Errorf("failed to create kubernetes client: %w", err) - } - - return &KubernetesRuntime{client: client, namespace: namespace, nodeID: nodeID}, nil -} - -func (k *KubernetesRuntime) Provision(ctx context.Context, payload payloads.ServerOperationPayload) (*ports.RunningServer, error) { - serverLabels := labels.WorkloadLabels{ - OwnerID: payload.OwnerID, - ServerID: payload.ServerID, - BlueprintID: payload.BlueprintID, - NodeID: k.nodeID, - } - - labelMap := serverLabels.ToMap() - labelInterface := make(map[string]interface{}) - for k, v := range labelMap { - labelInterface[k] = v - } - - env := payload.EnvOverrides - maxPlayers, _ := strconv.ParseInt(env["MAX_PLAYERS"], 10, 64) - viewDistance, _ := strconv.ParseInt(env["VIEW_DISTANCE"], 10, 64) - onlineMode, _ := strconv.ParseBool(env["ONLINE_MODE"]) - - spec := map[string]interface{}{ - "serverName": payload.ServerID, - "type": env["TYPE"], - "version": env["VERSION"], - "maxPlayers": maxPlayers, - "difficulty": env["DIFFICULTY"], - "gamemode": env["MODE"], - "viewDistance": viewDistance, - "worldSeed": env["LEVEL_SEED"], - "onlineMode": onlineMode, - } - - claim := &unstructured.Unstructured{ - Object: map[string]interface{}{ - "apiVersion": "kleff.io/v1alpha1", - "kind": "MinecraftServer", - "metadata": map[string]interface{}{ - "name": payload.ServerID, - "namespace": k.namespace, - "labels": labelInterface, - }, - "spec": spec, - }, - } - - _, err := k.client.Resource(minecraftServerGVR).Namespace(k.namespace).Create(ctx, claim, metav1.CreateOptions{}) - if err != nil { - return nil, fmt.Errorf("failed to create MinecraftServer claim: %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) 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 { - 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.client.Resource(minecraftServerGVR).Namespace(k.namespace).Delete(ctx, serverID, metav1.DeleteOptions{}) -} - -func (k *KubernetesRuntime) GetByID(ctx context.Context, serverID string) (*ports.RunningServer, error) { - gs, err := k.client.Resource(gameServerGVR).Namespace(k.namespace).Get(ctx, serverID, metav1.GetOptions{}) - if err != nil { - return nil, fmt.Errorf("failed to get game server: %w", err) - } - - state, _, _ := unstructured.NestedString(gs.Object, "status", "state") - rawLabels, _, _ := unstructured.NestedStringMap(gs.Object, "metadata", "labels") - - return &ports.RunningServer{ - Labels: labels.FromMap(rawLabels), - RuntimeRef: serverID, - State: state, - }, nil -} - -func (k *KubernetesRuntime) Reconcile(ctx context.Context, nodeID string) ([]*ports.RunningServer, error) { - list, err := k.client.Resource(gameServerGVR).Namespace(k.namespace).List(ctx, metav1.ListOptions{ - LabelSelector: fmt.Sprintf("%s=%s,%s=%s", labels.ManagedBy, labels.ManagedByValue, labels.NodeID, nodeID), - }) - if err != nil { - return nil, fmt.Errorf("failed to list game servers: %w", err) - } - - var servers []*ports.RunningServer - for _, item := range list.Items { - state, _, _ := unstructured.NestedString(item.Object, "status", "state") - rawLabels, _, _ := unstructured.NestedStringMap(item.Object, "metadata", "labels") - servers = append(servers, &ports.RunningServer{ - Labels: labels.FromMap(rawLabels), - RuntimeRef: item.GetName(), - State: state, - }) - } - - return servers, nil -} - -func (k *KubernetesRuntime) Stats(ctx context.Context, serverID string) (*ports.RawStats, error) { - 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 - - err := wait.PollUntilContextTimeout(ctx, 5*time.Second, 5*time.Minute, true, func(ctx context.Context) (bool, error) { - gs, err := k.client.Resource(gameServerGVR).Namespace(k.namespace).Get(ctx, name, metav1.GetOptions{}) - if err != nil { - return false, nil - } - - state, _, _ := unstructured.NestedString(gs.Object, "status", "state") - if state != "Ready" { - return false, nil - } - - server = &ports.RunningServer{ - Labels: serverLabels, - RuntimeRef: name, - State: "Ready", - } - return true, nil - }) - - if err != nil { - return nil, err - } - - return server, nil -} diff --git a/internal/adapters/out/runtime/kubernetes/kubernetes.go b/internal/adapters/out/runtime/kubernetes/kubernetes.go new file mode 100644 index 0000000..f5427e0 --- /dev/null +++ b/internal/adapters/out/runtime/kubernetes/kubernetes.go @@ -0,0 +1,530 @@ +package kubernetes + +import ( + "bytes" + "context" + "fmt" + "io" + "strings" + "time" + + "github.com/kleffio/kleff-daemon/internal/application/ports" + "github.com/kleffio/kleff-daemon/pkg/labels" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "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/util/wait" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" +) + +const labelStrategy = "kleff.io/runtime-strategy" + +var gameServerGVR = schema.GroupVersionResource{ + Group: "agones.dev", + Version: "v1", + Resource: "gameservers", +} + +// Adapter is a generic Kubernetes RuntimeAdapter. +// It routes deployments to one of three strategies based on WorkloadSpec.RuntimeHints.KubernetesStrategy: +// +// "agones" → Agones GameServer CRD (game servers) +// "statefulset" → StatefulSet + PVC (databases, persistent services) +// "" → Deployment + Service (stateless web/app workloads) +type Adapter struct { + dynamic dynamic.Interface + typed kubernetes.Interface + namespace string + nodeID string +} + +func New(kubeconfig, namespace, nodeID string) (*Adapter, error) { + cfg, err := buildConfig(kubeconfig) + if err != nil { + return nil, err + } + + dyn, err := dynamic.NewForConfig(cfg) + if err != nil { + return nil, fmt.Errorf("failed to create dynamic client: %w", err) + } + + typed, err := kubernetes.NewForConfig(cfg) + if err != nil { + return nil, fmt.Errorf("failed to create typed client: %w", err) + } + + return &Adapter{dynamic: dyn, typed: typed, namespace: namespace, nodeID: nodeID}, nil +} + +func buildConfig(kubeconfig string) (*rest.Config, error) { + if kubeconfig == "" { + cfg, err := rest.InClusterConfig() + if err != nil { + return nil, fmt.Errorf("failed to build in-cluster config: %w", err) + } + return cfg, nil + } + if strings.HasPrefix(kubeconfig, "http") { + return &rest.Config{Host: kubeconfig}, nil + } + cfg, err := clientcmd.BuildConfigFromFlags("", kubeconfig) + if err != nil { + return nil, fmt.Errorf("failed to build kubeconfig: %w", err) + } + return cfg, nil +} + +// Deploy provisions and starts a new workload. +func (a *Adapter) Deploy(ctx context.Context, spec ports.WorkloadSpec) (*ports.RunningServer, error) { + switch spec.RuntimeHints.KubernetesStrategy { + case "agones": + return a.deployAgones(ctx, spec) + case "statefulset": + return a.deployStatefulSet(ctx, spec) + default: + return a.deployDeployment(ctx, spec) + } +} + +// Start resumes a previously stopped workload. +func (a *Adapter) Start(ctx context.Context, spec ports.WorkloadSpec) (*ports.RunningServer, error) { + switch spec.RuntimeHints.KubernetesStrategy { + case "agones": + // Agones GameServers are ephemeral — recreate it. + return a.deployAgones(ctx, spec) + case "statefulset": + return a.scaleStatefulSet(ctx, spec.ServerID, 1) + default: + return a.scaleDeployment(ctx, spec.ServerID, 1) + } +} + +// Stop suspends a workload without removing it. +func (a *Adapter) Stop(ctx context.Context, workloadID string) error { + strategy, err := a.strategyFor(ctx, workloadID) + if err != nil { + return err + } + switch strategy { + case "agones": + return a.dynamic.Resource(gameServerGVR).Namespace(a.namespace).Delete(ctx, workloadID, metav1.DeleteOptions{}) + case "statefulset": + _, err := a.scaleStatefulSet(ctx, workloadID, 0) + return err + default: + _, err := a.scaleDeployment(ctx, workloadID, 0) + return err + } +} + +// Remove permanently deletes a workload and all associated resources. +func (a *Adapter) Remove(ctx context.Context, workloadID string) error { + strategy, err := a.strategyFor(ctx, workloadID) + if err != nil { + return err + } + switch strategy { + case "agones": + return a.dynamic.Resource(gameServerGVR).Namespace(a.namespace).Delete(ctx, workloadID, metav1.DeleteOptions{}) + case "statefulset": + return a.typed.AppsV1().StatefulSets(a.namespace).Delete(ctx, workloadID, metav1.DeleteOptions{}) + default: + return a.typed.AppsV1().Deployments(a.namespace).Delete(ctx, workloadID, metav1.DeleteOptions{}) + } +} + +// Status returns the current state of a workload. +func (a *Adapter) Status(ctx context.Context, workloadID string) (*ports.WorkloadHealth, error) { + strategy, err := a.strategyFor(ctx, workloadID) + if err != nil { + return nil, err + } + switch strategy { + case "agones": + gs, err := a.dynamic.Resource(gameServerGVR).Namespace(a.namespace).Get(ctx, workloadID, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to get game server: %w", err) + } + state, _, _ := unstructured.NestedString(gs.Object, "status", "state") + return &ports.WorkloadHealth{WorkloadID: workloadID, State: strings.ToLower(state)}, nil + default: + return &ports.WorkloadHealth{WorkloadID: workloadID, State: "unknown"}, nil + } +} + +// Endpoint returns the address users connect to. +func (a *Adapter) Endpoint(ctx context.Context, workloadID string) (string, error) { + strategy, err := a.strategyFor(ctx, workloadID) + if err != nil { + return "", err + } + if strategy == "agones" { + gs, err := a.dynamic.Resource(gameServerGVR).Namespace(a.namespace).Get(ctx, workloadID, metav1.GetOptions{}) + if err != nil { + return "", fmt.Errorf("failed to get game server: %w", err) + } + address, _, _ := unstructured.NestedString(gs.Object, "status", "address") + ports, _, _ := unstructured.NestedSlice(gs.Object, "status", "ports") + if len(ports) > 0 { + if p, ok := ports[0].(map[string]interface{}); ok { + port := fmt.Sprintf("%v", p["port"]) + return fmt.Sprintf("%s:%s", address, port), nil + } + } + return address, nil + } + svc, err := a.typed.CoreV1().Services(a.namespace).Get(ctx, workloadID, metav1.GetOptions{}) + if err != nil { + return "", fmt.Errorf("failed to get service: %w", err) + } + if len(svc.Spec.Ports) > 0 { + return fmt.Sprintf("%s:%d", svc.Spec.ClusterIP, svc.Spec.Ports[0].Port), nil + } + return svc.Spec.ClusterIP, nil +} + +// Logs is not yet implemented for Kubernetes. +func (a *Adapter) Logs(_ context.Context, _ string, _ bool) (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(nil)), nil +} + +// --- Agones strategy --- + +func (a *Adapter) deployAgones(ctx context.Context, spec ports.WorkloadSpec) (*ports.RunningServer, error) { + serverLabels := labels.WorkloadLabels{ + OwnerID: spec.OwnerID, + ServerID: spec.ServerID, + BlueprintID: spec.BlueprintID, + NodeID: a.nodeID, + } + + labelMap := serverLabels.ToMap() + labelMap[labelStrategy] = "agones" + labelInterface := toInterfaceMap(labelMap) + + agonesPortSpecs := make([]interface{}, 0, len(spec.PortRequirements)) + for i, p := range spec.PortRequirements { + agonesPortSpecs = append(agonesPortSpecs, map[string]interface{}{ + "name": fmt.Sprintf("port-%d", i), + "portPolicy": "Dynamic", + "containerPort": int64(p.TargetPort), + "protocol": strings.ToUpper(p.Protocol), + }) + } + + gs := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "agones.dev/v1", + "kind": "GameServer", + "metadata": map[string]interface{}{ + "name": spec.ServerID, + "namespace": a.namespace, + "labels": labelInterface, + }, + "spec": map[string]interface{}{ + "container": "game", + "health": map[string]interface{}{"disabled": true}, + "ports": agonesPortSpecs, + "template": map[string]interface{}{ + "spec": map[string]interface{}{ + "containers": []interface{}{ + map[string]interface{}{ + "name": "game", + "image": spec.Image, + "env": buildEnvList(spec.EnvOverrides), + "resources": buildResourceRequirements(spec.MemoryBytes, spec.CPUMillicores), + "lifecycle": map[string]interface{}{ + "postStart": map[string]interface{}{ + "exec": map[string]interface{}{ + "command": []interface{}{ + "/bin/sh", "-c", + "sleep 5 && curl -sf -X POST http://localhost:9358/ready || true", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + _, err := a.dynamic.Resource(gameServerGVR).Namespace(a.namespace).Create(ctx, gs, metav1.CreateOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to create game server: %w", err) + } + + return a.waitForAgonesReady(ctx, spec.ServerID, serverLabels) +} + +func (a *Adapter) waitForAgonesReady(ctx context.Context, name string, serverLabels labels.WorkloadLabels) (*ports.RunningServer, error) { + var server *ports.RunningServer + + err := wait.PollUntilContextTimeout(ctx, 5*time.Second, 5*time.Minute, true, func(ctx context.Context) (bool, error) { + gs, err := a.dynamic.Resource(gameServerGVR).Namespace(a.namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return false, nil + } + state, _, _ := unstructured.NestedString(gs.Object, "status", "state") + if state != "Ready" { + return false, nil + } + server = &ports.RunningServer{Labels: serverLabels, RuntimeRef: name, State: "Ready"} + return true, nil + }) + if err != nil { + return nil, err + } + return server, nil +} + +// --- StatefulSet strategy --- + +func (a *Adapter) deployStatefulSet(ctx context.Context, spec ports.WorkloadSpec) (*ports.RunningServer, error) { + serverLabels := labels.WorkloadLabels{ + OwnerID: spec.OwnerID, ServerID: spec.ServerID, BlueprintID: spec.BlueprintID, NodeID: a.nodeID, + } + selectorLabels := map[string]string{"app": spec.ServerID, labelStrategy: "statefulset"} + + replicas := int32(1) + sts := buildStatefulSet(spec, replicas, selectorLabels) + + if _, err := a.typed.AppsV1().StatefulSets(a.namespace).Create(ctx, sts, metav1.CreateOptions{}); err != nil { + return nil, fmt.Errorf("failed to create statefulset: %w", err) + } + + svc := buildService(spec, selectorLabels) + if _, err := a.typed.CoreV1().Services(a.namespace).Create(ctx, svc, metav1.CreateOptions{}); err != nil { + return nil, fmt.Errorf("failed to create service: %w", err) + } + + return &ports.RunningServer{Labels: serverLabels, RuntimeRef: spec.ServerID, State: "Running"}, nil +} + +func (a *Adapter) scaleStatefulSet(ctx context.Context, workloadID string, replicas int32) (*ports.RunningServer, error) { + scale, err := a.typed.AppsV1().StatefulSets(a.namespace).GetScale(ctx, workloadID, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to get statefulset scale: %w", err) + } + scale.Spec.Replicas = replicas + if _, err := a.typed.AppsV1().StatefulSets(a.namespace).UpdateScale(ctx, workloadID, scale, metav1.UpdateOptions{}); err != nil { + return nil, fmt.Errorf("failed to scale statefulset: %w", err) + } + state := "Running" + if replicas == 0 { + state = "Stopped" + } + return &ports.RunningServer{RuntimeRef: workloadID, State: state}, nil +} + +// --- Deployment strategy --- + +func (a *Adapter) deployDeployment(ctx context.Context, spec ports.WorkloadSpec) (*ports.RunningServer, error) { + serverLabels := labels.WorkloadLabels{ + OwnerID: spec.OwnerID, ServerID: spec.ServerID, BlueprintID: spec.BlueprintID, NodeID: a.nodeID, + } + selectorLabels := map[string]string{"app": spec.ServerID, labelStrategy: "deployment"} + + replicas := int32(1) + deploy := buildDeployment(spec, replicas, selectorLabels) + + if _, err := a.typed.AppsV1().Deployments(a.namespace).Create(ctx, deploy, metav1.CreateOptions{}); err != nil { + return nil, fmt.Errorf("failed to create deployment: %w", err) + } + + svc := buildService(spec, selectorLabels) + if _, err := a.typed.CoreV1().Services(a.namespace).Create(ctx, svc, metav1.CreateOptions{}); err != nil { + return nil, fmt.Errorf("failed to create service: %w", err) + } + + return &ports.RunningServer{Labels: serverLabels, RuntimeRef: spec.ServerID, State: "Running"}, nil +} + +func (a *Adapter) scaleDeployment(ctx context.Context, workloadID string, replicas int32) (*ports.RunningServer, error) { + scale, err := a.typed.AppsV1().Deployments(a.namespace).GetScale(ctx, workloadID, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to get deployment scale: %w", err) + } + scale.Spec.Replicas = replicas + if _, err := a.typed.AppsV1().Deployments(a.namespace).UpdateScale(ctx, workloadID, scale, metav1.UpdateOptions{}); err != nil { + return nil, fmt.Errorf("failed to scale deployment: %w", err) + } + state := "Running" + if replicas == 0 { + state = "Stopped" + } + return &ports.RunningServer{RuntimeRef: workloadID, State: state}, nil +} + +// --- Helpers --- + +// strategyFor looks up the runtime strategy label on the live resource. +func (a *Adapter) strategyFor(ctx context.Context, workloadID string) (string, error) { + // Try Agones GameServer first. + gs, err := a.dynamic.Resource(gameServerGVR).Namespace(a.namespace).Get(ctx, workloadID, metav1.GetOptions{}) + if err == nil { + lbls, _, _ := unstructured.NestedStringMap(gs.Object, "metadata", "labels") + return lbls[labelStrategy], nil + } + // Try StatefulSet. + sts, err := a.typed.AppsV1().StatefulSets(a.namespace).Get(ctx, workloadID, metav1.GetOptions{}) + if err == nil { + return sts.Labels[labelStrategy], nil + } + // Try Deployment. + deploy, err := a.typed.AppsV1().Deployments(a.namespace).Get(ctx, workloadID, metav1.GetOptions{}) + if err == nil { + return deploy.Labels[labelStrategy], nil + } + return "", fmt.Errorf("workload %q not found in any strategy", workloadID) +} + +func buildEnvList(overrides map[string]string) []interface{} { + env := make([]interface{}, 0, len(overrides)) + for k, v := range overrides { + env = append(env, map[string]interface{}{"name": k, "value": v}) + } + return env +} + +func buildEnvVars(overrides map[string]string) []corev1.EnvVar { + env := make([]corev1.EnvVar, 0, len(overrides)) + for k, v := range overrides { + env = append(env, corev1.EnvVar{Name: k, Value: v}) + } + return env +} + +func buildResourceRequirements(memBytes, cpuMillicores int64) map[string]interface{} { + mem := resource.NewQuantity(4*1024*1024*1024, resource.BinarySI) + if memBytes > 0 { + mem = resource.NewQuantity(memBytes, resource.BinarySI) + } + cpu := resource.NewMilliQuantity(2000, resource.DecimalSI) + if cpuMillicores > 0 { + cpu = resource.NewMilliQuantity(cpuMillicores, resource.DecimalSI) + } + return map[string]interface{}{ + "requests": map[string]interface{}{"memory": mem.String(), "cpu": cpu.String()}, + "limits": map[string]interface{}{"memory": mem.String(), "cpu": cpu.String()}, + } +} + +func buildTypedResourceRequirements(memBytes, cpuMillicores int64) corev1.ResourceRequirements { + mem := resource.NewQuantity(4*1024*1024*1024, resource.BinarySI) + if memBytes > 0 { + mem = resource.NewQuantity(memBytes, resource.BinarySI) + } + cpu := resource.NewMilliQuantity(2000, resource.DecimalSI) + if cpuMillicores > 0 { + cpu = resource.NewMilliQuantity(cpuMillicores, resource.DecimalSI) + } + return corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceMemory: *mem, + corev1.ResourceCPU: *cpu, + }, + Limits: corev1.ResourceList{ + corev1.ResourceMemory: *mem, + corev1.ResourceCPU: *cpu, + }, + } +} + +func buildServicePorts(portReqs []ports.PortRequirement) []corev1.ServicePort { + svcPorts := make([]corev1.ServicePort, 0, len(portReqs)) + for i, p := range portReqs { + protocol := corev1.ProtocolTCP + if strings.ToLower(p.Protocol) == "udp" { + protocol = corev1.ProtocolUDP + } + svcPorts = append(svcPorts, corev1.ServicePort{ + Name: fmt.Sprintf("port-%d", i), + Port: int32(p.TargetPort), + Protocol: protocol, + }) + } + return svcPorts +} + +func toInterfaceMap(m map[string]string) map[string]interface{} { + out := make(map[string]interface{}, len(m)) + for k, v := range m { + out[k] = v + } + return out +} + +func buildStatefulSet(spec ports.WorkloadSpec, replicas int32, selectorLabels map[string]string) *appsv1.StatefulSet { + return &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: spec.ServerID, + Labels: selectorLabels, + }, + Spec: appsv1.StatefulSetSpec{ + Replicas: &replicas, + Selector: &metav1.LabelSelector{MatchLabels: selectorLabels}, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: selectorLabels}, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "app", + Image: spec.Image, + Env: buildEnvVars(spec.EnvOverrides), + Resources: buildTypedResourceRequirements(spec.MemoryBytes, spec.CPUMillicores), + }, + }, + }, + }, + }, + } +} + +func buildDeployment(spec ports.WorkloadSpec, replicas int32, selectorLabels map[string]string) *appsv1.Deployment { + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: spec.ServerID, + Labels: selectorLabels, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: &replicas, + Selector: &metav1.LabelSelector{MatchLabels: selectorLabels}, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: selectorLabels}, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "app", + Image: spec.Image, + Env: buildEnvVars(spec.EnvOverrides), + Resources: buildTypedResourceRequirements(spec.MemoryBytes, spec.CPUMillicores), + }, + }, + }, + }, + }, + } +} + +func buildService(spec ports.WorkloadSpec, selectorLabels map[string]string) *corev1.Service { + return &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: spec.ServerID, + Labels: selectorLabels, + }, + Spec: corev1.ServiceSpec{ + Selector: selectorLabels, + Ports: buildServicePorts(spec.PortRequirements), + }, + } +} diff --git a/internal/app/config/config.go b/internal/app/config/config.go index a5020c8..915ef7f 100644 --- a/internal/app/config/config.go +++ b/internal/app/config/config.go @@ -9,13 +9,6 @@ import ( "github.com/spf13/viper" ) -type RuntimeMode string - -const ( - RuntimeModeDocker RuntimeMode = "docker" - RuntimeModeKubernetes RuntimeMode = "kubernetes" -) - type QueueBackend string const ( @@ -24,7 +17,11 @@ const ( ) type Config struct { - RuntimeMode RuntimeMode `mapstructure:"runtime.mode"` + // Kubeconfig is optional. If empty and a Kubernetes environment is detected, + // the daemon uses in-cluster config. If set, it can be a kubeconfig file path + // or an API server URL (e.g. http://localhost:8001). + Kubeconfig string `mapstructure:"kubeconfig"` + KubeNamespace string `mapstructure:"kube.namespace"` ClusterRegion string `mapstructure:"cluster.region"` NodeID string `mapstructure:"node.id"` GRPCPort int `mapstructure:"grpc.port"` @@ -39,13 +36,6 @@ type Config struct { } func (c *Config) Validate() error { - switch c.RuntimeMode { - case RuntimeModeDocker, RuntimeModeKubernetes: - // valid - default: - return fmt.Errorf("invalid runtime.mode: %q (must be 'docker' or 'kubernetes')", c.RuntimeMode) - } - switch c.QueueBackend { case QueueBackendMemory, QueueBackendRedis: // valid @@ -69,7 +59,6 @@ func (c *Config) Validate() error { } func Load() (*Config, error) { - v := viper.New() hostname, err := os.Hostname() @@ -77,7 +66,8 @@ func Load() (*Config, error) { hostname = "unknown-node" } - v.SetDefault("runtime.mode", string(RuntimeModeDocker)) + v.SetDefault("kubeconfig", "") + v.SetDefault("kube.namespace", "default") v.SetDefault("cluster.region", "local") v.SetDefault("node.id", hostname) v.SetDefault("grpc.port", 50051) @@ -108,7 +98,8 @@ func Load() (*Config, error) { fs := pflag.NewFlagSet("kleff", pflag.ContinueOnError) fs.ParseErrorsWhitelist.UnknownFlags = true - fs.String("runtime.mode", v.GetString("runtime.mode"), "Runtime mode for the daemon (e.g. docker, kubernetes)") + fs.String("kubeconfig", v.GetString("kubeconfig"), "Path to kubeconfig file, or API server URL (empty = auto-detect)") + fs.String("kube.namespace", v.GetString("kube.namespace"), "Kubernetes namespace to deploy workloads into") fs.String("cluster.region", v.GetString("cluster.region"), "Cluster region this daemon belongs to") fs.String("node.id", v.GetString("node.id"), "Unique identifier for this daemon node") fs.Int("grpc.port", v.GetInt("grpc.port"), "Port for the gRPC server to listen on") @@ -118,23 +109,24 @@ func Load() (*Config, error) { fs.Parse(os.Args[1:]) v.BindPFlags(fs) - var config Config - config.RuntimeMode = RuntimeMode(v.GetString("runtime.mode")) - config.ClusterRegion = v.GetString("cluster.region") - config.NodeID = v.GetString("node.id") - config.GRPCPort = v.GetInt("grpc.port") - config.MetricsPort = v.GetInt("metrics.port") - config.QueueBackend = QueueBackend(v.GetString("queue.backend")) - config.DatabasePath = v.GetString("database.path") - config.RedisURL = v.GetString("redis.url") - config.RedisPassword = v.GetString("redis.password") - config.RedisTLS = v.GetBool("redis.tls") - config.PlatformURL = v.GetString("platform.url") - config.SharedSecret = v.GetString("shared_secret") - - if err := config.Validate(); err != nil { + var cfg Config + cfg.Kubeconfig = v.GetString("kubeconfig") + cfg.KubeNamespace = v.GetString("kube.namespace") + cfg.ClusterRegion = v.GetString("cluster.region") + cfg.NodeID = v.GetString("node.id") + cfg.GRPCPort = v.GetInt("grpc.port") + cfg.MetricsPort = v.GetInt("metrics.port") + cfg.QueueBackend = QueueBackend(v.GetString("queue.backend")) + cfg.DatabasePath = v.GetString("database.path") + cfg.RedisURL = v.GetString("redis.url") + cfg.RedisPassword = v.GetString("redis.password") + cfg.RedisTLS = v.GetBool("redis.tls") + cfg.PlatformURL = v.GetString("platform.url") + cfg.SharedSecret = v.GetString("shared_secret") + + if err := cfg.Validate(); err != nil { return nil, fmt.Errorf("configuration validation failed: %w", err) } - return &config, nil + return &cfg, nil } diff --git a/internal/app/config/config_test.go b/internal/app/config/config_test.go index b113687..c3c303e 100644 --- a/internal/app/config/config_test.go +++ b/internal/app/config/config_test.go @@ -13,8 +13,8 @@ func resetViperAndFlags() { pflag.CommandLine = pflag.NewFlagSet(os.Args[0], pflag.ExitOnError) } -// setRequiredKleffVars sets the two new required KLEFF_* vars that every -// test must supply unless it is specifically testing their absence. +// setRequiredKleffVars sets the required KLEFF_* vars that every test must supply +// unless it is specifically testing their absence. func setRequiredKleffVars(t *testing.T) { t.Helper() os.Setenv("KLEFF_PLATFORM_URL", "http://platform.test") @@ -25,12 +25,9 @@ func setRequiredKleffVars(t *testing.T) { }) } -func TestConfigLoadsCorrectlyDefaults(t *testing.T) { +func TestConfigLoadsDefaults(t *testing.T) { resetViperAndFlags() - os.Unsetenv("KLEFF_RUNTIME_MODE") - os.Unsetenv("KLEFF_CLUSTER_REGION") - os.Setenv("KLEFF_NODE_ID", "default-node") defer os.Unsetenv("KLEFF_NODE_ID") setRequiredKleffVars(t) @@ -40,26 +37,24 @@ func TestConfigLoadsCorrectlyDefaults(t *testing.T) { t.Fatalf("Failed to load config: %v", err) } - if cfg.RuntimeMode != RuntimeModeDocker { - t.Errorf("Expected default runtime mode 'docker', got '%s'", cfg.RuntimeMode) - } if cfg.GRPCPort != 50051 { t.Errorf("Expected default gRPC port 50051, got %d", cfg.GRPCPort) } if cfg.QueueBackend != QueueBackendMemory { t.Errorf("Expected default queue backend 'memory', got '%s'", cfg.QueueBackend) } + if cfg.KubeNamespace != "default" { + t.Errorf("Expected default kube namespace 'default', got '%s'", cfg.KubeNamespace) + } } -func TestConfigRuntimeModeConfigurableViaEnv(t *testing.T) { +func TestConfigQueueBackendViaEnv(t *testing.T) { resetViperAndFlags() os.Setenv("KLEFF_NODE_ID", "env-node") - os.Setenv("KLEFF_RUNTIME_MODE", "kubernetes") os.Setenv("KLEFF_QUEUE_BACKEND", "redis") defer func() { os.Unsetenv("KLEFF_NODE_ID") - os.Unsetenv("KLEFF_RUNTIME_MODE") os.Unsetenv("KLEFF_QUEUE_BACKEND") }() setRequiredKleffVars(t) @@ -69,9 +64,6 @@ func TestConfigRuntimeModeConfigurableViaEnv(t *testing.T) { t.Fatalf("Failed to load config: %v", err) } - if cfg.RuntimeMode != RuntimeModeKubernetes { - t.Errorf("Expected runtime mode 'kubernetes', got '%s'", cfg.RuntimeMode) - } if cfg.QueueBackend != QueueBackendRedis { t.Errorf("Expected queue backend 'redis', got '%s'", cfg.QueueBackend) } @@ -109,7 +101,6 @@ func TestConfigNodesAndPortsViaEnv(t *testing.T) { func TestConfigValidationFailsForInvalidInputs(t *testing.T) { resetViperAndFlags() - os.Setenv("KLEFF_RUNTIME_MODE", "docker") os.Setenv("KLEFF_QUEUE_BACKEND", "memory") os.Setenv("KLEFF_PLATFORM_URL", "http://platform.test") os.Setenv("KLEFF_SHARED_SECRET", "test-secret") @@ -121,23 +112,11 @@ func TestConfigValidationFailsForInvalidInputs(t *testing.T) { resetViperAndFlags() os.Setenv("KLEFF_NODE_ID", "valid-node") - os.Setenv("KLEFF_RUNTIME_MODE", "invalid-runtime") - os.Setenv("KLEFF_PLATFORM_URL", "http://platform.test") - os.Setenv("KLEFF_SHARED_SECRET", "test-secret") - _, err = Load() - if err == nil { - t.Errorf("Expected validation to fail for invalid runtime.mode") - } - - resetViperAndFlags() - os.Setenv("KLEFF_NODE_ID", "valid-node") - os.Setenv("KLEFF_RUNTIME_MODE", "docker") os.Setenv("KLEFF_QUEUE_BACKEND", "invalid-queue") os.Setenv("KLEFF_PLATFORM_URL", "http://platform.test") os.Setenv("KLEFF_SHARED_SECRET", "test-secret") defer func() { os.Unsetenv("KLEFF_NODE_ID") - os.Unsetenv("KLEFF_RUNTIME_MODE") os.Unsetenv("KLEFF_QUEUE_BACKEND") os.Unsetenv("KLEFF_PLATFORM_URL") os.Unsetenv("KLEFF_SHARED_SECRET") @@ -152,8 +131,6 @@ func TestConfigPrecedence(t *testing.T) { resetViperAndFlags() yamlContent := []byte(` -runtime: - mode: docker node: id: file-node grpc: @@ -165,12 +142,8 @@ grpc: } defer os.Remove("config.yaml") - os.Setenv("KLEFF_RUNTIME_MODE", "kubernetes") os.Setenv("KLEFF_NODE_ID", "env-node") - defer func() { - os.Unsetenv("KLEFF_RUNTIME_MODE") - os.Unsetenv("KLEFF_NODE_ID") - }() + defer os.Unsetenv("KLEFF_NODE_ID") setRequiredKleffVars(t) os.Args = []string{"cmd", "--node.id=flag-node"} @@ -181,12 +154,9 @@ grpc: } if cfg.NodeID != "flag-node" { - t.Errorf("Expected node.id to be 'flag-node' (Flag precedence), got '%s'", cfg.NodeID) - } - if cfg.RuntimeMode != RuntimeModeKubernetes { - t.Errorf("Expected runtime.mode to be 'kubernetes' (Env precedence over File), got '%s'", cfg.RuntimeMode) + t.Errorf("Expected node.id to be 'flag-node' (flag precedence), got '%s'", cfg.NodeID) } if cfg.GRPCPort != 10000 { - t.Errorf("Expected grpc.port to be 10000 (File precedence over Default), got %d", cfg.GRPCPort) + t.Errorf("Expected grpc.port to be 10000 (file precedence over default), got %d", cfg.GRPCPort) } } diff --git a/internal/application/ports/container_runtime.go b/internal/application/ports/container_runtime.go deleted file mode 100644 index 14278f2..0000000 --- a/internal/application/ports/container_runtime.go +++ /dev/null @@ -1,31 +0,0 @@ -package ports - -import ( - "context" - - "github.com/kleffio/kleff-daemon/pkg/labels" -) - -type RunningServer struct { - Labels labels.WorkloadLabels - RuntimeRef string - State string -} - -type RawStats struct { - CPUMillicores float64 - MemoryBytes int64 - NetBytesIn int64 - NetBytesOut int64 - ActivePlayers int -} - -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 - GetByID(ctx context.Context, serverID string) (*RunningServer, error) - Reconcile(ctx context.Context, nodeID string) ([]*RunningServer, error) - Stats(ctx context.Context, serverID string) (*RawStats, error) -} diff --git a/internal/application/ports/runtime_adapter.go b/internal/application/ports/runtime_adapter.go index 6053a9e..8fe88ca 100644 --- a/internal/application/ports/runtime_adapter.go +++ b/internal/application/ports/runtime_adapter.go @@ -5,14 +5,21 @@ import ( "io" ) -// RuntimeAdapter replaces ContainerRuntime as the generic interface for deploying -// workloads on either Docker or Kubernetes. +// RuntimeAdapter is the generic interface for deploying workloads on any backend. type RuntimeAdapter interface { + // Deploy provisions and starts a new workload from scratch. Deploy(ctx context.Context, spec WorkloadSpec) (*RunningServer, error) - Remove(ctx context.Context, workloadID string) error - Start(ctx context.Context, workloadID string) error + // Start resumes a previously stopped workload. The full spec is required + // because some backends (e.g. Agones) are stateless and need it to recreate resources. + Start(ctx context.Context, spec WorkloadSpec) (*RunningServer, error) + // Stop suspends a running workload without removing it. Stop(ctx context.Context, workloadID string) error + // Remove permanently deletes a workload and all its resources. + Remove(ctx context.Context, workloadID string) error + // Status returns the current health and resource usage of a workload. Status(ctx context.Context, workloadID string) (*WorkloadHealth, error) + // Endpoint returns the host:port address players/users connect to. Endpoint(ctx context.Context, workloadID string) (string, error) + // Logs streams the workload's stdout/stderr. Logs(ctx context.Context, workloadID string, follow bool) (io.ReadCloser, error) } diff --git a/internal/application/ports/workload.go b/internal/application/ports/workload.go index dd52eab..15bc20d 100644 --- a/internal/application/ports/workload.go +++ b/internal/application/ports/workload.go @@ -1,5 +1,14 @@ package ports +import "github.com/kleffio/kleff-daemon/pkg/labels" + +// RunningServer is the result returned by Deploy/Start. +type RunningServer struct { + Labels labels.WorkloadLabels + RuntimeRef string + State string +} + // WorkloadSpec is the typed payload for all workload operations. // It is a superset of the old ServerOperationPayload — all existing fields carry over. type WorkloadSpec struct { diff --git a/internal/workers/delete_worker.go b/internal/workers/delete_worker.go index 9d02625..3d3c7a0 100644 --- a/internal/workers/delete_worker.go +++ b/internal/workers/delete_worker.go @@ -6,45 +6,37 @@ import ( "github.com/kleffio/kleff-daemon/internal/application/ports" "github.com/kleffio/kleff-daemon/internal/workers/jobs" - "github.com/kleffio/kleff-daemon/internal/workers/payloads" ) type DeleteWorker struct { - runtime ports.ContainerRuntime + runtime ports.RuntimeAdapter repository ports.ServerRepository logger ports.Logger } -func NewDeleteWorker(runtime ports.ContainerRuntime, repository ports.ServerRepository, logger ports.Logger) *DeleteWorker { - return &DeleteWorker{ - runtime: runtime, - repository: repository, - logger: logger, - } +func NewDeleteWorker(runtime ports.RuntimeAdapter, repository ports.ServerRepository, logger ports.Logger) *DeleteWorker { + return &DeleteWorker{runtime: runtime, repository: repository, logger: logger} } func (w *DeleteWorker) Handle(ctx context.Context, job *jobs.Job) error { - log := w.logger.With( - ports.LogKeyJobID, job.JobID, - ports.LogKeyWorkerType, "server.delete", - ) + log := w.logger.With(ports.LogKeyJobID, job.JobID, ports.LogKeyWorkerType, "server.delete") - var payload payloads.ServerOperationPayload - if err := job.UnmarshalPayload(&payload); err != nil { + var spec ports.WorkloadSpec + if err := job.UnmarshalPayload(&spec); err != nil { return fmt.Errorf("invalid payload: %w", err) } - log.Info("Deleting server", ports.LogKeyServerID, payload.ServerID) + log.Info("Deleting server", ports.LogKeyServerID, spec.ServerID) - if err := w.runtime.Delete(ctx, payload.ServerID); err != nil { + if err := w.runtime.Remove(ctx, spec.ServerID); err != nil { log.Error("Failed to delete server", err) return fmt.Errorf("delete failed: %w", err) } - if err := w.repository.UpdateStatus(ctx, payload.ServerID, "deleted"); err != nil { - log.Warn("Failed to update server status after delete", "server_id", payload.ServerID) + if err := w.repository.UpdateStatus(ctx, spec.ServerID, "deleted"); err != nil { + log.Warn("Failed to update server status after delete", "server_id", spec.ServerID) } - log.Info("Server deleted successfully", ports.LogKeyServerID, payload.ServerID) + log.Info("Server deleted successfully", ports.LogKeyServerID, spec.ServerID) return nil } diff --git a/internal/workers/delete_worker_test.go b/internal/workers/delete_worker_test.go index f932d3a..bc37a71 100644 --- a/internal/workers/delete_worker_test.go +++ b/internal/workers/delete_worker_test.go @@ -6,9 +6,9 @@ import ( "testing" "github.com/kleffio/kleff-daemon/internal/adapters/out/observability/logging" + "github.com/kleffio/kleff-daemon/internal/application/ports" "github.com/kleffio/kleff-daemon/internal/workers" "github.com/kleffio/kleff-daemon/internal/workers/jobs" - "github.com/kleffio/kleff-daemon/internal/workers/payloads" ) func TestDeleteWorkerHandleSuccess(t *testing.T) { @@ -18,12 +18,12 @@ func TestDeleteWorkerHandleSuccess(t *testing.T) { worker := workers.NewDeleteWorker(runtime, repo, logger) - payload := payloads.ServerOperationPayload{ + spec := ports.WorkloadSpec{ OwnerID: "owner-1", ServerID: "test-server", } - job, _ := jobs.New(jobs.JobTypeServerDelete, "test-server", payload, 3) + job, _ := jobs.New(jobs.JobTypeServerDelete, "test-server", spec, 3) if err := worker.Handle(context.Background(), job); err != nil { t.Fatalf("expected no error, got %v", err) @@ -32,19 +32,19 @@ func TestDeleteWorkerHandleSuccess(t *testing.T) { func TestDeleteWorkerHandleRuntimeFailure(t *testing.T) { runtime := &mockRuntime{ - deleteErr: fmt.Errorf("agones unavailable"), + removeErr: fmt.Errorf("runtime unavailable"), } repo := &mockRepository{} logger := logging.NewNoopLogger() worker := workers.NewDeleteWorker(runtime, repo, logger) - payload := payloads.ServerOperationPayload{ + spec := ports.WorkloadSpec{ OwnerID: "owner-1", ServerID: "test-server", } - job, _ := jobs.New(jobs.JobTypeServerDelete, "test-server", payload, 3) + job, _ := jobs.New(jobs.JobTypeServerDelete, "test-server", spec, 3) if err := worker.Handle(context.Background(), job); err == nil { t.Error("expected error when runtime fails") diff --git a/internal/workers/mocks_test.go b/internal/workers/mocks_test.go index 6b1d827..0ddd9e3 100644 --- a/internal/workers/mocks_test.go +++ b/internal/workers/mocks_test.go @@ -2,41 +2,39 @@ package workers_test import ( "context" + "io" "github.com/kleffio/kleff-daemon/internal/application/ports" - "github.com/kleffio/kleff-daemon/internal/workers/payloads" ) type mockRuntime struct { - provisionCalled bool - startCalled bool - returnServer *ports.RunningServer - returnErr error - deleteErr error - stopErr error + deployCalled bool + startCalled bool + returnServer *ports.RunningServer + returnErr error + removeErr error + stopErr error } -func (m *mockRuntime) Provision(ctx context.Context, payload payloads.ServerOperationPayload) (*ports.RunningServer, error) { - m.provisionCalled = true +func (m *mockRuntime) Deploy(ctx context.Context, spec ports.WorkloadSpec) (*ports.RunningServer, error) { + m.deployCalled = true return m.returnServer, m.returnErr } -func (m *mockRuntime) Start(ctx context.Context, payload payloads.ServerOperationPayload) (*ports.RunningServer, error) { +func (m *mockRuntime) Start(ctx context.Context, spec ports.WorkloadSpec) (*ports.RunningServer, error) { m.startCalled = true return m.returnServer, m.returnErr } -func (m *mockRuntime) Stop(ctx context.Context, serverID string) error { return m.stopErr } -func (m *mockRuntime) Delete(ctx context.Context, serverID string) error { - return m.deleteErr -} -func (m *mockRuntime) GetByID(ctx context.Context, serverID string) (*ports.RunningServer, error) { +func (m *mockRuntime) Stop(ctx context.Context, workloadID string) error { return m.stopErr } +func (m *mockRuntime) Remove(ctx context.Context, workloadID string) error { return m.removeErr } +func (m *mockRuntime) Status(ctx context.Context, workloadID string) (*ports.WorkloadHealth, error) { return nil, nil } -func (m *mockRuntime) Reconcile(ctx context.Context, nodeID string) ([]*ports.RunningServer, error) { - return nil, nil +func (m *mockRuntime) Endpoint(ctx context.Context, workloadID string) (string, error) { + return "", nil } -func (m *mockRuntime) Stats(ctx context.Context, serverID string) (*ports.RawStats, error) { +func (m *mockRuntime) Logs(ctx context.Context, workloadID string, follow bool) (io.ReadCloser, error) { return nil, nil } diff --git a/internal/workers/payloads/server.go b/internal/workers/payloads/server.go deleted file mode 100644 index 65e9a3c..0000000 --- a/internal/workers/payloads/server.go +++ /dev/null @@ -1,10 +0,0 @@ -package payloads - -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. -type ServerOperationPayload = ports.WorkloadSpec - -// PortRequirement is deprecated: use ports.PortRequirement directly. -type PortRequirement = ports.PortRequirement diff --git a/internal/workers/payloads/server_test.go b/internal/workers/payloads/server_test.go deleted file mode 100644 index 1e739c3..0000000 --- a/internal/workers/payloads/server_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package payloads_test - -import ( - "encoding/json" - "reflect" - "testing" - "github.com/kleffio/kleff-daemon/internal/workers/payloads" -) - -func TestServerOperationPayload_JSON(t *testing.T) { - payload := payloads.ServerOperationPayload{ - OwnerID: "user-123", - ServerID: "server-456", - BlueprintID: "blue-789", - Image: "itzg/minecraft-server:latest", - MemoryBytes: 1024 * 1024 * 1024, - CPUMillicores: 1000, - EnvOverrides: map[string]string{ - "EULA": "TRUE", - }, - PortRequirements: []payloads.PortRequirement{ - {TargetPort: 25565, Protocol: "tcp"}, - }, - } - - // Marshal to JSON - bytes, err := json.Marshal(payload) - if err != nil { - t.Fatalf("Failed to marshal payload to JSON: %v", err) - } - - // The serialized bytes should contain the json keys representing the exact structure Redis jobs contain. - // For testing, let's unmarshal and make sure we get the same thing back. - var unmarshaled payloads.ServerOperationPayload - if err := json.Unmarshal(bytes, &unmarshaled); err != nil { - t.Fatalf("Failed to unmarshal from JSON: %v", err) - } - - if !reflect.DeepEqual(payload, unmarshaled) { - t.Errorf("Unmarshaled payload does not match original.\nExpected: %+v\nGot: %+v", payload, unmarshaled) - } -} diff --git a/internal/workers/provision_worker.go b/internal/workers/provision_worker.go index bcb310f..b8f05fd 100644 --- a/internal/workers/provision_worker.go +++ b/internal/workers/provision_worker.go @@ -6,48 +6,39 @@ import ( "github.com/kleffio/kleff-daemon/internal/application/ports" "github.com/kleffio/kleff-daemon/internal/workers/jobs" - "github.com/kleffio/kleff-daemon/internal/workers/payloads" ) type ProvisionWorker struct { - runtime ports.ContainerRuntime + runtime ports.RuntimeAdapter repository ports.ServerRepository logger ports.Logger } -func NewProvisionWorker(runtime ports.ContainerRuntime, repository ports.ServerRepository, logger ports.Logger) *ProvisionWorker { - return &ProvisionWorker{ - runtime: runtime, - repository: repository, - logger: logger, - } +func NewProvisionWorker(runtime ports.RuntimeAdapter, repository ports.ServerRepository, logger ports.Logger) *ProvisionWorker { + return &ProvisionWorker{runtime: runtime, repository: repository, logger: logger} } func (w *ProvisionWorker) Handle(ctx context.Context, job *jobs.Job) error { - log := w.logger.With( - ports.LogKeyJobID, job.JobID, - ports.LogKeyWorkerType, "server.provision", - ) + log := w.logger.With(ports.LogKeyJobID, job.JobID, ports.LogKeyWorkerType, "server.provision") - var payload payloads.ServerOperationPayload - if err := job.UnmarshalPayload(&payload); err != nil { + var spec ports.WorkloadSpec + if err := job.UnmarshalPayload(&spec); err != nil { return fmt.Errorf("invalid payload: %w", err) } - log.Info("Provisioning server", ports.LogKeyServerID, payload.ServerID) + log.Info("Provisioning server", ports.LogKeyServerID, spec.ServerID) - server, err := w.runtime.Provision(ctx, payload) + server, err := w.runtime.Deploy(ctx, spec) if err != nil { log.Error("Failed to provision server", err) return fmt.Errorf("provision failed: %w", err) } record := &ports.ServerRecord{ - ID: payload.ServerID, - Name: payload.ServerID, + ID: spec.ServerID, + Name: spec.ServerID, Status: server.State, NodeID: server.Labels.NodeID, - Runtime: "agones", RuntimeRef: server.RuntimeRef, } diff --git a/internal/workers/provision_worker_test.go b/internal/workers/provision_worker_test.go index a32caaa..43f0d04 100644 --- a/internal/workers/provision_worker_test.go +++ b/internal/workers/provision_worker_test.go @@ -9,7 +9,6 @@ import ( "github.com/kleffio/kleff-daemon/internal/application/ports" "github.com/kleffio/kleff-daemon/internal/workers" "github.com/kleffio/kleff-daemon/internal/workers/jobs" - "github.com/kleffio/kleff-daemon/internal/workers/payloads" "github.com/kleffio/kleff-daemon/pkg/labels" ) @@ -29,7 +28,7 @@ func TestProvisionWorkerHandleSuccess(t *testing.T) { worker := workers.NewProvisionWorker(runtime, repo, logger) - payload := payloads.ServerOperationPayload{ + spec := ports.WorkloadSpec{ OwnerID: "owner-1", ServerID: "test-server", BlueprintID: "blueprint-1", @@ -40,14 +39,14 @@ func TestProvisionWorkerHandleSuccess(t *testing.T) { }, } - job, _ := jobs.New(jobs.JobTypeServerProvision, "test-server", payload, 3) + job, _ := jobs.New(jobs.JobTypeServerProvision, "test-server", spec, 3) if err := worker.Handle(context.Background(), job); err != nil { t.Fatalf("expected no error, got %v", err) } - if !runtime.startCalled { - t.Error("expected runtime.Start to be called") + if !runtime.deployCalled { + t.Error("expected runtime.Deploy to be called") } if !repo.saveCalled { t.Error("expected repository.Save to be called") @@ -59,21 +58,21 @@ func TestProvisionWorkerHandleSuccess(t *testing.T) { func TestProvisionWorkerHandleRuntimeFailure(t *testing.T) { runtime := &mockRuntime{ - returnErr: fmt.Errorf("agones unavailable"), + returnErr: fmt.Errorf("runtime unavailable"), } repo := &mockRepository{} logger := logging.NewNoopLogger() worker := workers.NewProvisionWorker(runtime, repo, logger) - payload := payloads.ServerOperationPayload{ + spec := ports.WorkloadSpec{ OwnerID: "owner-1", ServerID: "test-server", BlueprintID: "blueprint-1", Image: "itzg/minecraft-server:latest", } - job, _ := jobs.New(jobs.JobTypeServerProvision, "test-server", payload, 3) + job, _ := jobs.New(jobs.JobTypeServerProvision, "test-server", spec, 3) if err := worker.Handle(context.Background(), job); err == nil { t.Error("expected error when runtime fails") diff --git a/internal/workers/restart_worker.go b/internal/workers/restart_worker.go index a06f063..f46a90d 100644 --- a/internal/workers/restart_worker.go +++ b/internal/workers/restart_worker.go @@ -6,51 +6,43 @@ import ( "github.com/kleffio/kleff-daemon/internal/application/ports" "github.com/kleffio/kleff-daemon/internal/workers/jobs" - "github.com/kleffio/kleff-daemon/internal/workers/payloads" ) type RestartWorker struct { - runtime ports.ContainerRuntime + runtime ports.RuntimeAdapter repository ports.ServerRepository logger ports.Logger } -func NewRestartWorker(runtime ports.ContainerRuntime, repository ports.ServerRepository, logger ports.Logger) *RestartWorker { - return &RestartWorker{ - runtime: runtime, - repository: repository, - logger: logger, - } +func NewRestartWorker(runtime ports.RuntimeAdapter, repository ports.ServerRepository, logger ports.Logger) *RestartWorker { + return &RestartWorker{runtime: runtime, repository: repository, logger: logger} } func (w *RestartWorker) Handle(ctx context.Context, job *jobs.Job) error { - log := w.logger.With( - ports.LogKeyJobID, job.JobID, - ports.LogKeyWorkerType, "server.restart", - ) + log := w.logger.With(ports.LogKeyJobID, job.JobID, ports.LogKeyWorkerType, "server.restart") - var payload payloads.ServerOperationPayload - if err := job.UnmarshalPayload(&payload); err != nil { + var spec ports.WorkloadSpec + if err := job.UnmarshalPayload(&spec); err != nil { return fmt.Errorf("invalid payload: %w", err) } - log.Info("Restarting server", ports.LogKeyServerID, payload.ServerID) + log.Info("Restarting server", ports.LogKeyServerID, spec.ServerID) - if err := w.runtime.Stop(ctx, payload.ServerID); err != nil { + if err := w.runtime.Stop(ctx, spec.ServerID); err != nil { log.Error("Failed to stop server during restart", err) return fmt.Errorf("restart failed on stop: %w", err) } - server, err := w.runtime.Start(ctx, payload) + server, err := w.runtime.Start(ctx, spec) if err != nil { log.Error("Failed to start server during restart", err) return fmt.Errorf("restart failed on start: %w", err) } - if err := w.repository.UpdateStatus(ctx, payload.ServerID, server.State); err != nil { - log.Warn("Failed to update server status after restart", "server_id", payload.ServerID) + if err := w.repository.UpdateStatus(ctx, spec.ServerID, server.State); err != nil { + log.Warn("Failed to update server status after restart", "server_id", spec.ServerID) } - log.Info("Server restarted successfully", ports.LogKeyServerID, payload.ServerID) + log.Info("Server restarted successfully", ports.LogKeyServerID, spec.ServerID) return nil } diff --git a/internal/workers/restart_worker_test.go b/internal/workers/restart_worker_test.go index f997272..72a0887 100644 --- a/internal/workers/restart_worker_test.go +++ b/internal/workers/restart_worker_test.go @@ -9,7 +9,6 @@ import ( "github.com/kleffio/kleff-daemon/internal/application/ports" "github.com/kleffio/kleff-daemon/internal/workers" "github.com/kleffio/kleff-daemon/internal/workers/jobs" - "github.com/kleffio/kleff-daemon/internal/workers/payloads" "github.com/kleffio/kleff-daemon/pkg/labels" ) @@ -29,12 +28,12 @@ func TestRestartWorkerHandleSuccess(t *testing.T) { worker := workers.NewRestartWorker(runtime, repo, logger) - payload := payloads.ServerOperationPayload{ + spec := ports.WorkloadSpec{ OwnerID: "owner-1", ServerID: "test-server", } - job, _ := jobs.New(jobs.JobTypeServerRestart, "test-server", payload, 3) + job, _ := jobs.New(jobs.JobTypeServerRestart, "test-server", spec, 3) if err := worker.Handle(context.Background(), job); err != nil { t.Fatalf("expected no error, got %v", err) @@ -47,19 +46,19 @@ func TestRestartWorkerHandleSuccess(t *testing.T) { func TestRestartWorkerStopFailure(t *testing.T) { runtime := &mockRuntime{ - stopErr: fmt.Errorf("agones unavailable"), + stopErr: fmt.Errorf("runtime unavailable"), } repo := &mockRepository{} logger := logging.NewNoopLogger() worker := workers.NewRestartWorker(runtime, repo, logger) - payload := payloads.ServerOperationPayload{ + spec := ports.WorkloadSpec{ OwnerID: "owner-1", ServerID: "test-server", } - job, _ := jobs.New(jobs.JobTypeServerRestart, "test-server", payload, 3) + job, _ := jobs.New(jobs.JobTypeServerRestart, "test-server", spec, 3) if err := worker.Handle(context.Background(), job); err == nil { t.Error("expected error when stop fails") @@ -68,19 +67,19 @@ func TestRestartWorkerStopFailure(t *testing.T) { func TestRestartWorkerStartFailure(t *testing.T) { runtime := &mockRuntime{ - returnErr: fmt.Errorf("agones unavailable"), + returnErr: fmt.Errorf("runtime unavailable"), } repo := &mockRepository{} logger := logging.NewNoopLogger() worker := workers.NewRestartWorker(runtime, repo, logger) - payload := payloads.ServerOperationPayload{ + spec := ports.WorkloadSpec{ OwnerID: "owner-1", ServerID: "test-server", } - job, _ := jobs.New(jobs.JobTypeServerRestart, "test-server", payload, 3) + job, _ := jobs.New(jobs.JobTypeServerRestart, "test-server", spec, 3) if err := worker.Handle(context.Background(), job); err == nil { t.Error("expected error when start fails") diff --git a/internal/workers/start_worker.go b/internal/workers/start_worker.go index b205c3f..064b95e 100644 --- a/internal/workers/start_worker.go +++ b/internal/workers/start_worker.go @@ -6,46 +6,38 @@ import ( "github.com/kleffio/kleff-daemon/internal/application/ports" "github.com/kleffio/kleff-daemon/internal/workers/jobs" - "github.com/kleffio/kleff-daemon/internal/workers/payloads" ) type StartWorker struct { - runtime ports.ContainerRuntime + runtime ports.RuntimeAdapter repository ports.ServerRepository logger ports.Logger } -func NewStartWorker(runtime ports.ContainerRuntime, repository ports.ServerRepository, logger ports.Logger) *StartWorker { - return &StartWorker{ - runtime: runtime, - repository: repository, - logger: logger, - } +func NewStartWorker(runtime ports.RuntimeAdapter, repository ports.ServerRepository, logger ports.Logger) *StartWorker { + return &StartWorker{runtime: runtime, repository: repository, logger: logger} } func (w *StartWorker) Handle(ctx context.Context, job *jobs.Job) error { - log := w.logger.With( - ports.LogKeyJobID, job.JobID, - ports.LogKeyWorkerType, "server.start", - ) + log := w.logger.With(ports.LogKeyJobID, job.JobID, ports.LogKeyWorkerType, "server.start") - var payload payloads.ServerOperationPayload - if err := job.UnmarshalPayload(&payload); err != nil { + var spec ports.WorkloadSpec + if err := job.UnmarshalPayload(&spec); err != nil { return fmt.Errorf("invalid payload: %w", err) } - log.Info("Starting server", ports.LogKeyServerID, payload.ServerID) + log.Info("Starting server", ports.LogKeyServerID, spec.ServerID) - server, err := w.runtime.Start(ctx, payload) + server, err := w.runtime.Start(ctx, spec) if err != nil { log.Error("Failed to start server", err) return fmt.Errorf("start failed: %w", err) } - if err := w.repository.UpdateStatus(ctx, payload.ServerID, server.State); err != nil { - log.Warn("Failed to update server status after start", "server_id", payload.ServerID) + if err := w.repository.UpdateStatus(ctx, spec.ServerID, server.State); err != nil { + log.Warn("Failed to update server status after start", "server_id", spec.ServerID) } - log.Info("Server started successfully", ports.LogKeyServerID, payload.ServerID) + log.Info("Server started successfully", ports.LogKeyServerID, spec.ServerID) return nil } diff --git a/internal/workers/start_worker_test.go b/internal/workers/start_worker_test.go index 05ba1be..0f8bb44 100644 --- a/internal/workers/start_worker_test.go +++ b/internal/workers/start_worker_test.go @@ -9,7 +9,6 @@ import ( "github.com/kleffio/kleff-daemon/internal/application/ports" "github.com/kleffio/kleff-daemon/internal/workers" "github.com/kleffio/kleff-daemon/internal/workers/jobs" - "github.com/kleffio/kleff-daemon/internal/workers/payloads" "github.com/kleffio/kleff-daemon/pkg/labels" ) @@ -29,7 +28,7 @@ func TestStartWorkerHandleSuccess(t *testing.T) { worker := workers.NewStartWorker(runtime, repo, logger) - payload := payloads.ServerOperationPayload{ + spec := ports.WorkloadSpec{ OwnerID: "owner-1", ServerID: "test-server", BlueprintID: "blueprint-1", @@ -40,7 +39,7 @@ func TestStartWorkerHandleSuccess(t *testing.T) { }, } - job, _ := jobs.New(jobs.JobTypeServerStart, "test-server", payload, 3) + job, _ := jobs.New(jobs.JobTypeServerStart, "test-server", spec, 3) if err := worker.Handle(context.Background(), job); err != nil { t.Fatalf("expected no error, got %v", err) @@ -60,14 +59,14 @@ func TestStartWorkerHandleRuntimeFailure(t *testing.T) { worker := workers.NewStartWorker(runtime, repo, logger) - payload := payloads.ServerOperationPayload{ + spec := ports.WorkloadSpec{ OwnerID: "owner-1", ServerID: "test-server", BlueprintID: "blueprint-1", Image: "itzg/minecraft-server:latest", } - job, _ := jobs.New(jobs.JobTypeServerStart, "test-server", payload, 3) + job, _ := jobs.New(jobs.JobTypeServerStart, "test-server", spec, 3) if err := worker.Handle(context.Background(), job); err == nil { t.Error("expected error when runtime fails") diff --git a/internal/workers/stop_worker.go b/internal/workers/stop_worker.go index 28b18ef..1d0baeb 100644 --- a/internal/workers/stop_worker.go +++ b/internal/workers/stop_worker.go @@ -6,45 +6,37 @@ import ( "github.com/kleffio/kleff-daemon/internal/application/ports" "github.com/kleffio/kleff-daemon/internal/workers/jobs" - "github.com/kleffio/kleff-daemon/internal/workers/payloads" ) type StopWorker struct { - runtime ports.ContainerRuntime + runtime ports.RuntimeAdapter repository ports.ServerRepository logger ports.Logger } -func NewStopWorker(runtime ports.ContainerRuntime, repository ports.ServerRepository, logger ports.Logger) *StopWorker { - return &StopWorker{ - runtime: runtime, - repository: repository, - logger: logger, - } +func NewStopWorker(runtime ports.RuntimeAdapter, repository ports.ServerRepository, logger ports.Logger) *StopWorker { + return &StopWorker{runtime: runtime, repository: repository, logger: logger} } func (w *StopWorker) Handle(ctx context.Context, job *jobs.Job) error { - log := w.logger.With( - ports.LogKeyJobID, job.JobID, - ports.LogKeyWorkerType, "server.stop", - ) + log := w.logger.With(ports.LogKeyJobID, job.JobID, ports.LogKeyWorkerType, "server.stop") - var payload payloads.ServerOperationPayload - if err := job.UnmarshalPayload(&payload); err != nil { + var spec ports.WorkloadSpec + if err := job.UnmarshalPayload(&spec); err != nil { return fmt.Errorf("invalid payload: %w", err) } - log.Info("Stopping server", ports.LogKeyServerID, payload.ServerID) + log.Info("Stopping server", ports.LogKeyServerID, spec.ServerID) - if err := w.runtime.Stop(ctx, payload.ServerID); err != nil { + if err := w.runtime.Stop(ctx, spec.ServerID); err != nil { log.Error("Failed to stop server", err) return fmt.Errorf("stop failed: %w", err) } - if err := w.repository.UpdateStatus(ctx, payload.ServerID, "stopped"); err != nil { - log.Warn("Failed to update server status after stop", "server_id", payload.ServerID) + if err := w.repository.UpdateStatus(ctx, spec.ServerID, "stopped"); err != nil { + log.Warn("Failed to update server status after stop", "server_id", spec.ServerID) } - log.Info("Server stopped successfully", ports.LogKeyServerID, payload.ServerID) + log.Info("Server stopped successfully", ports.LogKeyServerID, spec.ServerID) return nil } diff --git a/internal/workers/stop_worker_test.go b/internal/workers/stop_worker_test.go index a233d09..0570b66 100644 --- a/internal/workers/stop_worker_test.go +++ b/internal/workers/stop_worker_test.go @@ -6,9 +6,9 @@ import ( "testing" "github.com/kleffio/kleff-daemon/internal/adapters/out/observability/logging" + "github.com/kleffio/kleff-daemon/internal/application/ports" "github.com/kleffio/kleff-daemon/internal/workers" "github.com/kleffio/kleff-daemon/internal/workers/jobs" - "github.com/kleffio/kleff-daemon/internal/workers/payloads" ) func TestStopWorkerHandleSuccess(t *testing.T) { @@ -18,12 +18,12 @@ func TestStopWorkerHandleSuccess(t *testing.T) { worker := workers.NewStopWorker(runtime, repo, logger) - payload := payloads.ServerOperationPayload{ + spec := ports.WorkloadSpec{ OwnerID: "owner-1", ServerID: "test-crate", } - job, _ := jobs.New(jobs.JobTypeServerStop, "test-crate", payload, 3) + job, _ := jobs.New(jobs.JobTypeServerStop, "test-crate", spec, 3) if err := worker.Handle(context.Background(), job); err != nil { t.Fatalf("expected no error, got %v", err) @@ -32,19 +32,19 @@ func TestStopWorkerHandleSuccess(t *testing.T) { func TestStopWorkerHandleRuntimeFailure(t *testing.T) { runtime := &mockRuntime{ - stopErr: fmt.Errorf("agones unavailable"), + stopErr: fmt.Errorf("runtime unavailable"), } repo := &mockRepository{} logger := logging.NewNoopLogger() worker := workers.NewStopWorker(runtime, repo, logger) - payload := payloads.ServerOperationPayload{ + spec := ports.WorkloadSpec{ OwnerID: "owner-1", ServerID: "test-crate", } - job, _ := jobs.New(jobs.JobTypeServerStop, "test-crate", payload, 3) + job, _ := jobs.New(jobs.JobTypeServerStop, "test-crate", spec, 3) if err := worker.Handle(context.Background(), job); err == nil { t.Error("expected error when runtime fails") From 9877622354e84752d9600d0bb82e10f2a5ae8160 Mon Sep 17 00:00:00 2001 From: Jeefos Date: Fri, 10 Apr 2026 11:28:59 -0400 Subject: [PATCH 2/3] docker game server works locally --- cmd/testprovision/main.go | 144 ++++++++++++------ .../adapters/out/runtime/docker/docker.go | 28 +++- internal/application/ports/workload.go | 3 + 3 files changed, 130 insertions(+), 45 deletions(-) diff --git a/cmd/testprovision/main.go b/cmd/testprovision/main.go index 8d04b63..a0fdcfa 100644 --- a/cmd/testprovision/main.go +++ b/cmd/testprovision/main.go @@ -7,61 +7,44 @@ import ( "os" "time" + dockeradapter "github.com/kleffio/kleff-daemon/internal/adapters/out/runtime/docker" k8sadapter "github.com/kleffio/kleff-daemon/internal/adapters/out/runtime/kubernetes" "github.com/kleffio/kleff-daemon/internal/application/ports" ) -// testprovision provisions a PaperMC Minecraft server via the Kubernetes adapter -// and prints the result. It expects kubectl proxy running on localhost:8888. +// testprovision provisions a Minecraft server via either the Kubernetes or Docker adapter. // -// Usage (on the cluster node): +// Kubernetes usage (on the cluster node): // // kubectl proxy --port=8888 & -// ./testprovision +// ./testprovision k8s +// ./testprovision k8s cleanup // -// To clean up afterwards: +// Docker usage (local): // -// ./testprovision cleanup +// ./testprovision docker +// ./testprovision docker cleanup func main() { const ( - proxyURL = "http://localhost:8888" - namespace = "default" nodeID = "test-node" - serverID = "kleff-test-papermc" + serverID = "kleff-test-minecraft" ownerID = "test-owner" blueprintID = "minecraft-vanilla" ) - adapter, err := k8sadapter.New(proxyURL, namespace, nodeID) - if err != nil { - log.Fatalf("failed to create kubernetes adapter: %v", err) - } - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) - defer cancel() - - // Cleanup mode — remove the GameServer if it exists. - if len(os.Args) > 1 && os.Args[1] == "cleanup" { - fmt.Printf("Removing %s...\n", serverID) - if err := adapter.Remove(ctx, serverID); err != nil { - log.Fatalf("remove failed: %v", err) - } - fmt.Println("Removed.") - return + mode := "k8s" + if len(os.Args) > 1 { + mode = os.Args[1] } + cleanup := len(os.Args) > 2 && os.Args[2] == "cleanup" - // Build the WorkloadSpec from the minecraft-papermc blueprint/construct. spec := ports.WorkloadSpec{ - OwnerID: ownerID, - ServerID: serverID, - BlueprintID: blueprintID, - Image: "itzg/minecraft-server", - - // From blueprint.json resources - MemoryBytes: 2048 * 1024 * 1024, // 2048 MB - CPUMillicores: 500, // 0.5 vCPU (test only) - - // From construct.json env + user config defaults + OwnerID: ownerID, + ServerID: serverID, + BlueprintID: blueprintID, + Image: "itzg/minecraft-server", + MemoryBytes: 2048 * 1024 * 1024, + CPUMillicores: 500, EnvOverrides: map[string]string{ "EULA": "TRUE", "TYPE": "VANILLA", @@ -70,21 +53,94 @@ func main() { "MAX_PLAYERS": "20", "MEMORY": "2G", }, - - // From construct.json ports PortRequirements: []ports.PortRequirement{ {TargetPort: 25565, Protocol: "tcp"}, {TargetPort: 25565, Protocol: "udp"}, - {TargetPort: 25575, Protocol: "tcp"}, // RCON + {TargetPort: 25575, Protocol: "tcp"}, }, - - // From construct.json runtime_hints RuntimeHints: ports.RuntimeHints{ - KubernetesStrategy: "agones", - ExposeUDP: true, + PersistentStorage: true, + StoragePath: "/data", }, } + switch mode { + case "docker": + runDocker(cleanup, serverID, spec) + default: + spec.RuntimeHints.KubernetesStrategy = "agones" + spec.RuntimeHints.ExposeUDP = true + runK8s(cleanup, serverID, spec) + } +} + +func runDocker(cleanup bool, serverID string, spec ports.WorkloadSpec) { + adapter, err := dockeradapter.New("test-node") + if err != nil { + log.Fatalf("failed to create docker adapter: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + if cleanup { + fmt.Printf("Removing %s...\n", serverID) + if err := adapter.Remove(ctx, serverID); err != nil { + log.Fatalf("remove failed: %v", err) + } + fmt.Println("Removed.") + return + } + + fmt.Printf("Provisioning %s (%s) via Docker...\n", serverID, spec.Image) + fmt.Printf(" Memory: %d MB CPU: %dm\n", spec.MemoryBytes/1024/1024, spec.CPUMillicores) + fmt.Printf(" Ports: 25565/tcp, 25565/udp, 25575/tcp\n") + fmt.Printf(" Storage: %s\n", spec.RuntimeHints.StoragePath) + fmt.Println(" Pulling image and starting container...") + + start := time.Now() + server, err := adapter.Deploy(ctx, spec) + if err != nil { + log.Fatalf("deploy failed: %v", err) + } + + fmt.Printf("\nServer is running! (took %s)\n", time.Since(start).Round(time.Second)) + fmt.Printf(" RuntimeRef : %s\n", server.RuntimeRef) + fmt.Printf(" State : %s\n", server.State) + + endpoint, err := adapter.Endpoint(ctx, serverID) + if err != nil { + fmt.Printf(" Endpoint : (could not resolve: %v)\n", err) + } else { + fmt.Printf(" Endpoint : %s\n", endpoint) + } + + fmt.Printf("\nTo clean up: ./testprovision docker cleanup\n") +} + +func runK8s(cleanup bool, serverID string, spec ports.WorkloadSpec) { + const ( + proxyURL = "http://localhost:8888" + namespace = "default" + ) + + adapter, err := k8sadapter.New(proxyURL, namespace, "test-node") + if err != nil { + log.Fatalf("failed to create kubernetes adapter: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + if cleanup { + fmt.Printf("Removing %s...\n", serverID) + if err := adapter.Remove(ctx, serverID); err != nil { + log.Fatalf("remove failed: %v", err) + } + fmt.Println("Removed.") + return + } + fmt.Printf("Provisioning %s (%s) via Agones...\n", serverID, spec.Image) fmt.Printf(" Memory: %d MB CPU: %dm\n", spec.MemoryBytes/1024/1024, spec.CPUMillicores) fmt.Printf(" Ports: 25565/tcp, 25565/udp, 25575/tcp\n") @@ -109,5 +165,5 @@ func main() { fmt.Printf(" Endpoint : %s\n", endpoint) } - fmt.Printf("\nTo clean up: ./testprovision cleanup\n") + fmt.Printf("\nTo clean up: ./testprovision k8s cleanup\n") } diff --git a/internal/adapters/out/runtime/docker/docker.go b/internal/adapters/out/runtime/docker/docker.go index 1e6f820..de1ac89 100644 --- a/internal/adapters/out/runtime/docker/docker.go +++ b/internal/adapters/out/runtime/docker/docker.go @@ -9,6 +9,7 @@ import ( "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/image" + "github.com/docker/docker/api/types/mount" "github.com/docker/docker/api/types/network" "github.com/docker/docker/client" "github.com/docker/go-connections/nat" @@ -36,11 +37,15 @@ func New(nodeID string) (*Adapter, error) { // Deploy pulls the image and starts a new container. func (a *Adapter) Deploy(ctx context.Context, spec ports.WorkloadSpec) (*ports.RunningServer, error) { - // Pull image. + // Pull image and wait for completion. rc, err := a.client.ImagePull(ctx, spec.Image, image.PullOptions{}) if err != nil { return nil, fmt.Errorf("failed to pull image %s: %w", spec.Image, err) } + if _, err := io.Copy(io.Discard, rc); err != nil { + rc.Close() + return nil, fmt.Errorf("failed to pull image %s: %w", spec.Image, err) + } rc.Close() containerID, err := a.createContainer(ctx, spec) @@ -180,6 +185,25 @@ func (a *Adapter) createContainer(ctx context.Context, spec ports.WorkloadSpec) labelPrefix + "node_id": a.nodeID, } + resources := container.Resources{} + if spec.MemoryBytes > 0 { + resources.Memory = spec.MemoryBytes + } + if spec.CPUMillicores > 0 { + // Docker uses CPU quota: 1 vCPU = 100000 quota per 100000 period + resources.CPUQuota = spec.CPUMillicores * 100 + resources.CPUPeriod = 100000 + } + + var mounts []mount.Mount + if spec.RuntimeHints.PersistentStorage && spec.RuntimeHints.StoragePath != "" { + mounts = append(mounts, mount.Mount{ + Type: mount.TypeVolume, + Source: fmt.Sprintf("kleff-%s-data", spec.ServerID), + Target: spec.RuntimeHints.StoragePath, + }) + } + resp, err := a.client.ContainerCreate(ctx, &container.Config{ Image: spec.Image, @@ -189,6 +213,8 @@ func (a *Adapter) createContainer(ctx context.Context, spec ports.WorkloadSpec) }, &container.HostConfig{ PortBindings: portBindings, + Resources: resources, + Mounts: mounts, }, &network.NetworkingConfig{}, nil, diff --git a/internal/application/ports/workload.go b/internal/application/ports/workload.go index 15bc20d..ed8f5bd 100644 --- a/internal/application/ports/workload.go +++ b/internal/application/ports/workload.go @@ -53,6 +53,9 @@ type RuntimeHints struct { ExposeUDP bool `json:"expose_udp,omitempty"` HealthCheckPath string `json:"health_check_path,omitempty"` HealthCheckPort int `json:"health_check_port,omitempty"` + PersistentStorage bool `json:"persistent_storage,omitempty"` + StoragePath string `json:"storage_path,omitempty"` + StorageGB int `json:"storage_gb,omitempty"` } // WorkloadHealth is the per-workload status reported in heartbeats. From 064efa7701ddb4ae6f33769a456fd06ffae320c5 Mon Sep 17 00:00:00 2001 From: Jeefos Date: Fri, 10 Apr 2026 12:09:08 -0400 Subject: [PATCH 3/3] detects both docker and kubernetes and doesnt fall bacl on one --- cmd/kleffd/main.go | 22 ++++++++++++------- .../adapters/out/runtime/docker/docker.go | 9 ++++++++ 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/cmd/kleffd/main.go b/cmd/kleffd/main.go index d78d0f6..ef3244c 100644 --- a/cmd/kleffd/main.go +++ b/cmd/kleffd/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "fmt" "log" "os" "os/signal" @@ -82,31 +83,36 @@ func main() { } func detectRuntime(cfg *config.Config, logger ports.Logger) (ports.RuntimeAdapter, error) { - // Explicit kubeconfig → always Kubernetes. + ctx := context.Background() + + // Explicit kubeconfig → always Kubernetes, fail hard if unreachable. if cfg.Kubeconfig != "" { adapter, err := k8sadapter.New(cfg.Kubeconfig, cfg.KubeNamespace, cfg.NodeID) if err != nil { - return nil, err + return nil, fmt.Errorf("kubernetes runtime unavailable: %w", err) } logger.Info("Runtime: Kubernetes", "kubeconfig", cfg.Kubeconfig) return adapter, nil } - // In-cluster environment detected → Kubernetes. + // In-cluster environment → Kubernetes, fail hard if unreachable. if _, err := rest.InClusterConfig(); err == nil { adapter, err := k8sadapter.New("", cfg.KubeNamespace, cfg.NodeID) if err != nil { - return nil, err + return nil, fmt.Errorf("kubernetes in-cluster runtime unavailable: %w", err) } logger.Info("Runtime: Kubernetes (in-cluster)") return adapter, nil } - // Fall back to Docker. - adapter, err := dockeradapter.New(cfg.NodeID) + // No Kubernetes — check if Docker is actually reachable before using it. + dockerAdapter, err := dockeradapter.New(cfg.NodeID) if err != nil { - return nil, err + return nil, fmt.Errorf("no runtime available: kubernetes not detected, docker client failed: %w", err) + } + if err := dockerAdapter.Ping(ctx); err != nil { + return nil, fmt.Errorf("no runtime available: kubernetes not detected, docker unreachable: %w", err) } logger.Info("Runtime: Docker") - return adapter, nil + return dockerAdapter, nil } diff --git a/internal/adapters/out/runtime/docker/docker.go b/internal/adapters/out/runtime/docker/docker.go index de1ac89..661d241 100644 --- a/internal/adapters/out/runtime/docker/docker.go +++ b/internal/adapters/out/runtime/docker/docker.go @@ -35,6 +35,15 @@ func New(nodeID string) (*Adapter, error) { return &Adapter{client: c, nodeID: nodeID}, nil } +// Ping checks if the Docker daemon is reachable. +func (a *Adapter) Ping(ctx context.Context) error { + _, err := a.client.Ping(ctx) + if err != nil { + return fmt.Errorf("docker daemon unreachable: %w", err) + } + return nil +} + // Deploy pulls the image and starts a new container. func (a *Adapter) Deploy(ctx context.Context, spec ports.WorkloadSpec) (*ports.RunningServer, error) { // Pull image and wait for completion.