From 86b48045761ccaad7685c5e3cc26a5a8d2af2f08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Levilain?= Date: Sun, 25 Aug 2024 19:45:50 +0200 Subject: [PATCH] feat: support providing external servers (#631) * feat(shulker-crds): add external_servers to cluster * feat(shulker-operator): create configmap with external servers and mount on proxies * feat(shulker-proxy-agent): watch external servers and register them * style: fix lint --- .../crds/shulkermc.io_minecraftclusters.yaml | 22 ++ .../src/v1alpha1/minecraft_cluster.rs | 22 ++ .../external_servers_config_map.rs | 110 ++++++++++ .../reconcilers/minecraft_cluster/fixtures.rs | 11 +- .../src/reconcilers/minecraft_cluster/mod.rs | 18 +- ...ers_config_map__tests__build_snapshot.snap | 18 ++ .../src/reconcilers/proxy_fleet/fleet.rs | 194 +++++++++++------- ...y_fleet__fleet__tests__build_snapshot.snap | 6 + packages/shulker-proxy-agent/build.gradle.kts | 4 + .../adapters/filesystem/FileSystemAdapter.kt | 8 + .../filesystem/LocalFileSystemAdapter.kt | 67 +++++- .../services/ServerDirectoryService.kt | 26 ++- settings.gradle.kts | 2 + 13 files changed, 430 insertions(+), 78 deletions(-) create mode 100644 packages/shulker-operator/src/reconcilers/minecraft_cluster/external_servers_config_map.rs create mode 100644 packages/shulker-operator/src/reconcilers/minecraft_cluster/snapshots/shulker_operator__reconcilers__minecraft_cluster__external_servers_config_map__tests__build_snapshot.snap diff --git a/kube/helm/templates/crds/shulkermc.io_minecraftclusters.yaml b/kube/helm/templates/crds/shulkermc.io_minecraftclusters.yaml index f9550ae9..e06a0e90 100644 --- a/kube/helm/templates/crds/shulkermc.io_minecraftclusters.yaml +++ b/kube/helm/templates/crds/shulkermc.io_minecraftclusters.yaml @@ -23,6 +23,28 @@ spec: properties: spec: properties: + externalServers: + description: List of servers that should be registered on the proxies that are not managed by Shulker + items: + properties: + address: + description: Address of the server, may contain a port after a colon + type: string + name: + description: Name of the server, as the proxies will register it. Allowed names only are lowercased, dash-separated alphanumerical string + pattern: ^[a-z0-9\-]+$ + type: string + tags: + description: Tags associated to the server + items: + type: string + type: array + required: + - address + - name + type: object + nullable: true + type: array networkAdmins: 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: diff --git a/packages/shulker-crds/src/v1alpha1/minecraft_cluster.rs b/packages/shulker-crds/src/v1alpha1/minecraft_cluster.rs index 80d55b78..17639a43 100644 --- a/packages/shulker-crds/src/v1alpha1/minecraft_cluster.rs +++ b/packages/shulker-crds/src/v1alpha1/minecraft_cluster.rs @@ -24,6 +24,11 @@ pub struct MinecraftClusterSpec { /// for the different Shulker components #[serde(skip_serializing_if = "Option::is_none")] pub redis: Option, + + /// List of servers that should be registered on the proxies + /// that are not managed by Shulker + #[serde(skip_serializing_if = "Option::is_none")] + pub external_servers: Option>, } #[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] @@ -72,6 +77,23 @@ impl MinecraftClusterRedisProvidedSpec { } } +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct MinecraftClusterExternalServerSpec { + /// Name of the server, as the proxies will register it. + /// Allowed names only are lowercased, dash-separated + /// alphanumerical string + #[schemars(regex(pattern = r"^[a-z0-9\-]+$"))] + pub name: String, + + /// Address of the server, may contain a port after a colon + pub address: String, + + /// Tags associated to the server + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, +} + /// The status object of `MinecraftCluster` #[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] #[serde(rename_all = "camelCase")] diff --git a/packages/shulker-operator/src/reconcilers/minecraft_cluster/external_servers_config_map.rs b/packages/shulker-operator/src/reconcilers/minecraft_cluster/external_servers_config_map.rs new file mode 100644 index 00000000..dc529ef7 --- /dev/null +++ b/packages/shulker-operator/src/reconcilers/minecraft_cluster/external_servers_config_map.rs @@ -0,0 +1,110 @@ +use std::collections::BTreeMap; + +use k8s_openapi::api::core::v1::ConfigMap; +use kube::core::ObjectMeta; +use kube::Api; +use kube::Client; +use kube::ResourceExt; + +use shulker_crds::v1alpha1::minecraft_cluster::MinecraftCluster; +use shulker_kube_utils::reconcilers::builder::ResourceBuilder; + +use super::MinecraftClusterReconciler; + +pub struct ExternalServersConfigMapBuilder { + client: Client, +} + +#[async_trait::async_trait] +impl<'a> ResourceBuilder<'a> for ExternalServersConfigMapBuilder { + type OwnerType = MinecraftCluster; + type ResourceType = ConfigMap; + type Context = (); + + fn name(cluster: &Self::OwnerType) -> String { + format!("{}-external-servers", cluster.name_any()) + } + + fn api(&self, cluster: &Self::OwnerType) -> kube::Api { + Api::namespaced(self.client.clone(), cluster.namespace().as_ref().unwrap()) + } + + fn is_needed(&self, cluster: &Self::OwnerType) -> bool { + cluster + .spec + .external_servers + .as_ref() + .map_or(false, |list| !list.is_empty()) + } + + async fn build( + &self, + cluster: &Self::OwnerType, + name: &str, + _existing_config_map: Option<&Self::ResourceType>, + _context: Option, + ) -> Result { + let config_map = ConfigMap { + metadata: ObjectMeta { + name: Some(name.to_string()), + namespace: Some(cluster.namespace().unwrap().clone()), + labels: Some(MinecraftClusterReconciler::get_labels( + cluster, + "external-servers".to_string(), + "proxy".to_string(), + )), + ..ObjectMeta::default() + }, + data: Some(BTreeMap::from([( + "external-servers.yaml".to_string(), + ExternalServersConfigMapBuilder::get_content_from_server_list(cluster), + )])), + ..ConfigMap::default() + }; + + Ok(config_map) + } +} + +impl ExternalServersConfigMapBuilder { + pub fn new(client: Client) -> Self { + ExternalServersConfigMapBuilder { client } + } + + fn get_content_from_server_list(cluster: &MinecraftCluster) -> String { + serde_yaml::to_string(cluster.spec.external_servers.as_ref().unwrap()).unwrap() + } +} + +#[cfg(test)] +mod tests { + use shulker_kube_utils::reconcilers::builder::ResourceBuilder; + + use crate::reconcilers::minecraft_cluster::fixtures::{create_client_mock, TEST_CLUSTER}; + + #[test] + fn name_contains_cluster_name() { + // W + let name = super::ExternalServersConfigMapBuilder::name(&TEST_CLUSTER); + + // T + assert_eq!(name, "my-cluster-external-servers"); + } + + #[tokio::test] + async fn build_snapshot() { + // G + let client = create_client_mock(); + let builder = super::ExternalServersConfigMapBuilder::new(client); + let name = super::ExternalServersConfigMapBuilder::name(&TEST_CLUSTER); + + // W + let config_map = builder + .build(&TEST_CLUSTER, &name, None, None) + .await + .unwrap(); + + // T + insta::assert_yaml_snapshot!(config_map); + } +} diff --git a/packages/shulker-operator/src/reconcilers/minecraft_cluster/fixtures.rs b/packages/shulker-operator/src/reconcilers/minecraft_cluster/fixtures.rs index 4c97c2eb..025f7997 100644 --- a/packages/shulker-operator/src/reconcilers/minecraft_cluster/fixtures.rs +++ b/packages/shulker-operator/src/reconcilers/minecraft_cluster/fixtures.rs @@ -2,7 +2,9 @@ use http::{Request, Response}; use kube::client::Body; use kube::{core::ObjectMeta, Client}; use lazy_static::lazy_static; -use shulker_crds::v1alpha1::minecraft_cluster::{MinecraftCluster, MinecraftClusterSpec}; +use shulker_crds::v1alpha1::minecraft_cluster::{ + MinecraftCluster, MinecraftClusterExternalServerSpec, MinecraftClusterSpec, +}; lazy_static! { pub static ref TEST_CLUSTER: MinecraftCluster = MinecraftCluster { @@ -13,7 +15,12 @@ lazy_static! { }, spec: MinecraftClusterSpec { network_admins: None, - redis: None + redis: None, + external_servers: Some(vec![MinecraftClusterExternalServerSpec { + name: "my-external-server".to_string(), + address: "127.0.0.1:25565".to_string(), + tags: vec!["game".to_string()] + }]) }, status: None, }; diff --git a/packages/shulker-operator/src/reconcilers/minecraft_cluster/mod.rs b/packages/shulker-operator/src/reconcilers/minecraft_cluster/mod.rs index 54909c1b..7f6f4580 100644 --- a/packages/shulker-operator/src/reconcilers/minecraft_cluster/mod.rs +++ b/packages/shulker-operator/src/reconcilers/minecraft_cluster/mod.rs @@ -1,9 +1,10 @@ use std::{collections::BTreeMap, sync::Arc, time::Duration}; +use external_servers_config_map::ExternalServersConfigMapBuilder; use futures::StreamExt; use k8s_openapi::api::{ apps::v1::StatefulSet, - core::v1::{Secret, Service, ServiceAccount}, + core::v1::{ConfigMap, Secret, Service, ServiceAccount}, rbac::v1::{Role, RoleBinding}, }; use kube::{ @@ -35,6 +36,7 @@ use self::{ use super::Result; +pub mod external_servers_config_map; mod forwarding_secret; mod headless_service; mod minecraft_server_role; @@ -65,6 +67,7 @@ struct MinecraftClusterReconciler { minecraft_server_role_binding_builder: MinecraftServerRoleBindingBuilder, redis_service_builder: RedisServiceBuilder, redis_stateful_set_builder: RedisStatefulSetBuilder, + external_servers_config_map_builder: ExternalServersConfigMapBuilder, } impl MinecraftClusterReconciler { @@ -111,6 +114,13 @@ impl MinecraftClusterReconciler { reconcile_builder(&self.redis_stateful_set_builder, cluster.as_ref(), None) .await .map_err(ReconcilerError::BuilderError)?; + reconcile_builder( + &self.external_servers_config_map_builder, + cluster.as_ref(), + None, + ) + .await + .map_err(ReconcilerError::BuilderError)?; Ok(Action::requeue(Duration::from_secs(5 * 60))) } @@ -210,9 +220,15 @@ pub async fn run(client: Client) { ), redis_service_builder: RedisServiceBuilder::new(client.clone()), redis_stateful_set_builder: RedisStatefulSetBuilder::new(client.clone()), + + external_servers_config_map_builder: ExternalServersConfigMapBuilder::new(client.clone()), }; Controller::new(clusters_api, Config::default().any_semantic()) + .owns( + Api::::all(client.clone()), + Config::default().any_semantic(), + ) .owns( Api::::all(client.clone()), Config::default().any_semantic(), diff --git a/packages/shulker-operator/src/reconcilers/minecraft_cluster/snapshots/shulker_operator__reconcilers__minecraft_cluster__external_servers_config_map__tests__build_snapshot.snap b/packages/shulker-operator/src/reconcilers/minecraft_cluster/snapshots/shulker_operator__reconcilers__minecraft_cluster__external_servers_config_map__tests__build_snapshot.snap new file mode 100644 index 00000000..d0217460 --- /dev/null +++ b/packages/shulker-operator/src/reconcilers/minecraft_cluster/snapshots/shulker_operator__reconcilers__minecraft_cluster__external_servers_config_map__tests__build_snapshot.snap @@ -0,0 +1,18 @@ +--- +source: packages/shulker-operator/src/reconcilers/minecraft_cluster/external_servers_config_map.rs +expression: config_map +--- +apiVersion: v1 +kind: ConfigMap +data: + external-servers.yaml: "- name: my-external-server\n address: 127.0.0.1:25565\n tags:\n - game\n" +metadata: + labels: + app.kubernetes.io/component: proxy + app.kubernetes.io/instance: external-servers-my-cluster + app.kubernetes.io/managed-by: shulker-operator + app.kubernetes.io/name: external-servers + app.kubernetes.io/part-of: cluster-my-cluster + minecraftcluster.shulkermc.io/name: my-cluster + name: my-cluster-external-servers + namespace: default diff --git a/packages/shulker-operator/src/reconcilers/proxy_fleet/fleet.rs b/packages/shulker-operator/src/reconcilers/proxy_fleet/fleet.rs index 4e1f41e7..5414f034 100644 --- a/packages/shulker-operator/src/reconcilers/proxy_fleet/fleet.rs +++ b/packages/shulker-operator/src/reconcilers/proxy_fleet/fleet.rs @@ -34,6 +34,7 @@ use crate::agent::AgentConfig; use crate::constants; use crate::reconcilers::agent::get_agent_plugin_url; use crate::reconcilers::agent::AgentSide; +use crate::reconcilers::minecraft_cluster::external_servers_config_map::ExternalServersConfigMapBuilder; use crate::reconcilers::redis_ref::RedisRef; use crate::resources::resourceref_resolver::ResourceRefResolver; use google_agones_crds::v1::fleet::Fleet; @@ -51,6 +52,7 @@ const PROXY_SHULKER_CONFIG_DIR: &str = "/mnt/shulker/config"; const PROXY_SHULKER_FORWARDING_SECRET_DIR: &str = "/mnt/shulker/forwarding-secret"; const PROXY_DATA_DIR: &str = "/server"; const PROXY_DRAIN_LOCK_DIR: &str = "/mnt/drain-lock"; +const PROXY_SHULKER_EXTERNAL_SERVERS_DIR: &str = "/mnt/shulker/external-servers"; lazy_static! { static ref PROXY_SECURITY_CONTEXT: SecurityContext = SecurityContext { @@ -225,30 +227,7 @@ impl<'a> FleetBuilder { }), image_pull_policy: Some("IfNotPresent".to_string()), security_context: Some(PROXY_SECURITY_CONTEXT.clone()), - volume_mounts: Some(vec![ - VolumeMount { - name: "shulker-forwarding-secret".to_string(), - mount_path: PROXY_SHULKER_FORWARDING_SECRET_DIR.to_string(), - read_only: Some(true), - ..VolumeMount::default() - }, - VolumeMount { - name: "proxy-data".to_string(), - mount_path: PROXY_DATA_DIR.to_string(), - ..VolumeMount::default() - }, - VolumeMount { - name: "proxy-drain-lock".to_string(), - mount_path: PROXY_DRAIN_LOCK_DIR.to_string(), - read_only: Some(true), - ..VolumeMount::default() - }, - VolumeMount { - name: "proxy-tmp".to_string(), - mount_path: "/tmp".to_string(), - ..VolumeMount::default() - }, - ]), + volume_mounts: Some(self.get_volume_mounts(context, proxy_fleet)), ..Container::default() }], service_account_name: Some(format!( @@ -256,51 +235,7 @@ impl<'a> FleetBuilder { &proxy_fleet.spec.cluster_ref.name )), restart_policy: Some("Never".to_string()), - volumes: Some(vec![ - Volume { - name: "shulker-config".to_string(), - config_map: Some(ConfigMapVolumeSource { - name: Some( - proxy_fleet - .spec - .template - .spec - .config - .existing_config_map_name - .clone() - .unwrap_or_else(|| ConfigMapBuilder::name(proxy_fleet)), - ), - ..ConfigMapVolumeSource::default() - }), - ..Volume::default() - }, - Volume { - name: "shulker-forwarding-secret".to_string(), - secret: Some(SecretVolumeSource { - secret_name: Some(format!( - "{}-forwarding-secret", - &proxy_fleet.spec.cluster_ref.name - )), - ..SecretVolumeSource::default() - }), - ..Volume::default() - }, - Volume { - name: "proxy-data".to_string(), - empty_dir: Some(EmptyDirVolumeSource::default()), - ..Volume::default() - }, - Volume { - name: "proxy-drain-lock".to_string(), - empty_dir: Some(EmptyDirVolumeSource::default()), - ..Volume::default() - }, - Volume { - name: "proxy-tmp".to_string(), - empty_dir: Some(EmptyDirVolumeSource::default()), - ..Volume::default() - }, - ]), + volumes: Some(self.get_volumes(context, proxy_fleet)), ..PodSpec::default() }; @@ -635,6 +570,127 @@ impl<'a> FleetBuilder { ProxyFleetTemplateVersion::Waterfall => "WATERFALL_BUILD_ID".to_string(), } } + + fn get_volumes( + &self, + context: &FleetBuilderContext<'a>, + proxy_fleet: &ProxyFleet, + ) -> Vec { + let mut volumes = vec![ + Volume { + name: "shulker-config".to_string(), + config_map: Some(ConfigMapVolumeSource { + name: Some( + proxy_fleet + .spec + .template + .spec + .config + .existing_config_map_name + .clone() + .unwrap_or_else(|| ConfigMapBuilder::name(proxy_fleet)), + ), + ..ConfigMapVolumeSource::default() + }), + ..Volume::default() + }, + Volume { + name: "shulker-forwarding-secret".to_string(), + secret: Some(SecretVolumeSource { + secret_name: Some(format!( + "{}-forwarding-secret", + &proxy_fleet.spec.cluster_ref.name + )), + ..SecretVolumeSource::default() + }), + ..Volume::default() + }, + Volume { + name: "proxy-data".to_string(), + empty_dir: Some(EmptyDirVolumeSource::default()), + ..Volume::default() + }, + Volume { + name: "proxy-drain-lock".to_string(), + empty_dir: Some(EmptyDirVolumeSource::default()), + ..Volume::default() + }, + Volume { + name: "proxy-tmp".to_string(), + empty_dir: Some(EmptyDirVolumeSource::default()), + ..Volume::default() + }, + ]; + + let has_external_servers = context + .cluster + .spec + .external_servers + .as_ref() + .map_or(false, |list| !list.is_empty()); + + if has_external_servers { + volumes.push(Volume { + name: "shulker-external-servers".to_string(), + config_map: Some(ConfigMapVolumeSource { + name: Some(ExternalServersConfigMapBuilder::name(context.cluster)), + ..ConfigMapVolumeSource::default() + }), + ..Volume::default() + }) + } + + volumes + } + + fn get_volume_mounts( + &self, + context: &FleetBuilderContext<'a>, + _proxy_fleet: &ProxyFleet, + ) -> Vec { + let mut volume_mounts = vec![ + VolumeMount { + name: "shulker-forwarding-secret".to_string(), + mount_path: PROXY_SHULKER_FORWARDING_SECRET_DIR.to_string(), + read_only: Some(true), + ..VolumeMount::default() + }, + VolumeMount { + name: "proxy-data".to_string(), + mount_path: PROXY_DATA_DIR.to_string(), + ..VolumeMount::default() + }, + VolumeMount { + name: "proxy-drain-lock".to_string(), + mount_path: PROXY_DRAIN_LOCK_DIR.to_string(), + read_only: Some(true), + ..VolumeMount::default() + }, + VolumeMount { + name: "proxy-tmp".to_string(), + mount_path: "/tmp".to_string(), + ..VolumeMount::default() + }, + ]; + + let has_external_servers = context + .cluster + .spec + .external_servers + .as_ref() + .map_or(false, |list| !list.is_empty()); + + if has_external_servers { + volume_mounts.push(VolumeMount { + name: "shulker-external-servers".to_string(), + mount_path: PROXY_SHULKER_EXTERNAL_SERVERS_DIR.to_string(), + read_only: Some(true), + ..VolumeMount::default() + }) + } + + volume_mounts + } } #[cfg(test)] diff --git a/packages/shulker-operator/src/reconcilers/proxy_fleet/snapshots/shulker_operator__reconcilers__proxy_fleet__fleet__tests__build_snapshot.snap b/packages/shulker-operator/src/reconcilers/proxy_fleet/snapshots/shulker_operator__reconcilers__proxy_fleet__fleet__tests__build_snapshot.snap index 2909dfd6..2461ac2a 100644 --- a/packages/shulker-operator/src/reconcilers/proxy_fleet/snapshots/shulker_operator__reconcilers__proxy_fleet__fleet__tests__build_snapshot.snap +++ b/packages/shulker-operator/src/reconcilers/proxy_fleet/snapshots/shulker_operator__reconcilers__proxy_fleet__fleet__tests__build_snapshot.snap @@ -123,6 +123,9 @@ spec: readOnly: true - mountPath: /tmp name: proxy-tmp + - mountPath: /mnt/shulker/external-servers + name: shulker-external-servers + readOnly: true initContainers: - command: - sh @@ -171,5 +174,8 @@ spec: name: proxy-drain-lock - emptyDir: {} name: proxy-tmp + - configMap: + name: my-cluster-external-servers + name: shulker-external-servers eviction: safe: OnUpgrade diff --git a/packages/shulker-proxy-agent/build.gradle.kts b/packages/shulker-proxy-agent/build.gradle.kts index e6d17369..78b6e665 100644 --- a/packages/shulker-proxy-agent/build.gradle.kts +++ b/packages/shulker-proxy-agent/build.gradle.kts @@ -7,6 +7,10 @@ plugins { dependencies { commonApi(project(":packages:shulker-proxy-api")) + // Filesystem + commonImplementation(libs.apache.commons.io) + commonImplementation(libs.snakeyaml) + // Kubernetes commonCompileOnly(libs.kubernetes.client.api) commonRuntimeOnly(libs.kubernetes.client) diff --git a/packages/shulker-proxy-agent/src/common/kotlin/io/shulkermc/proxyagent/adapters/filesystem/FileSystemAdapter.kt b/packages/shulker-proxy-agent/src/common/kotlin/io/shulkermc/proxyagent/adapters/filesystem/FileSystemAdapter.kt index 47db07ed..6e1b7ab2 100644 --- a/packages/shulker-proxy-agent/src/common/kotlin/io/shulkermc/proxyagent/adapters/filesystem/FileSystemAdapter.kt +++ b/packages/shulker-proxy-agent/src/common/kotlin/io/shulkermc/proxyagent/adapters/filesystem/FileSystemAdapter.kt @@ -1,9 +1,17 @@ package io.shulkermc.proxyagent.adapters.filesystem +import java.net.InetSocketAddress + interface FileSystemAdapter { fun createDrainLock() fun createReadinessLock() fun deleteReadinessLock() + + fun watchExternalServersUpdates( + callback: (servers: Map) -> Unit, + ) + + data class ExternalServer(val name: String, val address: InetSocketAddress, val tags: Set) } diff --git a/packages/shulker-proxy-agent/src/common/kotlin/io/shulkermc/proxyagent/adapters/filesystem/LocalFileSystemAdapter.kt b/packages/shulker-proxy-agent/src/common/kotlin/io/shulkermc/proxyagent/adapters/filesystem/LocalFileSystemAdapter.kt index 9190ca9a..aaba9d85 100644 --- a/packages/shulker-proxy-agent/src/common/kotlin/io/shulkermc/proxyagent/adapters/filesystem/LocalFileSystemAdapter.kt +++ b/packages/shulker-proxy-agent/src/common/kotlin/io/shulkermc/proxyagent/adapters/filesystem/LocalFileSystemAdapter.kt @@ -1,12 +1,24 @@ package io.shulkermc.proxyagent.adapters.filesystem +import org.apache.commons.io.monitor.FileAlterationListenerAdaptor +import org.apache.commons.io.monitor.FileAlterationMonitor +import org.apache.commons.io.monitor.FileAlterationObserver +import org.yaml.snakeyaml.Yaml +import java.io.File +import java.net.InetSocketAddress import java.nio.file.Files import java.nio.file.Path - -private val DRAIN_LOCK_PATH = Path.of("/tmp/drain-lock") -private val READINESS_LOCK_PATH = Path.of("/tmp/readiness-lock") +import kotlin.io.path.exists class LocalFileSystemAdapter : FileSystemAdapter { + companion object { + private const val EXTERNAL_SERVERS_WATCH_INTERVAL_MS = 10_000L + + private val EXTERNAL_SERVERS_PATH = Path.of("/mnt/shulker/external-servers/external-servers.yaml") + private val DRAIN_LOCK_PATH = Path.of("/tmp/drain-lock") + private val READINESS_LOCK_PATH = Path.of("/tmp/readiness-lock") + } + override fun createDrainLock() { if (!Files.exists(DRAIN_LOCK_PATH)) { Files.createFile(DRAIN_LOCK_PATH) @@ -22,4 +34,53 @@ class LocalFileSystemAdapter : FileSystemAdapter { override fun deleteReadinessLock() { Files.deleteIfExists(READINESS_LOCK_PATH) } + + override fun watchExternalServersUpdates( + callback: (servers: Map) -> Unit, + ) { + val observer = FileAlterationObserver(EXTERNAL_SERVERS_PATH.parent.toFile()) + observer.addListener( + object : FileAlterationListenerAdaptor() { + override fun onFileChange(file: File) { + callback(parseExternalServersFile(file)) + } + + override fun onFileDelete(file: File) { + callback(emptyMap()) + } + }, + ) + + val monitor = FileAlterationMonitor(EXTERNAL_SERVERS_WATCH_INTERVAL_MS, observer) + monitor.start() + + if (EXTERNAL_SERVERS_PATH.exists()) { + callback(this.parseExternalServersFile(EXTERNAL_SERVERS_PATH.toFile())) + } + } + + private fun parseExternalServersFile(file: File): Map { + val yaml = Yaml() + val list: List> = yaml.load(file.inputStream()) + + @Suppress("UNCHECKED_CAST") + return list.associate { entry -> + val name = entry["name"] as String + val address = entry["address"] as String + val tags = entry.getOrDefault("tags", emptyList()) as List + + val addressParts = address.split(":") + + name to + FileSystemAdapter.ExternalServer( + name, + if (addressParts.size == 2) { + InetSocketAddress(addressParts[0], addressParts[1].toInt()) + } else { + InetSocketAddress(address, 25565) + }, + tags.toSet(), + ) + } + } } diff --git a/packages/shulker-proxy-agent/src/common/kotlin/io/shulkermc/proxyagent/services/ServerDirectoryService.kt b/packages/shulker-proxy-agent/src/common/kotlin/io/shulkermc/proxyagent/services/ServerDirectoryService.kt index 593c1660..79657929 100644 --- a/packages/shulker-proxy-agent/src/common/kotlin/io/shulkermc/proxyagent/services/ServerDirectoryService.kt +++ b/packages/shulker-proxy-agent/src/common/kotlin/io/shulkermc/proxyagent/services/ServerDirectoryService.kt @@ -2,20 +2,24 @@ package io.shulkermc.proxyagent.services import io.shulkermc.proxyagent.Configuration import io.shulkermc.proxyagent.ShulkerProxyAgentCommon +import io.shulkermc.proxyagent.adapters.filesystem.FileSystemAdapter import io.shulkermc.proxyagent.adapters.kubernetes.WatchAction import io.shulkermc.proxyagent.adapters.kubernetes.models.AgonesV1GameServer import java.net.InetSocketAddress +import java.util.Optional class ServerDirectoryService( private val agent: ShulkerProxyAgentCommon, ) { - private val serversByTag = HashMap>() - private val tagsByServer = HashMap>() - companion object { private const val DEFAULT_MINECRAFT_PORT = 25565 } + private val serversByTag = HashMap>() + private val tagsByServer = HashMap>() + + private var externalServers: Optional> = Optional.empty() + init { this.agent.kubernetesGateway.watchMinecraftServerEvents { action, minecraftServer -> this.agent.logger.fine("Detected modification on MinecraftServer '${minecraftServer.metadata.name}'") @@ -30,6 +34,8 @@ class ServerDirectoryService( existingMinecraftServers.items .filterNotNull() .forEach(this::registerServer) + + this.agent.fileSystem.watchExternalServersUpdates(this::onExternalServersUpdate) } fun getServersByTag(tag: String): Set = this.serversByTag.getOrDefault(tag, setOf()) @@ -82,4 +88,18 @@ class ServerDirectoryService( this.agent.logger.info("Removed server '$name' from directory") } } + + private fun onExternalServersUpdate(servers: Map) { + this.agent.logger.info("External servers file was updated, updating directory") + + this.externalServers.ifPresent { existingServer -> + existingServer.keys.forEach(this::unregisterServer) + } + + this.externalServers = Optional.of(servers) + + servers.values.forEach { server -> + this.registerServer(server.name, server.address, server.tags) + } + } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 064ce149..87df665a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -10,6 +10,7 @@ dependencyResolutionManagement { library("adventure-api", "net.kyori:adventure-api:4.17.0") library("adventure-platform-bungeecord", "net.kyori:adventure-platform-bungeecord:4.3.4") library("annotations-api", "org.apache.tomcat:annotations-api:6.0.53") + library("apache-commons-io", "commons-io:commons-io:2.16.1") library("bungeecord-api", "net.md-5:bungeecord-api:1.21-R0.1-SNAPSHOT") library("folia-api", "dev.folia:folia-api:1.20.6-R0.1-SNAPSHOT") library("guava", "com.google.guava:guava:33.3.0-jre") @@ -23,6 +24,7 @@ dependencyResolutionManagement { library("kubernetes-client-api", "io.fabric8", "kubernetes-client-api").versionRef("kubernetes-client") library("kubernetes-client-http", "io.fabric8", "kubernetes-httpclient-okhttp").versionRef("kubernetes-client") library("protobuf", "com.google.protobuf:protobuf-java:3.25.4") + library("snakeyaml", "org.yaml:snakeyaml:2.2") library("velocity-api", "com.velocitypowered:velocity-api:3.3.0-SNAPSHOT") plugin("buildconfig", "com.github.gmazzo.buildconfig").version("5.4.0")