fix(docker): make data persistence reliable (#3692)

* fix(docker): make data persistence reliable

* fix(docker): preserve existing bind-mounted data
This commit is contained in:
Cayden Tisdale
2026-09-23 18:47:09 -04:00
committed by GitHub
parent bfd024ce80
commit b97ff75c1a
6 changed files with 81 additions and 28 deletions

View File

@@ -1,5 +1,5 @@
use std::{
fs::File,
fs::{self, File},
io::ErrorKind,
path::Path,
time::{SystemTime, UNIX_EPOCH},
@@ -362,7 +362,14 @@ impl WorldInfoReader for AnvilLevelInfo {
fn read_world_info(&self, level_folder: &Path) -> Result<LevelData, WorldInfoError> {
let path = level_folder.join(LEVEL_DAT_FILE_NAME);
let root = read_gzip_compound_tag(File::open(path)?)
let file = match File::open(path) {
Ok(file) => file,
Err(error) if error.kind() == ErrorKind::NotFound => {
return Err(WorldInfoError::InfoNotFound);
}
Err(error) => return Err(error.into()),
};
let root = read_gzip_compound_tag(file)
.map_err(|e| WorldInfoError::DeserializationError(e.to_string()))?;
let Some(data) = root.get_compound(LEVEL_DATA_TAG) else {
error!("The level.dat file has no {LEVEL_DATA_TAG} compound and is therefore corrupt");
@@ -419,6 +426,8 @@ impl WorldInfoWriter for AnvilLevelInfo {
info: &LevelData,
level_folder: &Path,
) -> Result<(), WorldInfoError> {
fs::create_dir_all(level_folder)?;
let start = SystemTime::now();
let since_the_epoch = start.duration_since(UNIX_EPOCH).unwrap_or_default();
let mut level_data = info.clone();
@@ -442,9 +451,9 @@ impl WorldInfoWriter for AnvilLevelInfo {
.map_err(|e| WorldInfoError::SerializationError(e.to_string()))?;
if path.exists() {
let _ = std::fs::copy(&path, &path_old);
fs::copy(&path, &path_old)?;
}
let _ = std::fs::rename(&path_new, &path);
fs::rename(&path_new, &path)?;
let data_version = level_data.data_version;
@@ -540,6 +549,7 @@ mod test {
use pumpkin_util::{Difficulty, world_seed::Seed};
use std::{
fs::{self, File},
io::ErrorKind,
path::Path,
sync::LazyLock,
};
@@ -620,6 +630,44 @@ mod test {
assert_eq!(data.world_gen_settings.seed, seed);
}
#[test]
fn creates_missing_world_directory_when_writing() {
let temp_dir = TempDir::new().unwrap();
let level_folder = temp_dir.path().join("world");
AnvilLevelInfo
.write_world_info(&LevelData::default(Seed(42)), &level_folder)
.unwrap();
assert!(level_folder.join(LEVEL_DAT_FILE_NAME).is_file());
assert!(!level_folder.join("level.dat_new").exists());
}
#[test]
fn reports_missing_level_dat_as_info_not_found() {
let temp_dir = TempDir::new().unwrap();
let error = AnvilLevelInfo.read_world_info(temp_dir.path()).unwrap_err();
assert!(matches!(error, WorldInfoError::InfoNotFound));
}
#[test]
fn reports_writer_io_errors_as_io_errors() {
let temp_dir = TempDir::new().unwrap();
let level_folder = temp_dir.path().join("world");
File::create(&level_folder).unwrap();
let error = AnvilLevelInfo
.write_world_info(&LevelData::default(Seed(42)), &level_folder)
.unwrap_err();
let WorldInfoError::IoError(error) = error else {
panic!("expected writer I/O error");
};
assert_eq!(error.kind(), ErrorKind::AlreadyExists);
}
#[test]
fn read_level_dat_without_series() {
let temp_dir = TempDir::new().unwrap();

View File

@@ -588,8 +588,8 @@ impl LevelData {
#[derive(Error, Debug)]
pub enum WorldInfoError {
#[error("Io error: {0}")]
IoError(std::io::ErrorKind),
#[error("I/O error: {0}")]
IoError(#[from] std::io::Error),
#[error("Info not found!")]
InfoNotFound,
#[error("Deserialization error: {0}")]
@@ -606,15 +606,6 @@ pub enum WorldInfoError {
UnsupportedLevelVersion(i32),
}
impl From<std::io::Error> for WorldInfoError {
fn from(value: std::io::Error) -> Self {
match value.kind() {
std::io::ErrorKind::NotFound => Self::InfoNotFound,
value => Self::IoError(value),
}
}
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -27,6 +27,7 @@ use plugin::server::server_load::{LoadType, ServerLoadEvent};
use pumpkin_config::{AdvancedConfiguration, BasicConfiguration, TelemetryConfig};
use pumpkin_util::text::TextComponent;
use pumpkin_util::text::color::{Color, NamedColor};
use pumpkin_world::world_info::WorldInfoError;
use rustyline::Editor;
use rustyline::history::FileHistory;
use rustyline::{Config, error::ReadlineError};
@@ -251,14 +252,14 @@ impl PumpkinServer {
advanced_config: AdvancedConfiguration,
telemetry_config: TelemetryConfig,
vanilla_data: VanillaData,
) -> Self {
) -> Result<Self, WorldInfoError> {
let server = Server::new(
basic_config,
advanced_config,
telemetry_config,
vanilla_data,
)
.await;
.await?;
#[cfg(target_family = "unix")]
adjust_file_descriptor_limit();
@@ -349,12 +350,12 @@ impl PumpkinServer {
};
let nethernet_listener = Self::bind_nethernet(&server, ice_socket).await;
Self {
Ok(Self {
server,
tcp_listener,
bedrock_status,
nethernet_listener,
}
})
}
async fn bind_nethernet(

View File

@@ -126,7 +126,11 @@ async fn main() {
config.telemetry,
vanilla_data,
)
.await;
.await
.unwrap_or_else(|error| {
tracing::error!("Failed to initialize world storage: {error}");
exit(1);
});
let plugin_wait_time = pumpkin_server.init_plugins().await;
let time_elapsed = time.elapsed().saturating_sub(plugin_wait_time);

View File

@@ -156,13 +156,12 @@ pub struct Server {
impl Server {
#[expect(clippy::too_many_lines)]
#[must_use]
pub async fn new(
basic_config: BasicConfiguration,
advanced_config: AdvancedConfiguration,
telemetry_config: TelemetryConfig,
vanilla_data: VanillaData,
) -> Arc<Self> {
) -> Result<Arc<Self>, WorldInfoError> {
let permission_manager = Arc::new(PermissionManager::new());
// First register the default commands. After that, plugins can put in their own.
let command_dispatcher = ArcSwap::from_pointee(default_dispatcher(
@@ -201,9 +200,7 @@ impl Server {
);
let default_data =
LevelData::from_world_generator(basic_config.seed, &overworld_gen);
if let Err(err) = AnvilLevelInfo.write_world_info(&default_data, &world_path) {
error!("Failed to save level.dat: {err}");
}
AnvilLevelInfo.write_world_info(&default_data, &world_path)?;
default_data
}
Err(
@@ -436,7 +433,7 @@ impl Server {
.datapack_manager
.execute_function(&server, &source, "#minecraft:load");
server
Ok(server)
}
/// Spawns a task associated with this server. All tasks spawned with this method are awaited

View File

@@ -8,9 +8,17 @@ services:
- "25565:25565"
- "19132:19132/udp"
volumes:
# Pumpkin runs as UID/GID 2613. On Linux hosts, make sure the data
# directory has the correct ownership before starting the server:
# mkdir -p ./data
# sudo chown -R 2613:2613 ./data
- ./data:/pumpkin
# Use the following, if you want to make your config immutable
#- ./config:/pumpkin/config:ro
# As an alternative, Docker can manage the storage volume. Existing
# installations must migrate ./data before switching to avoid creating
# a new, empty world:
# - pumpkin-data:/pumpkin
# Use the following mount if you want to make your config immutable.
# - ./pumpkin.toml:/pumpkin/pumpkin.toml:ro
security_opt:
- no-new-privileges:true
restart: unless-stopped
@@ -19,3 +27,7 @@ services:
cap_drop:
- ALL
read_only: true
# Uncomment this section when using the pumpkin-data volume above.
# volumes:
# pumpkin-data: