Skip to content

Instantly share code, notes, and snippets.

@ninjarobot
Created January 5, 2021 17:02
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ninjarobot/a0f1ba879e38030b26968e6ced0b0df3 to your computer and use it in GitHub Desktop.
Save ninjarobot/a0f1ba879e38030b26968e6ced0b0df3 to your computer and use it in GitHub Desktop.
Generates an ARM deployment template for creating a Minecraft Server running on Azure container instances
#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<Gb>
}
/// 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<Gb>
// 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 "deployMinecraftServer"
@ninjarobot
Copy link
Author

{
  "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
  "contentVersion": "1.0.0.0",
  "outputs": {},
  "parameters": {},
  "resources": [
    {
      "apiVersion": "2019-06-01",
      "dependsOn": [],
      "kind": "StorageV2",
      "location": "eastus",
      "name": "mcworlddata",
      "properties": {},
      "sku": {
        "name": "Standard_LRS"
      },
      "tags": {},
      "type": "Microsoft.Storage/storageAccounts"
    },
    {
      "apiVersion": "2019-06-01",
      "dependsOn": [
        "[resourceId('Microsoft.Storage/storageAccounts', 'mcworlddata')]"
      ],
      "name": "mcworlddata/default/world1",
      "properties": {
        "shareQuota": 5
      },
      "type": "Microsoft.Storage/storageAccounts/fileServices/shares"
    },
    {
      "apiVersion": "2018-11-30",
      "dependsOn": [],
      "location": "eastus",
      "name": "deployMinecraftConfig-identity",
      "tags": {},
      "type": "Microsoft.ManagedIdentity/userAssignedIdentities"
    },
    {
      "apiVersion": "2020-04-01-preview",
      "dependsOn": [],
      "name": "[guid(concat(resourceGroup().id, 'b24988ac-6180-42a0-ab88-20f7382dd24c'))]",
      "properties": {
        "principalId": "[reference(resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', 'deployMinecraftConfig-identity')).principalId]",
        "principalType": "ServicePrincipal",
        "roleDefinitionId": "[concat('/subscriptions/', subscription().subscriptionId, '/providers/Microsoft.Authorization/roleDefinitions/', 'b24988ac-6180-42a0-ab88-20f7382dd24c')]"
      },
      "type": "Microsoft.Authorization/roleAssignments"
    },
    {
      "apiVersion": "2019-10-01-preview",
      "dependsOn": [
        "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', 'deployMinecraftConfig-identity')]",
        "[resourceId('Microsoft.Storage/storageAccounts', 'mcworlddata')]"
      ],
      "identity": {
        "type": "UserAssigned",
        "userAssignedIdentities": {
          "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', 'deployMinecraftConfig-identity')]": {}
        }
      },
      "kind": "AzureCLI",
      "location": "eastus",
      "name": "deployMinecraftConfig",
      "properties": {
        "azCliVersion": "2.9.1",
        "cleanupPreference": "Always",
        "environmentVariables": [],
        "forceUpdateTag": "3c885080-34ed-44c9-ab05-c00f57f3d647",
        "retentionInterval": "P1D",
        "scriptContent": "echo c2VydmVyLXBvcnQ9MjU1NjUKcmNvbi5wb3J0PTI1NTc1CmVuZm9yY2Utd2hpdGVsaXN0PVRydWUKd2hpdGUtbGlzdD1UcnVlCm1vdGQ9QXp1cmUgTWluZWNyYWZ0IFNlcnZlcgpsZXZlbC1uYW1lPXdvcmxkMQpnYW1lbW9kZT1zdXJ2aXZhbA== | base64 -d > server.properties && az storage file upload --account-name mcworlddata --share-name world1 --source server.properties; echo I0J5IGNoYW5naW5nIHRoZSBzZXR0aW5nIGJlbG93IHRvIFRSVUUgeW91IGFyZSBpbmRpY2F0aW5nIHlvdXIgYWdyZWVtZW50IHRvIG91ciBFVUxBIChodHRwczovL2FjY291bnQubW9qYW5nLmNvbS9kb2N1bWVudHMvbWluZWNyYWZ0X2V1bGEpLgojRnJpLCAyOSBKYW4gMjAyMSAyMjo1NDozNCBHTVQKZXVsYT1UcnVlCg== | base64 -d > eula.txt && az storage file upload --account-name mcworlddata --share-name world1 --source eula.txt; echo WwogIHsKICAgICJuYW1lIjogIk1jVXNlcjEiLAogICAgInV1aWQiOiAiYTZhNjZiZmItNmZmNy00NmUzLTk4MWUtNTE4ZTZhM2YwZTcxIiwKICAgICJsZXZlbCI6IDQKICB9Cl0= | base64 -d > ops.json && az storage file upload --account-name mcworlddata --share-name world1 --source ops.json; echo WwogIHsKICAgICJuYW1lIjogIk1jVXNlcjEiLAogICAgInV1aWQiOiAiYTZhNjZiZmItNmZmNy00NmUzLTk4MWUtNTE4ZTZhM2YwZTcxIgogIH0sCiAgewogICAgIm5hbWUiOiAiTWNVc2VyMiIsCiAgICAidXVpZCI6ICJkM2YyZTQ1Ni1kNmE0LTQ3YWMtYTdmMC00MWE0ZGM4ZWQxNTYiCiAgfSwKICB7CiAgICAibmFtZSI6ICJNY1VzZXIzIiwKICAgICJ1dWlkIjogImNlYjUwMzMwLTY4MWEtNGQ5ZC04ZTg0LWY3NjEzM2QwZmQyOCIKICB9Cl0= | base64 -d > whitelist.json && az storage file upload --account-name mcworlddata --share-name world1 --source whitelist.json; curl -O https://launcher.mojang.com/v1/objects/1b557e7b033b583cd9f66746b7a9ab1ec1673ced/server.jar && az storage file upload --account-name mcworlddata --share-name world1 --source server.jar"
      },
      "tags": {},
      "type": "Microsoft.Resources/deploymentScripts"
    },
    {
      "apiVersion": "2018-10-01",
      "dependsOn": [
        "[resourceId('Microsoft.Storage/storageAccounts/fileServices/shares', 'mcworlddata', 'default', 'world1')]"
      ],
      "identity": {
        "type": "None"
      },
      "location": "eastus",
      "name": "minecraft-server",
      "properties": {
        "containers": [
          {
            "name": "minecraftserver",
            "properties": {
              "command": [
                "/bin/sh",
                "-c",
                "cd /data/world1; while true; do java -Djava.net.preferIPv4Stack=true -Xms1G -Xmx3G -jar server.jar nogui && break; sleep 30; done"
              ],
              "environmentVariables": [],
              "image": "mcr.microsoft.com/java/jre-headless:8-zulu-alpine",
              "ports": [
                {
                  "port": 25565
                }
              ],
              "resources": {
                "requests": {
                  "cpu": 2.0,
                  "memoryInGB": 3.5
                }
              },
              "volumeMounts": [
                {
                  "mountPath": "/data/world1",
                  "name": "world1"
                }
              ]
            }
          }
        ],
        "imageRegistryCredentials": [],
        "ipAddress": {
          "dnsNameLabel": "azmcworld1",
          "ports": [
            {
              "port": 25565,
              "protocol": "TCP"
            }
          ],
          "type": "Public"
        },
        "osType": "Linux",
        "restartPolicy": "Always",
        "volumes": [
          {
            "azureFile": {
              "shareName": "world1",
              "storageAccountKey": "[listKeys('Microsoft.Storage/storageAccounts/mcworlddata', '2018-07-01').keys[0].value]",
              "storageAccountName": "mcworlddata"
            },
            "name": "world1"
          }
        ]
      },
      "tags": {},
      "type": "Microsoft.ContainerInstance/containerGroups"
    }
  ]
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment