diff --git a/api/v1alpha1/gameserver_types.go b/api/v1alpha1/gameserver_types.go index daa7810..6384aa8 100644 --- a/api/v1alpha1/gameserver_types.go +++ b/api/v1alpha1/gameserver_types.go @@ -31,6 +31,48 @@ import ( "k8s.io/apimachinery/pkg/util/intstr" ) +// EditorAuthSpec configures authentication for the code-server sidecar. +type EditorAuthSpec struct { + // Enabled controls whether authentication is required to access the editor. + // When set to false, any process that can reach the pod — including other pods in the + // same cluster — can access the editor without credentials. Only disable this if + // access is restricted by network policies or the cluster is fully trusted. + // +kubebuilder:default=true + // +optional + Enabled *bool `json:"enabled,omitempty"` + + // PasswordSecretRef references an existing Secret that contains a "password" key. + // If not set and Enabled is true, the operator creates a Secret with a randomly + // generated password automatically. Retrieve it with: + // kubectl get secret -editor-password -o jsonpath='{.data.password}' | base64 -d + // +optional + PasswordSecretRef *corev1.LocalObjectReference `json:"passwordSecretRef,omitempty"` +} + +// EditorSpec defines the configuration for the web-based editor sidecar. +type EditorSpec struct { + // Enabled indicates whether the code-server (VS Code in browser) sidecar is added to the pod. + // Access it via: kubectl port-forward pod/ 8080:8080 + // +kubebuilder:default=false + // +optional + Enabled bool `json:"enabled,omitempty"` + + // Auth configures authentication for the editor. If omitted, a Secret with a + // randomly generated password is created automatically (secure default). + // +optional + Auth *EditorAuthSpec `json:"auth,omitempty"` + + // ShareProcessNamespace enables sharing the process namespace between the gameserver + // and editor containers, allowing the editor terminal to inspect and signal gameserver processes. + // +kubebuilder:default=false + // +optional + ShareProcessNamespace bool `json:"shareProcessNamespace,omitempty"` + + // Resources defines resource requests and limits for the editor sidecar container. + // +optional + Resources *corev1.ResourceRequirements `json:"resources,omitempty"` +} + // GameServerSpec defines the desired state of GameServer // leave out for now //+kubebuilder:validation:XValidation:rule="size(self.gameConfigs) <= 1",message="Cannot specify more than one game-specific configuration block." type GameServerSpec struct { @@ -78,6 +120,11 @@ type GameServerSpec struct { // If not specified, Kubernetes scheduler defaults apply. // +optional Resources *corev1.ResourceRequirements `json:"resources,omitempty"` + + // Editor defines the configuration for the web-based editor sidecar. + // When enabled, a code-server (VS Code in browser) container is injected into the pod. + // +optional + Editor *EditorSpec `json:"editor,omitempty"` } type GameConfigs struct { diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index cfb39a9..9d20fec 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -34,6 +34,56 @@ import ( "k8s.io/apimachinery/pkg/runtime" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EditorAuthSpec) DeepCopyInto(out *EditorAuthSpec) { + *out = *in + if in.Enabled != nil { + in, out := &in.Enabled, &out.Enabled + *out = new(bool) + **out = **in + } + if in.PasswordSecretRef != nil { + in, out := &in.PasswordSecretRef, &out.PasswordSecretRef + *out = new(v1.LocalObjectReference) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EditorAuthSpec. +func (in *EditorAuthSpec) DeepCopy() *EditorAuthSpec { + if in == nil { + return nil + } + out := new(EditorAuthSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EditorSpec) DeepCopyInto(out *EditorSpec) { + *out = *in + if in.Auth != nil { + in, out := &in.Auth, &out.Auth + *out = new(EditorAuthSpec) + (*in).DeepCopyInto(*out) + } + if in.Resources != nil { + in, out := &in.Resources, &out.Resources + *out = new(v1.ResourceRequirements) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EditorSpec. +func (in *EditorSpec) DeepCopy() *EditorSpec { + if in == nil { + return nil + } + out := new(EditorSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GameConfigs) DeepCopyInto(out *GameConfigs) { *out = *in @@ -136,6 +186,11 @@ func (in *GameServerSpec) DeepCopyInto(out *GameServerSpec) { *out = new(v1.ResourceRequirements) (*in).DeepCopyInto(*out) } + if in.Editor != nil { + in, out := &in.Editor, &out.Editor + *out = new(EditorSpec) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GameServerSpec. diff --git a/charts/chart/templates/_helpers.tpl b/charts/chart/templates/_helpers.tpl index 1643cda..4795fa7 100644 --- a/charts/chart/templates/_helpers.tpl +++ b/charts/chart/templates/_helpers.tpl @@ -31,6 +31,13 @@ Always uses the Helm release namespace. {{- .Release.Namespace }} {{- end }} +{{/* +ServiceAccount name used by the controller manager. +*/}} +{{- define "gameserver-operator.serviceAccountName" -}} +{{- include "gameserver-operator.fullname" . }} +{{- end }} + {{/* Resource name with proper truncation for Kubernetes 63-character limit. Takes a dict with: diff --git a/charts/chart/templates/crd/gameservers.games.idebeijer.github.io.yaml b/charts/chart/templates/crd/gameservers.games.idebeijer.github.io.yaml index ff8cd47..d8719ac 100644 --- a/charts/chart/templates/crd/gameservers.games.idebeijer.github.io.yaml +++ b/charts/chart/templates/crd/gameservers.games.idebeijer.github.io.yaml @@ -1,4 +1,4 @@ -{{- if .Values.crd.enable }} +{{- if .Values.crd.enabled }} apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: @@ -42,6 +42,116 @@ spec: spec: description: spec defines the desired state of GameServer properties: + editor: + description: |- + Editor defines the configuration for the web-based editor sidecar. + When enabled, a code-server (VS Code in browser) container is injected into the pod. + properties: + auth: + description: |- + Auth configures authentication for the editor. If omitted, a Secret with a + randomly generated password is created automatically (secure default). + properties: + enabled: + default: true + description: |- + Enabled controls whether authentication is required to access the editor. + When set to false, any process that can reach the pod — including other pods in the + same cluster — can access the editor without credentials. Only disable this if + access is restricted by network policies or the cluster is fully trusted. + type: boolean + passwordSecretRef: + description: |- + PasswordSecretRef references an existing Secret that contains a "password" key. + If not set and Enabled is true, the operator creates a Secret with a randomly + generated password automatically. Retrieve it with: + kubectl get secret -editor-password -o jsonpath='{.data.password}' | base64 -d + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + type: object + enabled: + default: false + description: |- + Enabled indicates whether the code-server (VS Code in browser) sidecar is added to the pod. + Access it via: kubectl port-forward pod/ 8080:8080 + type: boolean + resources: + description: Resources defines resource requests and limits for + the editor sidecar container. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + shareProcessNamespace: + default: false + description: |- + ShareProcessNamespace enables sharing the process namespace between the gameserver + and editor containers, allowing the editor terminal to inspect and signal gameserver processes. + type: boolean + type: object gameConfigs: description: GameConfigs holds game-specific configuration options. properties: diff --git a/charts/chart/templates/manager/manager.yaml b/charts/chart/templates/manager/manager.yaml index 349de08..744d913 100644 --- a/charts/chart/templates/manager/manager.yaml +++ b/charts/chart/templates/manager/manager.yaml @@ -74,7 +74,7 @@ spec: {{- end }} containers: - args: - {{- if .Values.metrics.enable }} + {{- if .Values.metrics.enabled }} - --metrics-bind-address=:{{ .Values.metrics.port }} {{- if not .Values.metrics.secure }} - --metrics-secure=false diff --git a/charts/chart/templates/metrics/controller-manager-metrics-service.yaml b/charts/chart/templates/metrics/controller-manager-metrics-service.yaml index 01fc8eb..f171421 100644 --- a/charts/chart/templates/metrics/controller-manager-metrics-service.yaml +++ b/charts/chart/templates/metrics/controller-manager-metrics-service.yaml @@ -1,4 +1,4 @@ -{{- if .Values.metrics.enable }} +{{- if .Values.metrics.enabled }} apiVersion: v1 kind: Service metadata: diff --git a/charts/chart/templates/monitoring/servicemonitor.yaml b/charts/chart/templates/monitoring/servicemonitor.yaml index 69241d7..923dea9 100644 --- a/charts/chart/templates/monitoring/servicemonitor.yaml +++ b/charts/chart/templates/monitoring/servicemonitor.yaml @@ -1,4 +1,4 @@ -{{- if .Values.prometheus.enable }} +{{- if .Values.prometheus.enabled }} apiVersion: monitoring.coreos.com/v1 kind: ServiceMonitor metadata: @@ -17,7 +17,7 @@ spec: scheme: https bearerTokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token tlsConfig: - {{- if .Values.certManager.enable }} + {{- if .Values.certManager.enabled }} serverName: {{ include "gameserver-operator.resourceName" (dict "suffix" "controller-manager-metrics-service" "context" $) }}.{{ .Release.Namespace }}.svc # Apply secure TLS configuration with cert-manager insecureSkipVerify: false diff --git a/charts/chart/templates/network-policy/allow-metrics-traffic.yaml b/charts/chart/templates/network-policy/allow-metrics-traffic.yaml new file mode 100644 index 0000000..a46d229 --- /dev/null +++ b/charts/chart/templates/network-policy/allow-metrics-traffic.yaml @@ -0,0 +1,25 @@ +{{- if .Values.networkPolicy.enabled }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + labels: + app.kubernetes.io/managed-by: {{ .Release.Service }} + app.kubernetes.io/name: {{ include "gameserver-operator.name" . }} + name: {{ include "gameserver-operator.resourceName" (dict "suffix" "allow-metrics-traffic" "context" $) }} + namespace: {{ .Release.Namespace }} +spec: + podSelector: + matchLabels: + control-plane: controller-manager + app.kubernetes.io/name: {{ include "gameserver-operator.name" . }} + policyTypes: + - Ingress + ingress: + - from: + - namespaceSelector: + matchLabels: + metrics: enabled + ports: + - port: {{ .Values.metrics.port }} + protocol: TCP +{{- end }} diff --git a/charts/chart/templates/prometheus/controller-manager-metrics-monitor.yaml b/charts/chart/templates/prometheus/controller-manager-metrics-monitor.yaml index bbf465e..898d222 100644 --- a/charts/chart/templates/prometheus/controller-manager-metrics-monitor.yaml +++ b/charts/chart/templates/prometheus/controller-manager-metrics-monitor.yaml @@ -1,4 +1,4 @@ -{{- if .Values.prometheus.enable }} +{{- if .Values.prometheus.enabled }} apiVersion: monitoring.coreos.com/v1 kind: ServiceMonitor metadata: @@ -21,7 +21,7 @@ spec: {{- if .Values.metrics.secure }} tlsConfig: serverName: {{ include "gameserver-operator.resourceName" (dict "suffix" "controller-manager-metrics-service" "context" $) }}.{{ .Release.Namespace }}.svc - {{- if .Values.certManager.enable }} + {{- if .Values.certManager.enabled }} ca: secret: name: metrics-server-cert diff --git a/charts/chart/templates/rbac/controller-manager.yaml b/charts/chart/templates/rbac/controller-manager.yaml index cce5fe6..8a114b0 100644 --- a/charts/chart/templates/rbac/controller-manager.yaml +++ b/charts/chart/templates/rbac/controller-manager.yaml @@ -1,4 +1,4 @@ -{{- if ne .Values.serviceAccount.enable false }} +{{- if ne .Values.serviceAccount.enabled false }} apiVersion: v1 kind: ServiceAccount metadata: diff --git a/charts/chart/templates/rbac/gameserver-admin-role.yaml b/charts/chart/templates/rbac/gameserver-admin-role.yaml index 3c73dee..e0fdd7f 100644 --- a/charts/chart/templates/rbac/gameserver-admin-role.yaml +++ b/charts/chart/templates/rbac/gameserver-admin-role.yaml @@ -1,4 +1,4 @@ -{{- if .Values.rbac.helpers.enable }} +{{- if .Values.rbac.helpers.enabled }} apiVersion: rbac.authorization.k8s.io/v1 {{- if .Values.rbac.namespaced }} kind: Role diff --git a/charts/chart/templates/rbac/gameserver-editor-role.yaml b/charts/chart/templates/rbac/gameserver-editor-role.yaml index 52eebf8..7ab5b78 100644 --- a/charts/chart/templates/rbac/gameserver-editor-role.yaml +++ b/charts/chart/templates/rbac/gameserver-editor-role.yaml @@ -1,4 +1,4 @@ -{{- if .Values.rbac.helpers.enable }} +{{- if .Values.rbac.helpers.enabled }} apiVersion: rbac.authorization.k8s.io/v1 {{- if .Values.rbac.namespaced }} kind: Role diff --git a/charts/chart/templates/rbac/gameserver-viewer-role.yaml b/charts/chart/templates/rbac/gameserver-viewer-role.yaml index 7b08340..5cdad8a 100644 --- a/charts/chart/templates/rbac/gameserver-viewer-role.yaml +++ b/charts/chart/templates/rbac/gameserver-viewer-role.yaml @@ -1,4 +1,4 @@ -{{- if .Values.rbac.helpers.enable }} +{{- if .Values.rbac.helpers.enabled }} apiVersion: rbac.authorization.k8s.io/v1 {{- if .Values.rbac.namespaced }} kind: Role diff --git a/charts/chart/templates/rbac/manager-role.yaml b/charts/chart/templates/rbac/manager-role.yaml index 0bf4b2c..8a36198 100644 --- a/charts/chart/templates/rbac/manager-role.yaml +++ b/charts/chart/templates/rbac/manager-role.yaml @@ -10,6 +10,15 @@ metadata: {{- end }} name: {{ include "gameserver-operator.resourceName" (dict "suffix" "manager-role" "context" $) }} rules: +- apiGroups: + - "" + resources: + - secrets + verbs: + - create + - get + - list + - watch - apiGroups: - "" resources: diff --git a/charts/chart/templates/rbac/metrics-auth-role.yaml b/charts/chart/templates/rbac/metrics-auth-role.yaml index 83745f5..46ee3af 100644 --- a/charts/chart/templates/rbac/metrics-auth-role.yaml +++ b/charts/chart/templates/rbac/metrics-auth-role.yaml @@ -1,4 +1,4 @@ -{{- if and .Values.metrics.enable .Values.metrics.secure }} +{{- if and .Values.metrics.enabled .Values.metrics.secure }} apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: diff --git a/charts/chart/templates/rbac/metrics-auth-rolebinding.yaml b/charts/chart/templates/rbac/metrics-auth-rolebinding.yaml index f3e61b7..d919758 100644 --- a/charts/chart/templates/rbac/metrics-auth-rolebinding.yaml +++ b/charts/chart/templates/rbac/metrics-auth-rolebinding.yaml @@ -1,4 +1,4 @@ -{{- if and .Values.metrics.enable .Values.metrics.secure }} +{{- if and .Values.metrics.enabled .Values.metrics.secure }} apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: diff --git a/charts/chart/templates/rbac/metrics-reader.yaml b/charts/chart/templates/rbac/metrics-reader.yaml index d26a79f..6dae0bc 100644 --- a/charts/chart/templates/rbac/metrics-reader.yaml +++ b/charts/chart/templates/rbac/metrics-reader.yaml @@ -1,4 +1,4 @@ -{{- if and .Values.metrics.enable .Values.metrics.secure }} +{{- if and .Values.metrics.enabled .Values.metrics.secure }} apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: diff --git a/charts/chart/values.yaml b/charts/chart/values.yaml index 23694ae..9cb4fe0 100644 --- a/charts/chart/values.yaml +++ b/charts/chart/values.yaml @@ -67,17 +67,32 @@ manager: ## tolerations: [] -## Helper RBAC roles for managing custom resources +## RBAC configuration ## -rbacHelpers: - # Install convenience admin/editor/viewer roles for CRDs - enable: false +rbac: + ## Install convenience admin/editor/viewer ClusterRoles for the GameServer CRD + helpers: + enabled: false + ## Use namespaced Role/RoleBinding instead of ClusterRole/ClusterRoleBinding + namespaced: false + +## ServiceAccount for the controller manager +## +serviceAccount: + enabled: true + labels: {} + annotations: {} + +## NetworkPolicy to restrict ingress to the metrics endpoint +## +networkPolicy: + enabled: false ## Custom Resource Definitions ## crd: # Install CRDs with the chart - enable: true + enabled: true # Keep CRDs when uninstalling keep: true @@ -85,7 +100,7 @@ crd: ## Enable to expose /metrics endpoint with RBAC protection. ## metrics: - enable: true + enabled: true # Metrics server port port: 8443 @@ -93,11 +108,11 @@ metrics: ## Required for webhook certificates and metrics endpoint certificates. ## certManager: - enable: false + enabled: false ## Prometheus ServiceMonitor for metrics scraping. ## Requires prometheus-operator to be installed in the cluster. ## prometheus: - enable: false + enabled: false diff --git a/config/crd/bases/games.idebeijer.github.io_gameservers.yaml b/config/crd/bases/games.idebeijer.github.io_gameservers.yaml index d3ba86a..2a6a8ac 100644 --- a/config/crd/bases/games.idebeijer.github.io_gameservers.yaml +++ b/config/crd/bases/games.idebeijer.github.io_gameservers.yaml @@ -39,6 +39,116 @@ spec: spec: description: spec defines the desired state of GameServer properties: + editor: + description: |- + Editor defines the configuration for the web-based editor sidecar. + When enabled, a code-server (VS Code in browser) container is injected into the pod. + properties: + auth: + description: |- + Auth configures authentication for the editor. If omitted, a Secret with a + randomly generated password is created automatically (secure default). + properties: + enabled: + default: true + description: |- + Enabled controls whether authentication is required to access the editor. + When set to false, any process that can reach the pod — including other pods in the + same cluster — can access the editor without credentials. Only disable this if + access is restricted by network policies or the cluster is fully trusted. + type: boolean + passwordSecretRef: + description: |- + PasswordSecretRef references an existing Secret that contains a "password" key. + If not set and Enabled is true, the operator creates a Secret with a randomly + generated password automatically. Retrieve it with: + kubectl get secret -editor-password -o jsonpath='{.data.password}' | base64 -d + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + type: object + enabled: + default: false + description: |- + Enabled indicates whether the code-server (VS Code in browser) sidecar is added to the pod. + Access it via: kubectl port-forward pod/ 8080:8080 + type: boolean + resources: + description: Resources defines resource requests and limits for + the editor sidecar container. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + shareProcessNamespace: + default: false + description: |- + ShareProcessNamespace enables sharing the process namespace between the gameserver + and editor containers, allowing the editor terminal to inspect and signal gameserver processes. + type: boolean + type: object gameConfigs: description: GameConfigs holds game-specific configuration options. properties: diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 001fe54..fdfe02c 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -4,6 +4,15 @@ kind: ClusterRole metadata: name: manager-role rules: +- apiGroups: + - "" + resources: + - secrets + verbs: + - create + - get + - list + - watch - apiGroups: - "" resources: diff --git a/config/samples/games_v1alpha1_gameserver_sf.yaml b/config/samples/games_v1alpha1_gameserver_sf.yaml new file mode 100644 index 0000000..62d6bb2 --- /dev/null +++ b/config/samples/games_v1alpha1_gameserver_sf.yaml @@ -0,0 +1,34 @@ +apiVersion: games.idebeijer.github.io/v1alpha1 +kind: GameServer +metadata: + name: sf +spec: + gameName: sf + editor: + enabled: true + shareProcessNamespace: true + # auth is omitted: the operator auto-creates a Secret named "sf-editor-password". + # Retrieve the password with: + # kubectl get secret sf-editor-password -o jsonpath='{.data.password}' | base64 -d + # + # To use your own Secret: + # auth: + # passwordSecretRef: + # name: my-secret # must contain a "password" key + # + # To disable authentication (only safe with restrictive network policies): + # auth: + # enabled: false + service: + type: LoadBalancer + ports: + - name: game-tcp + port: 7777 + protocol: TCP + - name: game-udp + port: 7777 + protocol: UDP + - name: api-tcp + protocol: TCP + port: 8888 + targetPort: 8888 diff --git a/dist/install.yaml b/dist/install.yaml index ade50ec..9d85a13 100644 --- a/dist/install.yaml +++ b/dist/install.yaml @@ -47,6 +47,116 @@ spec: spec: description: spec defines the desired state of GameServer properties: + editor: + description: |- + Editor defines the configuration for the web-based editor sidecar. + When enabled, a code-server (VS Code in browser) container is injected into the pod. + properties: + auth: + description: |- + Auth configures authentication for the editor. If omitted, a Secret with a + randomly generated password is created automatically (secure default). + properties: + enabled: + default: true + description: |- + Enabled controls whether authentication is required to access the editor. + When set to false, any process that can reach the pod — including other pods in the + same cluster — can access the editor without credentials. Only disable this if + access is restricted by network policies or the cluster is fully trusted. + type: boolean + passwordSecretRef: + description: |- + PasswordSecretRef references an existing Secret that contains a "password" key. + If not set and Enabled is true, the operator creates a Secret with a randomly + generated password automatically. Retrieve it with: + kubectl get secret -editor-password -o jsonpath='{.data.password}' | base64 -d + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + type: object + enabled: + default: false + description: |- + Enabled indicates whether the code-server (VS Code in browser) sidecar is added to the pod. + Access it via: kubectl port-forward pod/ 8080:8080 + type: boolean + resources: + description: Resources defines resource requests and limits for + the editor sidecar container. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + shareProcessNamespace: + default: false + description: |- + ShareProcessNamespace enables sharing the process namespace between the gameserver + and editor containers, allowing the editor terminal to inspect and signal gameserver processes. + type: boolean + type: object gameConfigs: description: GameConfigs holds game-specific configuration options. properties: @@ -463,6 +573,15 @@ kind: ClusterRole metadata: name: gameserver-operator-manager-role rules: +- apiGroups: + - "" + resources: + - secrets + verbs: + - create + - get + - list + - watch - apiGroups: - "" resources: diff --git a/internal/controller/gameserver_controller.go b/internal/controller/gameserver_controller.go index 3574ec3..9c0c83a 100644 --- a/internal/controller/gameserver_controller.go +++ b/internal/controller/gameserver_controller.go @@ -55,6 +55,7 @@ type GameServerReconciler struct { // +kubebuilder:rbac:groups=games.idebeijer.github.io,resources=gameservers/finalizers,verbs=update // +kubebuilder:rbac:groups=apps,resources=statefulsets,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=core,resources=services,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=core,resources=secrets,verbs=get;list;watch;create // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. @@ -83,6 +84,7 @@ func (r *GameServerReconciler) SetupWithManager(mgr ctrl.Manager) error { For(&gamesv1alpha1.GameServer{}). Named("gameserver"). Owns(&corev1.Service{}). + Owns(&corev1.Secret{}). Owns(&appsv1.StatefulSet{}). Complete(r) } diff --git a/internal/controller/gameserver_reconcile.go b/internal/controller/gameserver_reconcile.go index 4e1c049..1c5389c 100644 --- a/internal/controller/gameserver_reconcile.go +++ b/internal/controller/gameserver_reconcile.go @@ -2,7 +2,13 @@ package controller import ( "context" + "crypto/rand" + "fmt" + "math/big" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1ac "k8s.io/client-go/applyconfigurations/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" @@ -11,6 +17,10 @@ import ( ) func (r *GameServerReconciler) reconcileGameServer(ctx context.Context, gs *gamesv1alpha1.GameServer) error { + if err := r.reconcileEditorSecret(ctx, gs); err != nil { + return err + } + if err := r.reconcileGameServerStatefulSet(ctx, gs); err != nil { return err } @@ -76,3 +86,72 @@ func (r *GameServerReconciler) reconcileGameServerService(ctx context.Context, g return nil } + +// reconcileEditorSecret ensures an auto-generated password Secret exists when the editor +// sidecar is enabled with password auth and no external secret is referenced. +// The Secret is only created, never updated, so the password survives reconcile loops. +func (r *GameServerReconciler) reconcileEditorSecret(ctx context.Context, gs *gamesv1alpha1.GameServer) error { + editor := gs.Spec.Editor + if editor == nil || !editor.Enabled { + return nil + } + if editor.Auth != nil && ((editor.Auth.Enabled != nil && !*editor.Auth.Enabled) || + (editor.Auth.PasswordSecretRef != nil && editor.Auth.PasswordSecretRef.Name != "")) { + return nil + } + + secretName := specs.EditorPasswordSecretName(gs) + existing := &corev1.Secret{} + err := r.Get(ctx, client.ObjectKey{Namespace: gs.Namespace, Name: secretName}, existing) + if err == nil { + return nil + } + if !apierrors.IsNotFound(err) { + return fmt.Errorf("failed to get editor Secret: %w", err) + } + + password, err := generatePassword(24) + if err != nil { + return fmt.Errorf("failed to generate editor password: %w", err) + } + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: gs.Namespace, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: gs.APIVersion, + Kind: gs.Kind, + Name: gs.Name, + UID: gs.UID, + Controller: &[]bool{true}[0], + BlockOwnerDeletion: &[]bool{true}[0], + }, + }, + }, + StringData: map[string]string{ + "password": password, + }, + } + + if err := r.Create(ctx, secret); err != nil && !apierrors.IsAlreadyExists(err) { + return fmt.Errorf("failed to create editor Secret: %w", err) + } + + return nil +} + +const passwordChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + +func generatePassword(length int) (string, error) { + b := make([]byte, length) + for i := range b { + n, err := rand.Int(rand.Reader, big.NewInt(int64(len(passwordChars)))) + if err != nil { + return "", err + } + b[i] = passwordChars[n.Int64()] + } + return string(b), nil +} diff --git a/pkg/specs/gameserver.go b/pkg/specs/gameserver.go index 9ce4897..781803d 100644 --- a/pkg/specs/gameserver.go +++ b/pkg/specs/gameserver.go @@ -17,7 +17,7 @@ import ( func BuildLinuxGSMGameServerStatefulSet(gs *gamesv1alpha1.GameServer) *appsv1ac.StatefulSetApplyConfiguration { storageEnabled := linuxGSMStorageEnabled(gs) container := buildLinuxGSMContainer(gs, storageEnabled) - podSpec := buildLinuxGSMPodSpec(container) + podSpec := buildLinuxGSMPodSpec(gs, container, storageEnabled) stsSpec := buildLinuxGSMStatefulSetSpec(gs, podSpec, storageEnabled) sts := appsv1ac.StatefulSet(gs.Name, gs.Namespace). @@ -93,8 +93,12 @@ func buildLinuxGSMContainer(gs *gamesv1alpha1.GameServer, storageEnabled bool) * return container } -func buildLinuxGSMPodSpec(container *corev1ac.ContainerApplyConfiguration) *corev1ac.PodSpecApplyConfiguration { - return corev1ac.PodSpec(). +func buildLinuxGSMPodSpec( + gs *gamesv1alpha1.GameServer, + container *corev1ac.ContainerApplyConfiguration, + storageEnabled bool, +) *corev1ac.PodSpecApplyConfiguration { + podSpec := corev1ac.PodSpec(). WithAutomountServiceAccountToken(false). WithSecurityContext(corev1ac.PodSecurityContext(). WithRunAsNonRoot(true). @@ -107,6 +111,93 @@ func buildLinuxGSMPodSpec(container *corev1ac.ContainerApplyConfiguration) *core ), ). WithContainers(container) + + if gs.Spec.Editor != nil && gs.Spec.Editor.Enabled { + podSpec.WithContainers(buildCodeServerSidecar(gs, storageEnabled)) + if gs.Spec.Editor.ShareProcessNamespace { + podSpec.WithShareProcessNamespace(true) + } + } + + return podSpec +} + +// EditorPasswordSecretName returns the name of the auto-generated Secret holding the +// code-server password for the given GameServer. The controller creates this Secret +// when editor auth is not disabled and no external secret is referenced. +func EditorPasswordSecretName(gs *gamesv1alpha1.GameServer) string { + return gs.Name + "-editor-password" +} + +func buildCodeServerSidecar(gs *gamesv1alpha1.GameServer, storageEnabled bool) *corev1ac.ContainerApplyConfiguration { + args := []string{"--bind-addr", "0.0.0.0:8080", "--disable-telemetry"} + + var passwordEnv *corev1ac.EnvVarApplyConfiguration + auth := gs.Spec.Editor.Auth + if auth != nil && auth.Enabled != nil && !*auth.Enabled { + args = append(args, "--auth", "none") + } else { + args = append(args, "--auth", "password") + secretName := EditorPasswordSecretName(gs) + if auth != nil && auth.PasswordSecretRef != nil && auth.PasswordSecretRef.Name != "" { + secretName = auth.PasswordSecretRef.Name + } + passwordEnv = corev1ac.EnvVar(). + WithName("PASSWORD"). + WithValueFrom(corev1ac.EnvVarSource(). + WithSecretKeyRef(corev1ac.SecretKeySelector(). + WithName(secretName). + WithKey("password"), + ), + ) + } + + if storageEnabled { + args = append(args, "/data") + } + + sidecar := corev1ac.Container(). + WithName("editor"). + WithImage("codercom/code-server:latest"). + WithImagePullPolicy(v1.PullIfNotPresent). + WithArgs(args...). + WithPorts( + corev1ac.ContainerPort(). + WithName("editor"). + WithContainerPort(8080). + WithProtocol(v1.ProtocolTCP), + ). + WithSecurityContext(corev1ac.SecurityContext(). + WithAllowPrivilegeEscalation(false). + WithCapabilities(corev1ac.Capabilities(). + WithDrop("ALL"), + ), + ) + + if passwordEnv != nil { + sidecar.WithEnv(passwordEnv) + } + + if storageEnabled { + sidecar.WithVolumeMounts( + corev1ac.VolumeMount(). + WithName("data"). + WithMountPath("/data"), + ) + } + + if gs.Spec.Editor.Resources != nil { + resources := corev1ac.ResourceRequirements() + if gs.Spec.Editor.Resources.Limits != nil { + resources.WithLimits(gs.Spec.Editor.Resources.Limits) + } + if gs.Spec.Editor.Resources.Requests != nil { + resources.WithRequests(gs.Spec.Editor.Resources.Requests) + } + sidecar.WithResources(resources) + } + + return sidecar } func buildLinuxGSMStatefulSetSpec(gs *gamesv1alpha1.GameServer, diff --git a/pkg/specs/gameserver_test.go b/pkg/specs/gameserver_test.go index ad5924e..03d8408 100644 --- a/pkg/specs/gameserver_test.go +++ b/pkg/specs/gameserver_test.go @@ -100,6 +100,98 @@ var _ = Describe("LinuxGSM spec builders", func() { Expect(statefulSet.Spec.VolumeClaimTemplates).To(BeNil()) }) + Context("editor sidecar", func() { + It("injects no editor container when editor is disabled", func() { + gs := newGameServer(func(gs *gamesv1alpha1.GameServer) { + gs.Spec.Editor = &gamesv1alpha1.EditorSpec{Enabled: false} + }) + statefulSet := specs.BuildLinuxGSMGameServerStatefulSet(gs) + Expect(statefulSet.Spec.Template.Spec.Containers).To(HaveLen(1)) + Expect(statefulSet.Spec.Template.Spec.ShareProcessNamespace).To(BeNil()) + }) + + It("injects the editor container with auto-secret ref when editor is enabled", func() { + gs := newGameServer(func(gs *gamesv1alpha1.GameServer) { + gs.Spec.Editor = &gamesv1alpha1.EditorSpec{Enabled: true} + }) + statefulSet := specs.BuildLinuxGSMGameServerStatefulSet(gs) + podSpec := statefulSet.Spec.Template.Spec + + Expect(podSpec.Containers).To(HaveLen(2)) + Expect(podSpec.ShareProcessNamespace).To(BeNil()) + + editor := podSpec.Containers[1] + Expect(*editor.Name).To(Equal("editor")) + Expect(*editor.Image).To(Equal("codercom/code-server:latest")) + Expect(editor.Args).To(ContainElements("--auth", "password")) + Expect(editor.Args).NotTo(ContainElement("none")) + + Expect(editor.Env).To(HaveLen(1)) + env := editor.Env[0] + Expect(*env.Name).To(Equal("PASSWORD")) + Expect(env.ValueFrom).NotTo(BeNil()) + Expect(env.ValueFrom.SecretKeyRef).NotTo(BeNil()) + Expect(*env.ValueFrom.SecretKeyRef.Name).To(Equal(specs.EditorPasswordSecretName(gs))) + Expect(*env.ValueFrom.SecretKeyRef.Key).To(Equal("password")) + + Expect(editor.VolumeMounts).To(HaveLen(1)) + Expect(*editor.VolumeMounts[0].MountPath).To(Equal("/data")) + }) + + It("uses the referenced secret when passwordSecretRef is set", func() { + gs := newGameServer(func(gs *gamesv1alpha1.GameServer) { + gs.Spec.Editor = &gamesv1alpha1.EditorSpec{ + Enabled: true, + Auth: &gamesv1alpha1.EditorAuthSpec{ + PasswordSecretRef: &corev1.LocalObjectReference{Name: "my-secret"}, + }, + } + }) + statefulSet := specs.BuildLinuxGSMGameServerStatefulSet(gs) + editor := statefulSet.Spec.Template.Spec.Containers[1] + + Expect(editor.Env).To(HaveLen(1)) + Expect(*editor.Env[0].ValueFrom.SecretKeyRef.Name).To(Equal("my-secret")) + }) + + It("disables auth and omits password env when auth.enabled is false", func() { + gs := newGameServer(func(gs *gamesv1alpha1.GameServer) { + gs.Spec.Editor = &gamesv1alpha1.EditorSpec{ + Enabled: true, + Auth: &gamesv1alpha1.EditorAuthSpec{Enabled: new(false)}, + } + }) + statefulSet := specs.BuildLinuxGSMGameServerStatefulSet(gs) + editor := statefulSet.Spec.Template.Spec.Containers[1] + + Expect(editor.Args).To(ContainElements("--auth", "none")) + Expect(editor.Env).To(BeEmpty()) + }) + + It("sets shareProcessNamespace when enabled", func() { + gs := newGameServer(func(gs *gamesv1alpha1.GameServer) { + gs.Spec.Editor = &gamesv1alpha1.EditorSpec{ + Enabled: true, + ShareProcessNamespace: true, + } + }) + statefulSet := specs.BuildLinuxGSMGameServerStatefulSet(gs) + Expect(statefulSet.Spec.Template.Spec.ShareProcessNamespace).To(HaveValue(BeTrue())) + }) + + It("omits the data volume mount when storage is disabled", func() { + gs := newGameServer(func(gs *gamesv1alpha1.GameServer) { + gs.Spec.Storage = &gamesv1alpha1.StorageSpec{Enabled: new(false)} + gs.Spec.Editor = &gamesv1alpha1.EditorSpec{Enabled: true} + }) + statefulSet := specs.BuildLinuxGSMGameServerStatefulSet(gs) + editor := statefulSet.Spec.Template.Spec.Containers[1] + + Expect(editor.VolumeMounts).To(BeEmpty()) + Expect(editor.Args).NotTo(ContainElement("/data")) + }) + }) + It("adds container ports for numeric service target ports", func() { gs := newGameServer(func(gs *gamesv1alpha1.GameServer) { gs.Spec.Service = &gamesv1alpha1.ServiceSpec{