From ae7ef10b67737f08a86743f687bca30a12e9e17a Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 12 Jul 2026 19:38:03 +0200 Subject: first stable --- src/docker.rs | 213 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 src/docker.rs (limited to 'src/docker.rs') diff --git a/src/docker.rs b/src/docker.rs new file mode 100644 index 0000000..b18bb47 --- /dev/null +++ b/src/docker.rs @@ -0,0 +1,213 @@ +use bollard::Docker; +use bollard::container::{ + Config as ContainerConfig, CreateContainerOptions, ListContainersOptions, + 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, +} + +#[derive(Debug, Clone)] +pub enum ContainerStatus { + Running, + Stopped, + Starting, + NotFound, + Error(String), +} + +impl DockerManager { + pub fn new(config: Config) -> Result { + let client = Docker::connect_with_local_defaults()?; + Ok(Self { client, config }) + } + + pub async fn container_status(&self) -> ContainerStatus { + let mut filters = HashMap::new(); + filters.insert("name".to_string(), vec!["mc".to_string()]); + + let options = ListContainersOptions { + all: true, + filters, + ..Default::default() + }; + + match self.client.list_containers(Some(options)).await { + Ok(containers) => { + if containers.is_empty() { + ContainerStatus::NotFound + } else { + match containers[0].state.as_deref() { + Some("running") => ContainerStatus::Running, + Some("exited") | Some("stopped") => ContainerStatus::Stopped, + _ => ContainerStatus::Starting, + } + } + } + Err(e) => ContainerStatus::Error(e.to_string()), + } + } + + pub async fn start_container(&self, instance: &Instance) -> Result<(), String> { + tracing::info!("Pulling itzg/minecraft-server:latest image..."); + self.pull_image().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) -> Result<(), String> { + let options = CreateImageOptions { + from_image: "itzg/minecraft-server", + tag: "latest", + ..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 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(), + "ENABLE_WHITELIST=true".into(), + "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(), + 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 { + if !seed.is_empty() { + env_strings.push(format!("LEVEL_SEED={seed}")); + } + } + + let env_refs: Vec<&str> = env_strings.iter().map(|s| s.as_str()).collect(); + + let mut exposed_ports: HashMap<&str, HashMap<(), ()>> = HashMap::new(); + exposed_ports.insert("25565/tcp", HashMap::new()); + exposed_ports.insert("25575/tcp", 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), + ..Default::default() + }; + + let container_config = ContainerConfig { + image: Some("itzg/minecraft-server:latest"), + tty: Some(true), + env: Some(env_refs), + 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(()) + } +} -- cgit v1.2.3