diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9b229ef8..7f847da6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -15,3 +15,31 @@ jobs: - name: Run regression tests run: python3 -m unittest discover -s tests -v + + azure-template: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Checkout ARM Template Toolkit + uses: actions/checkout@v6 + with: + repository: Azure/arm-ttk + ref: 2aa61e7b9d4eb764c4e3bce7f07973adf633eeff + path: .arm-ttk + persist-credentials: false + + - name: Validate Azure template + shell: pwsh + run: | + Import-Module ./.arm-ttk/arm-ttk/arm-ttk.psd1 -ErrorAction Stop + $results = @(Test-AzTemplate -TemplatePath ./configs/Azure/CreateContainer.json -Test deploymentTemplate) + $results | Format-Table -Property Group, Passed -AutoSize + $failed = @($results | Where-Object { -not $_.Passed }) + if ($failed.Count -or -not $results.Count) { + $failed | Format-List * + throw "Azure template validation failed" + } diff --git a/README.md b/README.md index 1910c2dd..83f35ae4 100644 --- a/README.md +++ b/README.md @@ -441,6 +441,10 @@ For LAN games the VM needs an internal IP in order for clients to connect. One w If you're looking for a simple way to deploy this to the Amazon Web Services Cloud, check out the [Factorio Server Deployment (CloudFormation) repository](https://github.com/m-chandler/factorio-spot-pricing). This repository contains a CloudFormation template that will get you up and running in AWS in a matter of minutes. Optionally it uses Spot Pricing so the server is very cheap, and you can easily turn it off when not in use. +### Azure Deployment + +See [Factorio on Azure Container Instances](configs/Azure/README.md) for an ARM template and deployment instructions using Azure Files for persistent saves and configuration. + ## Using a reverse proxy If you need to use a reverse proxy you can use the following nginx snippet: diff --git a/configs/Azure/CreateContainer.json b/configs/Azure/CreateContainer.json new file mode 100644 index 00000000..7f6a55bb --- /dev/null +++ b/configs/Azure/CreateContainer.json @@ -0,0 +1,198 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]", + "metadata": { + "description": "Location for all resources." + } + }, + "dnsPrefix": { + "type": "string", + "defaultValue": "[concat('factorio-', uniqueString(resourceGroup().id, parameters('containerGroupsName')))]", + "minLength": 1, + "maxLength": 63, + "metadata": { + "description": "DNS label: lowercase letters, numbers and hyphens. Read the assigned FQDN from the deployment outputs." + } + }, + "containerGroupsName": { + "type": "string", + "defaultValue": "factorio", + "minLength": 1, + "maxLength": 63, + "metadata": { + "description": "Container group name. Use lowercase letters, numbers and hyphens." + } + }, + "storageAccountName": { + "type": "string", + "defaultValue": "[concat('factorio', uniqueString(resourceGroup().id, parameters('containerGroupsName')))]", + "minLength": 3, + "maxLength": 24, + "metadata": { + "description": "Globally unique storage account name: lowercase letters and numbers only. Keep this name unchanged when recreating the container." + } + }, + "storageAccountType": { + "type": "string", + "defaultValue": "Standard_LRS", + "allowedValues": [ + "Standard_LRS", + "Standard_GRS", + "Standard_ZRS" + ], + "metadata": { + "description": "Storage redundancy for the game data. Availability varies by region." + } + }, + "cpuCores": { + "type": "int", + "defaultValue": 2, + "minValue": 1, + "maxValue": 31, + "metadata": { + "description": "CPU cores requested. The selected region and subscription must have sufficient ACI quota." + } + }, + "memoryInGb": { + "type": "int", + "defaultValue": 4, + "minValue": 1, + "maxValue": 240, + "metadata": { + "description": "Memory requested in GiB. Large or modded saves may require more memory." + } + }, + "factorioVersion": { + "type": "string", + "defaultValue": "stable", + "minLength": 1, + "maxLength": 128, + "metadata": { + "description": "Regular factoriotools/factorio image tag. Pin a version matching your clients for controlled upgrades; rootless tags are not supported with this Azure Files mount." + } + } + }, + "variables": { + "fileShareName": "factorio" + }, + "resources": [ + { + "type": "Microsoft.Storage/storageAccounts", + "apiVersion": "2025-06-01", + "name": "[parameters('storageAccountName')]", + "location": "[parameters('location')]", + "sku": { + "name": "[parameters('storageAccountType')]", + "tier": "Standard" + }, + "kind": "StorageV2", + "properties": { + "minimumTlsVersion": "TLS1_2", + "supportsHttpsTrafficOnly": true, + "allowBlobPublicAccess": false + } + }, + { + "type": "Microsoft.Storage/storageAccounts/fileServices/shares", + "apiVersion": "2025-06-01", + "name": "[concat(parameters('storageAccountName'), '/default/', variables('fileShareName'))]", + "dependsOn": [ + "[resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName'))]" + ], + "properties": { + "enabledProtocols": "SMB" + } + }, + { + "type": "Microsoft.ContainerInstance/containerGroups", + "apiVersion": "2025-09-01", + "name": "[parameters('containerGroupsName')]", + "location": "[parameters('location')]", + "dependsOn": [ + "[resourceId('Microsoft.Storage/storageAccounts/fileServices/shares', parameters('storageAccountName'), 'default', variables('fileShareName'))]" + ], + "properties": { + "sku": "Standard", + "containers": [ + { + "name": "[parameters('containerGroupsName')]", + "properties": { + "image": "[concat('factoriotools/factorio:', parameters('factorioVersion'))]", + "ports": [ + { + "protocol": "UDP", + "port": 34197 + } + ], + "environmentVariables": [ + { + "name": "PUID", + "value": "0" + }, + { + "name": "PGID", + "value": "0" + } + ], + "resources": { + "requests": { + "cpu": "[parameters('cpuCores')]", + "memoryInGB": "[parameters('memoryInGb')]" + } + }, + "volumeMounts": [ + { + "name": "data", + "readOnly": false, + "mountPath": "/factorio" + } + ] + } + } + ], + "restartPolicy": "OnFailure", + "ipAddress": { + "ports": [ + { + "protocol": "UDP", + "port": 34197 + } + ], + "type": "Public", + "dnsNameLabel": "[parameters('dnsPrefix')]", + "autoGeneratedDomainNameLabelScope": "ResourceGroupReuse" + }, + "osType": "Linux", + "volumes": [ + { + "name": "data", + "azureFile": { + "readOnly": false, + "shareName": "[variables('fileShareName')]", + "storageAccountName": "[parameters('storageAccountName')]", + "storageAccountKey": "[listKeys(resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName')), '2025-06-01').keys[0].value]" + } + } + ] + } + } + ], + "outputs": { + "serverAddress": { + "type": "string", + "value": "[concat(reference(resourceId('Microsoft.ContainerInstance/containerGroups', parameters('containerGroupsName')), '2025-09-01').ipAddress.fqdn, ':34197')]" + }, + "storageAccountName": { + "type": "string", + "value": "[parameters('storageAccountName')]" + }, + "fileShareName": { + "type": "string", + "value": "[variables('fileShareName')]" + } + } +} diff --git a/configs/Azure/README.md b/configs/Azure/README.md new file mode 100644 index 00000000..961a554b --- /dev/null +++ b/configs/Azure/README.md @@ -0,0 +1,107 @@ +# Factorio on Azure Container Instances + +Deploy a Linux container group and an Azure Files share mounted at `/factorio`. Saves, mods and configuration survive container replacement as long as you keep the storage account and share. + +[![Deploy to Azure](https://aka.ms/deploytoazurebutton)](https://portal.azure.com/#create/Microsoft.Template/uri/https%3A%2F%2Fraw.githubusercontent.com%2Ffactoriotools%2Ffactorio-docker%2Fmaster%2Fconfigs%2FAzure%2FCreateContainer.json) + +**Costs:** this creates billable compute and storage. Use a dedicated resource group, check regional ACI availability/quota and pricing, and select the intended subscription before deploying. Stopping or deleting the container does not delete the storage account or stop storage charges. + +## Deploy + +Install the [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli), sign in, and run these commands from `configs/Azure`. Replace the subscription ID and choose an available region. + +```sh +az login +az account set --subscription YOUR_SUBSCRIPTION_ID +az group create --name GameResources --location eastus + +az deployment group validate \ + --resource-group GameResources \ + --template-file CreateContainer.json + +az deployment group create \ + --name factorio \ + --resource-group GameResources \ + --mode Incremental \ + --template-file CreateContainer.json + +az deployment group show \ + --name factorio \ + --resource-group GameResources \ + --query properties.outputs \ + --output json +``` + +Connect using the `serverAddress` output (the assigned FQDN and UDP port 34197). Azure allocates the public IP; it can change when the container is recreated. Do not copy an IP from another deployment or construct the FQDN yourself. + +The default storage account and DNS label are derived from the resource group and container group name. You may override them if a name is unavailable. Save any overrides and use the same values for subsequent deployments so the existing data share is reused. + +### Parameters + +| Parameter | Default | Purpose | +| --- | --- | --- | +| `location` | Resource group's region | Region supporting ACI and the selected storage redundancy | +| `containerGroupsName` | `factorio` | Container group name; lowercase letters, digits and hyphens | +| `storageAccountName` | Generated | Globally unique name of 3–24 lowercase letters/digits | +| `dnsPrefix` | Generated | DNS label; lowercase letters, digits and hyphens | +| `storageAccountType` | `Standard_LRS` | `Standard_LRS`, `Standard_GRS` or `Standard_ZRS`, subject to regional availability | +| `cpuCores` | `2` | Requested CPU cores; regional capacity and subscription quota apply | +| `memoryInGb` | `4` | Requested memory; large saves and mods may need more | +| `factorioVersion` | `stable` | Regular image tag; pin a version matching your clients for controlled upgrades | + +For example, append `--parameters factorioVersion=2.1.24 cpuCores=2 memoryInGb=8` to **both** validation and deployment commands to select that version and memory size. An existing save may not be compatible with a downgrade. Keep resource names, location and storage settings unchanged during upgrades. + +## First start and server configuration + +The image initializes files in the `factorio` share, including `config/server-settings.json`. Use the Azure portal's Storage browser or Azure Storage Explorer to edit them. The default settings may require configuration before the server will host successfully: + +- For an unlisted server, set `visibility.public` to `false`. +- For a public listing, configure your Factorio `username` and `token` in the settings file. +- Configure `game_password` and/or the whitelist before inviting players. An unlisted server still has a publicly reachable UDP port. +- Use compatible client/server Factorio versions, DLC settings and mods. + +Do not commit credentials or storage keys to this template. The storage key is obtained by ARM for the mount and is not emitted in deployment outputs. + +After changing settings, restart the container (replace the name if you changed `containerGroupsName`): + +```sh +az container restart --resource-group GameResources --name factorio +az container logs --resource-group GameResources --name factorio +``` + +### Azure Files permissions and RCON + +[ACI requires Azure Files containers to run as root and mounts shares using CIFS](https://learn.microsoft.com/azure/container-instances/container-instances-volume-azure-files#limitations). This template therefore uses the **regular** image with `PUID=0` and `PGID=0`. Do not select a `-rootless` tag. This is an Azure Files compatibility choice, not a recommendation for other deployments; the server has root access inside its container and to its mounted share. + +Only UDP 34197 is published. RCON TCP 27015 is deliberately **not** exposed to the internet. To administer the server, use an authenticated Azure CLI session: + +```sh +az container exec --resource-group GameResources --name factorio --exec-command /bin/bash +``` + +Inside that shell, use `rcon`, for example `rcon /server-save`. The client reads the generated password from `/factorio/config/rconpw`; do not publish that password. Save and confirm completion in the logs before stopping or replacing a server. + +## Upgrade without deleting game data + +Back up the Azure Files share first. Stop the container, delete **only the container group**, then repeat the deployment command with the same resource/storage names and the desired `factorioVersion`: + +```sh +az container stop --resource-group GameResources --name factorio +az container delete --resource-group GameResources --name factorio +``` + +Redeployment is incremental and reuses the share. Restarting a container is not a substitute for redeploying to select a new image version. Do not delete the storage account, share or resource group during an upgrade. + +## Validation and troubleshooting + +The repository tests the template's dependency chain, persistence wiring, networking and safe defaults, and runs Microsoft's ARM Template Toolkit. These are offline checks, **not proof of a successful Azure deployment or multiplayer connection**. Before relying on a deployment, verify: + +1. Creation from an empty resource group and successful startup after configuring server settings. +2. A real client can join the FQDN on UDP 34197. +3. A saved world survives stop/start and container-only deletion/redeployment with unchanged storage parameters. + +If a client stalls at “waiting for the server to save the map”, inspect server logs, available memory/CPU and Azure Files I/O, then reproduce with a small unmodded save and matching client version. An `EOF on stdin` log message alone does not establish why multiplayer failed. The historical connection report on PR #413 was not reproduced or root-caused by the template's offline tests. + +If provisioning fails, inspect `az deployment group show --name factorio --resource-group GameResources`, verify resource-provider registration, region/quota/name availability, and check access to Azure Files (SMB requires outbound TCP 445). The template explicitly waits for the file share before creating the container group. + +For full cleanup, delete the dedicated resource group only after backing up anything you need. **Deleting the group permanently deletes the saves and configuration in its storage account.** diff --git a/tests/test_azure_template.py b/tests/test_azure_template.py new file mode 100644 index 00000000..09e1f91f --- /dev/null +++ b/tests/test_azure_template.py @@ -0,0 +1,140 @@ +"""Offline regression checks for the Azure deployment template. + +These protect deployment wiring; Azure provisioning and multiplayer still need +validation in the target subscription. +""" + +import json +from pathlib import Path +import unittest +from urllib.parse import unquote + +ROOT = Path(__file__).resolve().parents[1] +TEMPLATE = ROOT / "configs/Azure/CreateContainer.json" +STORAGE_TYPE = "Microsoft.Storage/storageAccounts" +SHARE_TYPE = STORAGE_TYPE + "/fileServices/shares" +GROUP_TYPE = "Microsoft.ContainerInstance/containerGroups" +STORAGE_ID = "resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName'))" +SHARE_ID = "resourceId('Microsoft.Storage/storageAccounts/fileServices/shares', parameters('storageAccountName'), 'default', variables('fileShareName'))" +GROUP_ID = "resourceId('Microsoft.ContainerInstance/containerGroups', parameters('containerGroupsName'))" + + +def unique_keys(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError(f"Duplicate JSON key: {key}") + result[key] = value + return result + + +class AzureTemplateTests(unittest.TestCase): + def setUp(self): + self.template = json.loads(TEMPLATE.read_text(), object_pairs_hook=unique_keys) + self.resources = {resource["type"]: resource for resource in self.template["resources"]} + self.storage = self.resources[STORAGE_TYPE] + self.share = self.resources[SHARE_TYPE] + self.group = self.resources[GROUP_TYPE] + self.container = self.group["properties"]["containers"][0]["properties"] + + def test_complete_dependency_chain(self): + self.assertEqual(set(self.resources), {STORAGE_TYPE, SHARE_TYPE, GROUP_TYPE}) + self.assertEqual(len(self.template["resources"]), 3) + self.assertIn(f"[{STORAGE_ID}]", self.share["dependsOn"]) + self.assertIn(f"[{SHARE_ID}]", self.group["dependsOn"]) + self.assertEqual(self.share["name"], "[concat(parameters('storageAccountName'), '/default/', variables('fileShareName'))]") + self.assertEqual(self.storage["name"], "[parameters('storageAccountName')]") + self.assertEqual(self.group["name"], "[parameters('containerGroupsName')]") + + def test_generated_names_are_stable_and_scoped(self): + parameters = self.template["parameters"] + for name in ("storageAccountName", "dnsPrefix"): + with self.subTest(parameter=name): + default = parameters[name]["defaultValue"] + self.assertIn("uniqueString(resourceGroup().id, parameters('containerGroupsName'))", default) + self.assertNotIn("newGuid", default) + self.assertNotIn("utcNow", default) + self.assertNotIn("<", default) + self.assertEqual(parameters["storageAccountName"]["minLength"], 3) + self.assertEqual(parameters["storageAccountName"]["maxLength"], 24) + self.assertLessEqual(parameters["dnsPrefix"]["maxLength"], 63) + + def test_public_ip_is_assigned_and_dns_reuse_is_scoped(self): + address = self.group["properties"]["ipAddress"] + self.assertNotIn("ip", address) + self.assertEqual(address["type"], "Public") + self.assertEqual(address["dnsNameLabel"], "[parameters('dnsPrefix')]") + self.assertEqual(address["autoGeneratedDomainNameLabelScope"], "ResourceGroupReuse") + output = self.template["outputs"]["serverAddress"]["value"] + self.assertIn(f"reference({GROUP_ID}, '{self.group['apiVersion']}').ipAddress.fqdn", output) + self.assertIn(":34197", output) + + def test_only_game_udp_port_is_published(self): + expected = [{"protocol": "UDP", "port": 34197}] + self.assertEqual(self.group["properties"]["ipAddress"]["ports"], expected) + self.assertEqual(self.container["ports"], expected) + + def test_persistent_share_is_mounted_at_factorio(self): + volumes = self.group["properties"]["volumes"] + self.assertEqual(len(volumes), 1) + volume = volumes[0] + self.assertEqual(self.container["volumeMounts"], [ + {"name": volume["name"], "readOnly": False, "mountPath": "/factorio"} + ]) + share = volume["azureFile"] + self.assertFalse(share["readOnly"]) + self.assertEqual(share["shareName"], "[variables('fileShareName')]") + self.assertEqual(share["storageAccountName"], "[parameters('storageAccountName')]") + self.assertEqual(self.share["properties"]["enabledProtocols"], "SMB") + self.assertEqual(self.template["variables"]["fileShareName"], "factorio") + + def test_keys_are_looked_up_and_not_output(self): + key = self.group["properties"]["volumes"][0]["azureFile"]["storageAccountKey"] + self.assertEqual(key, f"[listKeys({STORAGE_ID}, '{self.storage['apiVersion']}').keys[0].value]") + outputs = json.dumps(self.template["outputs"]).lower() + for secret in ("listkeys", "storageaccountkey", "password", "token"): + self.assertNotIn(secret, outputs) + self.assertEqual(self.template["outputs"]["storageAccountName"]["value"], "[parameters('storageAccountName')]") + + def test_storage_security_defaults(self): + props = self.storage["properties"] + self.assertEqual(props["minimumTlsVersion"], "TLS1_2") + self.assertTrue(props["supportsHttpsTrafficOnly"]) + self.assertFalse(props["allowBlobPublicAccess"]) + self.assertEqual(self.storage["kind"], "StorageV2") + + def test_linux_root_compatibility_without_privileged_container(self): + self.assertEqual(self.group["properties"]["osType"], "Linux") + env = {item["name"]: item["value"] for item in self.container["environmentVariables"]} + self.assertEqual(env["PUID"], "0") + self.assertEqual(env["PGID"], "0") + self.assertFalse(self.container.get("securityContext", {}).get("privileged", False)) + self.assertEqual(self.container["image"], "[concat('factoriotools/factorio:', parameters('factorioVersion'))]") + self.assertEqual(self.template["parameters"]["factorioVersion"]["defaultValue"], "stable") + self.assertNotIn("command", self.container, "Keep the image's normal entrypoint") + + def test_compute_requests_are_bounded_parameters(self): + requests = self.container["resources"]["requests"] + self.assertEqual(requests["cpu"], "[parameters('cpuCores')]") + self.assertEqual(requests["memoryInGB"], "[parameters('memoryInGb')]") + for name, maximum in (("cpuCores", 31), ("memoryInGb", 240)): + parameter = self.template["parameters"][name] + self.assertEqual(parameter["type"], "int") + self.assertGreaterEqual(parameter["minValue"], 1) + self.assertLessEqual(parameter["maxValue"], maximum) + self.assertTrue(parameter["minValue"] <= parameter["defaultValue"] <= parameter["maxValue"]) + + def test_deployment_guide_and_portal_button(self): + guide = (TEMPLATE.parent / "README.md").read_text() + decoded = unquote(guide) + self.assertIn("https://raw.githubusercontent.com/factoriotools/factorio-docker/master/configs/Azure/CreateContainer.json", decoded) + self.assertIn("--mode Incremental", guide) + self.assertNotIn("--mode Complete", guide) + self.assertIn("PUID=0", guide) + self.assertIn("PGID=0", guide) + self.assertIn("not proof", guide) + self.assertIn("configs/Azure/README.md", (ROOT / "README.md").read_text()) + + +if __name__ == "__main__": + unittest.main()