use bollard::Docker; use bollard::container::{ Config as ContainerConfig, CreateContainerOptions, RemoveContainerOptions, StartContainerOptions, StopContainerOptions, }; use bollard::image::CreateImageOptions; use bollard::models::HostConfig; use futures_util::StreamExt; use std::collections::HashMap; use crate::config::Config; use crate::models::Instance; #[derive(Clone)] pub struct DockerManager { client: Docker, config: Config, } impl DockerManager { pub fn new(config: Config) -> Result { let client = Docker::connect_with_local_defaults()?; Ok(Self { client, config }) } /// Map a Minecraft version string (e.g. "1.12.2", "1.20.1") to the /// appropriate itzg/minecraft-server Java image tag. /// /// - < 1.17 → java8 (Minecraft 1.12.2 and earlier) /// - 1.17 .. 1.20.4 → java17 /// - >= 1.20.5 → java21 fn java_tag(version: &str) -> &'static str { // Parse major.minor from version strings like "1.12.2", "1.20.1", "1.21" let parts: Vec<&str> = version.split('.').collect(); if parts.len() < 2 { return "java17"; // sensible default } let major: u32 = parts.first().and_then(|s| s.parse().ok()).unwrap_or(1); let minor: u32 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(12); if major <= 1 && minor < 17 { "java8" } else if major <= 1 && minor < 21 { // 1.17.x through 1.20.4 use Java 17 "java17" } else { // 1.20.5+ and 1.21+ use Java 21 "java21" } } /// Build the full image reference, e.g. "itzg/minecraft-server:java8" fn image_ref(version: &str) -> String { format!("itzg/minecraft-server:{}", Self::java_tag(version)) } pub async fn start_container(&self, instance: &Instance) -> Result<(), String> { let tag = Self::java_tag(&instance.version); tracing::info!("Pulling itzg/minecraft-server:{tag} image..."); self.pull_image(tag).await?; tracing::info!("Creating container for instance '{}'", instance.name); self.recreate_container(instance).await?; tracing::info!("Starting container..."); self.start_existing().await } async fn pull_image(&self, tag: &str) -> Result<(), String> { let options = CreateImageOptions { from_image: "itzg/minecraft-server", tag, ..Default::default() }; let mut stream = self.client.create_image(Some(options), None, None); while let Some(result) = stream.next().await { match result { Ok(_info) => {} Err(e) => return Err(format!("Image pull failed: {e}")), } } Ok(()) } async fn recreate_container(&self, instance: &Instance) -> Result<(), String> { // Remove existing container if present let remove_options = RemoveContainerOptions { force: true, ..Default::default() }; match self .client .remove_container("mc", Some(remove_options)) .await { Ok(_) => tracing::info!("Removed existing container"), Err(e) => tracing::debug!("No existing container to remove: {e}"), } let server_dir = self .config .server_directory .canonicalize() .unwrap_or_else(|_| self.config.server_directory.clone()) .display() .to_string(); let instance_data = format!("{server_dir}/instances/{}/data", instance.name); let image_ref = Self::image_ref(&instance.version); let mut env_strings: Vec = vec![ "EULA=TRUE".into(), format!("VERSION={}", instance.version), format!("TYPE={}", instance.server_type), format!("MEMORY={}M", instance.memory_mb), format!("MAX_PLAYERS={}", instance.max_players), format!("DIFFICULTY={}", instance.difficulty), "FORCE_GAMEMODE=true".into(), "SPAWN_PROTECTION=0".into(), format!("ENABLE_WHITELIST={}", instance.whitelist_enabled), "PAUSE_WHEN_EMPTY_SECONDS=30".into(), "ONLINE_MODE=false".into(), "TZ=Europe/Warsaw".into(), "USE_AIKAR_FLAGS=true".into(), "ENABLE_RCON=true".into(), format!("RCON_PASSWORD={}", instance.rcon_password), "RCON_PORT=25575".into(), "JVM_OPTS=-Dfml.queryResult=confirm".into(), format!("MOTD={}", instance.motd), format!("GAMEMODE={}", instance.gamemode), format!("LEVEL_TYPE={}", instance.level_type), ]; if instance.pvp { env_strings.push("PVP=true".into()); } else { env_strings.push("PVP=false".into()); } if let Some(ref seed) = instance.world_seed && !seed.is_empty() { env_strings.push(format!("LEVEL_SEED={seed}")); } let mut exposed_ports: HashMap> = HashMap::new(); exposed_ports.insert("25565/tcp".into(), HashMap::new()); exposed_ports.insert("25575/tcp".into(), HashMap::new()); let mut port_bindings = HashMap::new(); port_bindings.insert( "25565/tcp".to_string(), Some(vec![bollard::models::PortBinding { host_ip: None, host_port: Some("25565".to_string()), }]), ); port_bindings.insert( "25575/tcp".to_string(), Some(vec![bollard::models::PortBinding { host_ip: None, host_port: Some("25575".to_string()), }]), ); let host_config = HostConfig { binds: Some(vec![format!("{instance_data}:/data")]), port_bindings: Some(port_bindings), network_mode: Some("host".into()), ..Default::default() }; let container_config = ContainerConfig { image: Some(image_ref.clone()), tty: Some(true), env: Some(env_strings.clone()), exposed_ports: Some(exposed_ports), host_config: Some(host_config), ..Default::default() }; let options = CreateContainerOptions { name: "mc", ..Default::default() }; self.client .create_container(Some(options), container_config) .await .map_err(|e| format!("Failed to create container: {e}"))?; tracing::info!("Container 'mc' created successfully"); Ok(()) } async fn start_existing(&self) -> Result<(), String> { self.client .start_container("mc", None::>) .await .map_err(|e| format!("Failed to start container: {e}"))?; Ok(()) } pub async fn stop_container(&self) -> Result<(), String> { let options = StopContainerOptions { t: 30 }; self.client .stop_container("mc", Some(options)) .await .map_err(|e| format!("Failed to stop container: {e}"))?; Ok(()) } }