BlueMap/implementations/cli/src/main/java/de/bluecolored/bluemap/cli/BlueMapCLI.java

482 lines
20 KiB
Java

/*
* This file is part of BlueMap, licensed under the MIT License (MIT).
*
* Copyright (c) Blue (Lukas Rieger) <https://bluecolored.de>
* Copyright (c) contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
package de.bluecolored.bluemap.cli;
import de.bluecolored.bluemap.common.BlueMapConfiguration;
import de.bluecolored.bluemap.common.BlueMapService;
import de.bluecolored.bluemap.common.MissingResourcesException;
import de.bluecolored.bluemap.common.config.BlueMapConfigManager;
import de.bluecolored.bluemap.common.config.ConfigurationException;
import de.bluecolored.bluemap.common.config.CoreConfig;
import de.bluecolored.bluemap.common.config.WebserverConfig;
import de.bluecolored.bluemap.common.plugin.RegionFileWatchService;
import de.bluecolored.bluemap.common.rendermanager.MapUpdateTask;
import de.bluecolored.bluemap.common.rendermanager.RenderManager;
import de.bluecolored.bluemap.common.rendermanager.RenderTask;
import de.bluecolored.bluemap.common.web.*;
import de.bluecolored.bluemap.common.web.http.HttpRequestHandler;
import de.bluecolored.bluemap.common.web.http.HttpServer;
import de.bluecolored.bluemap.core.BlueMap;
import de.bluecolored.bluemap.core.MinecraftVersion;
import de.bluecolored.bluemap.core.logger.Logger;
import de.bluecolored.bluemap.core.map.BmMap;
import de.bluecolored.bluemap.core.metrics.Metrics;
import de.bluecolored.bluemap.core.storage.Storage;
import de.bluecolored.bluemap.core.util.FileHelper;
import org.apache.commons.cli.*;
import org.apache.commons.lang3.time.DurationFormatUtils;
import org.checkerframework.checker.nullness.qual.Nullable;
import java.io.File;
import java.io.IOException;
import java.net.BindException;
import java.net.InetSocketAddress;
import java.net.UnknownHostException;
import java.nio.file.Path;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.function.Predicate;
import java.util.regex.Pattern;
public class BlueMapCLI {
private MinecraftVersion minecraftVersion = MinecraftVersion.LATEST_SUPPORTED;
private Path configFolder = Path.of("config");
public void renderMaps(BlueMapService blueMap, boolean watch, boolean forceRender, boolean forceGenerateWebapp,
@Nullable String mapsToRender) throws ConfigurationException, IOException, InterruptedException {
//metrics report
if (blueMap.getConfig().getCoreConfig().isMetrics()) Metrics.sendReportAsync("cli");
if (blueMap.getConfig().getWebappConfig().isEnabled())
blueMap.createOrUpdateWebApp(forceGenerateWebapp);
//try load resources
blueMap.getOrLoadResourcePack();
//create renderManager
RenderManager renderManager = new RenderManager();
//load maps
Predicate<String> mapFilter = mapId -> true;
if (mapsToRender != null) {
Set<String> mapsToRenderSet = Set.of(mapsToRender.split(","));
mapFilter = mapsToRenderSet::contains;
}
Map<String, BmMap> maps = blueMap.getOrLoadMaps(mapFilter);
//watcher
List<RegionFileWatchService> regionFileWatchServices = new ArrayList<>();
if (watch) {
for (BmMap map : maps.values()) {
try {
RegionFileWatchService watcher = new RegionFileWatchService(renderManager, map);
watcher.start();
regionFileWatchServices.add(watcher);
} catch (IOException ex) {
Logger.global.logError("Failed to create file-watcher for map: " + map.getId() +
" (This map might not automatically update)", ex);
}
}
}
//update all maps
int totalRegions = 0;
for (BmMap map : maps.values()) {
MapUpdateTask updateTask = new MapUpdateTask(map, forceRender);
renderManager.scheduleRenderTask(updateTask);
totalRegions += updateTask.getRegions().size();
}
Logger.global.logInfo("Start updating " + maps.size() + " maps (" + totalRegions + " regions, ~" + totalRegions * 1024L + " chunks)...");
// start rendering
renderManager.start(blueMap.getConfig().getCoreConfig().resolveRenderThreadCount());
Timer timer = new Timer("BlueMap-CLI-Timer", true);
TimerTask updateInfoTask = new TimerTask() {
@Override
public void run() {
RenderTask task = renderManager.getCurrentRenderTask();
if (task == null) return;
double progress = task.estimateProgress();
long etaMs = renderManager.estimateCurrentRenderTaskTimeRemaining();
String eta = "";
if (etaMs > 0) {
String etrDurationString = DurationFormatUtils.formatDuration(etaMs, "HH:mm:ss");
eta = " (ETA: " + etrDurationString + ")";
}
Logger.global.logInfo(task.getDescription() + ": " + (Math.round(progress * 100000) / 1000.0) + "%" + eta);
}
};
timer.scheduleAtFixedRate(updateInfoTask, TimeUnit.SECONDS.toMillis(10), TimeUnit.SECONDS.toMillis(10));
TimerTask saveTask = new TimerTask() {
@Override
public void run() {
for (BmMap map : maps.values()) {
map.save();
}
}
};
timer.scheduleAtFixedRate(saveTask, TimeUnit.MINUTES.toMillis(2), TimeUnit.MINUTES.toMillis(2));
Runnable shutdown = () -> {
Logger.global.logInfo("Stopping...");
updateInfoTask.cancel();
saveTask.cancel();
renderManager.stop();
for (RegionFileWatchService watcher : regionFileWatchServices) {
watcher.close();
}
regionFileWatchServices.clear();
try {
renderManager.awaitShutdown();
} catch (InterruptedException e) {
Logger.global.logError("Unexpected interruption: ", e);
}
Logger.global.logInfo("Saving...");
saveTask.run();
Logger.global.logInfo("Stopped.");
};
Thread shutdownHook = new Thread(shutdown, "BlueMap-CLI-ShutdownHook");
Runtime.getRuntime().addShutdownHook(shutdownHook);
// wait until done, then shutdown if not watching
renderManager.awaitIdle();
Logger.global.logInfo("Your maps are now all up-to-date!");
if (watch) {
updateInfoTask.cancel();
Logger.global.logInfo("Waiting for changes on the world-files...");
} else {
Runtime.getRuntime().removeShutdownHook(shutdownHook);
shutdown.run();
}
}
public void startWebserver(BlueMapService blueMap, boolean verbose) throws IOException, ConfigurationException, InterruptedException {
Logger.global.logInfo("Starting webserver ...");
WebserverConfig config = blueMap.getConfig().getWebserverConfig();
FileHelper.createDirectories(config.getWebroot());
RoutingRequestHandler routingRequestHandler = new RoutingRequestHandler();
// default route
routingRequestHandler.register(".*", new FileRequestHandler(config.getWebroot()));
// map route
for (var mapConfigEntry : blueMap.getConfig().getMapConfigs().entrySet()) {
Storage storage = blueMap.getOrLoadStorage(mapConfigEntry.getValue().getStorage());
routingRequestHandler.register(
"maps/" + Pattern.quote(mapConfigEntry.getKey()) + "/(.*)",
"$1",
new MapRequestHandler(mapConfigEntry.getKey(), storage)
);
}
List<Logger> webLoggerList = new ArrayList<>();
if (verbose) webLoggerList.add(Logger.stdOut(true));
if (config.getLog().getFile() != null) {
ZonedDateTime zdt = ZonedDateTime.ofInstant(Instant.now(), ZoneId.systemDefault());
webLoggerList.add(Logger.file(
Path.of(String.format(config.getLog().getFile(), zdt)),
config.getLog().isAppend()
));
}
HttpRequestHandler handler = new BlueMapResponseModifier(routingRequestHandler);
handler = new LoggingRequestHandler(
handler,
config.getLog().getFormat(),
Logger.combine(webLoggerList)
);
try {
//noinspection resource
HttpServer webServer = new HttpServer(handler);
webServer.bind(new InetSocketAddress(
config.resolveIp(),
config.getPort()
));
webServer.start();
} catch (UnknownHostException ex) {
throw new ConfigurationException("BlueMap failed to resolve the ip in your webserver-config.\n" +
"Check if that is correctly configured.", ex);
} catch (BindException ex) {
throw new ConfigurationException("BlueMap failed to bind to the configured address.\n" +
"This usually happens when the configured port (" + config.getPort() + ") is already in use by some other program.", ex);
} catch (IOException ex) {
throw new ConfigurationException("BlueMap failed to initialize the webserver.\n" +
"Check your webserver-config if everything is configured correctly.\n" +
"(Make sure you DON'T use the same port for bluemap that you also use for your minecraft server)", ex);
}
}
public static void main(String[] args) {
CommandLineParser parser = new DefaultParser();
BlueMapCLI cli = new BlueMapCLI();
BlueMapService blueMap = null;
try {
CommandLine cmd = parser.parse(BlueMapCLI.createOptions(), args, false);
if (cmd.hasOption("b")) {
Logger.global.clear();
Logger.global.put(Logger.stdOut(true));
}
if (cmd.hasOption("l")) {
Logger.global.put(Logger.file(Path.of(cmd.getOptionValue("l")), cmd.hasOption("a")));
}
//help
if (cmd.hasOption("h")) {
BlueMapCLI.printHelp();
return;
}
//version
if (cmd.hasOption("V")) {
BlueMapCLI.printVersion();
return;
}
//config folder
if (cmd.hasOption("c")) {
cli.configFolder = Path.of(cmd.getOptionValue("c"));
FileHelper.createDirectories(cli.configFolder);
}
//minecraft version
if (cmd.hasOption("v")) {
String versionString = cmd.getOptionValue("v");
try {
cli.minecraftVersion = MinecraftVersion.of(versionString);
} catch (IllegalArgumentException e) {
Logger.global.logWarning("Could not determine a version from the provided version-string: '" + versionString + "'");
System.exit(1);
return;
}
}
BlueMapConfigManager configs = BlueMapConfigManager.builder()
.minecraftVersion(cli.minecraftVersion)
.configRoot(cli.configFolder)
.usePluginConfig(false)
.defaultDataFolder(Path.of("data"))
.defaultWebroot(Path.of("web"))
.build();
//apply new file-logger config
CoreConfig coreConfig = configs.getCoreConfig();
if (coreConfig.getLog().getFile() != null) {
ZonedDateTime zdt = ZonedDateTime.ofInstant(Instant.now(), ZoneId.systemDefault());
Logger.global.put(Logger.file(
Path.of(String.format(coreConfig.getLog().getFile(), zdt)),
coreConfig.getLog().isAppend()
));
}
blueMap = new BlueMapService(configs);
boolean noActions = true;
if (cmd.hasOption("w")) {
noActions = false;
cli.startWebserver(blueMap, cmd.hasOption("b"));
Thread.sleep(1000); //wait a second to let the webserver start, looks nicer in the log if anything comes after that
}
if (cmd.hasOption("r")) {
noActions = false;
boolean watch = cmd.hasOption("u");
boolean force = cmd.hasOption("f");
boolean generateWebappFiles = cmd.hasOption("g");
String mapsToRender = cmd.getOptionValue("m", null);
cli.renderMaps(blueMap, watch, force, generateWebappFiles, mapsToRender);
} else {
if (cmd.hasOption("g")) {
noActions = false;
blueMap.createOrUpdateWebApp(true);
}
if (cmd.hasOption("s")) {
noActions = false;
blueMap.createOrUpdateWebApp(false);
}
}
// if nothing has been defined to do
if (noActions) {
Logger.global.logInfo("Generated default config files for you, here: " + cli.configFolder.toAbsolutePath().normalize() + "\n");
//create resourcepacks folder
FileHelper.createDirectories(cli.configFolder.resolve( "resourcepacks"));
//print help
BlueMapCLI.printHelp();
System.exit(1);
}
} catch (MissingResourcesException e) {
Logger.global.logWarning("BlueMap is missing important resources!");
Logger.global.logWarning("You must accept the required file download in order for BlueMap to work!");
if (blueMap != null) {
BlueMapConfiguration configProvider = blueMap.getConfig();
if (configProvider instanceof BlueMapConfigManager) {
Logger.global.logWarning("Please check: " + ((BlueMapConfigManager) configProvider).getConfigManager().findConfigPath(Path.of("core")).toAbsolutePath().normalize());
}
}
System.exit(2);
} catch (ParseException e) {
Logger.global.logError("Failed to parse provided arguments!", e);
BlueMapCLI.printHelp();
System.exit(1);
} catch (ConfigurationException e) {
Logger.global.logWarning(e.getFormattedExplanation());
Throwable cause = e.getRootCause();
if (cause != null) {
Logger.global.logError("Detailed error:", e);
}
} catch (IOException e) {
Logger.global.logError("An IO-error occurred!", e);
System.exit(1);
} catch (InterruptedException ex) {
System.exit(1);
} catch (RuntimeException e) {
Logger.global.logError("An unexpected error occurred!", e);
System.exit(1);
}
}
private static Options createOptions() {
Options options = new Options();
options.addOption("h", "help", false, "Displays this message");
options.addOption(
Option.builder("c")
.longOpt("config")
.hasArg()
.argName("config-folder")
.desc("Sets path of the folder containing the configuration-files to use (configurations will be generated here if they don't exist)")
.build()
);
options.addOption(
Option.builder("v")
.longOpt("mc-version")
.hasArg()
.argName("mc-version")
.desc("Sets the minecraft-version, used e.g. to load resource-packs correctly. Defaults to the latest compatible version.")
.build()
);
options.addOption(
Option.builder("l")
.longOpt("log-file")
.hasArg()
.argName("file-name")
.desc("Sets a file to save the log to. If not specified, no log will be saved.")
.build()
);
options.addOption("a", "append", false, "Causes log save file to be appended rather than replaced.");
options.addOption("w", "webserver", false, "Starts the web-server, configured in the 'webserver.conf' file");
options.addOption("b", "verbose", false, "Causes the web-server to log requests to the console");
options.addOption("g", "generate-webapp", false, "Generates the files for the web-app to the folder configured in the 'webapp.conf' file");
options.addOption("s", "generate-websettings", false, "Updates the settings.json for the web-app");
options.addOption("r", "render", false, "Renders the maps configured in the 'render.conf' file");
options.addOption("f", "force-render", false, "Forces rendering everything, instead of only rendering chunks that have been modified since the last render");
options.addOption("m", "maps", true, "A comma-separated list of map-id's that should be rendered. Example: 'world,nether'");
options.addOption("u", "watch", false, "Watches for file-changes after rendering and updates the map");
options.addOption("V", "version", false, "Print the current BlueMap version");
return options;
}
private static void printHelp() {
HelpFormatter formatter = new HelpFormatter();
String command = getCliCommand();
@SuppressWarnings("StringBufferReplaceableByString")
StringBuilder footer = new StringBuilder();
footer.append("Examples:\n\n");
footer.append(command).append(" -c './config/'\n");
footer.append("Generates the default/example configurations in a folder named 'config' if they are not already present\n\n");
footer.append(command).append(" -r\n");
footer.append("Render the configured maps\n\n");
footer.append(command).append(" -w\n");
footer.append("Start only the webserver without doing anything else\n\n");
footer.append(command).append(" -ru\n");
footer.append("Render the configured maps and then keeps watching the world-files and updates the map once something changed.\n\n");
formatter.printHelp(command + " [options]", "\nOptions:", createOptions(), "\n" + footer);
}
private static String getCliCommand() {
String filename = "bluemap-cli.jar";
try {
File file = new File(BlueMapCLI.class.getProtectionDomain()
.getCodeSource()
.getLocation()
.getPath());
if (file.isFile()) {
try {
filename = "." + File.separator + new File("").getCanonicalFile().toPath().relativize(file.toPath());
} catch (IllegalArgumentException ex) {
filename = file.getAbsolutePath();
}
}
} catch (IOException ignore) {}
return "java -jar " + filename;
}
private static void printVersion() {
System.out.printf("%s\n%s\n", BlueMap.VERSION, BlueMap.GIT_HASH);
}
}