use super::{Difficulty, Gamemode, Server, DEFAULT_MINECRAFT_PORT};
use crate::instance::Instance;
use crate::local_storage;
use crate::local_storage::PersistedEntity;
use crate::pack::Pack;
use crate::server::backup;
use bon::bon;
use docker_compose_types::{AdvancedVolumes, Compose, Environment, Service, SingleValue, Volumes};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::{fs, io};
pub const DATA_VOLUME_PATH: &str = "server";
pub const DEFAULT_ICON_URL: &str =
"https://raw.githubusercontent.com/exoumoon/ground-zero/main/assets/icon.png";
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct DockerCompose(pub Compose);
impl PersistedEntity for DockerCompose {
const FILE_PATH: &'static str = "docker-compose.yml";
}
#[allow(clippy::empty_enum, reason = "Rises from within bon")]
#[bon]
impl DockerCompose {
pub const MODPACK_PATH: &'static str = "/data/modpack.mrpack";
#[builder]
#[must_use]
pub fn environment(
instance: &Instance,
operator_username: &str,
memlimit_gb: u8,
max_players: u16,
online_mode: bool,
allow_flight: bool,
gamemode: &Gamemode,
difficulty: &Difficulty,
) -> Environment {
let kv_pairs = [
("EULA", SingleValue::String("TRUE".into())),
(
"VERSION",
SingleValue::String(instance.minecraft_version.to_string()),
),
("TYPE", SingleValue::String("MODRINTH".into())),
(
format!("{}_VERSION", instance.loader.to_string().to_uppercase()).as_str(),
SingleValue::String(instance.loader_version.to_string()),
),
(
"MODRINTH_MODPACK",
SingleValue::String(Self::MODPACK_PATH.into()),
),
("MEMORY", SingleValue::String(format!("{memlimit_gb}G"))),
("USE_AIKAR_FLAGS", SingleValue::Bool(true)),
("ENABLE_AUTOPAUSE", SingleValue::Bool(true)),
("VIEW_DISTANCE", SingleValue::Unsigned(12)),
("MODE", SingleValue::String(gamemode.to_string())),
("DIFFICULTY", SingleValue::String(difficulty.to_string())),
("MAX_PLAYERS", SingleValue::Unsigned(max_players.into())),
("MOTD", SingleValue::String("TODO".into())),
("ICON", SingleValue::String(DEFAULT_ICON_URL.into())),
("ALLOW_FLIGHT", SingleValue::Bool(allow_flight)),
("ONLINE_MODE", SingleValue::Bool(online_mode)),
{
let rcon_first_connect = indoc::indoc! {"
/whitelist on
/whitelist add username
/op username
"}
.replace("username", operator_username);
(
"RCON_CMDS_FIRST_CONNECT",
SingleValue::String(rcon_first_connect),
)
},
]
.map(|(key, value)| (key.to_string(), Some(value)));
let kv_hashmap = HashMap::from_iter(kv_pairs);
Environment::KvPair(kv_hashmap)
}
}
#[derive(Debug, thiserror::Error)]
pub enum SetupError {
#[error("A local server is already configured for this pack")]
AlreadySetUp,
#[error(transparent)]
Other(#[from] local_storage::Error),
}
#[derive(Debug, thiserror::Error)]
pub enum StartStopError {
#[error(transparent)]
ExitCode(#[from] io::Error),
#[error("Process terminated by signal")]
Terminated,
#[error("Failed to backup server")]
BackupError(#[from] backup::Error),
}
impl Server for DockerCompose {
type SetupError = self::SetupError;
type StartStopError = self::StartStopError;
fn setup() -> Result<Self, Self::SetupError> {
let pack = Pack::read()?;
if let Err(error) = fs::create_dir_all(DATA_VOLUME_PATH) {
match error.kind() {
io::ErrorKind::AlreadyExists => {}
_ => {
return Err(local_storage::Error::Io {
source: error,
faulty_path: Some(PathBuf::from(DATA_VOLUME_PATH)),
}
.into())
}
}
}
let volumes = vec![
Volumes::Advanced(AdvancedVolumes {
source: Some(DATA_VOLUME_PATH.into()),
target: "/data".into(),
_type: "bind".into(),
read_only: false,
bind: None,
volume: None,
tmpfs: None,
}),
Volumes::Advanced(AdvancedVolumes {
source: Some({
pack.export()?;
format!("./{}.mrpack", pack.name)
}),
target: Self::MODPACK_PATH.into(),
_type: "bind".into(),
read_only: true,
bind: None,
volume: None,
tmpfs: None,
}),
];
let ports = docker_compose_types::Ports::Short(vec![format!(
"{DEFAULT_MINECRAFT_PORT}:{DEFAULT_MINECRAFT_PORT}"
)]);
let hostname = format!("{}_server", pack.name);
let image = "itzg/minecraft-server:java17-alpine".to_string();
let environment = Self::environment()
.instance(&pack.instance)
.operator_username("mxxntype")
.memlimit_gb(12)
.max_players(4)
.online_mode(false)
.allow_flight(true)
.gamemode(&Gamemode::Survival)
.difficulty(&Difficulty::Hard)
.call();
let services = HashMap::from([(
"server".to_string(),
Some(Service {
image: Some(image),
hostname: Some(hostname.clone()),
container_name: Some(hostname),
environment,
restart: Some("unless-stopped".into()),
volumes,
networks: docker_compose_types::Networks::Simple(vec![]),
ports,
..Default::default()
}),
)]);
let manifest = Compose {
version: None,
services: docker_compose_types::Services(services),
volumes: docker_compose_types::TopLevelVolumes::default(),
networks: docker_compose_types::ComposeNetworks::default(),
service: None,
secrets: None,
extensions: HashMap::default(),
};
let manifest_path = <Self as PersistedEntity>::FILE_PATH;
match std::fs::exists(manifest_path) {
Ok(true) => {
tracing::warn!(
"A {server_type:?} server is already set up. Delete {manifest_path:?} before re-setup",
server_type = std::any::type_name::<Self>()
);
return Err(SetupError::AlreadySetUp);
}
Err(error) => {
return Err(local_storage::Error::Io {
source: error,
faulty_path: Some(PathBuf::from(DATA_VOLUME_PATH)),
}
.into())
}
_ => { }
}
let docker_compose = Self(manifest);
docker_compose.write()?;
Ok(docker_compose)
}
fn start(&self) -> Result<(), Self::StartStopError> {
let _new_backup = backup::create_new(Some("pre-start"))?;
let _gc_result = backup::gc()?;
let status = std::process::Command::new("docker")
.args([
"compose",
"--file",
<Self as PersistedEntity>::FILE_PATH,
"up",
"--detach",
])
.status()?;
if let Some(status_code) = status.code() {
match status_code {
0 => Ok(()),
error => Err(io::Error::from_raw_os_error(error).into()),
}
} else {
Err(StartStopError::Terminated)
}
}
fn stop(&self) -> Result<(), Self::StartStopError> {
let _new_backup = backup::create_new(Some("post-stop"))?;
let _gc_result = backup::gc()?;
let status = std::process::Command::new("docker")
.args([
"compose",
"--file",
<Self as PersistedEntity>::FILE_PATH,
"down",
])
.status()?;
if let Some(status_code) = status.code() {
match status_code {
0 => Ok(()),
error => Err(io::Error::from_raw_os_error(error).into()),
}
} else {
Err(StartStopError::Terminated)
}
}
}