mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-08-27 04:37:47 +00:00
oops
This commit is contained in:
+14
-21
@@ -6,8 +6,12 @@ import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration;
|
|||||||
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.GeneratorReplacement;
|
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.GeneratorReplacement;
|
||||||
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.WorldGeneratorSnapshot;
|
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.WorldGeneratorSnapshot;
|
||||||
import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
|
import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
|
||||||
|
import art.arcane.iris.core.lifecycle.WorldReplacementBootstrap;
|
||||||
import art.arcane.iris.core.lifecycle.WorldReplacementFilesystem;
|
import art.arcane.iris.core.lifecycle.WorldReplacementFilesystem;
|
||||||
import art.arcane.iris.core.lifecycle.WorldReplacementFilesystem.ReplacementPaths;
|
import art.arcane.iris.core.lifecycle.WorldReplacementFilesystem.ReplacementPaths;
|
||||||
|
import art.arcane.iris.core.lifecycle.WorldReplacementJournal;
|
||||||
|
import art.arcane.iris.core.lifecycle.WorldReplacementJournal.Phase;
|
||||||
|
import art.arcane.iris.core.lifecycle.WorldReplacementJournal.Transaction;
|
||||||
import art.arcane.iris.core.pack.PackValidationRegistry;
|
import art.arcane.iris.core.pack.PackValidationRegistry;
|
||||||
import art.arcane.iris.core.service.StudioSVC;
|
import art.arcane.iris.core.service.StudioSVC;
|
||||||
import art.arcane.iris.core.tools.IrisToolbelt;
|
import art.arcane.iris.core.tools.IrisToolbelt;
|
||||||
@@ -25,25 +29,17 @@ import org.bukkit.event.EventPriority;
|
|||||||
import org.bukkit.event.Listener;
|
import org.bukkit.event.Listener;
|
||||||
import org.bukkit.event.world.WorldLoadEvent;
|
import org.bukkit.event.world.WorldLoadEvent;
|
||||||
|
|
||||||
import java.io.ByteArrayOutputStream;
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
|
||||||
import java.nio.ByteBuffer;
|
|
||||||
import java.nio.channels.FileChannel;
|
|
||||||
import java.nio.file.AtomicMoveNotSupportedException;
|
|
||||||
import java.nio.file.DirectoryStream;
|
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.LinkOption;
|
import java.nio.file.LinkOption;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.StandardCopyOption;
|
|
||||||
import java.nio.file.StandardOpenOption;
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Comparator;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Properties;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.concurrent.ExecutionException;
|
import java.util.concurrent.ExecutionException;
|
||||||
@@ -51,10 +47,8 @@ import java.util.concurrent.TimeUnit;
|
|||||||
import java.util.concurrent.TimeoutException;
|
import java.util.concurrent.TimeoutException;
|
||||||
|
|
||||||
public final class PendingWorldReplacementManager implements Listener {
|
public final class PendingWorldReplacementManager implements Listener {
|
||||||
private static final String JOURNAL_DIRECTORY = "pending-world-replacements";
|
|
||||||
private static final String JOURNAL_SUFFIX = ".properties";
|
|
||||||
|
|
||||||
private final Iris plugin;
|
private final Iris plugin;
|
||||||
|
private final Set<UUID> cleanupInFlight = new HashSet<>();
|
||||||
|
|
||||||
public PendingWorldReplacementManager(Iris plugin) {
|
public PendingWorldReplacementManager(Iris plugin) {
|
||||||
this.plugin = Objects.requireNonNull(plugin, "plugin");
|
this.plugin = Objects.requireNonNull(plugin, "plugin");
|
||||||
@@ -89,13 +83,6 @@ public final class PendingWorldReplacementManager implements Listener {
|
|||||||
IrisDimension requiredDimension = Objects.requireNonNull(dimension, "dimension");
|
IrisDimension requiredDimension = Objects.requireNonNull(dimension, "dimension");
|
||||||
IrisStartupValidation.requireWorldCreationReady();
|
IrisStartupValidation.requireWorldCreationReady();
|
||||||
PackValidationRegistry.requireLoadable(requiredDimension.getLoader().getDataFolder().getName());
|
PackValidationRegistry.requireLoadable(requiredDimension.getLoader().getDataFolder().getName());
|
||||||
ExactWorldSlotPathPolicy.Target resolvedTarget = ExactWorldSlotPathPolicy.resolve(
|
|
||||||
IrisWorldStorage.levelRoot().toPath(),
|
|
||||||
requiredWorldKey
|
|
||||||
);
|
|
||||||
requireCompatibleEnvironment(resolvedTarget.slotKind(), requiredDimension.getEnvironment());
|
|
||||||
long effectiveSeed = resolveEffectiveSeed(resolvedTarget.slotKind(), seed);
|
|
||||||
String worldName = IrisWorldStorage.logicalName(requiredWorldKey);
|
|
||||||
LifecycleOperationCoordinator coordinator = LifecycleOperationCoordinator.get();
|
LifecycleOperationCoordinator coordinator = LifecycleOperationCoordinator.get();
|
||||||
try (LifecycleOperationCoordinator.Lease ignored = coordinator.acquire(
|
try (LifecycleOperationCoordinator.Lease ignored = coordinator.acquire(
|
||||||
LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
|
LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
|
||||||
@@ -106,13 +93,16 @@ public final class PendingWorldReplacementManager implements Listener {
|
|||||||
throw new IOException("A replacement is already pending for " + requiredWorldKey + ".");
|
throw new IOException("A replacement is already pending for " + requiredWorldKey + ".");
|
||||||
}
|
}
|
||||||
ExactWorldSlotPathPolicy.Target target = prepareTarget(requiredWorldKey);
|
ExactWorldSlotPathPolicy.Target target = prepareTarget(requiredWorldKey);
|
||||||
|
requireCompatibleEnvironment(target.slotKind(), requiredDimension.getEnvironment());
|
||||||
|
long effectiveSeed = resolveEffectiveSeed(target.slotKind(), seed);
|
||||||
|
String worldName = WorldReplacementJournal.logicalWorldName(target.levelRoot(), requiredWorldKey);
|
||||||
DatapackInstallResult datapacks = ServerConfigurator.installDataPacksIfChanged(true);
|
DatapackInstallResult datapacks = ServerConfigurator.installDataPacksIfChanged(true);
|
||||||
if (!datapacks.succeeded()) {
|
if (!datapacks.succeeded()) {
|
||||||
throw new IOException("Iris could not compile the dimension datapacks.");
|
throw new IOException("Iris could not compile the dimension datapacks.");
|
||||||
}
|
}
|
||||||
|
|
||||||
UUID transactionId = UUID.randomUUID();
|
UUID transactionId = UUID.randomUUID();
|
||||||
ReplacementPaths paths = replacementPaths(target, transactionId);
|
ReplacementPaths paths = WorldReplacementFilesystem.paths(target, transactionId);
|
||||||
boolean targetPresent = Files.exists(paths.target(), LinkOption.NOFOLLOW_LINKS);
|
boolean targetPresent = Files.exists(paths.target(), LinkOption.NOFOLLOW_LINKS);
|
||||||
WorldGeneratorSnapshot originalConfiguration = BukkitWorldConfiguration.snapshot(
|
WorldGeneratorSnapshot originalConfiguration = BukkitWorldConfiguration.snapshot(
|
||||||
ServerProperties.BUKKIT_YML,
|
ServerProperties.BUKKIT_YML,
|
||||||
@@ -131,12 +121,15 @@ public final class PendingWorldReplacementManager implements Listener {
|
|||||||
if (installed == null) {
|
if (installed == null) {
|
||||||
throw new IOException("Iris could not stage the dimension pack.");
|
throw new IOException("Iris could not stage the dimension pack.");
|
||||||
}
|
}
|
||||||
|
requireCompatibleEnvironment(target.slotKind(), installed.getEnvironment());
|
||||||
File stagedPack = paths.stage().resolve("iris/pack").toFile();
|
File stagedPack = paths.stage().resolve("iris/pack").toFile();
|
||||||
IrisWorldGeneratorResolver.requireSnapshotLoadable(stagedPack);
|
IrisWorldGeneratorResolver.requireSnapshotLoadable(stagedPack);
|
||||||
String packFingerprint = WorldReplacementFilesystem.fingerprintPack(stagedPack.toPath());
|
String packFingerprint = WorldReplacementFilesystem.fingerprintPack(stagedPack.toPath());
|
||||||
transaction = new Transaction(
|
transaction = new Transaction(
|
||||||
transactionId,
|
transactionId,
|
||||||
requiredWorldKey,
|
requiredWorldKey,
|
||||||
|
worldName,
|
||||||
|
target.levelRoot(),
|
||||||
installed.getLoadKey(),
|
installed.getLoadKey(),
|
||||||
effectiveSeed,
|
effectiveSeed,
|
||||||
packFingerprint,
|
packFingerprint,
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
package art.arcane.iris.core.lifecycle;
|
||||||
|
|
||||||
|
import art.arcane.iris.core.ExactWorldSlotPathPolicy;
|
||||||
|
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.WorldGeneratorSnapshot;
|
||||||
|
import art.arcane.iris.core.lifecycle.WorldReplacementFilesystem.ReplacementPaths;
|
||||||
|
import art.arcane.iris.core.lifecycle.WorldReplacementJournal.Phase;
|
||||||
|
import art.arcane.iris.core.lifecycle.WorldReplacementJournal.Transaction;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
public final class WorldReplacementBootstrap {
|
||||||
|
private WorldReplacementBootstrap() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ReconcileResult reconcile(
|
||||||
|
Path dataDirectory,
|
||||||
|
Path levelRoot,
|
||||||
|
Path bukkitConfiguration,
|
||||||
|
Consumer<String> feedback
|
||||||
|
) throws IOException {
|
||||||
|
Path requiredDataDirectory = Objects.requireNonNull(dataDirectory, "dataDirectory")
|
||||||
|
.toAbsolutePath()
|
||||||
|
.normalize();
|
||||||
|
Path requiredLevelRoot = Objects.requireNonNull(levelRoot, "levelRoot")
|
||||||
|
.toAbsolutePath()
|
||||||
|
.normalize();
|
||||||
|
File requiredBukkitConfiguration = Objects.requireNonNull(bukkitConfiguration, "bukkitConfiguration")
|
||||||
|
.toAbsolutePath()
|
||||||
|
.normalize()
|
||||||
|
.toFile();
|
||||||
|
Consumer<String> requiredFeedback = Objects.requireNonNull(feedback, "feedback");
|
||||||
|
List<Transaction> transactions = WorldReplacementJournal.load(requiredDataDirectory, requiredLevelRoot);
|
||||||
|
int published = 0;
|
||||||
|
int rolledBack = 0;
|
||||||
|
int retained = 0;
|
||||||
|
for (Transaction transaction : transactions) {
|
||||||
|
ReconcileAction action = reconcileTransaction(
|
||||||
|
requiredDataDirectory,
|
||||||
|
requiredLevelRoot,
|
||||||
|
requiredBukkitConfiguration,
|
||||||
|
transaction,
|
||||||
|
requiredFeedback
|
||||||
|
);
|
||||||
|
switch (action) {
|
||||||
|
case PUBLISHED -> published++;
|
||||||
|
case ROLLED_BACK -> rolledBack++;
|
||||||
|
case RETAINED -> retained++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new ReconcileResult(transactions.size(), published, rolledBack, retained);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static WorldGeneratorSnapshot replacementSnapshot(Transaction transaction) {
|
||||||
|
Transaction requiredTransaction = Objects.requireNonNull(transaction, "transaction");
|
||||||
|
return new WorldGeneratorSnapshot(
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
"Iris:" + requiredTransaction.dimension(),
|
||||||
|
true,
|
||||||
|
requiredTransaction.seed()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ReconcileAction reconcileTransaction(
|
||||||
|
Path dataDirectory,
|
||||||
|
Path levelRoot,
|
||||||
|
File bukkitConfiguration,
|
||||||
|
Transaction transaction,
|
||||||
|
Consumer<String> feedback
|
||||||
|
) throws IOException {
|
||||||
|
ExactWorldSlotPathPolicy.Target target = WorldReplacementJournal.resolveTarget(transaction, levelRoot);
|
||||||
|
ReplacementPaths paths = WorldReplacementFilesystem.paths(target, transaction.id());
|
||||||
|
WorldGeneratorSnapshot current = BukkitWorldConfiguration.snapshot(
|
||||||
|
bukkitConfiguration,
|
||||||
|
transaction.worldName()
|
||||||
|
);
|
||||||
|
WorldGeneratorSnapshot replacement = replacementSnapshot(transaction);
|
||||||
|
if (transaction.phase() == Phase.CLEANUP_PENDING) {
|
||||||
|
if (!current.matchesGeneratorAndSeed(replacement)) {
|
||||||
|
throw conflict(transaction, "A verified replacement no longer matches bukkit.yml.");
|
||||||
|
}
|
||||||
|
WorldReplacementFilesystem.validateCommittedTarget(paths, transaction.packFingerprint());
|
||||||
|
return ReconcileAction.RETAINED;
|
||||||
|
}
|
||||||
|
if (transaction.phase() == Phase.ROLLBACK_CLEANUP) {
|
||||||
|
if (!current.matchesGeneratorAndSeed(transaction.originalConfiguration())) {
|
||||||
|
throw conflict(transaction, "Rollback cleanup no longer matches the retained bukkit.yml state.");
|
||||||
|
}
|
||||||
|
WorldReplacementFilesystem.finishPreparedRollback(paths, transaction.originalTargetPresent());
|
||||||
|
WorldReplacementJournal.delete(dataDirectory, transaction.id());
|
||||||
|
feedback.accept("Restored the retained world for " + transaction.worldKey() + ".");
|
||||||
|
return ReconcileAction.ROLLED_BACK;
|
||||||
|
}
|
||||||
|
if (transaction.phase() == Phase.ROLLBACK_PENDING) {
|
||||||
|
rollback(dataDirectory, bukkitConfiguration, transaction, paths, current, replacement);
|
||||||
|
feedback.accept("Restored the retained world for " + transaction.worldKey() + ".");
|
||||||
|
return ReconcileAction.ROLLED_BACK;
|
||||||
|
}
|
||||||
|
if (current.matchesGeneratorAndSeed(transaction.originalConfiguration())) {
|
||||||
|
rollback(dataDirectory, bukkitConfiguration, transaction, paths, current, replacement);
|
||||||
|
feedback.accept("Cancelled or rolled back Iris world replacement for " + transaction.worldKey() + ".");
|
||||||
|
return ReconcileAction.ROLLED_BACK;
|
||||||
|
}
|
||||||
|
if (!current.matchesGeneratorAndSeed(replacement)) {
|
||||||
|
throw conflict(transaction, "bukkit.yml matches neither the replacement nor its retained original state.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Transaction active = transaction;
|
||||||
|
if (active.phase() == Phase.PREPARED) {
|
||||||
|
active = active.withPhase(Phase.ARMED);
|
||||||
|
WorldReplacementJournal.write(dataDirectory, active);
|
||||||
|
}
|
||||||
|
if (active.phase() == Phase.ARMED) {
|
||||||
|
WorldReplacementFilesystem.publish(
|
||||||
|
paths,
|
||||||
|
active.originalTargetPresent(),
|
||||||
|
active.packFingerprint()
|
||||||
|
);
|
||||||
|
active = active.withPhase(Phase.PUBLISHED);
|
||||||
|
WorldReplacementJournal.write(dataDirectory, active);
|
||||||
|
feedback.accept("Published Iris world replacement for " + active.worldKey()
|
||||||
|
+ "; waiting for runtime verification.");
|
||||||
|
return ReconcileAction.PUBLISHED;
|
||||||
|
}
|
||||||
|
if (active.phase() == Phase.PUBLISHED) {
|
||||||
|
WorldReplacementFilesystem.publish(
|
||||||
|
paths,
|
||||||
|
active.originalTargetPresent(),
|
||||||
|
active.packFingerprint()
|
||||||
|
);
|
||||||
|
return ReconcileAction.RETAINED;
|
||||||
|
}
|
||||||
|
throw conflict(active, "Replacement journal reached an unsupported bootstrap phase.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void rollback(
|
||||||
|
Path dataDirectory,
|
||||||
|
File bukkitConfiguration,
|
||||||
|
Transaction transaction,
|
||||||
|
ReplacementPaths paths,
|
||||||
|
WorldGeneratorSnapshot current,
|
||||||
|
WorldGeneratorSnapshot replacement
|
||||||
|
) throws IOException {
|
||||||
|
if (!current.matchesGeneratorAndSeed(replacement)
|
||||||
|
&& !current.matchesGeneratorAndSeed(transaction.originalConfiguration())) {
|
||||||
|
throw conflict(transaction, "bukkit.yml conflicts with the pending world rollback.");
|
||||||
|
}
|
||||||
|
Transaction rollback = transaction.phase() == Phase.ROLLBACK_PENDING
|
||||||
|
? transaction
|
||||||
|
: transaction.withPhase(Phase.ROLLBACK_PENDING);
|
||||||
|
if (rollback != transaction) {
|
||||||
|
WorldReplacementJournal.write(dataDirectory, rollback);
|
||||||
|
}
|
||||||
|
WorldReplacementFilesystem.prepareRollback(paths, rollback.originalTargetPresent());
|
||||||
|
if (current.matchesGeneratorAndSeed(replacement)) {
|
||||||
|
boolean restored;
|
||||||
|
try {
|
||||||
|
restored = BukkitWorldConfiguration.restoreIfMatching(
|
||||||
|
bukkitConfiguration,
|
||||||
|
rollback.worldName(),
|
||||||
|
replacement,
|
||||||
|
rollback.originalConfiguration()
|
||||||
|
);
|
||||||
|
} catch (IOException failure) {
|
||||||
|
republishAfterConfigurationFailure(rollback, paths, failure);
|
||||||
|
throw failure;
|
||||||
|
}
|
||||||
|
if (!restored) {
|
||||||
|
WorldGeneratorSnapshot observed = BukkitWorldConfiguration.snapshot(
|
||||||
|
bukkitConfiguration,
|
||||||
|
rollback.worldName()
|
||||||
|
);
|
||||||
|
if (!observed.matchesGeneratorAndSeed(rollback.originalConfiguration())) {
|
||||||
|
IOException failure = conflict(
|
||||||
|
rollback,
|
||||||
|
"bukkit.yml changed during startup rollback."
|
||||||
|
);
|
||||||
|
republishAfterConfigurationFailure(rollback, paths, failure);
|
||||||
|
throw failure;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Transaction cleanup = rollback.withPhase(Phase.ROLLBACK_CLEANUP);
|
||||||
|
WorldReplacementJournal.write(dataDirectory, cleanup);
|
||||||
|
WorldReplacementFilesystem.finishPreparedRollback(paths, cleanup.originalTargetPresent());
|
||||||
|
WorldReplacementJournal.delete(dataDirectory, cleanup.id());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void republishAfterConfigurationFailure(
|
||||||
|
Transaction transaction,
|
||||||
|
ReplacementPaths paths,
|
||||||
|
IOException failure
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
WorldReplacementFilesystem.publish(
|
||||||
|
paths,
|
||||||
|
transaction.originalTargetPresent(),
|
||||||
|
transaction.packFingerprint()
|
||||||
|
);
|
||||||
|
} catch (Throwable republishFailure) {
|
||||||
|
failure.addSuppressed(republishFailure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IOException conflict(Transaction transaction, String detail) {
|
||||||
|
return new IOException("Pending replacement for " + transaction.worldKey() + " is blocked: " + detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ReconcileResult(int transactions, int published, int rolledBack, int retained) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum ReconcileAction {
|
||||||
|
PUBLISHED,
|
||||||
|
ROLLED_BACK,
|
||||||
|
RETAINED
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package art.arcane.iris.core.lifecycle;
|
package art.arcane.iris.core.lifecycle;
|
||||||
|
|
||||||
import art.arcane.iris.core.SnapshotDirectoryTreeDeleter;
|
import art.arcane.iris.core.SnapshotDirectoryTreeDeleter;
|
||||||
|
import art.arcane.iris.core.ExactWorldSlotPathPolicy;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
@@ -20,6 +21,7 @@ import java.util.Comparator;
|
|||||||
import java.util.HexFormat;
|
import java.util.HexFormat;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
import java.util.UUID;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
@@ -32,6 +34,17 @@ public final class WorldReplacementFilesystem {
|
|||||||
private WorldReplacementFilesystem() {
|
private WorldReplacementFilesystem() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static ReplacementPaths paths(ExactWorldSlotPathPolicy.Target target, UUID id) {
|
||||||
|
ExactWorldSlotPathPolicy.Target requiredTarget = Objects.requireNonNull(target, "target");
|
||||||
|
UUID requiredId = Objects.requireNonNull(id, "id");
|
||||||
|
String artifactBase = ".iris-replace-" + requiredTarget.worldKey().getKey() + "-" + requiredId;
|
||||||
|
return new ReplacementPaths(
|
||||||
|
requiredTarget.worldDirectory(),
|
||||||
|
requiredTarget.namespaceRoot().resolve(artifactBase + ".stage"),
|
||||||
|
requiredTarget.namespaceRoot().resolve(artifactBase + ".backup")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public static void publish(
|
public static void publish(
|
||||||
ReplacementPaths paths,
|
ReplacementPaths paths,
|
||||||
boolean originalTargetPresent,
|
boolean originalTargetPresent,
|
||||||
@@ -94,7 +107,7 @@ public final class WorldReplacementFilesystem {
|
|||||||
move(requiredPaths.backup(), requiredPaths.target());
|
move(requiredPaths.backup(), requiredPaths.target());
|
||||||
state = inspect(requiredPaths);
|
state = inspect(requiredPaths);
|
||||||
}
|
}
|
||||||
if (!state.targetPresent() || state.backupPresent()) {
|
if (!state.targetPresent() || !state.stagePresent() || state.backupPresent()) {
|
||||||
throw new IOException("Rollback could not restore the original world target.");
|
throw new IOException("Rollback could not restore the original world target.");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -111,9 +124,25 @@ public final class WorldReplacementFilesystem {
|
|||||||
if (state.targetPresent()) {
|
if (state.targetPresent()) {
|
||||||
throw new IOException("Rollback could not remove the replacement target.");
|
throw new IOException("Rollback could not remove the replacement target.");
|
||||||
}
|
}
|
||||||
|
if (!state.stagePresent()) {
|
||||||
|
throw new IOException("Rollback lost the quarantined replacement target.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static void finishPreparedRollback(ReplacementPaths paths, boolean originalTargetPresent)
|
||||||
|
throws IOException {
|
||||||
|
ReplacementPaths requiredPaths = Objects.requireNonNull(paths, "paths");
|
||||||
|
State state = inspect(requiredPaths);
|
||||||
|
if (state.backupPresent()) {
|
||||||
|
throw new IOException("Rollback cleanup cannot continue while a retained backup is still published.");
|
||||||
|
}
|
||||||
|
if (originalTargetPresent != state.targetPresent()) {
|
||||||
|
throw new IOException("Rollback cleanup target state does not match the retained world state.");
|
||||||
|
}
|
||||||
|
discardStage(requiredPaths);
|
||||||
|
}
|
||||||
|
|
||||||
public static void discardStage(ReplacementPaths paths) throws IOException {
|
public static void discardStage(ReplacementPaths paths) throws IOException {
|
||||||
ReplacementPaths requiredPaths = Objects.requireNonNull(paths, "paths");
|
ReplacementPaths requiredPaths = Objects.requireNonNull(paths, "paths");
|
||||||
State state = inspect(requiredPaths);
|
State state = inspect(requiredPaths);
|
||||||
@@ -136,6 +165,18 @@ public final class WorldReplacementFilesystem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static void validateCommittedTarget(ReplacementPaths paths, String expectedPackFingerprint)
|
||||||
|
throws IOException {
|
||||||
|
ReplacementPaths requiredPaths = Objects.requireNonNull(paths, "paths");
|
||||||
|
String expectedFingerprint = requireFingerprint(expectedPackFingerprint);
|
||||||
|
State state = inspect(requiredPaths);
|
||||||
|
if (!state.targetPresent() || state.stagePresent()) {
|
||||||
|
throw new IOException("Committed replacement storage does not contain one exact target directory.");
|
||||||
|
}
|
||||||
|
requireSafeTree(requiredPaths.target(), "replacement target");
|
||||||
|
requireFingerprint(requiredPaths.target().resolve("iris/pack"), expectedFingerprint);
|
||||||
|
}
|
||||||
|
|
||||||
public static String fingerprintPack(Path packRoot) throws IOException {
|
public static String fingerprintPack(Path packRoot) throws IOException {
|
||||||
Path root = Objects.requireNonNull(packRoot, "packRoot").toAbsolutePath().normalize();
|
Path root = Objects.requireNonNull(packRoot, "packRoot").toAbsolutePath().normalize();
|
||||||
requireDirectory(root, "pack root");
|
requireDirectory(root, "pack root");
|
||||||
|
|||||||
@@ -0,0 +1,367 @@
|
|||||||
|
package art.arcane.iris.core.lifecycle;
|
||||||
|
|
||||||
|
import art.arcane.iris.core.ExactWorldSlotPathPolicy;
|
||||||
|
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.WorldGeneratorSnapshot;
|
||||||
|
import org.bukkit.NamespacedKey;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.channels.FileChannel;
|
||||||
|
import java.nio.file.AtomicMoveNotSupportedException;
|
||||||
|
import java.nio.file.DirectoryStream;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.LinkOption;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardCopyOption;
|
||||||
|
import java.nio.file.StandardOpenOption;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Properties;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public final class WorldReplacementJournal {
|
||||||
|
public static final String DIRECTORY_NAME = "pending-world-replacements";
|
||||||
|
|
||||||
|
private static final String JOURNAL_SUFFIX = ".properties";
|
||||||
|
|
||||||
|
private WorldReplacementJournal() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<Transaction> load(Path dataDirectory, Path currentLevelRoot) throws IOException {
|
||||||
|
Path directory = directory(dataDirectory, false);
|
||||||
|
if (directory == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
ArrayList<Transaction> transactions = new ArrayList<>();
|
||||||
|
try (DirectoryStream<Path> files = Files.newDirectoryStream(directory, "*" + JOURNAL_SUFFIX)) {
|
||||||
|
for (Path file : files) {
|
||||||
|
if (Files.isSymbolicLink(file) || !Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) {
|
||||||
|
throw new IOException("Replacement journal entry is unsafe: " + file);
|
||||||
|
}
|
||||||
|
transactions.add(read(file, currentLevelRoot));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transactions.sort(Comparator.comparing(transaction -> transaction.id().toString()));
|
||||||
|
return List.copyOf(transactions);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void write(Path dataDirectory, Transaction transaction) throws IOException {
|
||||||
|
Transaction requiredTransaction = Objects.requireNonNull(transaction, "transaction");
|
||||||
|
Path directory = Objects.requireNonNull(directory(dataDirectory, true));
|
||||||
|
Path target = directory.resolve(requiredTransaction.id() + JOURNAL_SUFFIX);
|
||||||
|
Properties properties = new Properties();
|
||||||
|
properties.setProperty("id", requiredTransaction.id().toString());
|
||||||
|
properties.setProperty("worldKey", requiredTransaction.worldKey().toString());
|
||||||
|
properties.setProperty("worldName", requiredTransaction.worldName());
|
||||||
|
properties.setProperty("levelRoot", requiredTransaction.levelRoot().toString());
|
||||||
|
properties.setProperty("dimension", requiredTransaction.dimension());
|
||||||
|
properties.setProperty("seed", Long.toString(requiredTransaction.seed()));
|
||||||
|
properties.setProperty("packFingerprint", requiredTransaction.packFingerprint());
|
||||||
|
properties.setProperty(
|
||||||
|
"originalTargetPresent",
|
||||||
|
Boolean.toString(requiredTransaction.originalTargetPresent())
|
||||||
|
);
|
||||||
|
properties.setProperty("phase", requiredTransaction.phase().name());
|
||||||
|
writeSnapshot(properties, "original.", requiredTransaction.originalConfiguration());
|
||||||
|
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||||
|
properties.store(output, null);
|
||||||
|
writeAtomic(target, output.toByteArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void delete(Path dataDirectory, UUID id) throws IOException {
|
||||||
|
Path directory = directory(dataDirectory, false);
|
||||||
|
if (directory == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Files.deleteIfExists(directory.resolve(Objects.requireNonNull(id, "id") + JOURNAL_SUFFIX));
|
||||||
|
forceDirectory(directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ExactWorldSlotPathPolicy.Target resolveTarget(Transaction transaction, Path currentLevelRoot)
|
||||||
|
throws IOException {
|
||||||
|
Transaction requiredTransaction = Objects.requireNonNull(transaction, "transaction");
|
||||||
|
ExactWorldSlotPathPolicy.Target target = ExactWorldSlotPathPolicy.resolve(
|
||||||
|
currentLevelRoot,
|
||||||
|
requiredTransaction.worldKey()
|
||||||
|
);
|
||||||
|
if (!target.levelRoot().equals(requiredTransaction.levelRoot())) {
|
||||||
|
throw new IOException("The configured level root changed after the world replacement was staged.");
|
||||||
|
}
|
||||||
|
String expectedWorldName = logicalWorldName(target.levelRoot(), requiredTransaction.worldKey());
|
||||||
|
if (!expectedWorldName.equals(requiredTransaction.worldName())) {
|
||||||
|
throw new IOException("The logical world name changed after the world replacement was staged.");
|
||||||
|
}
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String logicalWorldName(Path levelRoot, NamespacedKey worldKey) {
|
||||||
|
Path requiredLevelRoot = Objects.requireNonNull(levelRoot, "levelRoot").toAbsolutePath().normalize();
|
||||||
|
NamespacedKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey");
|
||||||
|
if ("iris".equals(requiredWorldKey.getNamespace())) {
|
||||||
|
return requiredWorldKey.getKey();
|
||||||
|
}
|
||||||
|
Path fileName = requiredLevelRoot.getFileName();
|
||||||
|
if (fileName == null || fileName.toString().isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Level root must have a logical world name.");
|
||||||
|
}
|
||||||
|
String levelName = fileName.toString();
|
||||||
|
if (NamespacedKey.minecraft("overworld").equals(requiredWorldKey)) {
|
||||||
|
return levelName;
|
||||||
|
}
|
||||||
|
if (NamespacedKey.minecraft("the_nether").equals(requiredWorldKey)) {
|
||||||
|
return levelName + "_nether";
|
||||||
|
}
|
||||||
|
if (NamespacedKey.minecraft("the_end").equals(requiredWorldKey)) {
|
||||||
|
return levelName + "_the_end";
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException("World key is not an exact replaceable world slot: " + requiredWorldKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Transaction read(Path file, Path currentLevelRoot) throws IOException {
|
||||||
|
Properties properties = new Properties();
|
||||||
|
try (InputStream input = Files.newInputStream(file)) {
|
||||||
|
properties.load(input);
|
||||||
|
}
|
||||||
|
UUID id;
|
||||||
|
try {
|
||||||
|
id = UUID.fromString(required(properties, "id"));
|
||||||
|
} catch (IllegalArgumentException failure) {
|
||||||
|
throw new IOException("Replacement journal contains an invalid transaction id.", failure);
|
||||||
|
}
|
||||||
|
if (!file.getFileName().toString().equals(id + JOURNAL_SUFFIX)) {
|
||||||
|
throw new IOException("Replacement journal filename does not match its transaction id.");
|
||||||
|
}
|
||||||
|
NamespacedKey worldKey = NamespacedKey.fromString(required(properties, "worldKey"));
|
||||||
|
if (worldKey == null) {
|
||||||
|
throw new IOException("Replacement journal contains an invalid world key.");
|
||||||
|
}
|
||||||
|
String worldName = exact(properties, "worldName");
|
||||||
|
Path recordedLevelRoot = recordedLevelRoot(properties);
|
||||||
|
String dimension = required(properties, "dimension");
|
||||||
|
if (!safeDimension(dimension)) {
|
||||||
|
throw new IOException("Replacement journal contains an invalid dimension key.");
|
||||||
|
}
|
||||||
|
long seed = parseLong(properties, "seed");
|
||||||
|
String packFingerprint = required(properties, "packFingerprint");
|
||||||
|
if (!packFingerprint.matches("[0-9a-f]{64}")) {
|
||||||
|
throw new IOException("Replacement journal contains an invalid pack fingerprint.");
|
||||||
|
}
|
||||||
|
WorldGeneratorSnapshot original = readSnapshot(properties, "original.");
|
||||||
|
boolean originalTargetPresent = parseBoolean(properties, "originalTargetPresent");
|
||||||
|
Phase phase;
|
||||||
|
try {
|
||||||
|
phase = Phase.valueOf(required(properties, "phase"));
|
||||||
|
} catch (IllegalArgumentException failure) {
|
||||||
|
throw new IOException("Replacement journal contains an invalid phase.", failure);
|
||||||
|
}
|
||||||
|
Transaction transaction = new Transaction(
|
||||||
|
id,
|
||||||
|
worldKey,
|
||||||
|
worldName,
|
||||||
|
recordedLevelRoot,
|
||||||
|
dimension,
|
||||||
|
seed,
|
||||||
|
packFingerprint,
|
||||||
|
original,
|
||||||
|
originalTargetPresent,
|
||||||
|
phase
|
||||||
|
);
|
||||||
|
resolveTarget(transaction, currentLevelRoot);
|
||||||
|
return transaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Path recordedLevelRoot(Properties properties) throws IOException {
|
||||||
|
String value = exact(properties, "levelRoot");
|
||||||
|
Path parsed;
|
||||||
|
try {
|
||||||
|
parsed = Path.of(value);
|
||||||
|
} catch (RuntimeException failure) {
|
||||||
|
throw new IOException("Replacement journal contains an invalid level root.", failure);
|
||||||
|
}
|
||||||
|
if (!parsed.isAbsolute() || !parsed.normalize().equals(parsed)) {
|
||||||
|
throw new IOException("Replacement journal level root must be an absolute normalized path.");
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Path directory(Path dataDirectory, boolean create) throws IOException {
|
||||||
|
Path dataRoot = Objects.requireNonNull(dataDirectory, "dataDirectory").toAbsolutePath().normalize();
|
||||||
|
Path directory = dataRoot.resolve(DIRECTORY_NAME);
|
||||||
|
if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) {
|
||||||
|
if (!create) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Files.createDirectories(directory);
|
||||||
|
}
|
||||||
|
if (Files.isSymbolicLink(directory) || !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
|
||||||
|
throw new IOException("Replacement journal storage is unsafe: " + directory);
|
||||||
|
}
|
||||||
|
return directory;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writeSnapshot(Properties properties, String prefix, WorldGeneratorSnapshot snapshot) {
|
||||||
|
properties.setProperty(prefix + "worldsSectionPresent", Boolean.toString(snapshot.worldsSectionPresent()));
|
||||||
|
properties.setProperty(prefix + "worldSectionPresent", Boolean.toString(snapshot.worldSectionPresent()));
|
||||||
|
properties.setProperty(prefix + "generatorPresent", Boolean.toString(snapshot.generatorPresent()));
|
||||||
|
if (snapshot.generatorPresent()) {
|
||||||
|
properties.setProperty(prefix + "generator", snapshot.generator());
|
||||||
|
}
|
||||||
|
properties.setProperty(prefix + "seedPresent", Boolean.toString(snapshot.seedPresent()));
|
||||||
|
if (snapshot.seedPresent()) {
|
||||||
|
properties.setProperty(prefix + "seed", Long.toString(snapshot.seed()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorldGeneratorSnapshot readSnapshot(Properties properties, String prefix) throws IOException {
|
||||||
|
boolean worldsPresent = parseBoolean(properties, prefix + "worldsSectionPresent");
|
||||||
|
boolean worldPresent = parseBoolean(properties, prefix + "worldSectionPresent");
|
||||||
|
boolean generatorPresent = parseBoolean(properties, prefix + "generatorPresent");
|
||||||
|
String generator = generatorPresent ? exact(properties, prefix + "generator") : null;
|
||||||
|
boolean seedPresent = parseBoolean(properties, prefix + "seedPresent");
|
||||||
|
Long seed = seedPresent ? parseLong(properties, prefix + "seed") : null;
|
||||||
|
try {
|
||||||
|
return new WorldGeneratorSnapshot(
|
||||||
|
worldsPresent,
|
||||||
|
worldPresent,
|
||||||
|
generatorPresent,
|
||||||
|
generator,
|
||||||
|
seedPresent,
|
||||||
|
seed
|
||||||
|
);
|
||||||
|
} catch (IllegalArgumentException failure) {
|
||||||
|
throw new IOException("Replacement journal contains an invalid configuration snapshot.", failure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean safeDimension(String value) {
|
||||||
|
if (value.isEmpty() || value.length() > 256 || value.startsWith(".") || value.contains("..")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String[] segments = value.split("/", -1);
|
||||||
|
if (segments.length > 16) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (String segment : segments) {
|
||||||
|
if (segment.isEmpty() || !segment.matches("[A-Za-z0-9_-]+")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String exact(Properties properties, String key) throws IOException {
|
||||||
|
String value = properties.getProperty(key);
|
||||||
|
if (value == null) {
|
||||||
|
throw new IOException("Replacement journal is missing " + key + ".");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String required(Properties properties, String key) throws IOException {
|
||||||
|
String value = exact(properties, key);
|
||||||
|
if (value.isBlank()) {
|
||||||
|
throw new IOException("Replacement journal is missing " + key + ".");
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean parseBoolean(Properties properties, String key) throws IOException {
|
||||||
|
String value = required(properties, key);
|
||||||
|
if (!"true".equals(value) && !"false".equals(value)) {
|
||||||
|
throw new IOException("Replacement journal contains an invalid boolean for " + key + ".");
|
||||||
|
}
|
||||||
|
return Boolean.parseBoolean(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long parseLong(Properties properties, String key) throws IOException {
|
||||||
|
try {
|
||||||
|
return Long.parseLong(required(properties, key));
|
||||||
|
} catch (NumberFormatException failure) {
|
||||||
|
throw new IOException("Replacement journal contains an invalid integer for " + key + ".", failure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writeAtomic(Path target, byte[] content) throws IOException {
|
||||||
|
Path parent = Objects.requireNonNull(target.getParent(), "journal parent");
|
||||||
|
Path temporary = parent.resolve("." + target.getFileName() + ".tmp-" + UUID.randomUUID());
|
||||||
|
try {
|
||||||
|
try (FileChannel channel = FileChannel.open(
|
||||||
|
temporary,
|
||||||
|
StandardOpenOption.CREATE_NEW,
|
||||||
|
StandardOpenOption.WRITE
|
||||||
|
)) {
|
||||||
|
ByteBuffer buffer = ByteBuffer.wrap(content);
|
||||||
|
while (buffer.hasRemaining()) {
|
||||||
|
channel.write(buffer);
|
||||||
|
}
|
||||||
|
channel.force(true);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
} catch (AtomicMoveNotSupportedException failure) {
|
||||||
|
Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
}
|
||||||
|
forceDirectory(parent);
|
||||||
|
} finally {
|
||||||
|
Files.deleteIfExists(temporary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void forceDirectory(Path directory) throws IOException {
|
||||||
|
try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) {
|
||||||
|
channel.force(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record Transaction(
|
||||||
|
UUID id,
|
||||||
|
NamespacedKey worldKey,
|
||||||
|
String worldName,
|
||||||
|
Path levelRoot,
|
||||||
|
String dimension,
|
||||||
|
long seed,
|
||||||
|
String packFingerprint,
|
||||||
|
WorldGeneratorSnapshot originalConfiguration,
|
||||||
|
boolean originalTargetPresent,
|
||||||
|
Phase phase
|
||||||
|
) {
|
||||||
|
public Transaction {
|
||||||
|
Objects.requireNonNull(id, "id");
|
||||||
|
Objects.requireNonNull(worldKey, "worldKey");
|
||||||
|
Objects.requireNonNull(worldName, "worldName");
|
||||||
|
levelRoot = Objects.requireNonNull(levelRoot, "levelRoot").toAbsolutePath().normalize();
|
||||||
|
Objects.requireNonNull(dimension, "dimension");
|
||||||
|
Objects.requireNonNull(packFingerprint, "packFingerprint");
|
||||||
|
Objects.requireNonNull(originalConfiguration, "originalConfiguration");
|
||||||
|
Objects.requireNonNull(phase, "phase");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Transaction withPhase(Phase nextPhase) {
|
||||||
|
return new Transaction(
|
||||||
|
id,
|
||||||
|
worldKey,
|
||||||
|
worldName,
|
||||||
|
levelRoot,
|
||||||
|
dimension,
|
||||||
|
seed,
|
||||||
|
packFingerprint,
|
||||||
|
originalConfiguration,
|
||||||
|
originalTargetPresent,
|
||||||
|
nextPhase
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Phase {
|
||||||
|
PREPARED,
|
||||||
|
ARMED,
|
||||||
|
PUBLISHED,
|
||||||
|
ROLLBACK_PENDING,
|
||||||
|
ROLLBACK_CLEANUP,
|
||||||
|
CLEANUP_PENDING
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user