diff --git a/.cargo/config.toml b/.cargo/config.toml index 9a646456..64e0d124 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -7,7 +7,7 @@ linker = "x86_64-linux-gnu-gcc" [target.aarch64-unknown-linux-gnu] linker = "aarch64-linux-gnu-gcc" -[target.'cfg(all())'] -rustflags = [ - "-Wunused_crate_dependencies", -] +# [target.'cfg(all())'] +# rustflags = [ +# "-Wunused_crate_dependencies", +# ] diff --git a/.vscode/settings.json b/.vscode/settings.json index 11b7ce46..aea411bd 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,11 +4,12 @@ "[rust]": { "editor.tabSize": 4, "editor.defaultFormatter": "rust-lang.rust-analyzer", - "editor.formatOnSave": true, + "editor.formatOnSave": true }, "rust-analyzer.check.command": "clippy", "yaml.schemas": { "https://json.schemastore.org/github-workflow.json": ".github/workflows/deploy.yml", "https://json.schemastore.org/github-issue-config.json": ".github/ISSUE_TEMPLATE/config.yml" - } -} \ No newline at end of file + }, + "rust-analyzer.cargo.features": "all" +} diff --git a/Cargo.lock b/Cargo.lock index 3a6d2b65..489051ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -923,6 +923,7 @@ dependencies = [ name = "google-open-match-sdk" version = "0.3.0" dependencies = [ + "futures-core", "pbjson-types", "prost", "tonic", @@ -2221,6 +2222,30 @@ dependencies = [ [[package]] name = "shulker-addon-matchmaking" version = "0.3.0" +dependencies = [ + "anyhow", + "clap", + "futures", + "google-agones-crds", + "google-open-match-sdk", + "kube", + "paste", + "pbjson-types", + "prost", + "shulker-crds", + "shulker-kube-utils", + "shulker-sdk", + "shulker-utils", + "tempfile", + "thiserror", + "tokio", + "tokio-stream", + "tokio-util", + "tonic", + "tower", + "tracing", + "uuid", +] [[package]] name = "shulker-crds" @@ -2282,8 +2307,10 @@ dependencies = [ "shulker-crds", "shulker-kube-utils", "shulker-sdk", + "shulker-utils", "thiserror", "tokio", + "tokio-util", "toml 0.8.8", "tonic", "tower-test", diff --git a/Cargo.toml b/Cargo.toml index 238105a6..b0211737 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,8 +32,9 @@ anyhow = "1.0.75" async-trait = "0.1.74" base64 = "0.21.5" chrono = { version = "0.4.31", features = ["serde"] } -clap = { version = "4.4.7", features = ["derive"] } +clap = { version = "4.4.7", features = ["derive", "env"] } futures = "0.3.29" +futures-core = "0.3.29" hostname = "0.3.1" http = "0.2.9" hyper = "0.14.27" @@ -41,6 +42,7 @@ insta = { version = "1.34.0", features = ["yaml", "toml", "redactions"] } k8s-openapi = { version = "0.20.0", features = ["latest", "schemars"] } kube = { version = "0.87.1", features = ["runtime", "client", "derive" ] } lazy_static = "1.4.0" +paste = "1.0.14" pbjson-types = "0.6.0" prometheus = "0.13.3" prost = "0.12.1" @@ -50,11 +52,14 @@ serde = { version = "1.0.192", features = ["derive"] } serde_json = "1.0.108" serde_yaml = "0.9.27" strum = { version = "0.25.0", features = ["derive"] } +tempfile = "3.8.1" thiserror = "1.0.50" tonic = { version = "0.10.2", features = ["gzip"] } tokio = { version = "1.33.0", features = ["macros", "rt-multi-thread"] } +tokio-stream = "0.1.14" tokio-util = "0.7.10" toml = "0.8.8" +tower = "0.4.13" tower-test = "0.4.0" tracing = "0.1.40" tracing-subscriber = { version = "0.3.17", features = ["json", "env-filter"] } diff --git a/kube/overlays/next/kustomization.yaml b/kube/overlays/next/kustomization.yaml index 0e188511..3a7bda5a 100644 --- a/kube/overlays/next/kustomization.yaml +++ b/kube/overlays/next/kustomization.yaml @@ -6,7 +6,10 @@ namespace: shulker-system resources: - ../../resources/crd - ../../resources/components/shulker-operator + - ../../resources/components/shulker-addon-matchmaking images: - name: ghcr.io/jeremylvln/shulker-operator newTag: next + - name: ghcr.io/jeremylvln/shulker-addon-matchmaking + newTag: next diff --git a/kube/resources/components/shulker-addon-matchmaking/director_deployment.yaml b/kube/resources/components/shulker-addon-matchmaking/director_deployment.yaml new file mode 100644 index 00000000..836c648d --- /dev/null +++ b/kube/resources/components/shulker-addon-matchmaking/director_deployment.yaml @@ -0,0 +1,84 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: shulker-addon-matchmaking-director + labels: + app.kubernetes.io/name: deployment + app.kubernetes.io/instance: shulker-addon-matchmaking-director + app.kubernetes.io/component: shulker-addon-matchmaking + app.kubernetes.io/part-of: shulker +spec: + selector: + matchLabels: + app.kubernetes.io/name: shulker-addon-matchmaking-director + app.kubernetes.io/instance: shulker-addon-matchmaking-director + app.kubernetes.io/component: shulker-addon-matchmaking + replicas: 1 + template: + metadata: + labels: + app.kubernetes.io/name: shulker-addon-matchmaking-director + app.kubernetes.io/instance: shulker-addon-matchmaking-director + app.kubernetes.io/component: shulker-addon-matchmaking + spec: + containers: + - name: shulker-addon-matchmaking-director + image: ghcr.io/jeremylvln/shulker-addon-matchmaking + imagePullPolicy: Always + args: + - --metrics-bind-address=0.0.0.0:8080 + env: + - name: OPEN_MATCH_BACKEND_HOST + value: open-match-backend.open-match + - name: OPEN_MATCH_BACKEND_GRPC_PORT + value: '50505' + - name: SHULKER_API_HOST + value: 'shulker-operator.shulker-system' + - name: SHULKER_API_GRPC_PORT + value: '8080' + ports: + - containerPort: 8080 + protocol: TCP + name: metrics + livenessProbe: + httpGet: + path: /healthz + port: metrics + periodSeconds: 15 + startupProbe: + httpGet: + path: /healthz + port: metrics + failureThreshold: 5 + periodSeconds: 15 + resources: + limits: + cpu: 500m + memory: 128Mi + requests: + cpu: 10m + memory: 64Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - 'ALL' + nodeSelector: + kubernetes.io/os: linux + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + serviceAccountName: shulker-addon-matchmaking + terminationGracePeriodSeconds: 30 + affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + - matchExpressions: + - key: kubernetes.io/arch + operator: In + values: + - amd64 + - arm64 diff --git a/kube/resources/components/shulker-addon-matchmaking/director_metrics_service.yaml b/kube/resources/components/shulker-addon-matchmaking/director_metrics_service.yaml new file mode 100644 index 00000000..66082a9b --- /dev/null +++ b/kube/resources/components/shulker-addon-matchmaking/director_metrics_service.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: Service +metadata: + name: shulker-addon-matchmaking-director-metrics + labels: + app.kubernetes.io/name: service + app.kubernetes.io/instance: shulker-addon-matchmaking-director-metrics + app.kubernetes.io/component: shulker-addon-matchmaking + app.kubernetes.io/part-of: shulker +spec: + selector: + app.kubernetes.io/name: shulker-addon-matchmaking-director + app.kubernetes.io/instance: shulker-addon-matchmaking-director + app.kubernetes.io/component: shulker-addon-matchmaking + ports: + - name: metrics + port: 8080 + protocol: TCP + targetPort: metrics diff --git a/kube/resources/components/shulker-addon-matchmaking/kustomization.yaml b/kube/resources/components/shulker-addon-matchmaking/kustomization.yaml new file mode 100644 index 00000000..de9e862c --- /dev/null +++ b/kube/resources/components/shulker-addon-matchmaking/kustomization.yaml @@ -0,0 +1,10 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - director_deployment.yaml + - director_metrics_service.yaml + - mmf_deployment.yaml + - mmf_metrics_service.yaml + - mmf_service.yaml + - rbac/ diff --git a/kube/resources/components/shulker-addon-matchmaking/mmf_deployment.yaml b/kube/resources/components/shulker-addon-matchmaking/mmf_deployment.yaml new file mode 100644 index 00000000..90b0376a --- /dev/null +++ b/kube/resources/components/shulker-addon-matchmaking/mmf_deployment.yaml @@ -0,0 +1,87 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: shulker-addon-matchmaking-mmf + labels: + app.kubernetes.io/name: deployment + app.kubernetes.io/instance: shulker-addon-matchmaking-mmf + app.kubernetes.io/component: shulker-addon-matchmaking + app.kubernetes.io/part-of: shulker +spec: + selector: + matchLabels: + app.kubernetes.io/name: shulker-addon-matchmaking-mmf + app.kubernetes.io/instance: shulker-addon-matchmaking-mmf + app.kubernetes.io/component: shulker-addon-matchmaking + replicas: 1 + template: + metadata: + labels: + app.kubernetes.io/name: shulker-addon-matchmaking-mmf + app.kubernetes.io/instance: shulker-addon-matchmaking-mmf + app.kubernetes.io/component: shulker-addon-matchmaking + spec: + containers: + - name: shulker-addon-matchmaking-mmf + image: ghcr.io/jeremylvln/shulker-addon-matchmaking + command: ['/shulker-addon-matchmaking-mmf'] + imagePullPolicy: Always + args: + - --metrics-bind-address=0.0.0.0:8080 + env: + - name: OPEN_MATCH_QUERY_HOST + value: open-match-query.open-match + - name: OPEN_MATCH_QUERY_GRPC_PORT + value: '50503' + ports: + - containerPort: 8080 + protocol: TCP + name: metrics + - containerPort: 9090 + protocol: TCP + name: mmf-batch + - containerPort: 9091 + protocol: TCP + name: mmf-elo + livenessProbe: + httpGet: + path: /healthz + port: metrics + periodSeconds: 15 + startupProbe: + httpGet: + path: /healthz + port: metrics + failureThreshold: 5 + periodSeconds: 15 + resources: + limits: + cpu: 500m + memory: 128Mi + requests: + cpu: 10m + memory: 64Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - 'ALL' + nodeSelector: + kubernetes.io/os: linux + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + serviceAccountName: shulker-addon-matchmaking + terminationGracePeriodSeconds: 30 + affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + - matchExpressions: + - key: kubernetes.io/arch + operator: In + values: + - amd64 + - arm64 diff --git a/kube/resources/components/shulker-addon-matchmaking/mmf_metrics_service.yaml b/kube/resources/components/shulker-addon-matchmaking/mmf_metrics_service.yaml new file mode 100644 index 00000000..da7b43d1 --- /dev/null +++ b/kube/resources/components/shulker-addon-matchmaking/mmf_metrics_service.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: Service +metadata: + name: shulker-addon-matchmaking-mmf-metrics + labels: + app.kubernetes.io/name: service + app.kubernetes.io/instance: shulker-addon-matchmaking-mmf-metrics + app.kubernetes.io/component: shulker-addon-matchmaking + app.kubernetes.io/part-of: shulker +spec: + selector: + app.kubernetes.io/name: shulker-addon-matchmaking-mmf + app.kubernetes.io/instance: shulker-addon-matchmaking-mmf + app.kubernetes.io/component: shulker-addon-matchmaking + ports: + - name: metrics + port: 8080 + protocol: TCP + targetPort: metrics diff --git a/kube/resources/components/shulker-addon-matchmaking/mmf_service.yaml b/kube/resources/components/shulker-addon-matchmaking/mmf_service.yaml new file mode 100644 index 00000000..b51bc4e5 --- /dev/null +++ b/kube/resources/components/shulker-addon-matchmaking/mmf_service.yaml @@ -0,0 +1,23 @@ +apiVersion: v1 +kind: Service +metadata: + name: shulker-addon-matchmaking-mmf + labels: + app.kubernetes.io/name: service + app.kubernetes.io/instance: shulker-addon-matchmaking-mmf + app.kubernetes.io/component: shulker-addon-matchmaking + app.kubernetes.io/part-of: shulker +spec: + selector: + app.kubernetes.io/name: shulker-addon-matchmaking-mmf + app.kubernetes.io/instance: shulker-addon-matchmaking-mmf + app.kubernetes.io/component: shulker-addon-matchmaking + ports: + - name: mmf-batch + port: 9090 + protocol: TCP + targetPort: mmf-batch + - name: mmf-elo + port: 9091 + protocol: TCP + targetPort: mmf-elo diff --git a/kube/resources/components/shulker-addon-matchmaking/prometheus/kustomization.yaml b/kube/resources/components/shulker-addon-matchmaking/prometheus/kustomization.yaml new file mode 100644 index 00000000..bffea4e5 --- /dev/null +++ b/kube/resources/components/shulker-addon-matchmaking/prometheus/kustomization.yaml @@ -0,0 +1,5 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - monitor.yaml diff --git a/kube/resources/components/shulker-addon-matchmaking/prometheus/monitor.yaml b/kube/resources/components/shulker-addon-matchmaking/prometheus/monitor.yaml new file mode 100644 index 00000000..3ca00626 --- /dev/null +++ b/kube/resources/components/shulker-addon-matchmaking/prometheus/monitor.yaml @@ -0,0 +1,19 @@ +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: shulker-addon-matchmaking + labels: + app.kubernetes.io/name: servicemonitor + app.kubernetes.io/instance: shulker-addon-matchmaking + app.kubernetes.io/component: shulker-addon-matchmaking-prometheus + app.kubernetes.io/part-of: shulker +spec: + selector: + matchLabels: + app.kubernetes.io/name: service + app.kubernetes.io/instance: shulker-addon-matchmaking-metrics + app.kubernetes.io/component: shulker-addon-matchmaking + endpoints: + - targetPort: metrics + path: /metrics + interval: 60s diff --git a/kube/resources/components/shulker-addon-matchmaking/rbac/kustomization.yaml b/kube/resources/components/shulker-addon-matchmaking/rbac/kustomization.yaml new file mode 100644 index 00000000..1e33dcb1 --- /dev/null +++ b/kube/resources/components/shulker-addon-matchmaking/rbac/kustomization.yaml @@ -0,0 +1,9 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - service_account.yaml + - leader_election_role.yaml + - leader_election_role_binding.yaml + - workload_cluster_role.yaml + - workload_cluster_role_binding.yaml diff --git a/kube/resources/components/shulker-addon-matchmaking/rbac/leader_election_role.yaml b/kube/resources/components/shulker-addon-matchmaking/rbac/leader_election_role.yaml new file mode 100644 index 00000000..0b37c97a --- /dev/null +++ b/kube/resources/components/shulker-addon-matchmaking/rbac/leader_election_role.yaml @@ -0,0 +1,26 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: shulker-addon-matchmaking:leader-election + labels: + app.kubernetes.io/name: role + app.kubernetes.io/instance: shulker-addon-matchmaking-leader-election + app.kubernetes.io/component: shulker-addon-matchmaking-rbac + app.kubernetes.io/part-of: shulker +rules: + - apiGroups: + - coordination.k8s.io + resources: + - leases + verbs: + - get + - update + - patch + resourceNames: + - shulker-addon-matchmaking.shulkermc.io + - apiGroups: + - coordination.k8s.io + resources: + - leases + verbs: + - create diff --git a/kube/resources/components/shulker-addon-matchmaking/rbac/leader_election_role_binding.yaml b/kube/resources/components/shulker-addon-matchmaking/rbac/leader_election_role_binding.yaml new file mode 100644 index 00000000..c696364e --- /dev/null +++ b/kube/resources/components/shulker-addon-matchmaking/rbac/leader_election_role_binding.yaml @@ -0,0 +1,16 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: shulker-addon-matchmaking:leader-election + labels: + app.kubernetes.io/name: rolebinding + app.kubernetes.io/instance: shulker-addon-matchmaking-leader-election + app.kubernetes.io/component: shulker-addon-matchmaking-rbac + app.kubernetes.io/part-of: shulker +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: shulker-addon-matchmaking:leader-election +subjects: + - kind: ServiceAccount + name: shulker-addon-matchmaking diff --git a/kube/resources/components/shulker-addon-matchmaking/rbac/service_account.yaml b/kube/resources/components/shulker-addon-matchmaking/rbac/service_account.yaml new file mode 100644 index 00000000..a4975633 --- /dev/null +++ b/kube/resources/components/shulker-addon-matchmaking/rbac/service_account.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: shulker-addon-matchmaking + labels: + app.kubernetes.io/name: serviceaccount + app.kuberentes.io/instance: shulker-addon-matchmaking + app.kubernetes.io/component: shulker-addon-matchmaking-rbac + app.kubernetes.io/part-of: shulker diff --git a/kube/resources/components/shulker-addon-matchmaking/rbac/workload_cluster_role.yaml b/kube/resources/components/shulker-addon-matchmaking/rbac/workload_cluster_role.yaml new file mode 100644 index 00000000..f59ef068 --- /dev/null +++ b/kube/resources/components/shulker-addon-matchmaking/rbac/workload_cluster_role.yaml @@ -0,0 +1,33 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: shulker-addon-matchmaking-workload + labels: + app.kubernetes.io/name: clusterrole + app.kubernetes.io/instance: shulker-addon-matchmaking-workload + app.kubernetes.io/component: shulker-addon-matchmaking-rbac + app.kubernetes.io/part-of: shulker +rules: + - apiGroups: + - matchmaking.shulkermc.io + resources: + - matchmakingqueues + verbs: [get, list, watch] + - apiGroups: + - matchmaking.shulkermc.io + resources: + - matchmakingqueues + - matchmakingqueues/status + verbs: [update, patch] + + - apiGroups: + - '' + resources: + - events + verbs: [create] + + - apiGroups: + - agones.dev + resources: + - gameservers + verbs: [list] diff --git a/kube/resources/components/shulker-addon-matchmaking/rbac/workload_cluster_role_binding.yaml b/kube/resources/components/shulker-addon-matchmaking/rbac/workload_cluster_role_binding.yaml new file mode 100644 index 00000000..44739f19 --- /dev/null +++ b/kube/resources/components/shulker-addon-matchmaking/rbac/workload_cluster_role_binding.yaml @@ -0,0 +1,17 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: shulker-addon-matchmaking-workload + labels: + app.kubernetes.io/name: clusterrolebinding + app.kubernetes.io/instance: shulker-addon-matchmaking-workload + app.kubernetes.io/component: shulker-addon-matchmaking-rbac + app.kubernetes.io/part-of: shulker +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: shulker-addon-matchmaking-workload +subjects: + - kind: ServiceAccount + name: shulker-addon-matchmaking + namespace: shulker-system diff --git a/kube/resources/components/shulker-operator/metrics_service.yaml b/kube/resources/components/shulker-operator/metrics_service.yaml index c81ece89..3b934318 100644 --- a/kube/resources/components/shulker-operator/metrics_service.yaml +++ b/kube/resources/components/shulker-operator/metrics_service.yaml @@ -13,6 +13,7 @@ spec: app.kubernetes.io/instance: shulker-operator app.kubernetes.io/component: shulker-operator ports: - - port: 8080 + - name: metrics + port: 8080 protocol: TCP targetPort: metrics diff --git a/kube/resources/components/shulker-operator/service.yaml b/kube/resources/components/shulker-operator/service.yaml index 3f6b1de4..fc79954f 100644 --- a/kube/resources/components/shulker-operator/service.yaml +++ b/kube/resources/components/shulker-operator/service.yaml @@ -13,6 +13,7 @@ spec: app.kubernetes.io/instance: shulker-operator app.kubernetes.io/component: shulker-operator ports: - - port: 8080 + - name: api + port: 8080 protocol: TCP targetPort: api diff --git a/kube/resources/crd/kustomization.yaml b/kube/resources/crd/kustomization.yaml index 94b3f8bb..c6b2b83e 100644 --- a/kube/resources/crd/kustomization.yaml +++ b/kube/resources/crd/kustomization.yaml @@ -2,7 +2,8 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: - - bases/shulkermc.io_minecraftclusters.yaml - - bases/shulkermc.io_proxyfleets.yaml - - bases/shulkermc.io_minecraftservers.yaml - - bases/shulkermc.io_minecraftserverfleets.yaml + - shulkermc.io_minecraftclusters.yaml + - shulkermc.io_minecraftserverfleets.yaml + - shulkermc.io_minecraftservers.yaml + - shulkermc.io_proxyfleets.yaml + - matchmaking/matchmaking.shulkermc.io_matchmakingqueues.yaml diff --git a/kube/resources/crd/matchmaking/matchmaking.shulkermc.io_matchmakingqueues.yaml b/kube/resources/crd/matchmaking/matchmaking.shulkermc.io_matchmakingqueues.yaml new file mode 100644 index 00000000..be72e200 --- /dev/null +++ b/kube/resources/crd/matchmaking/matchmaking.shulkermc.io_matchmakingqueues.yaml @@ -0,0 +1,95 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: matchmakingqueues.matchmaking.shulkermc.io +spec: + group: matchmaking.shulkermc.io + names: + categories: [] + kind: MatchmakingQueue + plural: matchmakingqueues + shortNames: [] + singular: matchmakingqueue + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for MatchmakingQueueSpec via `CustomResource` + properties: + spec: + properties: + maxPlayers: + description: The maximum number of players a match can contain + format: uint32 + minimum: 0.0 + type: integer + minPlayers: + description: The minimum number of players required to create a match. If `None`, the matchmaking function will wait for the maximum number of players + format: uint32 + minimum: 0.0 + nullable: true + type: integer + mmf: + description: The matchmaking function to use to create matches for this queue + properties: + builtIn: + description: The matchmaking function to use is provided by Shulker + nullable: true + properties: + type: + description: The type of the matchmaking function to use + enum: + - Batch + - Elo + type: string + required: + - type + type: object + provided: + description: The matchmaking function to use is provided by the user + nullable: true + properties: + host: + description: Host of the matchmaking function + type: string + port: + description: GRPC port of the matchmaking function + format: uint16 + minimum: 0.0 + type: integer + required: + - host + - port + type: object + type: object + targetFleetRef: + description: The `MinecraftServerFleet` to use as a target for this queue + properties: + name: + description: Name of the Kubernetes `MinecraftServerFleet` owning this resource + type: string + required: + - name + type: object + required: + - maxPlayers + - mmf + - targetFleetRef + type: object + status: + description: The status object of `MatchmakingQueue` + nullable: true + type: object + required: + - spec + title: MatchmakingQueue + type: object + served: true + storage: true + subresources: + status: {} diff --git a/kube/resources/crd/bases/shulkermc.io_minecraftclusters.yaml b/kube/resources/crd/shulkermc.io_minecraftclusters.yaml similarity index 98% rename from kube/resources/crd/bases/shulkermc.io_minecraftclusters.yaml rename to kube/resources/crd/shulkermc.io_minecraftclusters.yaml index 8a1a76eb..90f40329 100644 --- a/kube/resources/crd/bases/shulkermc.io_minecraftclusters.yaml +++ b/kube/resources/crd/shulkermc.io_minecraftclusters.yaml @@ -27,6 +27,7 @@ spec: description: List of player UUIDs that are automatically promoted as network administrators, which are granted all the permissions by default on all the proxies and servers items: type: string + nullable: true type: array redis: description: Redis configuration to use as a synchronization backend for the different Shulker components @@ -61,8 +62,6 @@ spec: required: - type type: object - required: - - networkAdmins type: object status: description: The status object of `MinecraftCluster` diff --git a/kube/resources/crd/bases/shulkermc.io_minecraftserverfleets.yaml b/kube/resources/crd/shulkermc.io_minecraftserverfleets.yaml similarity index 100% rename from kube/resources/crd/bases/shulkermc.io_minecraftserverfleets.yaml rename to kube/resources/crd/shulkermc.io_minecraftserverfleets.yaml diff --git a/kube/resources/crd/bases/shulkermc.io_minecraftservers.yaml b/kube/resources/crd/shulkermc.io_minecraftservers.yaml similarity index 100% rename from kube/resources/crd/bases/shulkermc.io_minecraftservers.yaml rename to kube/resources/crd/shulkermc.io_minecraftservers.yaml diff --git a/kube/resources/crd/bases/shulkermc.io_proxyfleets.yaml b/kube/resources/crd/shulkermc.io_proxyfleets.yaml similarity index 100% rename from kube/resources/crd/bases/shulkermc.io_proxyfleets.yaml rename to kube/resources/crd/shulkermc.io_proxyfleets.yaml diff --git a/packages/google-open-match-sdk/bindings/rust/Cargo.toml b/packages/google-open-match-sdk/bindings/rust/Cargo.toml index 7a086b06..06917502 100644 --- a/packages/google-open-match-sdk/bindings/rust/Cargo.toml +++ b/packages/google-open-match-sdk/bindings/rust/Cargo.toml @@ -8,8 +8,10 @@ publish.workspace = true [features] default = ["client"] client = [] +server = [] [dependencies] +futures-core.workspace = true tonic.workspace = true pbjson-types.workspace = true prost.workspace = true diff --git a/packages/google-open-match-sdk/buf.gen.yaml b/packages/google-open-match-sdk/buf.gen.yaml index e231ff23..25d05b3b 100644 --- a/packages/google-open-match-sdk/buf.gen.yaml +++ b/packages/google-open-match-sdk/buf.gen.yaml @@ -13,5 +13,5 @@ plugins: opt: - compile_well_known_types - extern_path=.google.protobuf=::pbjson_types - - no_server + - server_mod_attribute=.=#[cfg(feature = "server")] - client_mod_attribute=.=#[cfg(feature = "client")] diff --git a/packages/shulker-addon-matchmaking/Cargo.toml b/packages/shulker-addon-matchmaking/Cargo.toml index df0e24e2..d2303940 100644 --- a/packages/shulker-addon-matchmaking/Cargo.toml +++ b/packages/shulker-addon-matchmaking/Cargo.toml @@ -5,11 +5,17 @@ authors.workspace = true edition.workspace = true publish.workspace = true -# [[bin]] -# name = "shulker-operator" -# path = "src/main.rs" -# doc = false -# required-features = ["shulker-operator-bin"] +[[bin]] +name = "shulker-addon-matchmaking-director" +path = "src/bin/director.rs" +doc = false +# required-features = ["shulker-addon-matchmaking-bin"] + +[[bin]] +name = "shulker-addon-matchmaking-mmf" +path = "src/bin/mmf.rs" +doc = false +# required-features = ["shulker-addon-matchmaking-bin"] [lib] name = "shulker_addon_matchmaking" @@ -17,33 +23,31 @@ path = "src/lib.rs" [features] default = [] -# shulker-operator-bin = ["clap"] +# shulker-addon-matchmaking-bin = ["clap"] -# [dependencies] -# anyhow.workspace = true -# async-trait.workspace = true -# clap = { workspace = true, optional = true } -# futures.workspace = true -# google-agones-crds.workspace = true -# k8s-openapi.workspace = true -# kube.workspace = true -# lazy_static.workspace = true -# rand.workspace = true -# serde.workspace = true -# serde_yaml.workspace = true -# shulker-crds.workspace = true -# shulker-kube-utils.workspace = true -# shulker-sdk.workspace = true -# thiserror.workspace = true -# tonic.workspace = true -# toml.workspace = true -# tracing.workspace = true -# url.workspace = true +[dependencies] +anyhow.workspace = true +clap.workspace = true +futures.workspace = true +google-agones-crds.workspace = true +google-open-match-sdk = { workspace = true, features = ["server"] } +kube.workspace = true +paste.workspace = true +pbjson-types.workspace = true +prost.workspace = true +shulker-crds.workspace = true +shulker-kube-utils.workspace = true +shulker-sdk = { workspace = true, features = ["client"] } +shulker-utils.workspace = true +thiserror.workspace = true +tokio.workspace = true +tokio-stream.workspace = true +tokio-util.workspace = true +tonic.workspace = true +tracing.workspace = true +uuid.workspace = true -# [dev-dependencies] -# http.workspace = true -# hyper.workspace = true -# insta.workspace = true -# serde_json.workspace = true -# tokio.workspace = true -# tower-test.workspace = true +[dev-dependencies] +tempfile.workspace = true +tokio-stream = { workspace = true, features = ["net"] } +tower.workspace = true diff --git a/packages/shulker-addon-matchmaking/Dockerfile b/packages/shulker-addon-matchmaking/Dockerfile new file mode 100644 index 00000000..a6d1a8ad --- /dev/null +++ b/packages/shulker-addon-matchmaking/Dockerfile @@ -0,0 +1,41 @@ +FROM --platform=$BUILDPLATFORM node:20 AS builder +WORKDIR /build + +ARG BUILDPLATFORM +ARG TARGETPLATFORM + +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable +ENV PATH="/root/.cargo/bin:${PATH}" + +RUN case "$TARGETPLATFORM" in \ + "linux/amd64") \ + echo "x86_64-unknown-linux-gnu" > /.rust-triple; \ + echo "gcc-x86-64-linux-gnu" > /.rust-compiler \ + ;; \ + "linux/arm64") \ + echo "aarch64-unknown-linux-gnu" > /.rust-triple; \ + echo "gcc-aarch64-linux-gnu" > /.rust-compiler \ + ;; \ + *) exit 1 ;; \ + esac \ + && rustup target add "$(cat /.rust-triple)" \ + && if [ "${BUILDPLATFORM}" != "${TARGETPLATFORM}" ]; then \ + apt-get update \ + && apt-get install -y "$(cat /.rust-compiler)" \ + && rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \ + fi + +COPY package.json package-lock.json ./ +RUN npm i --ignore-scripts + +COPY . . +RUN npm run prepare + +RUN npx nx build shulker-addon-matchmaking --target=$(cat /.rust-triple) \ + && cp dist/rust/$(cat /.rust-triple)/release/shulker-addon-matchmaking-director dist/rust/release/shulker-addon-matchmaking-director \ + && cp dist/rust/$(cat /.rust-triple)/release/shulker-addon-matchmaking-mmf dist/rust/release/shulker-addon-matchmaking-mmf + +FROM gcr.io/distroless/cc-debian12:nonroot +COPY --from=builder /build/dist/rust/release/shulker-addon-matchmaking-director / +COPY --from=builder /build/dist/rust/release/shulker-addon-matchmaking-mmf / +ENTRYPOINT [ "/shulker-addon-matchmaking-director" ] diff --git a/packages/shulker-addon-matchmaking/project.json b/packages/shulker-addon-matchmaking/project.json index a3703ec2..18b1dd7f 100644 --- a/packages/shulker-addon-matchmaking/project.json +++ b/packages/shulker-addon-matchmaking/project.json @@ -7,7 +7,7 @@ "build": { "executor": "nx:run-commands", "options": { - "command": "cargo build --release", + "command": "cargo build --release --bins", "cwd": "packages/shulker-addon-matchmaking" }, "inputs": ["default", "rust:dependencies"] @@ -40,6 +40,7 @@ } }, "implicitDependencies": [ + "google-open-match-sdk-bindings-rust", "shulker-crds", "shulker-kube-utils", "shulker-utils", diff --git a/packages/shulker-addon-matchmaking/src/bin/director.rs b/packages/shulker-addon-matchmaking/src/bin/director.rs new file mode 100644 index 00000000..babc3617 --- /dev/null +++ b/packages/shulker-addon-matchmaking/src/bin/director.rs @@ -0,0 +1,119 @@ +use std::sync::{Arc, Mutex}; + +use clap::Parser; +use google_open_match_sdk::backend_service_client::BackendServiceClient; +use kube::Client; +use shulker_addon_matchmaking::{ + director, mmf::registry::MMFRegistry, queue_registry::QueueRegistry, reconcilers, +}; +use shulker_crds::matchmaking::v1alpha1::matchmaking_queue::MatchmakingQueueMMFBuiltInType; +use shulker_kube_utils::{lease, metrics}; +use shulker_sdk::minecraft_server_fleet_service_client::MinecraftServerFleetServiceClient; +use shulker_utils::telemetry; + +const LEASE_NAME: &str = "shulker-addon-matchmaking.shulkermc.io"; +const LEASE_CONTROLLER_NAME: &str = "shulker-addon-matchmaking"; + +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +struct Args { + /// The address the metrics HTTP server should bind to + #[arg(long, default_value = "127.0.0.1:8080", value_name = "address")] + metrics_bind_address: String, + + // The host of the backend service of Open Match + #[arg( + long, + default_value = "open-match-backend.open-match", + value_name = "host", + env = "OPEN_MATCH_BACKEND_HOST" + )] + open_match_backend_host: String, + + // The port of the backend service of Open Match + #[arg( + long, + default_value = "50505", + value_name = "port", + env = "OPEN_MATCH_BACKEND_GRPC_PORT" + )] + open_match_backend_grpc_port: u16, + + // The host of the API service of Shulker + #[arg( + long, + default_value = "shulker-operator.shulker-system", + value_name = "host", + env = "SHULKER_API_HOST" + )] + shulker_api_host: String, + + // The port of the API service of Shulker + #[arg( + long, + default_value = "8080", + value_name = "port", + env = "SHULKER_API_GRPC_PORT" + )] + shulker_api_grpc_port: u16, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let args = Args::parse(); + + telemetry::init().await; + + let cancellation_token = tokio_util::sync::CancellationToken::new(); + + let client = Client::try_default().await?; + let lease_holder = lease::try_acquire_and_hold( + client.clone(), + LEASE_NAME.to_string(), + LEASE_CONTROLLER_NAME.to_string(), + cancellation_token.clone(), + ) + .await?; + + let mut mmf_registry = MMFRegistry::new(); + mmf_registry.register_mmf( + MatchmakingQueueMMFBuiltInType::Batch, + "shulker-addon-matchmaking-mmf.shulker-system".to_string(), + 9090, + ); + mmf_registry.register_mmf( + MatchmakingQueueMMFBuiltInType::Elo, + "shulker-addon-matchmaking-mmf.shulker-system".to_string(), + 9091, + ); + + let queue_registry = Arc::new(Mutex::new(QueueRegistry::new(mmf_registry))); + + let director = director::run( + client.clone(), + BackendServiceClient::connect(format!( + "http://{}:{}", + args.open_match_backend_host, args.open_match_backend_grpc_port + )) + .await?, + MinecraftServerFleetServiceClient::connect(format!( + "http://{}:{}", + args.shulker_api_host, args.shulker_api_grpc_port + )) + .await?, + queue_registry.clone(), + cancellation_token.clone(), + )?; + + tokio::select! { + _ = tokio::signal::ctrl_c() => { + cancellation_token.cancel(); + }, + _ = lease_holder => {}, + _ = reconcilers::matchmaking_queue::run(client.clone(), queue_registry.clone()) => {}, + _ = director => {}, + _ = metrics::create_http_server(args.metrics_bind_address)? => {}, + } + + Ok(()) +} diff --git a/packages/shulker-addon-matchmaking/src/bin/mmf.rs b/packages/shulker-addon-matchmaking/src/bin/mmf.rs new file mode 100644 index 00000000..0e0d5bad --- /dev/null +++ b/packages/shulker-addon-matchmaking/src/bin/mmf.rs @@ -0,0 +1,74 @@ +use std::net::ToSocketAddrs; + +use clap::Parser; +use google_open_match_sdk::{ + match_function_server::MatchFunctionServer, query_service_client::QueryServiceClient, +}; +use shulker_addon_matchmaking::mmf::batch::MatchFunctionBatch; +use shulker_kube_utils::metrics; +use shulker_utils::telemetry; +use tonic::transport::Server; + +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +struct Args { + /// The address the metrics HTTP server should bind to + #[arg(long, default_value = "127.0.0.1:8080", value_name = "address")] + metrics_bind_address: String, + + // The host of the query service of Open Match + #[arg( + long, + default_value = "open-match-query.open-match", + value_name = "host", + env = "OPEN_MATCH_QUERY_HOST" + )] + open_match_query_host: String, + + // The port of the query service of Open Match + #[arg( + long, + default_value = "50503", + value_name = "port", + env = "OPEN_MATCH_QUERY_GRPC_PORT" + )] + open_match_query_grpc_port: u16, + + // The port of the built-in FIFO matchmaking function + #[arg( + long, + default_value = "9090", + value_name = "port", + env = "MMF_BATCH_GRPC_PORT" + )] + mmf_batch_grpc_port: u16, + + // The port of the built-in ELO matchmaking function + #[arg( + long, + default_value = "9091", + value_name = "port", + env = "MMF_ELO_GRPC_PORT" + )] + mmf_elo_grpc_port: u16, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let args = Args::parse(); + + telemetry::init().await; + + let query_client = QueryServiceClient::connect(format!( + "http://{}:{}", + args.open_match_query_host, args.open_match_query_grpc_port + )) + .await?; + + tokio::select! { + _ = Server::builder().add_service(MatchFunctionServer::new(MatchFunctionBatch::new(query_client.clone()))).serve(format!("0.0.0.0:{}", args.mmf_batch_grpc_port).to_socket_addrs().unwrap().next().unwrap()) => {}, + _ = metrics::create_http_server(args.metrics_bind_address)? => {}, + } + + Ok(()) +} diff --git a/packages/shulker-addon-matchmaking/src/director.rs b/packages/shulker-addon-matchmaking/src/director.rs new file mode 100644 index 00000000..c4e76c68 --- /dev/null +++ b/packages/shulker-addon-matchmaking/src/director.rs @@ -0,0 +1,225 @@ +use std::sync::{Arc, Mutex}; + +use futures::StreamExt; +use google_agones_crds::v1::game_server::GameServer; +use google_open_match_sdk::{ + backend_service_client::BackendServiceClient, AssignTicketsRequest, Assignment, + AssignmentGroup, FetchMatchesRequest, Match, +}; +use kube::{api::ListParams, Api, Client, ResourceExt}; +use shulker_crds::v1alpha1::minecraft_server_fleet::MinecraftServerFleetRef; +use shulker_sdk::{ + minecraft_server_fleet_service_client::MinecraftServerFleetServiceClient, + SummonFromFleetRequest, +}; +use thiserror::Error; +use tokio_util::sync::CancellationToken; +use tonic::transport::Channel; +use tracing::*; + +use crate::{ + extensions::get_game_server_id_from_backfill, + queue_registry::{PreparedQueue, QueueRegistry}, +}; + +const DIRECTOR_MATCH_POLL_INTERVAL_SECONDS: u64 = 5; + +#[derive(Debug, Error)] +enum DirectorError { + #[error("the provided backfill does not have a game server id")] + BackfillWithoutGameServerId, +} + +#[derive(Clone)] +struct Context { + game_server_api: Api, + open_match_backend_client: BackendServiceClient, + shulker_sdk_fleet_client: MinecraftServerFleetServiceClient, + queue_registry: Arc>, +} + +pub fn run( + client: Client, + open_match_backend_client: BackendServiceClient, + shulker_sdk_fleet_client: MinecraftServerFleetServiceClient, + queue_registry: Arc>, + cancellation_token: CancellationToken, +) -> Result, anyhow::Error> { + let context = Context { + game_server_api: Api::all(client), + open_match_backend_client, + shulker_sdk_fleet_client, + queue_registry, + }; + + let task = tokio::spawn(async move { + let mut interval = tokio::time::interval(tokio::time::Duration::from_secs( + DIRECTOR_MATCH_POLL_INTERVAL_SECONDS, + )); + interval.tick().await; + + loop { + tokio::select! { + _ = interval.tick() => { + let res = try_fetch_matches_for_all_queues(&context).await; + + if let Err(err) = res { + tracing::error!("failed to assign matches: {:?}", err); + } + } + _ = cancellation_token.cancelled() => { + break + } + } + } + }); + + Ok(task) +} + +async fn try_fetch_matches_for_all_queues(context: &Context) -> Result<(), anyhow::Error> { + debug!("fetching matches for all queues"); + + let prepared_queues = { + let queue_registry = context + .queue_registry + .try_lock() + .map_err(|_| anyhow::anyhow!("failed to lock queue registry"))?; + + queue_registry.get_queues().clone() + }; + + for (name, queue) in prepared_queues { + try_fetch_matches_for_queue(context, &name, &queue).await?; + } + + Ok(()) +} + +async fn try_fetch_matches_for_queue( + context: &Context, + queue_name: &str, + queue: &PreparedQueue, +) -> Result<(), anyhow::Error> { + debug!(name = queue_name, "fetching matches for queue"); + + let mut stream = context + .open_match_backend_client + .clone() + .fetch_matches(FetchMatchesRequest { + config: Some(queue.mmf_config.clone()), + profile: Some(queue.match_profile.clone()), + }) + .await? + .into_inner(); + + while let Some(res) = stream.next().await { + let created_match = res.unwrap().r#match.unwrap(); + try_allocate_match(context, queue, created_match).await?; + } + + Ok(()) +} + +async fn try_allocate_match( + context: &Context, + queue: &PreparedQueue, + created_match: Match, +) -> Result<(), anyhow::Error> { + info!( + match_id = created_match.match_id, + match_profile = created_match.match_profile, + has_backfill = created_match.backfill.is_some(), + "trying to allocate match" + ); + + let game_server_id = if created_match.allocate_gameserver || created_match.backfill.is_none() { + debug!( + match_id = created_match.match_id, + match_profile = created_match.match_profile, + "match needs a new server to be allocated" + ); + + find_available_server_or_create(context, &queue.namespace, &queue.fleet_ref).await? + } else { + created_match + .backfill + .as_ref() + .and_then(get_game_server_id_from_backfill) + .ok_or(DirectorError::BackfillWithoutGameServerId)? + }; + + info!( + match_id = created_match.match_id, + match_profile = created_match.match_profile, + has_backfill = created_match.backfill.is_some(), + "trying to allocate match" + ); + + context + .open_match_backend_client + .clone() + .assign_tickets(AssignTicketsRequest { + assignments: vec![AssignmentGroup { + ticket_ids: created_match + .tickets + .iter() + .map(|ticket| ticket.id.clone()) + .collect(), + assignment: Some(Assignment { + connection: game_server_id, + ..Assignment::default() + }), + }], + }) + .await?; + + Ok(()) +} + +async fn find_available_server_or_create( + context: &Context, + namespace: &str, + fleet_ref: &MinecraftServerFleetRef, +) -> Result { + let game_servers = context + .game_server_api + .list(&ListParams::default().labels(&format!( + "minecraftserverfleet.shulkermc.io/name={}", + fleet_ref.name + ))) + .await?; + + let existing_game_server_id = game_servers + .items + .iter() + .filter(|gs| gs.metadata.namespace.as_ref().unwrap() == namespace) + .find(|gs| { + gs.status + .as_ref() + .is_some_and(|status| status.state == "Ready") + }) + .map(|gs| gs.name_any()); + + if let Some(existing_game_server_id) = existing_game_server_id { + debug!( + game_server_id = existing_game_server_id, + "found existing game server in Ready state" + ); + + return Ok(existing_game_server_id); + } + + let created_game_server_id = context + .shulker_sdk_fleet_client + .clone() + .summon_from_fleet(SummonFromFleetRequest { + namespace: namespace.to_string(), + name: fleet_ref.name.clone(), + }) + .await? + .into_inner() + .game_server_id; + + Ok(created_game_server_id) +} diff --git a/packages/shulker-addon-matchmaking/src/extensions.rs b/packages/shulker-addon-matchmaking/src/extensions.rs new file mode 100644 index 00000000..a57d0c47 --- /dev/null +++ b/packages/shulker-addon-matchmaking/src/extensions.rs @@ -0,0 +1,193 @@ +use google_open_match_sdk::{Backfill, MatchProfile}; +use prost::Message; + +const PROFILE_MIN_PLAYERS_KEY: &str = "shulker:min_players"; +const PROFILE_MAX_PLAYERS_KEY: &str = "shulker:max_players"; +const BACKFILL_AVAILABLE_SLOTS_KEY: &str = "shulker:available_slots"; +const BACKFILL_GAME_SERVER_ID_KEY: &str = "shulker:game_server_id"; + +macro_rules! encode_any { + ($typ:ty, $typ_url:literal, $value:expr) => { + ::pbjson_types::Any { + type_url: $typ_url.to_string(), + value: ::prost::bytes::Bytes::from(<$typ>::from($value).encode_to_vec()), + } + }; + (i32, $value:expr) => { + encode_any!( + pbjson_types::Int32Value, + "type.googleapis.com/google.protobuf.Int32Value", + $value + ) + }; + (String, $value:expr) => { + encode_any!( + pbjson_types::StringValue, + "type.googleapis.com/google.protobuf.StringValue", + $value + ) + }; +} + +macro_rules! try_decode_any { + ($typ:ty, $typ_url:literal) => { + |value| match value.type_url.as_str() { + $typ_url => { + <$typ>::decode(value.value.as_ref()).map_or_else(|_| None, |x| Some(x.value)) + } + _ => None, + } + }; + (i32) => { + try_decode_any!( + pbjson_types::Int32Value, + "type.googleapis.com/google.protobuf.Int32Value" + ) + }; + (String) => { + try_decode_any!( + pbjson_types::StringValue, + "type.googleapis.com/google.protobuf.StringValue" + ) + }; +} + +macro_rules! create_extension_encoder_decoder { + ($target:ty, $target_name:literal, $typ:ty, $name:literal, $key:ident) => { + paste::item! { + pub fn [< set_ $name _in_ $target_name >] (target: &mut $target, value: $typ) { + target.extensions.insert( + $key.to_string(), + encode_any!($typ, value), + ); + } + + pub fn [< get_ $name _from_ $target_name >] (target: &$target) -> Option<$typ> { + target + .extensions + .get($key) + .and_then(try_decode_any!($typ)) + } + } + }; +} + +create_extension_encoder_decoder!( + MatchProfile, + "profile", + i32, + "min_players", + PROFILE_MIN_PLAYERS_KEY +); + +create_extension_encoder_decoder!( + MatchProfile, + "profile", + i32, + "max_players", + PROFILE_MAX_PLAYERS_KEY +); + +create_extension_encoder_decoder!( + Backfill, + "backfill", + i32, + "available_slots", + BACKFILL_AVAILABLE_SLOTS_KEY +); + +create_extension_encoder_decoder!( + Backfill, + "backfill", + String, + "game_server_id", + BACKFILL_GAME_SERVER_ID_KEY +); + +#[cfg(test)] +mod tests { + use google_open_match_sdk::{Backfill, MatchProfile}; + use prost::Message; + + macro_rules! create_encoder_decoder_tests { + ($target:ty, $target_name:literal, $typ:ty, $name:literal, $key:literal, $value:expr) => { + paste::item! { + #[test] + fn [< set_ $name _in_ $target_name >] () { + // G + let mut target = <$target>::default(); + + // W + super::[< set_ $name _in_ $target_name >](&mut target, $value); + + // T + assert_eq!( + target.extensions.get($key), + Some(&encode_any!($typ, $value)) + ); + } + + #[test] + fn [< get_ $name _from_ $target_name _exists >] () { + // G + let mut target = <$target>::default(); + target.extensions.insert($key.to_string(), encode_any!($typ, $value)); + + // W + let value = super::[< get_ $name _from_ $target_name >](&target); + + // T + assert_eq!(value.unwrap(), $value); + } + + #[test] + fn [< get_ $name _from_ $target_name _not_exists >] () { + // G + let target = <$target>::default(); + + // W + let value = super::[< get_ $name _from_ $target_name >](&target); + + // T + assert_eq!(value, None); + } + } + }; + } + + create_encoder_decoder_tests!( + MatchProfile, + "profile", + i32, + "min_players", + "shulker:min_players", + 42 + ); + + create_encoder_decoder_tests!( + MatchProfile, + "profile", + i32, + "max_players", + "shulker:max_players", + 42 + ); + + create_encoder_decoder_tests!( + Backfill, + "backfill", + i32, + "available_slots", + "shulker:available_slots", + 42 + ); + + create_encoder_decoder_tests!( + Backfill, + "backfill", + String, + "game_server_id", + "shulker:game_server_id", + "randomid".to_string() + ); +} diff --git a/packages/shulker-addon-matchmaking/src/lib.rs b/packages/shulker-addon-matchmaking/src/lib.rs index 8b137891..997953c2 100644 --- a/packages/shulker-addon-matchmaking/src/lib.rs +++ b/packages/shulker-addon-matchmaking/src/lib.rs @@ -1 +1,5 @@ - +pub mod director; +pub mod extensions; +pub mod mmf; +pub mod queue_registry; +pub mod reconcilers; diff --git a/packages/shulker-addon-matchmaking/src/mmf/batch.rs b/packages/shulker-addon-matchmaking/src/mmf/batch.rs new file mode 100644 index 00000000..f5260a83 --- /dev/null +++ b/packages/shulker-addon-matchmaking/src/mmf/batch.rs @@ -0,0 +1,415 @@ +use std::{cmp::min, pin::Pin}; + +use futures::Stream; +use google_open_match_sdk::{ + match_function_server::MatchFunction, query_service_client::QueryServiceClient, Backfill, + Match, MatchProfile, Pool, RunRequest, RunResponse, Ticket, +}; +use tonic::{transport::Channel, Response, Status}; +use tracing::*; + +use crate::extensions::{ + get_available_slots_from_backfill, get_max_players_from_profile, get_min_players_from_profile, + set_available_slots_in_backfill, +}; + +use super::{ + runner::MatchSupplier, + utils::{create_backfill_for_pool, create_full_match, create_match_with_backfill}, +}; + +const MMF_NAME: &str = "shulker-built-in-mmf-batch"; + +pub struct MatchFunctionBatch { + query_client: QueryServiceClient, +} + +impl MatchFunctionBatch { + pub fn new(query_client: QueryServiceClient) -> Self { + MatchFunctionBatch { query_client } + } + + fn fill_existing_backfills( + profile: &MatchProfile, + mut backfills: Vec, + mut tickets: Vec, + ) -> (Vec, Vec) { + let mut matches = vec![]; + + for backfill in backfills.iter_mut() { + let available_slots = get_available_slots_from_backfill(backfill).unwrap(); + + let tickets_to_drain = min(available_slots as usize, tickets.len()); + let match_tickets: Vec = tickets.drain(0..tickets_to_drain).collect(); + + if !match_tickets.is_empty() { + let remaining_slots = available_slots - match_tickets.len() as i32; + set_available_slots_in_backfill(backfill, remaining_slots); + + matches.push(create_match_with_backfill( + profile, + MMF_NAME.to_string(), + match_tickets, + backfill.clone(), + false, + )); + } + } + + (matches, tickets) + } + + fn create_full_matches( + profile: &MatchProfile, + mut tickets: Vec, + ) -> (Vec, Vec) { + let mut matches = vec![]; + let max_players_per_match = get_max_players_from_profile(profile).unwrap(); + + while tickets.len() >= max_players_per_match as usize { + let match_tickets = tickets.drain(0..max_players_per_match as usize).collect(); + matches.push(create_full_match( + profile, + MMF_NAME.to_string(), + match_tickets, + )); + } + + (matches, tickets) + } + + fn create_last_partial_match( + profile: &MatchProfile, + pool: &Pool, + mut tickets: Vec, + ) -> Option { + let max_players_per_match = get_max_players_from_profile(profile).unwrap(); + let min_players_per_match = + get_min_players_from_profile(profile).unwrap_or(max_players_per_match); + + if tickets.len() < min_players_per_match as usize { + debug!( + profile_name = profile.name, + pool_name = pool.name, + min_players_per_match = min_players_per_match, + tickets_count = tickets.len(), + "not enough tickets to create a partial match" + ); + return None; + } + + let partial_match_size = min(tickets.len(), max_players_per_match as usize); + let match_tickets: Vec = tickets.drain(0..partial_match_size).collect(); + let remaining_slots = max_players_per_match - match_tickets.len() as i32; + let backfill = create_backfill_for_pool(pool, remaining_slots); + + Some(create_match_with_backfill( + profile, + MMF_NAME.to_string(), + match_tickets, + backfill, + true, + )) + } +} + +impl MatchSupplier for MatchFunctionBatch { + fn create_matches( + &self, + profile: &MatchProfile, + pool: &Pool, + tickets: Vec, + backfills: Vec, + ) -> Vec { + let mut matches = vec![]; + + let (mut backfill_matches, remaining_tickets) = + Self::fill_existing_backfills(profile, backfills, tickets); + matches.append(&mut backfill_matches); + + let (mut full_matches, remaining_tickets) = + Self::create_full_matches(profile, remaining_tickets); + matches.append(&mut full_matches); + + if !remaining_tickets.is_empty() { + if let Some(last_partial_match) = + Self::create_last_partial_match(profile, pool, remaining_tickets) + { + matches.push(last_partial_match); + } + } + + matches + } +} + +super::runner::create_match_function_runner!(MatchFunctionBatch); + +#[cfg(test)] +mod tests { + use google_open_match_sdk::Pool; + + use crate::{ + extensions::set_available_slots_in_backfill, + mmf::{ + fixtures::{create_random_profile, create_random_ticket}, + utils::create_backfill_for_pool, + }, + }; + + use super::MatchFunctionBatch; + + #[test] + fn fill_existing_backfills_tickets_and_backfill_perfect_amount() { + // G + let profile = create_random_profile(Some(2), 4); + let pool = Pool::default(); + let backfills = vec![create_backfill_for_pool(&pool, 2)]; + let tickets = vec![create_random_ticket(), create_random_ticket()]; + + // W + let (matches, remaining_tickets) = MatchFunctionBatch::fill_existing_backfills( + &profile, + backfills.clone(), + tickets.clone(), + ); + + // T + assert_eq!(matches.len(), 1); + let first_match = matches.first().unwrap(); + assert_eq!(first_match.tickets, tickets); + assert_eq!(first_match.backfill, { + let mut backfill = backfills.first().unwrap().clone(); + set_available_slots_in_backfill(&mut backfill, 0); + Some(backfill) + }); + assert!(!first_match.allocate_gameserver); + assert!(remaining_tickets.is_empty()); + } + + #[test] + fn fill_existing_backfills_tickets_and_backfill_more_than_enough() { + // G + let profile = create_random_profile(Some(2), 4); + let pool = Pool::default(); + let backfills = vec![create_backfill_for_pool(&pool, 2)]; + let tickets = vec![ + create_random_ticket(), + create_random_ticket(), + create_random_ticket(), + create_random_ticket(), + ]; + + // W + let (matches, remaining_tickets) = MatchFunctionBatch::fill_existing_backfills( + &profile, + backfills.clone(), + tickets.clone(), + ); + + // T + assert_eq!(matches.len(), 1); + let first_match = matches.first().unwrap(); + assert_eq!( + first_match.tickets, + Vec::from_iter(tickets[0..2].iter().cloned()) + ); + assert_eq!(first_match.backfill, { + let mut backfill = backfills.first().unwrap().clone(); + set_available_slots_in_backfill(&mut backfill, 0); + Some(backfill) + }); + assert!(!first_match.allocate_gameserver); + assert_eq!( + remaining_tickets, + Vec::from_iter(tickets[2..4].iter().cloned()) + ); + } + + #[test] + fn fill_existing_backfills_tickets_and_backfill_not_enough() { + // G + let profile = create_random_profile(Some(2), 4); + let pool = Pool::default(); + let backfills = vec![create_backfill_for_pool(&pool, 2)]; + let tickets = vec![create_random_ticket()]; + + // W + let (matches, remaining_tickets) = MatchFunctionBatch::fill_existing_backfills( + &profile, + backfills.clone(), + tickets.clone(), + ); + + // T + assert_eq!(matches.len(), 1); + let first_match = matches.first().unwrap(); + assert_eq!(first_match.tickets, tickets); + assert_eq!(first_match.backfill, { + let mut backfill = backfills.first().unwrap().clone(); + set_available_slots_in_backfill(&mut backfill, 1); + Some(backfill) + }); + assert!(!first_match.allocate_gameserver); + assert!(remaining_tickets.is_empty()); + } + + #[test] + fn fill_existing_backfills_tickets_no_backfills() { + // G + let profile = create_random_profile(Some(2), 4); + let backfills = vec![]; + let tickets = vec![create_random_ticket()]; + + // W + let (matches, remaining_tickets) = + MatchFunctionBatch::fill_existing_backfills(&profile, backfills, tickets.clone()); + + // T + assert!(matches.is_empty()); + assert_eq!(remaining_tickets, tickets); + } + + #[test] + fn fill_existing_backfills_no_tickets_existing_backfills() { + // G + let profile = create_random_profile(Some(2), 4); + let pool = Pool::default(); + let backfills = vec![create_backfill_for_pool(&pool, 8)]; + let tickets = vec![]; + + // W + let (matches, remaining_tickets) = + MatchFunctionBatch::fill_existing_backfills(&profile, backfills, tickets); + + // T + assert!(matches.is_empty()); + assert!(remaining_tickets.is_empty()); + } + + #[test] + fn fill_existing_backfills_no_tickets_no_backfills() { + // G + let profile = create_random_profile(Some(2), 4); + let backfills = vec![]; + let tickets = vec![]; + + // W + let (matches, remaining_tickets) = + MatchFunctionBatch::fill_existing_backfills(&profile, backfills, tickets); + + // T + assert!(matches.is_empty()); + assert!(remaining_tickets.is_empty()); + } + + #[test] + fn create_full_matches_perfect_amount() { + // G + let profile = create_random_profile(Some(2), 4); + let tickets = vec![ + create_random_ticket(), + create_random_ticket(), + create_random_ticket(), + create_random_ticket(), + ]; + + // W + let (matches, remaining_tickets) = + MatchFunctionBatch::create_full_matches(&profile, tickets.clone()); + + // T + assert_eq!(matches.len(), 1); + let first_match = matches.first().unwrap(); + assert_eq!(first_match.tickets, tickets); + assert_eq!(first_match.backfill, None); + assert!(!first_match.allocate_gameserver); + assert!(remaining_tickets.is_empty()); + } + + #[test] + fn create_full_matches_perfect_more_than_enough() { + // G + let profile = create_random_profile(Some(2), 4); + let tickets = vec![ + create_random_ticket(), + create_random_ticket(), + create_random_ticket(), + create_random_ticket(), + create_random_ticket(), + ]; + + // W + let (matches, remaining_tickets) = + MatchFunctionBatch::create_full_matches(&profile, tickets.clone()); + + // T + assert_eq!(matches.len(), 1); + let first_match = matches.first().unwrap(); + assert_eq!( + first_match.tickets, + Vec::from_iter(tickets[0..4].iter().cloned()) + ); + assert_eq!(first_match.backfill, None); + assert!(!first_match.allocate_gameserver); + assert_eq!(remaining_tickets, vec![tickets[4].clone()]); + } + + #[test] + fn create_full_matches_not_enough() { + // G + let profile = create_random_profile(Some(2), 4); + let tickets = vec![create_random_ticket()]; + + // W + let (matches, remaining_tickets) = + MatchFunctionBatch::create_full_matches(&profile, tickets.clone()); + + // T + assert!(matches.is_empty()); + assert_eq!(remaining_tickets, tickets); + } + + #[test] + fn create_last_partial_match_more_than_minimum() { + // G + let profile = create_random_profile(Some(2), 4); + let pool = Pool::default(); + let tickets = vec![ + create_random_ticket(), + create_random_ticket(), + create_random_ticket(), + ]; + + // W + let last_match = + MatchFunctionBatch::create_last_partial_match(&profile, &pool, tickets.clone()) + .unwrap(); + + // T + assert_eq!( + last_match.tickets, + Vec::from_iter(tickets[0..3].iter().cloned()) + ); + assert_eq!( + last_match.backfill, + Some(create_backfill_for_pool(&pool, 1)) + ); + assert!(last_match.allocate_gameserver); + } + + #[test] + fn create_last_partial_match_less_than_minimum() { + // G + let profile = create_random_profile(Some(2), 4); + let pool = Pool::default(); + let tickets = vec![create_random_ticket()]; + + // W + let last_match = + MatchFunctionBatch::create_last_partial_match(&profile, &pool, tickets.clone()); + + // T + assert_eq!(last_match, None); + } +} diff --git a/packages/shulker-addon-matchmaking/src/mmf/fixtures.rs b/packages/shulker-addon-matchmaking/src/mmf/fixtures.rs new file mode 100644 index 00000000..4ccc204d --- /dev/null +++ b/packages/shulker-addon-matchmaking/src/mmf/fixtures.rs @@ -0,0 +1,169 @@ +use std::{pin::Pin, sync::Arc}; + +use futures::{Future, Stream}; +use google_open_match_sdk::{ + query_service_client::QueryServiceClient, + query_service_server::{QueryService, QueryServiceServer}, + Backfill, MatchProfile, QueryBackfillsRequest, QueryBackfillsResponse, QueryTicketIdsRequest, + QueryTicketIdsResponse, QueryTicketsRequest, QueryTicketsResponse, Ticket, +}; +use tempfile::NamedTempFile; +use tokio::{ + net::{UnixListener, UnixStream}, + sync::mpsc, +}; +use tokio_stream::wrappers::{ReceiverStream, UnixListenerStream}; +use tonic::{ + transport::{Channel, Endpoint, Server, Uri}, + Response, Status, +}; +use tower::service_fn; +use uuid::Uuid; + +use crate::extensions::{set_max_players_in_profile, set_min_players_in_profile}; + +pub enum Scenario { + NoTicketsNoBackfills, + TicketsWithNoExistingBackfills(Vec), + TicketsWithExistingBackfills(Vec, Vec), +} + +struct QueryServiceStub { + scenario: Scenario, +} + +impl QueryServiceStub { + fn from_scenario(scenario: Scenario) -> Self { + QueryServiceStub { scenario } + } +} + +#[tonic::async_trait] +impl QueryService for QueryServiceStub { + type QueryTicketsStream = + Pin> + Send>>; + + async fn query_tickets( + &self, + _request: tonic::Request, + ) -> std::result::Result, tonic::Status> { + let tickets = match &self.scenario { + Scenario::TicketsWithNoExistingBackfills(tickets) => tickets.clone(), + Scenario::TicketsWithExistingBackfills(tickets, _) => tickets.clone(), + _ => vec![], + }; + + let (tx, rx) = mpsc::channel(128); + + tokio::spawn(async move { + let res = QueryTicketsResponse { tickets }; + tx.send(Result::<_, Status>::Ok(res)).await.unwrap(); + }); + + let stream = ReceiverStream::new(rx); + Ok(Response::new(Box::pin(stream))) + } + + type QueryTicketIdsStream = + Pin> + Send>>; + + async fn query_ticket_ids( + &self, + _request: tonic::Request, + ) -> std::result::Result, tonic::Status> { + let tickets = match &self.scenario { + Scenario::TicketsWithNoExistingBackfills(tickets) => tickets.clone(), + Scenario::TicketsWithExistingBackfills(tickets, _) => tickets.clone(), + _ => vec![], + }; + + let (tx, rx) = mpsc::channel(128); + + tokio::spawn(async move { + let res = QueryTicketIdsResponse { + ids: tickets.iter().map(|t| t.id.clone()).collect(), + }; + tx.send(Result::<_, Status>::Ok(res)).await.unwrap(); + }); + + let stream = ReceiverStream::new(rx); + Ok(Response::new(Box::pin(stream))) + } + + type QueryBackfillsStream = + Pin> + Send>>; + + async fn query_backfills( + &self, + _request: tonic::Request, + ) -> std::result::Result, tonic::Status> { + let backfills = match &self.scenario { + Scenario::TicketsWithExistingBackfills(_, backfills) => backfills.clone(), + _ => vec![], + }; + + let (tx, rx) = mpsc::channel(128); + + tokio::spawn(async move { + let res = QueryBackfillsResponse { backfills }; + tx.send(Result::<_, Status>::Ok(res)).await.unwrap(); + }); + + let stream = ReceiverStream::new(rx); + Ok(Response::new(Box::pin(stream))) + } +} + +pub async fn create_query_service_stub( + scenario: Scenario, +) -> (impl Future, QueryServiceClient) { + let socket = NamedTempFile::new().unwrap(); + let socket = Arc::new(socket.into_temp_path()); + std::fs::remove_file(&*socket).unwrap(); + + let uds = UnixListener::bind(&*socket).unwrap(); + let stream = UnixListenerStream::new(uds); + + let server_fut = async { + let result = Server::builder() + .add_service(QueryServiceServer::new(QueryServiceStub::from_scenario( + scenario, + ))) + .serve_with_incoming(stream) + .await; + assert!(result.is_ok()); + }; + + let socket = Arc::clone(&socket); + let channel = Endpoint::try_from("http://any.url") + .unwrap() + .connect_with_connector(service_fn(move |_: Uri| { + let socket = Arc::clone(&socket); + async move { UnixStream::connect(&*socket).await } + })) + .await + .unwrap(); + + let client = QueryServiceClient::new(channel); + (server_fut, client) +} + +pub fn create_random_profile(min_players: Option, max_players: i32) -> MatchProfile { + let mut profile = MatchProfile { + name: Uuid::new_v4().to_string(), + ..MatchProfile::default() + }; + if let Some(min_players) = min_players { + set_min_players_in_profile(&mut profile, min_players); + } + set_max_players_in_profile(&mut profile, max_players); + + profile +} + +pub fn create_random_ticket() -> Ticket { + Ticket { + id: Uuid::new_v4().to_string(), + ..Ticket::default() + } +} diff --git a/packages/shulker-addon-matchmaking/src/mmf/mod.rs b/packages/shulker-addon-matchmaking/src/mmf/mod.rs new file mode 100644 index 00000000..3ff3100a --- /dev/null +++ b/packages/shulker-addon-matchmaking/src/mmf/mod.rs @@ -0,0 +1,7 @@ +pub mod batch; +pub mod registry; +pub mod runner; +pub mod utils; + +#[cfg(test)] +mod fixtures; diff --git a/packages/shulker-addon-matchmaking/src/mmf/registry.rs b/packages/shulker-addon-matchmaking/src/mmf/registry.rs new file mode 100644 index 00000000..802fbfd6 --- /dev/null +++ b/packages/shulker-addon-matchmaking/src/mmf/registry.rs @@ -0,0 +1,107 @@ +use std::collections::HashMap; + +use google_open_match_sdk::{function_config, FunctionConfig}; +use shulker_crds::matchmaking::v1alpha1::matchmaking_queue::MatchmakingQueueMMFBuiltInType; +use tracing::info; + +pub struct MMFRegistry(HashMap); + +impl MMFRegistry { + pub fn new() -> Self { + MMFRegistry(HashMap::new()) + } + + pub fn register_mmf(&mut self, type_: MatchmakingQueueMMFBuiltInType, host: String, port: u16) { + self.0.insert( + type_.clone(), + FunctionConfig { + host: host.clone(), + port: port as i32, + r#type: function_config::Type::Grpc as i32, + }, + ); + info!( + r#type = type_.to_string(), + host = host, + port = port, + "registered built-in matchmaking function", + ); + } + + pub fn get_mmf_config_for_type( + &self, + type_: &MatchmakingQueueMMFBuiltInType, + ) -> Option<&FunctionConfig> { + self.0.get(type_) + } +} + +impl Default for MMFRegistry { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_register_mmf() { + // G + let mut registry = MMFRegistry::new(); + + // W + registry.register_mmf( + MatchmakingQueueMMFBuiltInType::Batch, + "localhost".to_string(), + 50505, + ); + + // T + assert_eq!( + registry.0, + HashMap::from([( + MatchmakingQueueMMFBuiltInType::Batch, + FunctionConfig { + host: "localhost".to_string(), + port: 50505, + r#type: function_config::Type::Grpc as i32, + } + )]) + ); + } + + #[test] + fn get_mmf_config_for_type_exists() { + // G + let original_config = FunctionConfig { + host: "localhost".to_string(), + port: 50505, + r#type: function_config::Type::Grpc as i32, + }; + let mut registry = MMFRegistry::new(); + registry.0.insert( + MatchmakingQueueMMFBuiltInType::Batch, + original_config.clone(), + ); + + // W + let config = registry.get_mmf_config_for_type(&MatchmakingQueueMMFBuiltInType::Batch); + + // T + assert_eq!(config, Some(&original_config)); + } + + #[test] + fn get_mmf_config_for_type_not_exists() { + // G + let registry = MMFRegistry::new(); + + // W + let config = registry.get_mmf_config_for_type(&MatchmakingQueueMMFBuiltInType::Batch); + + // T + assert_eq!(config, None); + } +} diff --git a/packages/shulker-addon-matchmaking/src/mmf/runner.rs b/packages/shulker-addon-matchmaking/src/mmf/runner.rs new file mode 100644 index 00000000..0109edfd --- /dev/null +++ b/packages/shulker-addon-matchmaking/src/mmf/runner.rs @@ -0,0 +1,147 @@ +use futures::StreamExt; +use google_open_match_sdk::{ + query_service_client::QueryServiceClient, Backfill, Match, MatchProfile, Pool, + QueryBackfillsRequest, QueryTicketsRequest, RunResponse, Ticket, +}; +use tokio::sync::mpsc; +use tokio_stream::wrappers::ReceiverStream; +use tonic::{transport::Channel, Status}; + +pub trait MatchSupplier { + fn create_matches( + &self, + profile: &MatchProfile, + pool: &Pool, + tickets: Vec, + backfills: Vec, + ) -> Vec; +} + +macro_rules! create_match_function_runner { + ($typ:ty) => { + #[tonic::async_trait] + impl MatchFunction for $typ { + type RunStream = Pin> + Send>>; + + async fn run( + &self, + request: tonic::Request, + ) -> std::result::Result, tonic::Status> { + let profile = request.into_inner().profile.unwrap(); + + debug!(profile_name = profile.name, "running mmf for profile"); + + let mut proposals = vec![]; + + for pool in profile.pools.iter() { + let tickets = crate::mmf::runner::query_pool_tickets( + self.query_client.clone(), + pool.clone(), + ) + .await + .map_err(|e| { + tonic::Status::internal(format!("Failed to query tickets: {}", e)) + })?; + debug!( + profile_name = profile.name, + pool_name = pool.name, + tickets_count = tickets.len(), + "retrieved tickets for pool" + ); + + let backfills = crate::mmf::runner::query_pool_backfill_tickets( + self.query_client.clone(), + pool.clone(), + ) + .await + .map_err(|e| { + tonic::Status::internal(format!("Failed to query backfills: {}", e)) + })?; + debug!( + profile_name = profile.name, + pool_name = pool.name, + backfills_count = backfills.len(), + "retrieved backfills for pool" + ); + + let mut matches = self.create_matches(&profile, &pool, tickets, backfills); + debug!( + profile_name = profile.name, + pool_name = pool.name, + proposals_count = matches.len(), + "created match proposals for pool" + ); + + proposals.append(&mut matches); + } + + debug!( + profile_name = profile.name, + proposals_count = proposals.len(), + "replying match proposals" + ); + let response_stream = crate::mmf::runner::create_proposal_reply_stream(proposals); + Ok(Response::new(Box::pin(response_stream) as Self::RunStream)) + } + } + }; +} + +pub(crate) use create_match_function_runner; + +pub(crate) async fn query_pool_tickets( + mut query_client: QueryServiceClient, + pool: Pool, +) -> Result, anyhow::Error> { + let mut stream = query_client + .query_tickets(QueryTicketsRequest { pool: Some(pool) }) + .await? + .into_inner(); + + let mut tickets = vec![]; + + while let Some(res) = stream.next().await { + tickets.append(res.unwrap().tickets.as_mut()) + } + + Ok(tickets) +} + +pub(crate) async fn query_pool_backfill_tickets( + mut query_client: QueryServiceClient, + pool: Pool, +) -> Result, anyhow::Error> { + let mut stream = query_client + .query_backfills(QueryBackfillsRequest { pool: Some(pool) }) + .await? + .into_inner(); + + let mut backfills = vec![]; + + while let Some(res) = stream.next().await { + backfills.append(res.unwrap().backfills.as_mut()) + } + + Ok(backfills) +} + +pub(crate) fn create_proposal_reply_stream( + matches: Vec, +) -> ReceiverStream> { + let (tx, rx) = mpsc::channel(128); + + tokio::spawn(async move { + for match_proposal in matches.iter() { + let res = RunResponse { + proposal: Some(match_proposal.clone()), + }; + + match tx.send(Result::<_, Status>::Ok(res)).await { + Ok(_) => (), + Err(_item) => break, + } + } + }); + + ReceiverStream::new(rx) +} diff --git a/packages/shulker-addon-matchmaking/src/mmf/utils.rs b/packages/shulker-addon-matchmaking/src/mmf/utils.rs new file mode 100644 index 00000000..9872168a --- /dev/null +++ b/packages/shulker-addon-matchmaking/src/mmf/utils.rs @@ -0,0 +1,139 @@ +use google_open_match_sdk::{Backfill, Match, MatchProfile, Pool, SearchFields, Ticket}; +use pbjson_types::Timestamp; +use uuid::Uuid; + +use crate::extensions::set_available_slots_in_backfill; + +pub fn create_full_match( + profile: &MatchProfile, + function_name: String, + tickets: Vec, +) -> Match { + Match { + match_id: Uuid::new_v4().to_string(), + match_profile: profile.name.clone(), + match_function: function_name, + tickets, + ..Match::default() + } +} + +pub fn create_match_with_backfill( + profile: &MatchProfile, + function_name: String, + tickets: Vec, + backfill: Backfill, + allocate_gameserver: bool, +) -> Match { + let mut created_match = create_full_match(profile, function_name, tickets); + created_match.backfill = Some(backfill); + created_match.allocate_gameserver = allocate_gameserver; + created_match +} + +pub fn create_backfill_for_pool(pool: &Pool, remaining_slots: i32) -> Backfill { + let mut backfill = Backfill { + generation: 0, + create_time: Some(Timestamp { + seconds: shulker_utils::time::now().timestamp(), + nanos: 0, + }), + search_fields: Some(SearchFields { + tags: pool + .tag_present_filters + .iter() + .map(|f| f.tag.clone()) + .collect(), + ..SearchFields::default() + }), + ..Backfill::default() + }; + set_available_slots_in_backfill(&mut backfill, remaining_slots); + + backfill +} + +#[cfg(test)] +mod tests { + use google_open_match_sdk::{Backfill, Pool, SearchFields, TagPresentFilter}; + use uuid::Uuid; + + use crate::{ + extensions::get_available_slots_from_backfill, + mmf::fixtures::{create_random_profile, create_random_ticket}, + }; + + #[test] + fn create_full_match() { + // G + let profile = create_random_profile(Some(2), 4); + let function_name = "test".to_string(); + let tickets = vec![create_random_ticket(), create_random_ticket()]; + + // W + let created_match = + super::create_full_match(&profile, function_name.clone(), tickets.clone()); + + // T + assert_eq!(created_match.match_profile, profile.name); + assert_eq!(created_match.match_function, function_name); + assert_eq!(created_match.tickets, tickets); + assert_eq!(created_match.backfill, None); + assert!(!created_match.allocate_gameserver); + assert!(created_match.extensions.is_empty()); + } + + #[test] + fn create_match_with_backfill() { + // G + let profile = create_random_profile(Some(2), 4); + let function_name = "test".to_string(); + let tickets = vec![create_random_ticket(), create_random_ticket()]; + let backfill = Backfill { + id: Uuid::new_v4().to_string(), + ..Backfill::default() + }; + + // W + let created_match = super::create_match_with_backfill( + &profile, + function_name.clone(), + tickets.clone(), + backfill.clone(), + true, + ); + + // T + assert_eq!(created_match.match_profile, profile.name); + assert_eq!(created_match.match_function, function_name); + assert_eq!(created_match.tickets, tickets); + assert_eq!(created_match.backfill, Some(backfill)); + assert!(created_match.allocate_gameserver); + assert!(created_match.extensions.is_empty()); + } + + #[test] + fn create_backfill_for_pool() { + // G + let pool = Pool { + name: "pool".to_string(), + tag_present_filters: vec![TagPresentFilter { + tag: "tag".to_string(), + }], + ..Pool::default() + }; + + // W + let backfill = super::create_backfill_for_pool(&pool, 4); + + // T + assert_eq!(get_available_slots_from_backfill(&backfill), Some(4)); + assert_eq!( + backfill.search_fields, + Some(SearchFields { + tags: vec!["tag".to_string()], + ..SearchFields::default() + }) + ); + } +} diff --git a/packages/shulker-addon-matchmaking/src/queue_registry.rs b/packages/shulker-addon-matchmaking/src/queue_registry.rs new file mode 100644 index 00000000..1950c026 --- /dev/null +++ b/packages/shulker-addon-matchmaking/src/queue_registry.rs @@ -0,0 +1,147 @@ +use std::collections::HashMap; + +use google_open_match_sdk::{ + function_config, FunctionConfig, MatchProfile, Pool, TagPresentFilter, +}; +use kube::ResourceExt; +use shulker_crds::{ + matchmaking::v1alpha1::matchmaking_queue::MatchmakingQueue, + v1alpha1::minecraft_server_fleet::MinecraftServerFleetRef, +}; +use thiserror::Error; +use tracing::info; + +use crate::{ + extensions::{set_max_players_in_profile, set_min_players_in_profile}, + mmf::registry::MMFRegistry, +}; + +#[derive(Debug, Error)] +pub enum RegistryError { + #[error("built-in mmf {0} not found, it should not be possible")] + BuiltInMMFNotFound(String), + + #[error("no valid mmf configuration provided for queue {0}")] + NoValidMMFConfiguration(String), +} + +pub struct QueueRegistry { + mmf_registry: MMFRegistry, + queues: HashMap, +} + +impl QueueRegistry { + pub fn new(mmf_registry: MMFRegistry) -> Self { + QueueRegistry { + mmf_registry, + queues: HashMap::new(), + } + } + + pub fn register_queue( + &mut self, + matchmaking_queue: &MatchmakingQueue, + ) -> Result<(), RegistryError> { + let name = matchmaking_queue.name_any(); + + match self.queues.get_mut(&name) { + Some(prepared_queue) => prepared_queue.update(&self.mmf_registry, matchmaking_queue)?, + None => { + self.queues.insert( + name.clone(), + PreparedQueue::from(&self.mmf_registry, matchmaking_queue)?, + ); + } + } + + info!(name = name, "registered queue"); + Ok(()) + } + + pub fn unregister_queue(&mut self, matchmaking_queue: &MatchmakingQueue) { + let name = matchmaking_queue.name_any(); + + self.queues.remove(&name); + info!(name = name, "unregistered queue"); + } + + pub fn get_queues(&self) -> &HashMap { + &self.queues + } +} + +#[derive(Clone)] +pub struct PreparedQueue { + pub namespace: String, + pub fleet_ref: MinecraftServerFleetRef, + pub mmf_config: FunctionConfig, + pub match_profile: MatchProfile, +} + +impl PreparedQueue { + pub fn from( + mmf_registry: &MMFRegistry, + matchmaking_queue: &MatchmakingQueue, + ) -> Result { + Ok(PreparedQueue { + namespace: matchmaking_queue.namespace().unwrap(), + fleet_ref: matchmaking_queue.spec.target_fleet_ref.clone(), + mmf_config: Self::create_mmf_config(mmf_registry, matchmaking_queue)?, + match_profile: Self::create_match_profile(matchmaking_queue), + }) + } + + pub fn update( + &mut self, + mmf_registry: &MMFRegistry, + matchmaking_queue: &MatchmakingQueue, + ) -> Result<(), RegistryError> { + self.fleet_ref = matchmaking_queue.spec.target_fleet_ref.clone(); + self.mmf_config = Self::create_mmf_config(mmf_registry, matchmaking_queue)?; + self.match_profile = Self::create_match_profile(matchmaking_queue); + + Ok(()) + } + + fn create_mmf_config( + mmf_registry: &MMFRegistry, + matchmaking_queue: &MatchmakingQueue, + ) -> Result { + return if let Some(built_in) = matchmaking_queue.spec.mmf.built_in.as_ref() { + Ok(mmf_registry + .get_mmf_config_for_type(&built_in.type_) + .ok_or_else(|| RegistryError::BuiltInMMFNotFound(built_in.type_.to_string()))? + .clone()) + } else if let Some(provided) = matchmaking_queue.spec.mmf.provided.as_ref() { + Ok(FunctionConfig { + host: provided.host.clone(), + port: provided.port as i32, + r#type: function_config::Type::Grpc as i32, + }) + } else { + Err(RegistryError::NoValidMMFConfiguration( + matchmaking_queue.name_any(), + )) + }; + } + + fn create_match_profile(matchmaking_queue: &MatchmakingQueue) -> MatchProfile { + let name = matchmaking_queue.name_any(); + let mut profile = MatchProfile { + name: format!("shulker_{}", name.replace('-', "_")), + pools: vec![Pool { + name: "pool_default".to_string(), + tag_present_filters: vec![TagPresentFilter { tag: name }], + ..Pool::default() + }], + ..MatchProfile::default() + }; + + if let Some(min_players) = matchmaking_queue.spec.min_players { + set_min_players_in_profile(&mut profile, min_players as i32); + } + set_max_players_in_profile(&mut profile, matchmaking_queue.spec.max_players as i32); + + profile + } +} diff --git a/packages/shulker-addon-matchmaking/src/reconcilers/matchmaking_queue/mod.rs b/packages/shulker-addon-matchmaking/src/reconcilers/matchmaking_queue/mod.rs new file mode 100644 index 00000000..3e7e2ded --- /dev/null +++ b/packages/shulker-addon-matchmaking/src/reconcilers/matchmaking_queue/mod.rs @@ -0,0 +1,109 @@ +use std::{ + sync::{Arc, Mutex}, + time::Duration, +}; + +use futures::StreamExt; +use kube::{ + api::ListParams, + runtime::{controller::Action, watcher::Config, Controller}, + Api, Client, ResourceExt, +}; +use tracing::*; + +use shulker_crds::matchmaking::v1alpha1::matchmaking_queue::MatchmakingQueue; + +use crate::queue_registry::QueueRegistry; + +use super::{ReconcilerError, Result}; + +struct MatchmakingQueueReconciler { + client: kube::Client, + queue_registry: Arc>, +} + +impl MatchmakingQueueReconciler { + async fn reconcile( + &self, + _api: Api, + matchmaking_queue: Arc, + ) -> Result { + self.queue_registry + .lock() + .unwrap() + .register_queue(&matchmaking_queue) + .map_err(ReconcilerError::FailedToRegisterQueue)?; + + Ok(Action::requeue(Duration::from_secs(5 * 60))) + } + + async fn cleanup(&self, matchmaking_queue: Arc) -> Result { + info!( + name = matchmaking_queue.name_any(), + namespace = matchmaking_queue.namespace(), + "cleaning up MatchmakingQueue", + ); + + self.queue_registry + .lock() + .unwrap() + .unregister_queue(&matchmaking_queue); + + Ok(Action::await_change()) + } +} + +#[instrument(skip(ctx, matchmaking_queue))] +async fn reconcile( + matchmaking_queue: Arc, + ctx: Arc, +) -> Result { + let ns = matchmaking_queue.namespace().unwrap(); + let matchmaking_queues_api: Api = Api::namespaced(ctx.client.clone(), &ns); + + info!( + name = matchmaking_queue.name_any(), + namespace = ns, + "reconciling MatchmakingQueue", + ); + + if matchmaking_queue.metadata.deletion_timestamp.is_none() { + ctx.reconcile(matchmaking_queues_api.clone(), matchmaking_queue.clone()) + .await + } else { + ctx.cleanup(matchmaking_queue.clone()).await + } +} + +fn error_policy( + _matchmaking_queue: Arc, + error: &ReconcilerError, + _ctx: Arc, +) -> Action { + warn!("reconcile failed: {:?}", error); + // ctx.metrics.reconcile_failure(&matchmaking_queue, error); + Action::requeue(Duration::from_secs(5)) +} + +pub async fn run<'a>(client: Client, queue_registry: Arc>) { + let matchmaking_queues_api = Api::::all(client.clone()); + if let Err(e) = matchmaking_queues_api + .list(&ListParams::default().limit(1)) + .await + { + error!("CRD is not queryable; {e:?}. Is the CRD installed?"); + std::process::exit(1); + } + + let context = MatchmakingQueueReconciler { + client: client.clone(), + queue_registry, + }; + + Controller::new(matchmaking_queues_api, Config::default().any_semantic()) + .shutdown_on_signal() + .run(reconcile, error_policy, context.into()) + .filter_map(|x| async move { std::result::Result::ok(x) }) + .for_each(|_| futures::future::ready(())) + .await; +} diff --git a/packages/shulker-addon-matchmaking/src/reconcilers/mod.rs b/packages/shulker-addon-matchmaking/src/reconcilers/mod.rs new file mode 100644 index 00000000..d93a1b9d --- /dev/null +++ b/packages/shulker-addon-matchmaking/src/reconcilers/mod.rs @@ -0,0 +1,25 @@ +use thiserror::Error; + +use crate::queue_registry::RegistryError; + +pub mod matchmaking_queue; + +#[derive(Error, Debug)] +pub enum ReconcilerError { + #[error("failed to reconcile resource: {0}")] + FinalizerError(#[source] Box>), + + #[error("failed to resolve cluster ref: {1}")] + InvalidClusterRef(String, #[source] kube::Error), + + #[error("failed to register queue: {0}")] + FailedToRegisterQueue(#[source] RegistryError), + + #[error("failed to build resource: {0}")] + BuilderError(#[source] shulker_kube_utils::reconcilers::BuilderReconcilerError), + + #[error("failed to delete stale resource: {0}")] + FailedToDeleteStale(#[source] kube::Error), +} + +pub type Result = std::result::Result; diff --git a/packages/shulker-crds/Cargo.toml b/packages/shulker-crds/Cargo.toml index 53fd2faa..bf879948 100644 --- a/packages/shulker-crds/Cargo.toml +++ b/packages/shulker-crds/Cargo.toml @@ -10,7 +10,7 @@ name = "crdgen" path = "src/crdgen.rs" doc = false test = false -required-features = ["crdgen-bin"] +# required-features = ["crdgen-bin"] [lib] name = "shulker_crds" @@ -18,7 +18,7 @@ path = "src/lib.rs" [features] default = [] -crdgen-bin = ["serde_yaml"] +# crdgen-bin = ["serde_yaml"] [dependencies] google-agones-crds.workspace = true @@ -27,7 +27,7 @@ kube.workspace = true schemars.workspace = true serde.workspace = true serde_json.workspace = true -serde_yaml = { workspace = true, optional = true } +serde_yaml.workspace = true shulker-utils.workspace = true strum.workspace = true diff --git a/packages/shulker-crds/src/crdgen.rs b/packages/shulker-crds/src/crdgen.rs index d1b38e24..ba895798 100644 --- a/packages/shulker-crds/src/crdgen.rs +++ b/packages/shulker-crds/src/crdgen.rs @@ -1,21 +1,34 @@ use kube::CustomResourceExt; -use std::fs::File; +use std::fs::{create_dir_all, File}; use std::io::Write; use std::path::Path; macro_rules! generate_crd { ($crd_type:ty) => {{ + generate_crd!("", $crd_type) + }}; + ($subfolder:expr, $crd_type:ty) => {{ let group = <$crd_type>::api_resource().group; let plural = <$crd_type>::api_resource().plural; let file_name = format!("{}_{}.yaml", group, plural); - let path = Path::new(".") - .join("kube") - .join("resources") - .join("crd") - .join("bases") - .join(file_name); + + let path = if !$subfolder.is_empty() { + Path::new(".") + .join("kube") + .join("resources") + .join("crd") + .join($subfolder) + .join(file_name) + } else { + Path::new(".") + .join("kube") + .join("resources") + .join("crd") + .join(file_name) + }; println!("Generating CRD for {}", stringify!($crd_type)); + create_dir_all(path.parent().unwrap()).unwrap(); File::create(path) .unwrap() .write_all( @@ -32,4 +45,9 @@ fn main() { generate_crd!(shulker_crds::v1alpha1::proxy_fleet::ProxyFleet); generate_crd!(shulker_crds::v1alpha1::minecraft_server::MinecraftServer); generate_crd!(shulker_crds::v1alpha1::minecraft_server_fleet::MinecraftServerFleet); + + generate_crd!( + "matchmaking", + shulker_crds::matchmaking::v1alpha1::matchmaking_queue::MatchmakingQueue + ); } diff --git a/packages/shulker-crds/src/lib.rs b/packages/shulker-crds/src/lib.rs index 0e1098f7..122730c7 100644 --- a/packages/shulker-crds/src/lib.rs +++ b/packages/shulker-crds/src/lib.rs @@ -1,4 +1,5 @@ pub mod condition; +pub mod matchmaking; pub mod resourceref; pub mod schemas; pub mod v1alpha1; diff --git a/packages/shulker-crds/src/matchmaking/mod.rs b/packages/shulker-crds/src/matchmaking/mod.rs new file mode 100644 index 00000000..32a5a9d4 --- /dev/null +++ b/packages/shulker-crds/src/matchmaking/mod.rs @@ -0,0 +1 @@ +pub mod v1alpha1; diff --git a/packages/shulker-crds/src/matchmaking/v1alpha1/matchmaking_queue.rs b/packages/shulker-crds/src/matchmaking/v1alpha1/matchmaking_queue.rs new file mode 100644 index 00000000..8e465dc6 --- /dev/null +++ b/packages/shulker-crds/src/matchmaking/v1alpha1/matchmaking_queue.rs @@ -0,0 +1,82 @@ +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use strum::{Display, IntoStaticStr}; + +use crate::v1alpha1::minecraft_server_fleet::MinecraftServerFleetRef; + +#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[kube( + kind = "MatchmakingQueue", + group = "matchmaking.shulkermc.io", + version = "v1alpha1", + namespaced, + status = "MatchmakingQueueStatus", + printcolumn = r#"{"name": "Age", "type": "date", "jsonPath": ".metadata.creationTimestamp"}"# +)] +#[serde(rename_all = "camelCase")] +pub struct MatchmakingQueueSpec { + /// The `MinecraftServerFleet` to use as a target for this queue + pub target_fleet_ref: MinecraftServerFleetRef, + + /// The matchmaking function to use to create matches for this queue + pub mmf: MatchmakingQueueMMFSpec, + + /// The minimum number of players required to create a match. + /// If `None`, the matchmaking function will wait for the maximum + /// number of players + pub min_players: Option, + + /// The maximum number of players a match can contain + pub max_players: u32, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct MatchmakingQueueMMFSpec { + /// The matchmaking function to use is provided by Shulker + pub built_in: Option, + + /// The matchmaking function to use is provided by the user + pub provided: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct MatchmakingQueueMMFBuiltInSpec { + /// The type of the matchmaking function to use + pub type_: MatchmakingQueueMMFBuiltInType, +} + +#[derive( + PartialEq, + Eq, + Hash, + Deserialize, + Serialize, + Clone, + Debug, + Default, + JsonSchema, + IntoStaticStr, + Display, +)] +pub enum MatchmakingQueueMMFBuiltInType { + #[default] + Batch, + Elo, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct MatchmakingQueueMMFProvidedSpec { + /// Host of the matchmaking function + pub host: String, + /// GRPC port of the matchmaking function + pub port: u16, +} + +/// The status object of `MatchmakingQueue` +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct MatchmakingQueueStatus {} diff --git a/packages/shulker-crds/src/matchmaking/v1alpha1/mod.rs b/packages/shulker-crds/src/matchmaking/v1alpha1/mod.rs new file mode 100644 index 00000000..afa82873 --- /dev/null +++ b/packages/shulker-crds/src/matchmaking/v1alpha1/mod.rs @@ -0,0 +1 @@ +pub mod matchmaking_queue; diff --git a/packages/shulker-crds/src/v1alpha1/minecraft_cluster.rs b/packages/shulker-crds/src/v1alpha1/minecraft_cluster.rs index 099886da..80d55b78 100644 --- a/packages/shulker-crds/src/v1alpha1/minecraft_cluster.rs +++ b/packages/shulker-crds/src/v1alpha1/minecraft_cluster.rs @@ -89,7 +89,7 @@ pub struct MinecraftClusterRef { impl MinecraftClusterRef { /// Creates a new `MinecraftClusterRef` with the given name - pub fn new(name: impl Into) -> Self { - Self { name: name.into() } + pub fn new(name: String) -> Self { + Self { name } } } diff --git a/packages/shulker-crds/src/v1alpha1/minecraft_server_fleet.rs b/packages/shulker-crds/src/v1alpha1/minecraft_server_fleet.rs index 091bd918..b2526b79 100644 --- a/packages/shulker-crds/src/v1alpha1/minecraft_server_fleet.rs +++ b/packages/shulker-crds/src/v1alpha1/minecraft_server_fleet.rs @@ -65,3 +65,20 @@ impl HasConditions for MinecraftServerFleetStatus { &mut self.conditions } } + +/// MinecraftServerFleetRef is to be used on resources referencing +/// a MinecraftServerFleet. +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema, Default)] +#[serde(rename_all = "camelCase")] +pub struct MinecraftServerFleetRef { + /// Name of the Kubernetes `MinecraftServerFleet` owning + /// this resource + pub name: String, +} + +impl MinecraftServerFleetRef { + /// Creates a new `MinecraftServerFleet` with the given name + pub fn new(name: String) -> Self { + Self { name } + } +} diff --git a/packages/shulker-operator/Cargo.toml b/packages/shulker-operator/Cargo.toml index 2cbec65d..c84586fa 100644 --- a/packages/shulker-operator/Cargo.toml +++ b/packages/shulker-operator/Cargo.toml @@ -10,7 +10,7 @@ default-run = "shulker-operator" name = "shulker-operator" path = "src/main.rs" doc = false -required-features = ["shulker-operator-bin"] +# required-features = ["shulker-operator-bin"] [lib] name = "shulker_operator" @@ -18,12 +18,12 @@ path = "src/lib.rs" [features] default = [] -shulker-operator-bin = ["clap"] +# shulker-operator-bin = ["clap"] [dependencies] anyhow.workspace = true async-trait.workspace = true -clap = { workspace = true, optional = true } +clap.workspace = true futures.workspace = true google-agones-crds.workspace = true k8s-openapi.workspace = true @@ -34,9 +34,12 @@ serde.workspace = true serde_yaml.workspace = true shulker-crds.workspace = true shulker-kube-utils.workspace = true -shulker-sdk.workspace = true +shulker-sdk = { workspace = true, features = ["server"] } +shulker-utils.workspace = true thiserror.workspace = true tonic.workspace = true +tokio.workspace = true +tokio-util.workspace = true toml.workspace = true tracing.workspace = true url.workspace = true diff --git a/packages/shulker-operator/project.json b/packages/shulker-operator/project.json index 19d67c38..388531fd 100644 --- a/packages/shulker-operator/project.json +++ b/packages/shulker-operator/project.json @@ -7,7 +7,7 @@ "build": { "executor": "nx:run-commands", "options": { - "command": "cargo build --release", + "command": "cargo build --release --bins", "cwd": "packages/shulker-operator" }, "inputs": ["default", "rust:dependencies"] diff --git a/packages/shulker-operator/src/reconcilers/minecraft_server/fixtures.rs b/packages/shulker-operator/src/reconcilers/minecraft_server/fixtures.rs index 2f2a7631..265d4f34 100644 --- a/packages/shulker-operator/src/reconcilers/minecraft_server/fixtures.rs +++ b/packages/shulker-operator/src/reconcilers/minecraft_server/fixtures.rs @@ -25,7 +25,7 @@ lazy_static! { ..ObjectMeta::default() }, spec: MinecraftServerSpec { - cluster_ref: MinecraftClusterRef::new("my-cluster"), + cluster_ref: MinecraftClusterRef::new("my-cluster".to_string()), tags: vec!["lobby".to_string()], version: MinecraftServerVersionSpec { channel: MinecraftServerVersion::Paper, diff --git a/packages/shulker-operator/src/reconcilers/minecraft_server_fleet/fixtures.rs b/packages/shulker-operator/src/reconcilers/minecraft_server_fleet/fixtures.rs index b5bfd905..8c952f11 100644 --- a/packages/shulker-operator/src/reconcilers/minecraft_server_fleet/fixtures.rs +++ b/packages/shulker-operator/src/reconcilers/minecraft_server_fleet/fixtures.rs @@ -30,7 +30,7 @@ lazy_static! { ..ObjectMeta::default() }, spec: MinecraftServerFleetSpec { - cluster_ref: MinecraftClusterRef::new("my-cluster"), + cluster_ref: MinecraftClusterRef::new("my-cluster".to_string()), replicas: 3, template: TemplateSpec { metadata: Some(ObjectMeta { @@ -45,7 +45,7 @@ lazy_static! { ..ObjectMeta::default() }), spec: MinecraftServerSpec { - cluster_ref: MinecraftClusterRef::new("my-cluster"), + cluster_ref: MinecraftClusterRef::new("my-cluster".to_string()), tags: vec!["lobby".to_string()], version: MinecraftServerVersionSpec { channel: MinecraftServerVersion::Paper, diff --git a/packages/shulker-operator/src/reconcilers/proxy_fleet/fixtures.rs b/packages/shulker-operator/src/reconcilers/proxy_fleet/fixtures.rs index a68875f8..64bc5f98 100644 --- a/packages/shulker-operator/src/reconcilers/proxy_fleet/fixtures.rs +++ b/packages/shulker-operator/src/reconcilers/proxy_fleet/fixtures.rs @@ -30,7 +30,7 @@ lazy_static! { ..ObjectMeta::default() }, spec: ProxyFleetSpec { - cluster_ref: MinecraftClusterRef::new("my-cluster"), + cluster_ref: MinecraftClusterRef::new("my-cluster".to_string()), replicas: 3, template: TemplateSpec { metadata: Some(ObjectMeta { diff --git a/packages/shulker-sdk/bindings/rust/Cargo.toml b/packages/shulker-sdk/bindings/rust/Cargo.toml index d633fec6..f39493ef 100644 --- a/packages/shulker-sdk/bindings/rust/Cargo.toml +++ b/packages/shulker-sdk/bindings/rust/Cargo.toml @@ -6,7 +6,7 @@ edition.workspace = true publish.workspace = true [features] -default = ["client", "server"] +default = ["client"] client = [] server = [] diff --git a/packages/shulker-server-agent/project.json b/packages/shulker-server-agent/project.json index ff084017..6a379242 100644 --- a/packages/shulker-server-agent/project.json +++ b/packages/shulker-server-agent/project.json @@ -28,5 +28,8 @@ } }, "tags": ["lang:java"], - "implicitDependencies": ["google-agones-sdk-bindings-java"] + "implicitDependencies": [ + "google-agones-sdk-bindings-java", + "shulker-server-api" + ] }