Backup management from Paper plugin moved into Pandalib + new BackupCleaner + refactored working directory backup
This commit is contained in:
parent
5b20cb4372
commit
06815c5c75
@ -70,6 +70,13 @@
|
||||
<artifactId>paper-mojangapi</artifactId>
|
||||
<version>${paper.version}-SNAPSHOT</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Cron expression interpreter -->
|
||||
<dependency>
|
||||
<groupId>ch.eitchnet</groupId>
|
||||
<artifactId>cron</artifactId>
|
||||
<version>1.6.2</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
@ -1,5 +1,6 @@
|
||||
package fr.pandacube.lib.paper;
|
||||
|
||||
import fr.pandacube.lib.paper.modules.PerformanceAnalysisManager;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
public class PandaLibPaper {
|
||||
@ -9,6 +10,8 @@ public class PandaLibPaper {
|
||||
public static void init(Plugin plugin) {
|
||||
PandaLibPaper.plugin = plugin;
|
||||
|
||||
PerformanceAnalysisManager.getInstance(); // initialize
|
||||
|
||||
}
|
||||
|
||||
public static Plugin getPlugin() {
|
||||
|
@ -0,0 +1,101 @@
|
||||
package fr.pandacube.lib.paper.modules.backup;
|
||||
|
||||
import java.io.File;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map.Entry;
|
||||
import java.util.Optional;
|
||||
import java.util.TreeMap;
|
||||
import java.util.TreeSet;
|
||||
import java.util.function.UnaryOperator;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import fr.pandacube.lib.util.Log;
|
||||
|
||||
public abstract class BackupCleaner implements UnaryOperator<TreeSet<LocalDateTime>> {
|
||||
|
||||
public static BackupCleaner KEEPING_N_LAST(int n) {
|
||||
return new BackupCleaner() {
|
||||
@Override
|
||||
public TreeSet<LocalDateTime> apply(TreeSet<LocalDateTime> archives) {
|
||||
return archives.descendingSet().stream()
|
||||
.limit(n)
|
||||
.collect(Collectors.toCollection(TreeSet::new));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
public static BackupCleaner KEEPING_1_EVERY_N_MONTH(int n) {
|
||||
return new BackupCleaner() {
|
||||
@Override
|
||||
public TreeSet<LocalDateTime> apply(TreeSet<LocalDateTime> localDateTimes) {
|
||||
return localDateTimes.stream()
|
||||
.collect(Collectors.groupingBy(
|
||||
ldt -> {
|
||||
return ldt.getYear() * 4 + ldt.getMonthValue() / 2;
|
||||
},
|
||||
TreeMap::new,
|
||||
Collectors.minBy(LocalDateTime::compareTo))
|
||||
)
|
||||
.values()
|
||||
.stream()
|
||||
.filter(Optional::isPresent)
|
||||
.map(Optional::get)
|
||||
.collect(Collectors.toCollection(TreeSet::new));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
public BackupCleaner merge(BackupCleaner other) {
|
||||
BackupCleaner self = this;
|
||||
return new BackupCleaner() {
|
||||
@Override
|
||||
public TreeSet<LocalDateTime> apply(TreeSet<LocalDateTime> archives) {
|
||||
TreeSet<LocalDateTime> merged = new TreeSet<>();
|
||||
merged.addAll(self.apply(archives));
|
||||
merged.addAll(other.apply(archives));
|
||||
return merged;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
public void cleanupArchives(File archiveDir) {
|
||||
String[] files = archiveDir.list();
|
||||
|
||||
Log.info("[Backup] Cleaning up backup directory " + archiveDir + "...");
|
||||
|
||||
TreeMap<LocalDateTime, File> datedFiles = new TreeMap<>();
|
||||
|
||||
for (String filename : files) {
|
||||
File file = new File(archiveDir, filename);
|
||||
if (!filename.matches("\\d{8}-\\d{6}\\.zip")) {
|
||||
Log.warning("[Backup] Invalid file in backup directory: " + file);
|
||||
continue;
|
||||
}
|
||||
|
||||
String dateTimeStr = filename.substring(0, filename.length() - 4);
|
||||
LocalDateTime ldt = LocalDateTime.parse(dateTimeStr, CompressProcess.dateFileNameFormatter);
|
||||
|
||||
datedFiles.put(ldt, file);
|
||||
}
|
||||
|
||||
TreeSet<LocalDateTime> keptFiles = apply(new TreeSet<>(datedFiles.keySet()));
|
||||
|
||||
for (Entry<LocalDateTime, File> datedFile : datedFiles.entrySet()) {
|
||||
if (keptFiles.contains(datedFile.getKey()))
|
||||
continue;
|
||||
// datedFile.getValue().delete(); // TODO check if the filtering is ok before actually removing files
|
||||
Log.info("[Backup] Removed expired backup file " + datedFile.getValue());
|
||||
}
|
||||
|
||||
Log.info("[Backup] Backup directory " + archiveDir + " cleaned.");
|
||||
}
|
||||
|
||||
|
||||
}
|
@ -0,0 +1,15 @@
|
||||
package fr.pandacube.lib.paper.modules.backup;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class BackupConfig {
|
||||
public boolean worldBackupEnabled = true;
|
||||
public boolean workdirBackupEnabled = true;
|
||||
public String scheduling = "0 2 * * 1"; // cron format, here is everyday at 2am
|
||||
public File backupDirectory = null;
|
||||
public BackupCleaner worldBackupCleaner = BackupCleaner.KEEPING_1_EVERY_N_MONTH(3).merge(BackupCleaner.KEEPING_N_LAST(5));
|
||||
public BackupCleaner workdirBackupCleaner = BackupCleaner.KEEPING_1_EVERY_N_MONTH(3).merge(BackupCleaner.KEEPING_N_LAST(5));
|
||||
public List<String> workdirIgnoreList = new ArrayList<>();
|
||||
}
|
@ -0,0 +1,196 @@
|
||||
package fr.pandacube.lib.paper.modules.backup;
|
||||
|
||||
import fc.cron.CronExpression;
|
||||
import fr.pandacube.lib.paper.PandaLibPaper;
|
||||
import fr.pandacube.lib.util.Log;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerChangedWorldEvent;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.bukkit.event.player.PlayerQuitEvent;
|
||||
import org.bukkit.event.world.WorldLoadEvent;
|
||||
import org.bukkit.event.world.WorldSaveEvent;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Calendar;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
public class BackupManager implements Runnable, Listener {
|
||||
|
||||
|
||||
|
||||
Persist persist;
|
||||
|
||||
private final List<CompressProcess> compressQueue = new ArrayList<>();
|
||||
|
||||
private final Set<String> compressWorlds = new HashSet<>();
|
||||
|
||||
/* package */ AtomicReference<CompressProcess> compressRunning = new AtomicReference<>();
|
||||
|
||||
private final Set<String> dirtyForSave = new HashSet<>();
|
||||
|
||||
BackupConfig config;
|
||||
|
||||
public BackupManager(BackupConfig config) {
|
||||
setConfig(config);
|
||||
persist = new Persist(this);
|
||||
|
||||
|
||||
for (final World world : Bukkit.getWorlds()) {
|
||||
initCompressProcess(Type.WORLDS, world.getName());
|
||||
}
|
||||
|
||||
initCompressProcess(Type.WORKDIR, null);
|
||||
|
||||
Bukkit.getServer().getScheduler().runTaskTimer(PandaLibPaper.getPlugin(), this, (60 - Calendar.getInstance().get(Calendar.SECOND)) * 20L, 60 * 20L);
|
||||
|
||||
Bukkit.getServer().getPluginManager().registerEvents(this, PandaLibPaper.getPlugin());
|
||||
|
||||
}
|
||||
|
||||
public void setConfig(BackupConfig config) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
|
||||
public void onDisable() {
|
||||
|
||||
if (compressRunning.get() != null) {
|
||||
Log.warning("[Backup] Waiting after the end of a backup...");
|
||||
CompressProcess tmp;
|
||||
while ((tmp = compressRunning.get()) != null) {
|
||||
try {
|
||||
tmp.logProgress();
|
||||
// wait 5 seconds between each progress log
|
||||
// but check if the process has ended each .5 seconds
|
||||
for (int i = 0; i < 10; i++) {
|
||||
if (compressRunning.get() == null)
|
||||
break;
|
||||
Thread.sleep(500);
|
||||
}
|
||||
} catch (Throwable e) { // could occur because of synchronization errors/interruption/...
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// save dirty status of worlds
|
||||
for (String wName : dirtyForSave) {
|
||||
World w = Bukkit.getWorld(wName);
|
||||
if (w != null)
|
||||
persist.updateDirtyStatusAfterSave(w);
|
||||
}
|
||||
|
||||
persist.save();
|
||||
}
|
||||
|
||||
private void initCompressProcess(final Type type, final String worldName) {
|
||||
if (!type.backupEnabled(config))
|
||||
return;
|
||||
if (type == Type.WORLDS) {
|
||||
if (compressWorlds.contains(worldName))
|
||||
return;
|
||||
compressWorlds.add(worldName);
|
||||
}
|
||||
CompressProcess process = type == Type.WORLDS ? new CompressWorldProcess(this, worldName) : new CompressWorkdirProcess(this);
|
||||
process.displayDirtynessStatus();
|
||||
compressQueue.add(process);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
CompressProcess tmp;
|
||||
if ((tmp = compressRunning.get()) != null) {
|
||||
tmp.logProgress();
|
||||
}
|
||||
else {
|
||||
compressQueue.sort(null);
|
||||
for (CompressProcess process : compressQueue) {
|
||||
if (System.currentTimeMillis() >= process.getNext() && process.couldRunNow()) {
|
||||
process.run();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* get the timestamp (in ms) of when the next compress will run, depending on since when the files to compress are dirty.
|
||||
* @param dirtySince the timestamp in ms since the files are dirty
|
||||
* @return the timestamp in ms when the next compress of the files should be run, or 0 if it is not yet scheduled
|
||||
*/
|
||||
/* package */ long getNextCompress(long dirtySince) {
|
||||
if (dirtySince == -1)
|
||||
return 0;
|
||||
|
||||
CronExpression parsedScheduling;
|
||||
try {
|
||||
parsedScheduling = new CronExpression(config.scheduling, false);
|
||||
} catch (IllegalArgumentException e) {
|
||||
Log.severe("Invalid backup scheduling configuration '" + config.scheduling + "'.", e);
|
||||
return 0;
|
||||
}
|
||||
|
||||
ZonedDateTime ldt = parsedScheduling.nextTimeAfter(ZonedDateTime.from(Instant.ofEpochMilli(dirtySince)));
|
||||
// Log.info("Compress config: " + compressConfig + " - interval: " + interval);
|
||||
|
||||
return ldt.toInstant().toEpochMilli();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onWorldLoad(WorldLoadEvent event) {
|
||||
initCompressProcess(Type.WORLDS, event.getWorld().getName());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onWorldSave(WorldSaveEvent event) {
|
||||
if (event.getWorld().getLoadedChunks().length > 0
|
||||
|| dirtyForSave.contains(event.getWorld().getName())) {
|
||||
persist.updateDirtyStatusAfterSave(event.getWorld());
|
||||
dirtyForSave.remove(event.getWorld().getName());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onPlayerChangeWorldEvent(PlayerChangedWorldEvent event) {
|
||||
dirtyForSave.add(event.getFrom().getName());
|
||||
dirtyForSave.add(event.getPlayer().getWorld().getName());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onPlayerJoin(PlayerJoinEvent event) {
|
||||
dirtyForSave.add(event.getPlayer().getWorld().getName());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onPlayerQuit(PlayerQuitEvent event) {
|
||||
dirtyForSave.add(event.getPlayer().getWorld().getName());
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
@ -0,0 +1,194 @@
|
||||
package fr.pandacube.lib.paper.modules.backup;
|
||||
|
||||
import fr.pandacube.lib.chat.Chat;
|
||||
import fr.pandacube.lib.paper.PandaLibPaper;
|
||||
import fr.pandacube.lib.paper.modules.PerformanceAnalysisManager;
|
||||
import fr.pandacube.lib.paper.util.AutoUpdatedBossBar;
|
||||
import fr.pandacube.lib.util.FileUtils;
|
||||
import fr.pandacube.lib.util.Log;
|
||||
import net.kyori.adventure.bossbar.BossBar;
|
||||
import net.kyori.adventure.bossbar.BossBar.Color;
|
||||
import net.kyori.adventure.bossbar.BossBar.Overlay;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.ChatColor;
|
||||
|
||||
import java.io.File;
|
||||
import java.text.DateFormat;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeFormatterBuilder;
|
||||
import java.time.temporal.ChronoField;
|
||||
import java.util.Calendar;
|
||||
import java.util.Date;
|
||||
import java.util.function.BiPredicate;
|
||||
|
||||
public abstract class CompressProcess implements Comparable<CompressProcess>, Runnable {
|
||||
protected final BackupManager backupManager;
|
||||
public final Type type;
|
||||
public final String name;
|
||||
|
||||
|
||||
|
||||
private ZipCompressor compressor = null;
|
||||
|
||||
protected CompressProcess(BackupManager bm, final Type t, final String n) {
|
||||
backupManager = bm;
|
||||
type = t;
|
||||
name = n;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compareTo(final CompressProcess process) {
|
||||
return Long.compare(getNext(), process.getNext());
|
||||
}
|
||||
|
||||
|
||||
public abstract BiPredicate<File, String> getFilenameFilter();
|
||||
|
||||
public abstract File getSourceDir();
|
||||
|
||||
protected abstract void onCompressStart();
|
||||
|
||||
protected abstract void onCompressEnd(boolean success);
|
||||
|
||||
protected abstract File getTargetDir();
|
||||
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
backupManager.compressRunning.set(this);
|
||||
|
||||
BiPredicate<File, String> filter = getFilenameFilter();
|
||||
File sourceDir = getSourceDir();
|
||||
|
||||
if (!sourceDir.exists()) {
|
||||
Log.warning(String.format("%% unable to compress %s (check path: %s)", name, sourceDir.getPath()));
|
||||
backupManager.compressRunning.set(null);
|
||||
return;
|
||||
}
|
||||
|
||||
File targetDir = getTargetDir();
|
||||
File target = new File(targetDir, getDateFileName() + ".zip");
|
||||
|
||||
|
||||
BossBar bossBar = BossBar.bossBar(Chat.text("Archivage"), 0, Color.YELLOW, Overlay.NOTCHED_20);
|
||||
AutoUpdatedBossBar auBossBar = new AutoUpdatedBossBar(bossBar, (bar) -> {
|
||||
bar.setTitle(Chat.infoText("Archivage ")
|
||||
.thenData(type + "\\" + name)
|
||||
.thenText(" : ")
|
||||
.then(compressor == null
|
||||
? Chat.text("Démarrage...")
|
||||
: compressor.getState()
|
||||
)
|
||||
);
|
||||
bar.setProgress(compressor == null ? 0 : compressor.getProgress());
|
||||
});
|
||||
auBossBar.scheduleUpdateTimeSyncThreadAsync(100, 100);
|
||||
|
||||
onCompressStart();
|
||||
|
||||
Bukkit.getScheduler().runTaskAsynchronously(PandaLibPaper.getPlugin(), () -> {
|
||||
Log.info("[Backup] starting for " + ChatColor.GRAY + type + "\\" + name + ChatColor.RESET + " ...");
|
||||
|
||||
compressor = new ZipCompressor(sourceDir, target, 9, filter);
|
||||
|
||||
PerformanceAnalysisManager.getInstance().addBossBar(bossBar);
|
||||
|
||||
boolean success = false;
|
||||
try {
|
||||
compressor.compress();
|
||||
|
||||
success = true;
|
||||
|
||||
Log.info("[Backup] finished for " + ChatColor.GRAY + type + "\\" + name + ChatColor.RESET);
|
||||
|
||||
backupManager.persist.updateDirtyStatusAfterCompress(type, name);
|
||||
|
||||
displayDirtynessStatus();
|
||||
}
|
||||
catch (final Exception e) {
|
||||
Log.severe("[Backup] Failed: " + sourceDir + " -> " + target, e);
|
||||
|
||||
FileUtils.delete(target);
|
||||
if (target.exists())
|
||||
Log.warning("unable to delete: " + target);
|
||||
} finally {
|
||||
|
||||
backupManager.compressRunning.set(null);
|
||||
boolean successF = success;
|
||||
Bukkit.getScheduler().runTask(PandaLibPaper.getPlugin(), () -> onCompressEnd(successF));
|
||||
|
||||
try {
|
||||
Thread.sleep(2000);
|
||||
} catch(InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
|
||||
PerformanceAnalysisManager.getInstance().removeBossBar(bossBar);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
public void displayDirtynessStatus() {
|
||||
if (hasNextScheduled() && type == Type.WORLDS) {
|
||||
Log.info("[Backup] " + ChatColor.GRAY + type + "\\" + name + ChatColor.RESET + " is dirty. Next backup on "
|
||||
+ DateFormat.getDateTimeInstance(DateFormat.LONG, DateFormat.LONG).format(new Date(getNext())));
|
||||
}
|
||||
else if (hasNextScheduled()) {
|
||||
Log.info("[Backup] " + ChatColor.GRAY + type + "\\" + name + ChatColor.RESET + " next backup on "
|
||||
+ DateFormat.getDateTimeInstance(DateFormat.LONG, DateFormat.LONG).format(new Date(getNext())));
|
||||
}
|
||||
else {
|
||||
Log.info("[Backup] " + ChatColor.GRAY + type + "\\" + name + ChatColor.RESET + " is clean. Next backup not scheduled.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
static DateTimeFormatter dateFileNameFormatter = new DateTimeFormatterBuilder()
|
||||
.append(DateTimeFormatter.BASIC_ISO_DATE)
|
||||
.appendLiteral('-')
|
||||
.appendValue(ChronoField.HOUR_OF_DAY, 2) // there is no DateTimeFormatter.BASIC_ISO_TIME
|
||||
.appendValue(ChronoField.MINUTE_OF_HOUR, 2)
|
||||
.appendValue(ChronoField.SECOND_OF_MINUTE, 2)
|
||||
.toFormatter();
|
||||
|
||||
|
||||
private String getDateFileName() {
|
||||
Calendar calendar = Calendar.getInstance();
|
||||
return dateFileNameFormatter.format(calendar.toInstant());
|
||||
}
|
||||
|
||||
|
||||
public void logProgress() {
|
||||
if (compressor == null)
|
||||
return;
|
||||
Log.info("[Backup] " + ChatColor.GRAY + type + "\\" + name + ChatColor.RESET + ": " + compressor.getState().getLegacyText());
|
||||
}
|
||||
|
||||
|
||||
|
||||
public boolean couldRunNow() {
|
||||
if (!type.backupEnabled(backupManager.config))
|
||||
return false;
|
||||
if (!backupManager.persist.isDirty(type, name))
|
||||
return false;
|
||||
if (getNext() > System.currentTimeMillis())
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
public long getNext() {
|
||||
if (!hasNextScheduled())
|
||||
return Long.MAX_VALUE;
|
||||
return backupManager.getNextCompress(backupManager.persist.isDirtySince(type, name));
|
||||
}
|
||||
|
||||
public boolean hasNextScheduled() {
|
||||
return backupManager.persist.isDirty(type, name);
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,71 @@
|
||||
package fr.pandacube.lib.paper.modules.backup;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.List;
|
||||
import java.util.function.BiPredicate;
|
||||
|
||||
public class CompressWorkdirProcess extends CompressProcess {
|
||||
|
||||
protected CompressWorkdirProcess(BackupManager bm) {
|
||||
super(bm, Type.WORKDIR, Type.WORKDIR.toString());
|
||||
}
|
||||
|
||||
|
||||
public BiPredicate<File, String> getFilenameFilter() {
|
||||
List<String> ignoreList = backupManager.config.workdirIgnoreList;
|
||||
if (ignoreList == null)
|
||||
return null;
|
||||
return new SourceFileFilter();
|
||||
}
|
||||
|
||||
|
||||
|
||||
private class SourceFileFilter implements BiPredicate<File, String> {
|
||||
|
||||
|
||||
@Override
|
||||
public boolean test(File file, String path) {
|
||||
if (globalExcluded(file, path))
|
||||
return false;
|
||||
for (String exclude : backupManager.config.workdirIgnoreList) {
|
||||
if (exclude.startsWith("/")) { // relative to source of workdir
|
||||
if (path.matches(exclude.substring(1)))
|
||||
return false;
|
||||
}
|
||||
else {
|
||||
String name = path.substring(path.lastIndexOf("/") + 1);
|
||||
if (name.matches(exclude))
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public boolean globalExcluded(File file, String path) {
|
||||
if (file.isDirectory() && new File(file, "level.dat").exists())
|
||||
return true;
|
||||
if (new File(getSourceDir(), "logs").equals(file))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public File getSourceDir() {
|
||||
return new File(".");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCompressStart() { }
|
||||
|
||||
@Override
|
||||
protected void onCompressEnd(boolean success) { }
|
||||
|
||||
@Override
|
||||
protected File getTargetDir() {
|
||||
return new File(backupManager.config.backupDirectory, "workdir");
|
||||
}
|
||||
}
|
@ -0,0 +1,54 @@
|
||||
package fr.pandacube.lib.paper.modules.backup;
|
||||
|
||||
import fr.pandacube.lib.paper.util.WorldUtil;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.World;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.function.BiPredicate;
|
||||
|
||||
public class CompressWorldProcess extends CompressProcess {
|
||||
|
||||
private boolean autoSave = true;
|
||||
|
||||
protected CompressWorldProcess(BackupManager bm, final String n) {
|
||||
super(bm, Type.WORLDS, n);
|
||||
}
|
||||
|
||||
private World getWorld() {
|
||||
return Bukkit.getWorld(name);
|
||||
}
|
||||
|
||||
|
||||
public BiPredicate<File, String> getFilenameFilter() {
|
||||
return (f, s) -> true;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public File getSourceDir() {
|
||||
return WorldUtil.worldDir(name);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCompressStart() {
|
||||
World w = getWorld();
|
||||
if (w == null)
|
||||
return;
|
||||
autoSave = w.isAutoSave();
|
||||
w.setAutoSave(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCompressEnd(boolean success) {
|
||||
World w = getWorld();
|
||||
if (w == null)
|
||||
return;
|
||||
w.setAutoSave(autoSave);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected File getTargetDir() {
|
||||
return new File(backupManager.config.backupDirectory, type.toString() + "/" + name);
|
||||
}
|
||||
}
|
@ -0,0 +1,133 @@
|
||||
package fr.pandacube.lib.paper.modules.backup;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.text.DateFormat;
|
||||
import java.util.Date;
|
||||
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.configuration.InvalidConfigurationException;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.yaml.snakeyaml.error.YAMLException;
|
||||
|
||||
import fr.pandacube.lib.paper.PandaLibPaper;
|
||||
import fr.pandacube.lib.util.Log;
|
||||
|
||||
public class Persist extends YamlConfiguration {
|
||||
protected final BackupManager backupManager;
|
||||
|
||||
private final File file;
|
||||
|
||||
// private final Set<String> dirtyWorldsSave = new HashSet<>();
|
||||
|
||||
public Persist(BackupManager bm) {
|
||||
file = new File(PandaLibPaper.getPlugin().getDataFolder(), "backup_persist.yml");
|
||||
backupManager = bm;
|
||||
load();
|
||||
}
|
||||
|
||||
public void reload() {
|
||||
load();
|
||||
}
|
||||
|
||||
protected void load() {
|
||||
boolean loaded = false;
|
||||
try {
|
||||
load(file);
|
||||
loaded = true;
|
||||
}
|
||||
catch (final FileNotFoundException ignored) { }
|
||||
catch (final IOException e) {
|
||||
Log.severe("cannot load " + file, e);
|
||||
}
|
||||
catch (final InvalidConfigurationException e) {
|
||||
if (e.getCause() instanceof YAMLException) Log.severe("Config file " + file + " isn't valid!", e);
|
||||
else if (e.getCause() == null || e.getCause() instanceof ClassCastException) Log.severe("Config file " + file + " isn't valid!");
|
||||
else Log.severe("cannot load " + file, e);
|
||||
}
|
||||
|
||||
if (!loaded) {
|
||||
options().copyDefaults(true);
|
||||
save();
|
||||
}
|
||||
}
|
||||
|
||||
public void save() {
|
||||
try {
|
||||
save(file);
|
||||
}
|
||||
catch (final IOException e) {
|
||||
Log.severe("could not save " + file, e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Make the specified world dirty for compress. Also makes the specified world clean for saving if nobody is connected there.
|
||||
*/
|
||||
public void updateDirtyStatusAfterSave(final World world) {
|
||||
if (world == null)
|
||||
return;
|
||||
if (!isDirty(Type.WORLDS, world.getName())) { // don't set dirty if it is already
|
||||
setDirtySinceNow(Type.WORLDS, world.getName());
|
||||
Log.info("[Backup] " + Type.WORLDS + "\\" + world.getName() + " was saved and is now dirty. Next backup on "
|
||||
+ DateFormat.getDateTimeInstance(DateFormat.LONG, DateFormat.LONG)
|
||||
.format(new Date(backupManager.getNextCompress(System.currentTimeMillis())))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the dirty status after the specified compress process is done.
|
||||
* @param t the type of process
|
||||
* @param n the name of the process (for instance, the name of the world)
|
||||
*/
|
||||
public void updateDirtyStatusAfterCompress(Type t, String n) {
|
||||
if (t == Type.WORLDS)
|
||||
setNotDirty(t, n);
|
||||
else
|
||||
setDirtySinceNow(t, n);
|
||||
}
|
||||
|
||||
|
||||
private void setDirtySinceNow(Type t, String n) {
|
||||
set(t + "." + n + ".dirty_since", System.currentTimeMillis());
|
||||
save();
|
||||
}
|
||||
|
||||
private void setNotDirty(Type t, String n) {
|
||||
if (t == Type.WORKDIR)
|
||||
return; // WORKDIR are always considered dirty
|
||||
set(t + "." + n + ".dirty_since", -1);
|
||||
save();
|
||||
}
|
||||
|
||||
|
||||
public boolean isDirty(Type t, String n) {
|
||||
if (t == Type.WORKDIR)
|
||||
return true;
|
||||
return isDirtySince(t, n) != -1;
|
||||
}
|
||||
|
||||
public long isDirtySince(Type t, String n) {
|
||||
if (!contains(t + "." + n + ".dirty_since"))
|
||||
setDirtySinceNow(t, n);
|
||||
return getLong(t + "." + n + ".dirty_since");
|
||||
}
|
||||
|
||||
/*
|
||||
*
|
||||
* type: // (worlds|others)
|
||||
* name: // (root|plugin|<worldName>)
|
||||
* dirty_since: (true|false)
|
||||
*
|
||||
*/
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
@ -0,0 +1,19 @@
|
||||
package fr.pandacube.lib.paper.modules.backup;
|
||||
|
||||
public enum Type {
|
||||
WORLDS,
|
||||
WORKDIR;
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return name().toLowerCase();
|
||||
}
|
||||
|
||||
public boolean backupEnabled(BackupConfig cfg) {
|
||||
return switch (this) {
|
||||
case WORLDS -> cfg.worldBackupEnabled;
|
||||
case WORKDIR -> cfg.workdirBackupEnabled;
|
||||
};
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,194 @@
|
||||
package fr.pandacube.lib.paper.modules.backup;
|
||||
|
||||
import java.io.BufferedOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.BiPredicate;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
import fr.pandacube.lib.chat.Chat;
|
||||
import fr.pandacube.lib.util.MemoryUtil;
|
||||
import fr.pandacube.lib.util.TimeUtil;
|
||||
|
||||
/**
|
||||
* Handles the creation of a zip file that will have the content of a provided folder.
|
||||
*/
|
||||
public class ZipCompressor {
|
||||
private static final int BUFFER_SIZE = 16 * 1024;
|
||||
|
||||
|
||||
|
||||
private final File srcDir, destFile;
|
||||
private final int compressionLevel;
|
||||
private final BiPredicate<File, String> filter;
|
||||
|
||||
private final List<Entry> entriesToCompress;
|
||||
private ZipOutputStream zipOutStream;
|
||||
|
||||
private final Object stateLock = new Object();
|
||||
private final long inputByteSize;
|
||||
private long startTime;
|
||||
private long elapsedByte = 0;
|
||||
private Exception exception = null;
|
||||
private boolean started = false;
|
||||
private boolean finished = false;
|
||||
|
||||
/**
|
||||
* Creates a new zip compressor.
|
||||
* @param s the source directory.
|
||||
* @param d the destination file.
|
||||
* @param c the compression level, used in {@link ZipOutputStream#setLevel(int)} .
|
||||
* @param f a filter that returns true for the files to include in the zip file, false to exclude.
|
||||
*/
|
||||
public ZipCompressor(File s, File d, int c, BiPredicate<File, String> f) {
|
||||
srcDir = s;
|
||||
destFile = d;
|
||||
compressionLevel = c;
|
||||
filter = f;
|
||||
|
||||
entriesToCompress = new ArrayList<>();
|
||||
inputByteSize = addEntry("");
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a displayable representation of the running compression.
|
||||
* @return a displayable representation of the running compression.
|
||||
*/
|
||||
public Chat getState() {
|
||||
synchronized (stateLock) {
|
||||
if (!started) {
|
||||
return Chat.text("Démarrage...");
|
||||
}
|
||||
else if (!finished && exception == null) {
|
||||
float progress = getProgress();
|
||||
long elapsedTime = System.nanoTime() - startTime;
|
||||
long remainingTime = (long)(elapsedTime / progress) - elapsedTime;
|
||||
return Chat.chat()
|
||||
.infoColor()
|
||||
.thenData(Math.round(progress*100*10)/10 + "% ")
|
||||
.thenText("(")
|
||||
.thenData(MemoryUtil.humanReadableSize(elapsedByte) + "/" + MemoryUtil.humanReadableSize(inputByteSize))
|
||||
.thenText(") - Temps restant estimé : ")
|
||||
.thenData(TimeUtil.durationToString(remainingTime / 1000000));
|
||||
}
|
||||
else if (exception != null) {
|
||||
return Chat.failureText("Erreur lors de l'archivage (voir console pour les détails)");
|
||||
}
|
||||
else { // finished
|
||||
return Chat.successText("Terminé !");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the progress of the running compression.
|
||||
* @return the progress of the running compression 0 when it starts and 1 when it finishes.
|
||||
*/
|
||||
public float getProgress() {
|
||||
if (!started)
|
||||
return 0;
|
||||
if (finished)
|
||||
return 1;
|
||||
return elapsedByte / (float) inputByteSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the compression on the current thread, and returns after the end of the compression.
|
||||
* Should be run asynchronously (not on Server Thread).
|
||||
* @throws Exception if an error occurs during compression.
|
||||
*/
|
||||
public void compress() throws Exception {
|
||||
destFile.getParentFile().mkdirs();
|
||||
|
||||
try(ZipOutputStream zipStream = new ZipOutputStream(new BufferedOutputStream(new FileOutputStream(destFile), BUFFER_SIZE))) {
|
||||
zipOutStream = zipStream;
|
||||
zipOutStream.setLevel(compressionLevel);
|
||||
|
||||
synchronized (stateLock) {
|
||||
startTime = System.nanoTime();
|
||||
started = true;
|
||||
}
|
||||
|
||||
for (Entry entry : entriesToCompress) {
|
||||
entry.zip();
|
||||
}
|
||||
|
||||
synchronized (stateLock) {
|
||||
finished = true;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
synchronized (stateLock) {
|
||||
exception = e;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private long addEntry(String currentEntry) {
|
||||
final File currentFile = new File(srcDir, currentEntry);
|
||||
if (!currentFile.exists())
|
||||
return 0;
|
||||
if (currentFile.isDirectory()) {
|
||||
if (!currentEntry.isEmpty()) { // it's not the zip root directory
|
||||
currentEntry += "/";
|
||||
entriesToCompress.add(new Entry(currentFile, currentEntry));
|
||||
}
|
||||
|
||||
long sum = 0;
|
||||
for (String child : currentFile.list()) {
|
||||
String childEntry = currentEntry + child;
|
||||
if (filter.test(new File(currentFile, child), childEntry))
|
||||
sum += addEntry(childEntry);
|
||||
}
|
||||
|
||||
return sum;
|
||||
}
|
||||
else { // is a file
|
||||
entriesToCompress.add(new Entry(currentFile, currentEntry));
|
||||
return currentFile.length();
|
||||
}
|
||||
}
|
||||
|
||||
private class Entry {
|
||||
File file;
|
||||
String entry;
|
||||
Entry(File f, String e) {
|
||||
file = f;
|
||||
entry = e;
|
||||
}
|
||||
|
||||
void zip() throws IOException {
|
||||
ZipEntry zipEntry = new ZipEntry(entry);
|
||||
BasicFileAttributes attributes = Files.readAttributes(file.toPath(), BasicFileAttributes.class);
|
||||
if (attributes.isDirectory()) {
|
||||
zipOutStream.putNextEntry(zipEntry);
|
||||
zipOutStream.closeEntry();
|
||||
}
|
||||
else {
|
||||
|
||||
zipEntry.setTime(attributes.lastModifiedTime().toMillis());
|
||||
zipOutStream.putNextEntry(zipEntry);
|
||||
|
||||
try {
|
||||
Files.copy(file.toPath(), zipOutStream);
|
||||
}
|
||||
finally {
|
||||
zipOutStream.closeEntry();
|
||||
}
|
||||
|
||||
synchronized (stateLock) {
|
||||
elapsedByte += attributes.size();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user