From 87ef0a45205e6dbbd3c9cfeaeda1e854a55b41e2 Mon Sep 17 00:00:00 2001 From: Dave Curylo Date: Sat, 13 Feb 2021 19:28:20 -0500 Subject: [PATCH] Adds a tutorial for creating a Minecraft Server on ACI --- .../content/tutorials/minecraft-server-aci.md | 282 ++++++++++++++++++ .../tutorials/minecraft-server-aci.fsx | 163 ++++++++++ 2 files changed, 445 insertions(+) create mode 100644 docs/content/tutorials/minecraft-server-aci.md create mode 100644 samples/scripts/tutorials/minecraft-server-aci.fsx diff --git a/docs/content/tutorials/minecraft-server-aci.md b/docs/content/tutorials/minecraft-server-aci.md new file mode 100644 index 000000000..11fcdcf19 --- /dev/null +++ b/docs/content/tutorials/minecraft-server-aci.md @@ -0,0 +1,282 @@ +--- +title: "Create your own Minecraft Server" +date: 2021-02-13 +draft: false +weight: 5 +--- + +#### Introduction + +In this tutorial, you'll use dotnet framework features and F# language techniques when building a template to create a fully functional [Minecraft Server](https://www.minecraft.net) running on an Azure Container Instance with its world data stored in an Azure Storage Account. Because Farmer is a domain specific language embedded within F#, you are able to utilize the rich dotnet ecosystem and a static type system to "craft" an advanced deployment. + +1. Programmatically build our Minecraft configuration files. +1. Create a storage account for the world data. +1. Define a deployment script that will download the Minecraft Server .jar file and upload it to the storage account, along with the configuration files. +1. Create a container group for running the service;. + +#### A few dependencies + +We will need a few dependencies here, so first let's reference the packages and open the namespaces: + +* Farmer - for building that Azure resources and deployment template. +* MinecraftConfig - for building the server configuration files. +* FSharp.Data - for scraping the server download page for the current version of the server .jar file. + +```fsharp +#r "nuget: Farmer" +#r "nuget: MinecraftConfig" +#r "nuget: FSharp.Data" + +open System +open Farmer +open Farmer.Builders +open FSharp.Data +open MinecraftConfig +``` + +#### Building the Minecraft server configuration files + +Deploying infrastructure often also means building configuration files, and Minecraft is no different. There are four critical files for a server: + +* ops.json - this includes the list of operators that can manage the server. +* whitelist.json - this includes a list of gamers who are allowed to use the server. +* eula.txt - this indicates that you've accepted the [End User License Agreement](https://www.minecraft.net/en-us/eula/) for running a Minecraft Server, and you should read this since downloading and using the game implies you agree with it. +* server.properties - the server is a Java application, and this is the configuration for the application itself. + + +First we build a list of users that we will allow on our server. We'll use this list to build both the ops.json (operators) and whitelist.json (allowed gamers), so we will indicate which ones are operators. + +```fsharp +let operator = true +/// Our list of minecraft users - their username, uuid, and whether or not they are an operator. +let minecrafters = [ + "McUser1", "a6a66bfb-6ff7-46e3-981e-518e6a3f0e71", operator + "McUser2", "d3f2e456-d6a4-47ac-a7f0-41a4dc8ed156", not operator + "McUser3", "ceb50330-681a-4d9d-8e84-f76133d0fd28", not operator + ] +``` + +Now we build the whitelist.json and ops.json files - the MinecraftConfig application handles formatting the configuration file, we just need to map from our list of `minecrafters` to the lists of the records for the whitelist and the operator. `whitelist` holds the contents of our whitelist.json file, and `ops` hosts the contents of the ops.json file. We will write use those later. + +```fsharp +/// Let's allow our list of minecrafters on the whitelist. +let whitelist = + minecrafters + |> List.map (fun (name, uuid, _) -> { Name=name; Uuid=uuid }) + |> Whitelist.format + +/// Filter the minecrafters that aren't operators. +let ops = + minecrafters + |> List.filter (fun (_, _, op) -> op) // Filter anyone that isn't an operator + |> List.map (fun (name, uuid, _) -> { Name=name; Level=OperatorLevel.Level4; Uuid=uuid }) + |> Ops.format +``` + +And we can generate an accepted EULA, storing this content in `eula`. + +```fsharp +/// And accept the EULA. +let eula = Eula.format true +``` + +Now we need a few properties that are used both for the server.properties and for the resulting infrastructure. The `worldName` tells Minecraft where to store the world data. Since this will be mounted to an Azure Storage File share, we create a binding for it to make sure the name we use in the server.properties file matches what we use in the storage account. + +Same for the `serverPort`, which is both used in the server.properties file and must be exposed publicly on the Azure Container Group. + +The name of the storage account is used in three places: the storage account itself, in the deployment script that will upload files to the storage account, and in the container group that will mount a volume from it. The `storageAccountName` can be referenced in all three uses. + +```fsharp +/// Add bindings for fields that are referenced in a few places +/// Name of the share for the world. +let worldName = "world1" +/// Port for this world +let serverPort = 25565 +/// Storage account name +let storageAccountName = "mcworlddata" +``` + +And now we create the server.properties file, storing it in `serverProperties`. With that, we completed generating all of the configuration files for the server and can move on to defining and deploying the infrastructure. + +```fsharp +/// Write the customized server properties. +let serverProperties = + [ + ServerPort serverPort + RconPort (serverPort + 10) + EnforceWhitelist true + WhiteList true + Motd "Azure Minecraft Server" + LevelName worldName + Gamemode "survival" + ] + |> ServerProperties.format +``` + +#### Creating the Storage Account + +A Minecraft server stores some data for the world that is generated and people play in. That data, along with the configuration files, is stored in a directory that must be accessible to the server. Azure Container Groups are able to attach an Azure Storage Account File share as a volume, so we will create a storage account with a file share. + +```fsharp +/// A storage account, with a file share for the server config and world data. +let serverStorage = storageAccount { + name storageAccountName + sku Storage.Sku.Standard_LRS + add_file_share_with_quota worldName 5 +} +``` + +#### Defining the Deployment Script + +There are some deployment orchestration tasks that cannot be fully represented by Azure resources, but we need ARM to carry them out for us. We can use `deploymentScripts` as an Azure resource to represent script execution. This allows us to specify orchestration properties, such as that ARM should execute this deployment script _after_ the storage account is deployed. + +The script itself runs in a temporary container that has the Azure CLI ready and authenticated with a user that has the "Contributor" role over everything in this deployment. This is helpful because it means our script runs as a user that can access the storage account to upload content. + +We need this deployment script to do three things: + +1. Copy the configuration files to the storage account. +1. Download the current server.jar to the script container's temporary storage. +1. Upload the server.jar to the storage account. + +##### Embedding Configuration Files + +First we will tackle the configuration files. We are going use F# to generate the CLI script, so we can actually embed these in the deployment script itself. To avoid any trouble with escaping characters for our script, we will encode all of the configuration files as base64 strings when we build the script and then the script will decode the base64 data and write files out to the container file system where the Azure CLI can upload them. + +1. Convert each configuration file to base64. +1. Embed in shell script run by deployment. +1. When the deployment script runs, it will decode and save as files. +1. And then it will use `az storage file upload` to transfer them to the storage account. + +```fsharp +/// A deployment script to create the config in the file share. +let deployConfig = + // Helper function to base64 encode the files for embedding them in the + // deployment script. + let b64 (s:string) = + s |> System.Text.Encoding.UTF8.GetBytes |> Convert.ToBase64String + + // Build a script that embeds the content of these files, writes to the + // deploymentScript instance and then copies + // to the storageAccount file share. We will include the contents of these + // files as base64 encoded strings so there is no need to worry about + // special characters in the embedded script. + let uploadConfig = + [ + whitelist, Whitelist.Filename + ops, Ops.Filename + eula, Eula.Filename + serverProperties, ServerProperties.Filename + ] + |> List.map (fun (content, filename) -> + $"echo {b64 content} | base64 -d > {filename} && az storage file upload --account-name {storageAccountName} --share-name {worldName} --source {filename}") + +``` + +That seemed a bit complicated, but using the best of both F# and the Azure CLI, the actual code to do this is minimal. The `b64` function converts any string you give it to bytes and then base64 encodes those bytes into a string we can embed in the script. + +Next we have a list that contains the contents of each configuration file paired with the filename we need to write. We map each of those items to an interpolated string, which is where F# can execute little bits of code when building the string. Within the interpolated string, we call the `b64` function to encode the contents of each file, which is what `$"echo {b64 content}"` does. When the script executes, it will pass that string into `base64 -d` which decides the base64 back into bytes that are written to a file. After each file is written, it's uploaded with `az storage file upload` which again uses interpolated string values to get the `storageAccountName`, `worldName`, and `filename` values. + +##### Deploying the Server Software + +Having embedded the configuration files, now we need to add a line to the script to download the Minecraft server.jar and upload it as well. Whenever a new Minecraft Server is released, they update this page with a link that is named for the server version. + +Without F#, we would probably stop here and just use the link for whatever version is out today. But F# has nice toys for reading and exploring data, like FSharp.Data which can parse HTML files, so we're only a few lines away from scraping the download page for the link to the current version. + +When this F# code is executed to build the ARM template, it will load the Download page, find the link starting with `minecraft_server`, and copy the URL from the `href` on that link. We will embed that URL into our deployment script as a parameter to a `curl` call which will download the file before calling `az storage file upload` to copy the file to the storage account. + +```fsharp + /// The script will also need to download the server.jar and upload it. + let uploadServerJar = + let results = HtmlDocument.Load "https://www.minecraft.net/en-us/download/server" + // Scrape for anchor tags from this download page. + results.Descendants ["a"] + // where the inner text contains "minecraft_server" since that's what is + // displayed on that link + |> Seq.filter (fun (x:HtmlNode) -> x.InnerText().StartsWith "minecraft_server") + // And choose the "href" attribute if present + |> Seq.choose(fun (x:HtmlNode) -> x.TryGetAttribute("href") |> Option.map(fun (a:HtmlAttribute) -> a.Value())) + |> Seq.head // If it wasn't found, we'll get an error here. + |> (fun url -> $"curl -O {url} && az storage file upload --account-name {storageAccountName} --share-name {worldName} --source server.jar") +``` + +Now we have two lists: +* `uploadConfig` is a list of the four lines of `bash` that will decode and then upload the configuration files to the storage account. +* `uploadServerJar` is a line of `bash` to download the server software and upload it to the storage account. + +We concat those lines together with a semicolon `; ` to break up our commands, and we have a full script we can run. The `deploymentScript` resource itself is fairly simple, and we use `depends_on serverStorage` to make sure this only runs _after_ our storage account is deployed. + +```fsharp + let scriptSource = + uploadServerJar :: uploadConfig + |> List.rev // do the server upload last so it won't start until the configs are in place. + |> String.concat "; " + + deploymentScript { + name "deployMinecraftConfig" + // Depend on the storage account so this won't run until it's there. + depends_on serverStorage + script_content scriptSource + force_update + } +``` + +#### Creating the Container Instance + +The container instance runs a Java Runtime Environmennt, giving it enough CPU and memory for a small server with a few players. It has a volume mounted to the Azure Storage Account File share where the configuration files and server.jar are uploaded. + +The `containerGroup` has a dependency on the `storageAccount` so it won't be deployed until the storageAccount is deployed. There is a bit of a race condition since the container group could be deployed and start before the `deploymentScript` uploads the server.jar and configuration files. To prevent this issue the container runs a `while` loop in `bash` until the server starts successfully. + +```fsharp +let serverContainer = containerGroup { + name "minecraft-server" + public_dns "azmcworld1" [ TCP, uint16 serverPort ] + add_instances [ + containerInstance { + name "minecraftserver" + image "mcr.microsoft.com/java/jre-headless:8-zulu-alpine" + // The command line needs to change to the directory for the file share + // and then start the server. + // It needs a little more memory than the defaults, -Xmx3G gives it 3 GiB + // of memory. + command_line [ + "/bin/sh" + "-c" + // We will need to do a retry loop since we can't have a depends_on + // for the deploymentScript to finish. + $"cd /data/{worldName}; while true; do java -Djava.net.preferIPv4Stack=true -Xms1G -Xmx3G -jar server.jar nogui && break; sleep 30; done" + ] + // If we chose a custom port in the settings, it should go here. + add_public_ports [ uint16 serverPort ] + // It needs a couple cores or the world may lag with a few players + cpu_cores 2 + // Give it enough memory for the JVM + memory 3.5 + // Mount the path to the Azure Storage File share in the container + add_volume_mount worldName $"/data/{worldName}" + } + ] + // Add the file share for the world data and server configuration. + add_volumes [ + volume_mount.azureFile worldName worldName serverStorage.Name.ResourceName.Value + ] +} +``` + +Here we will build the template. The `deployConfig` deployment script is especially interesting as it contains the embedded configuration files and the `curl` command with the link to the current `server.jar` from scraping the download page. + +```fsharp +/// Build the deployment with storage, deployment script, and container group. +let deployment = arm { + location Location.EastUS + add_resources [ + serverStorage + deployConfig + serverContainer + ] +} +deployment |> Writer.quickWrite "minecraft-server" +``` + +After running this deployment, view the container group in the Azure Portal or with `az container logs` to watch the server start up and generate a world. Once the world is generated, it's ready to connect from your Minecraft Java Edition client by entering the DNS name for the container group! + +If you need to change the configuration you could connect to the terminal of the container instance. But in the spirit of mature configuration management and immutable infrastructure, you should rebuild the config, stop the container group, and redeploy. The existing state - the minecraft world data - is left intact in the storage account and the configuration is replaced with your updates. Once the update is deployed, you can restart the container group. diff --git a/samples/scripts/tutorials/minecraft-server-aci.fsx b/samples/scripts/tutorials/minecraft-server-aci.fsx new file mode 100644 index 000000000..b10cc0703 --- /dev/null +++ b/samples/scripts/tutorials/minecraft-server-aci.fsx @@ -0,0 +1,163 @@ +#r "nuget: Farmer" +#r "nuget: MinecraftConfig" +#r "nuget: FSharp.Data" + +open System +open Farmer +open Farmer.Builders +open FSharp.Data +open MinecraftConfig + +(* + * We want to get a Minecraft server with some customizations like the game mode and restricted users. + * To get that we need a few things: + * 1. A platform to host the server - for us that will be an Azure Container Group + * 2. A place to store the world and config data - Azure Storage Files will do nicely for access from the container. + * 3. Write the config data during deployment - we can generate it here and then pass it to a deploymentScript + * that can write the files to the storage account. + * + * That means the storageAccount and files need to be there first, then the deploymentScript should run to create + * the config, and finally the containerGroup should be deployed, reading the config and starting the server. + *) + +/// Add bindings for fields that are referenced in a few places +/// Name of the share for the world. +let worldName = "world1" +/// Port for this world +let serverPort = 25565 +/// Storage account name +let storageAccountName = "mcworlddata" + +let operator = true +/// Our list of minecraft users - their username, uuid, and whether they are an operator. +let minecrafters = [ + "McUser1", "a6a66bfb-6ff7-46e3-981e-518e6a3f0e71", operator + "McUser2", "d3f2e456-d6a4-47ac-a7f0-41a4dc8ed156", not operator + "McUser3", "ceb50330-681a-4d9d-8e84-f76133d0fd28", not operator + ] + +/// Let's allow our list of minecrafters on the whitelist. +let whitelist = + minecrafters + |> List.map (fun (name, uuid, _) -> { Name=name; Uuid=uuid }) + |> Whitelist.format + +/// Filter the minecrafters that aren't operators. +let ops = + minecrafters + |> List.filter (fun (_, _, op) -> op) // Filter anyone that isn't an operator + |> List.map (fun (name, uuid, _) -> { Name=name; Level=OperatorLevel.Level4; Uuid=uuid }) + |> Ops.format + + +/// And accept the EULA. +let eula = Eula.format true + +/// Write the customized server properties. +let serverProperties = + [ + ServerPort serverPort + RconPort (serverPort + 10) + EnforceWhitelist true + WhiteList true + Motd "Azure Minecraft Server" + LevelName worldName + Gamemode "survival" + ] + |> ServerProperties.format + +/// A storage account, with a file share for the server config and world data. +let serverStorage = storageAccount { + name storageAccountName + sku Storage.Sku.Standard_LRS + add_file_share_with_quota worldName 5 +} + +/// A deployment script to create the config in the file share. +let deployConfig = + /// Helper function to base64 encode the files for embedding them in the deployment script. + let b64 (s:string) = + s |> System.Text.Encoding.UTF8.GetBytes |> Convert.ToBase64String + + /// Build a script that embeds the content of these files, writes to the deploymentScript instance and then copies + /// to the storageAccount file share. We will include the contents of these files as base64 encoded strings so + /// there is no need to worry about special characters in the embedded script. + let uploadConfig = + [ + whitelist, Whitelist.Filename + ops, Ops.Filename + eula, Eula.Filename + serverProperties, ServerProperties.Filename + ] + |> List.map (fun (content, filename) -> + $"echo {b64 content} | base64 -d > {filename} && az storage file upload --account-name {storageAccountName} --share-name {worldName} --source {filename}") + + /// The script will also need to download the server.jar and upload it. + let uploadServerJar = + let results = HtmlDocument.Load "https://www.minecraft.net/en-us/download/server" + // Scrape for anchor tags from this download page. + results.Descendants ["a"] + // where the inner text contains "minecraft_server" since that's what is displayed on that link + |> Seq.filter (fun (x:HtmlNode) -> x.InnerText().StartsWith "minecraft_server") + // And choose the "href" attribute if present + |> Seq.choose(fun (x:HtmlNode) -> x.TryGetAttribute("href") |> Option.map(fun (a:HtmlAttribute) -> a.Value())) + |> Seq.head // If it wasn't found, we'll get an error here. + |> (fun url -> $"curl -O {url} && az storage file upload --account-name {storageAccountName} --share-name {worldName} --source server.jar") + + let scriptSource = + uploadServerJar :: uploadConfig + |> List.rev // do the server upload last so it won't start until the configs are in place. + |> String.concat "; " + + deploymentScript { + name "deployMinecraftConfig" + // Depend on the storage account so this won't run until it's there. + depends_on serverStorage + script_content scriptSource + force_update + } + +let serverContainer = containerGroup { + name "minecraft-server" + public_dns "azmcworld1" [ TCP, uint16 serverPort ] + add_instances [ + containerInstance { + name "minecraftserver" + image "mcr.microsoft.com/java/jre-headless:8-zulu-alpine" + // The command line needs to change to the directory for the file share and then start the server + // It needs a little more memory than the defaults, -Xmx3G gives it 3 GiB of memory. + command_line [ + "/bin/sh" + "-c" + // We will need to do a retry loop since we can't have a depends_on for the deploymentScript to finish. + $"cd /data/{worldName}; while true; do java -Djava.net.preferIPv4Stack=true -Xms1G -Xmx3G -jar server.jar nogui && break; sleep 30; done" + ] + // If we chose a custom port in the settings, it should go here. + add_public_ports [ uint16 serverPort ] + // It needs a couple cores or the world may lag with a few players + cpu_cores 2 + // Give it enough memory for the JVM + memory 3.5 + // Mount the path to the Azure Storage File share in the container + add_volume_mount worldName $"/data/{worldName}" + } + ] + // Add the file share for the world data and server configuration. + add_volumes [ + volume_mount.azureFile worldName worldName serverStorage.Name.ResourceName.Value + ] +} + +/// Build the deployment with storage, deployment script, and container group. +let deployment = arm { + location Location.EastUS + add_resources [ + serverStorage + deployConfig + serverContainer + ] +} + +// Usually takes about 2 minutes to run, mostly the deploymentScript resources. Another minute later, the Minecraft +// world is generated and it's ready to use! +deployment |> Writer.quickWrite "minecraft-server-aci"