Skip to content
Merged
17 changes: 17 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
name: Tests

on:
pull_request:
push:
branches:
- master

jobs:
regression:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6

- name: Run regression tests
run: python3 -m unittest discover -s tests -v
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -298,9 +298,22 @@ These are the environment variables which can be specified at container run time
| CONSOLE_LOG_LOCATION | Saves the console log to the specifies location | | |
| DLC_SPACE_AGE | Enables or disables the mods for DLC Space Age in mod-list.json[^1] | true | 2.0.8+ |
| MODS | Mod directory to use | /factorio/mods | 2.0.8+ |
| SERVER_SETTINGS_FILE | Absolute path to server settings | `$CONFIG/server-settings.json` | New builds |
| SERVER_ID_FILE | Absolute path to server identity | `$CONFIG/server-id.json` | New builds |
| SERVER_ADMINLIST_FILE | Absolute path to the server admin list | `$CONFIG/server-adminlist.json` | New builds |
| SERVER_BANLIST_FILE | Absolute path to the server ban list | `$CONFIG/server-banlist.json` | New builds |
| SERVER_WHITELIST_FILE | Absolute path to the server whitelist | `$CONFIG/server-whitelist.json` | New builds |

**Note:** All environment variables are compared as strings

#### Server configuration paths

The `SERVER_*_FILE` overrides apply to regular and rootless images, including scenario startup. Upgrade or rebuild older images to use this feature. Unset or empty values use the defaults above; `$CONFIG` defaults to `/factorio/config`. The mod updater also reads credentials from `SERVER_SETTINGS_FILE` unless credentials are supplied explicitly or through Docker secrets.

Use absolute file paths, not directory paths. Existing settings are preserved; if `SERVER_SETTINGS_FILE` is missing, the default settings are copied there. For example, `-e SERVER_SETTINGS_FILE=/factorio/config/private/settings.json` selects a separate settings file.

Parent directories are created when possible. Custom settings files must be readable by the server user. Files that Factorio updates (including its server ID and player lists), and their parent directories, must be writable by that user. For paths outside `/factorio`, pre-create directories with the appropriate permissions: the regular image only adjusts ownership inside `/factorio`, and the rootless image does not adjust ownership. An existing read-only settings-file mount is supported; a missing settings file in a read-only directory is not.

#### PRESET Values

The `PRESET` environment variable is used when generating a new map (when `GENERATE_NEW_SAVE=true`). It corresponds to Factorio's built-in map generation presets. Common values include:
Expand Down
17 changes: 10 additions & 7 deletions docker/files/docker-entrypoint-rootless.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ mkdir -p "$SAVES"
mkdir -p "$CONFIG"
mkdir -p "$MODS"
mkdir -p "$SCENARIOS"

# shellcheck disable=SC1091
source "${INSTALLED_DIRECTORY}/server-config.sh"
mkdir -p "$SCRIPTOUTPUT"

# Generate RCON password if needed
Expand All @@ -24,8 +27,8 @@ if [[ ! -f $CONFIG/rconpw ]]; then
fi

# Copy default configs if they don't exist
if [[ ! -f $CONFIG/server-settings.json ]]; then
cp /opt/factorio/data/server-settings.example.json "$CONFIG/server-settings.json"
if [[ ! -f $SERVER_SETTINGS_FILE ]]; then
cp /opt/factorio/data/server-settings.example.json "$SERVER_SETTINGS_FILE"
fi

if [[ ! -f $CONFIG/map-gen-settings.json ]]; then
Expand Down Expand Up @@ -93,14 +96,14 @@ fi
# Build command flags
FLAGS=(\
--port "$PORT" \
--server-settings "$CONFIG/server-settings.json" \
--server-banlist "$CONFIG/server-banlist.json" \
--server-settings "$SERVER_SETTINGS_FILE" \
--server-banlist "$SERVER_BANLIST_FILE" \
--rcon-port "$RCON_PORT" \
--server-whitelist "$CONFIG/server-whitelist.json" \
--server-whitelist "$SERVER_WHITELIST_FILE" \
--use-server-whitelist \
--server-adminlist "$CONFIG/server-adminlist.json" \
--server-adminlist "$SERVER_ADMINLIST_FILE" \
--rcon-password "$(cat "$CONFIG/rconpw")" \
--server-id /factorio/config/server-id.json \
--server-id "$SERVER_ID_FILE" \
--mod-directory "$MODS" \
)

Expand Down
19 changes: 11 additions & 8 deletions docker/files/docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,19 @@ mkdir -p "$SAVES"
mkdir -p "$CONFIG"
mkdir -p "$MODS"
mkdir -p "$SCENARIOS"

# shellcheck disable=SC1091
source "${INSTALLED_DIRECTORY}/server-config.sh"
mkdir -p "$SCRIPTOUTPUT"

if [[ ! -f $CONFIG/rconpw ]]; then
# Generate a new RCON password if none exists
pwgen 15 1 >"$CONFIG/rconpw"
fi

if [[ ! -f $CONFIG/server-settings.json ]]; then
if [[ ! -f $SERVER_SETTINGS_FILE ]]; then
# Copy default settings if server-settings.json doesn't exist
cp /opt/factorio/data/server-settings.example.json "$CONFIG/server-settings.json"
cp /opt/factorio/data/server-settings.example.json "$SERVER_SETTINGS_FILE"
fi

if [[ ! -f $CONFIG/map-gen-settings.json ]]; then
Expand Down Expand Up @@ -77,7 +80,7 @@ if [[ $GENERATE_NEW_SAVE == true ]]; then
if [[ -f "$SAVES/$SAVE_NAME.zip" ]]; then
echo "Map $SAVES/$SAVE_NAME.zip already exists, skipping map generation"
else
if [[ ! -z "$PRESET" ]]; then
if [[ -n "$PRESET" ]]; then
$EXEC /opt/factorio/bin/x64/factorio \
--create "$SAVES/$SAVE_NAME.zip" \
--preset "$PRESET" \
Expand All @@ -94,14 +97,14 @@ fi

FLAGS=(\
--port "$PORT" \
--server-settings "$CONFIG/server-settings.json" \
--server-banlist "$CONFIG/server-banlist.json" \
--server-settings "$SERVER_SETTINGS_FILE" \
--server-banlist "$SERVER_BANLIST_FILE" \
--rcon-port "$RCON_PORT" \
--server-whitelist "$CONFIG/server-whitelist.json" \
--server-whitelist "$SERVER_WHITELIST_FILE" \
--use-server-whitelist \
--server-adminlist "$CONFIG/server-adminlist.json" \
--server-adminlist "$SERVER_ADMINLIST_FILE" \
--rcon-password "$(cat "$CONFIG/rconpw")" \
--server-id /factorio/config/server-id.json \
--server-id "$SERVER_ID_FILE" \
--mod-directory "$MODS" \
)

Expand Down
7 changes: 5 additions & 2 deletions docker/files/docker-update-mods.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
#!/bin/bash
set -eou pipefail
INSTALLED_DIRECTORY=$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")
# shellcheck disable=SC1091
source "${INSTALLED_DIRECTORY}/server-config.sh"

if [[ -f /run/secrets/username ]]; then
USERNAME=$(cat /run/secrets/username)
Expand All @@ -10,11 +13,11 @@ if [[ -f /run/secrets/token ]]; then
fi

if [[ -z ${USERNAME:-} ]]; then
USERNAME="$(jq -j ".username" "$CONFIG/server-settings.json")"
USERNAME="$(jq -j ".username" "$SERVER_SETTINGS_FILE")"
fi

if [[ -z ${TOKEN:-} ]]; then
TOKEN="$(jq -j ".token" "$CONFIG/server-settings.json")"
TOKEN="$(jq -j ".token" "$SERVER_SETTINGS_FILE")"
fi

if [[ -z ${USERNAME:-} ]]; then
Expand Down
17 changes: 10 additions & 7 deletions docker/files/scenario.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,17 @@ mkdir -p "$CONFIG"
mkdir -p "$MODS"
mkdir -p "$SCENARIOS"

# shellcheck disable=SC1091
source "${INSTALLED_DIRECTORY}/server-config.sh"

#chown -R factorio /factorio

if [[ ! -f $CONFIG/rconpw ]]; then
pwgen 15 1 >"$CONFIG/rconpw"
fi

if [[ ! -f $CONFIG/server-settings.json ]]; then
cp /opt/factorio/data/server-settings.example.json "$CONFIG/server-settings.json"
if [[ ! -f $SERVER_SETTINGS_FILE ]]; then
cp /opt/factorio/data/server-settings.example.json "$SERVER_SETTINGS_FILE"
fi

if [[ ! -f $CONFIG/map-gen-settings.json ]]; then
Expand All @@ -45,11 +48,11 @@ exec $EXEC /opt/factorio/bin/x64/factorio \
--preset "$PRESET" \
--map-gen-settings "$CONFIG/map-gen-settings.json" \
--map-settings "$CONFIG/map-settings.json" \
--server-settings "$CONFIG/server-settings.json" \
--server-banlist "$CONFIG/server-banlist.json" \
--server-whitelist "$CONFIG/server-whitelist.json" \
--server-settings "$SERVER_SETTINGS_FILE" \
--server-banlist "$SERVER_BANLIST_FILE" \
--server-whitelist "$SERVER_WHITELIST_FILE" \
--use-server-whitelist \
--server-adminlist "$CONFIG/server-adminlist.json" \
--server-adminlist "$SERVER_ADMINLIST_FILE" \
--rcon-port "$RCON_PORT" \
--rcon-password "$(cat "$CONFIG/rconpw")" \
--server-id /factorio/config/server-id.json
--server-id "$SERVER_ID_FILE"
7 changes: 5 additions & 2 deletions docker/files/scenario2map.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@ mkdir -p "$CONFIG"
mkdir -p "$MODS"
mkdir -p "$SCENARIOS"

if [[ ! -f $CONFIG/server-settings.json ]]; then
cp /opt/factorio/data/server-settings.example.json "$CONFIG/server-settings.json"
# shellcheck disable=SC1091
source "${INSTALLED_DIRECTORY}/server-config.sh"

if [[ ! -f $SERVER_SETTINGS_FILE ]]; then
cp /opt/factorio/data/server-settings.example.json "$SERVER_SETTINGS_FILE"
fi

if [[ ! -f $CONFIG/map-gen-settings.json ]]; then
Expand Down
19 changes: 19 additions & 0 deletions docker/files/server-config.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#!/bin/bash
# Shared by server entrypoints and the standalone mod updater.
SERVER_ADMINLIST_FILE="${SERVER_ADMINLIST_FILE:-$CONFIG/server-adminlist.json}"
SERVER_BANLIST_FILE="${SERVER_BANLIST_FILE:-$CONFIG/server-banlist.json}"
SERVER_ID_FILE="${SERVER_ID_FILE:-$CONFIG/server-id.json}"
SERVER_SETTINGS_FILE="${SERVER_SETTINGS_FILE:-$CONFIG/server-settings.json}"
SERVER_WHITELIST_FILE="${SERVER_WHITELIST_FILE:-$CONFIG/server-whitelist.json}"

for config_file in "$SERVER_ADMINLIST_FILE" "$SERVER_BANLIST_FILE" "$SERVER_ID_FILE" \
"$SERVER_SETTINGS_FILE" "$SERVER_WHITELIST_FILE"; do
if [[ "$config_file" != /* || "$config_file" == */ || ( -e "$config_file" && ! -f "$config_file" ) ]]; then
echo "Invalid server config path: $config_file (expected an absolute file path)" >&2
return 1
fi
mkdir -p -- "$(dirname -- "$config_file")"
done
unset config_file

export SERVER_ADMINLIST_FILE SERVER_BANLIST_FILE SERVER_ID_FILE SERVER_SETTINGS_FILE SERVER_WHITELIST_FILE
170 changes: 170 additions & 0 deletions tests/test_config_paths.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
"""Run entrypoint/config-path regressions without Factorio or network access."""

import json
import os
from pathlib import Path
import subprocess
import tempfile
import unittest

ROOT = Path(__file__).resolve().parents[1]
FILES = ROOT / "docker" / "files"
PATHS = {
"SERVER_SETTINGS_FILE": ("--server-settings", "server-settings.json"),
"SERVER_ID_FILE": ("--server-id", "server-id.json"),
"SERVER_ADMINLIST_FILE": ("--server-adminlist", "server-adminlist.json"),
"SERVER_BANLIST_FILE": ("--server-banlist", "server-banlist.json"),
"SERVER_WHITELIST_FILE": ("--server-whitelist", "server-whitelist.json"),
}
ENTRYPOINTS = ("docker-entrypoint.sh", "docker-entrypoint-rootless.sh", "scenario.sh")


class ConfigPathTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.work = Path(self.temp.name)
self.scripts = self.work / "scripts"
self.scripts.mkdir()
self.opt = self.work / "opt"
for directory in ("data", "bin/x64", "config"):
(self.opt / directory).mkdir(parents=True)
self.volume = self.work / "volume"
self.volume.mkdir()
self.config = self.volume / "config"
self.config.mkdir()
self.calls = self.work / "calls.jsonl"
self.credentials = self.work / "credentials.json"
self.env = {**os.environ, "PATH": f"{self.scripts}:{os.environ['PATH']}",
"CONFIG": str(self.config), "PORT": "34197", "RCON_PORT": "27015",
"PUID": "845", "PGID": "845", "VERSION": "2.1.24", "UPDATE_MODS_ON_START": "false",
"CALLS": str(self.calls), "CREDENTIALS": str(self.credentials), "DLC_SPACE_AGE": "false"}
for name in ("SAVES", "MODS", "SCENARIOS", "SCRIPTOUTPUT"):
self.env[name] = str(self.volume / name.lower())
for name in (*PATHS, "USERNAME", "TOKEN", "BASH_ENV"):
self.env.pop(name, None)
for source in FILES.glob("*.sh"):
# Redirect only absolute container paths. Startup logic and shared helper stay intact.
contents = source.read_text().replace("/opt/factorio", str(self.opt))
contents = contents.replace("FACTORIO_VOL=/factorio", f"FACTORIO_VOL={self.volume}")
contents = contents.replace("/run/secrets", str(self.work / "secrets"))
target = self.scripts / source.name
target.write_text(contents)
target.chmod(0o755)
self.write_command(self.scripts / "id", "#!/bin/sh\nprintf '1000\\n'\n")
self.write_command(self.scripts / "pwgen", "#!/bin/sh\nprintf 'test-password\\n'\n")
self.write_command(self.scripts / "docker-dlc.sh", "#!/bin/sh\nexit 0\n")
self.write_command(self.scripts / "update-mods.sh", """#!/usr/bin/env python3
import json, os, sys
from pathlib import Path
Path(os.environ['CREDENTIALS']).write_text(json.dumps(sys.argv[1:]))
""")
self.write_command(self.opt / "bin/x64/factorio", """#!/usr/bin/env python3
import json, os, sys
from pathlib import Path
with open(os.environ['CALLS'], 'a') as stream:
stream.write(json.dumps(sys.argv[1:]) + '\\n')
if '--create' in sys.argv:
Path(sys.argv[sys.argv.index('--create') + 1]).touch()
""")
self.example = {"name": "example", "username": "file-user", "token": "file-token"}
(self.opt / "data/server-settings.example.json").write_text(json.dumps(self.example))
for name in ("map-settings", "map-gen-settings"):
(self.opt / f"data/{name}.example.json").write_text("{}")
(self.opt / "config/config.ini").write_text("write-data=/factorio\n")

@staticmethod
def write_command(path, contents):
path.write_text(contents)
path.chmod(0o755)

def run_script(self, script, overrides=None, success=True):
args = ["bash", str(self.scripts / script)]
if script.startswith("scenario"):
args.append("freeplay")
if self.calls.exists():
self.calls.unlink()
result = subprocess.run(args, cwd=self.scripts, env={**self.env, **(overrides or {})},
capture_output=True, text=True)
if success:
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
else:
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertFalse(self.calls.exists(), "Invalid path must fail before starting Factorio")
return result

def assert_paths(self, overrides):
args = json.loads(self.calls.read_text().splitlines()[-1])
for variable, (flag, filename) in PATHS.items():
expected = overrides.get(variable) or str(self.config / filename)
self.assertEqual(args[args.index(flag) + 1], expected)

def test_defaults_and_empty_overrides(self):
for script in ENTRYPOINTS:
for overrides in ({}, dict.fromkeys(PATHS, "")):
with self.subTest(script=script, overrides=overrides):
self.run_script(script, overrides)
self.assert_paths(overrides)

def test_individual_and_combined_overrides_with_spaces(self):
all_paths = {variable: str(self.work / "custom dir" / filename)
for variable, (_, filename) in PATHS.items()}
for script in ENTRYPOINTS:
for overrides in [{name: value} for name, value in all_paths.items()] + [all_paths]:
with self.subTest(script=script, overrides=overrides):
self.run_script(script, overrides)
self.assert_paths(overrides)
for path in overrides.values():
self.assertTrue(Path(path).parent.is_dir())

def test_custom_settings_created_and_existing_file_preserved(self):
path = self.work / "separate settings" / "settings.json"
for script in ENTRYPOINTS:
with self.subTest(script=script):
if path.exists():
path.chmod(0o644)
path.unlink()
self.run_script(script, {"SERVER_SETTINGS_FILE": str(path)})
self.assertEqual(json.loads(path.read_text()), self.example)
path.write_text('{"name":"keep me"}')
path.chmod(0o444)
self.run_script(script, {"SERVER_SETTINGS_FILE": str(path)})
self.assertEqual(path.read_text(), '{"name":"keep me"}')

def test_invalid_paths_fail_before_startup(self):
directory = self.work / "directory"
directory.mkdir()
for script in ENTRYPOINTS:
for variable in PATHS:
for path in ("relative.json", str(directory), str(self.work / "trailing") + "/"):
with self.subTest(script=script, variable=variable, path=path):
result = self.run_script(script, {variable: path}, success=False)
self.assertIn("Invalid server config path", result.stderr)

def test_blocked_parent_fails_before_startup(self):
blocker = self.work / "not-a-directory"
blocker.touch()
for script in ENTRYPOINTS:
with self.subTest(script=script):
self.run_script(script, {"SERVER_SETTINGS_FILE": str(blocker / "settings.json")}, success=False)

def test_mod_updater_uses_custom_settings_and_explicit_credentials_win(self):
settings = self.work / "credentials settings.json"
settings.write_text(json.dumps(self.example))
for script in ("docker-entrypoint.sh", "docker-entrypoint-rootless.sh", "docker-update-mods.sh"):
with self.subTest(script=script):
overrides = {"SERVER_SETTINGS_FILE": str(settings), "UPDATE_MODS_ON_START": "true"}
self.run_script(script, overrides)
self.assertEqual(json.loads(self.credentials.read_text())[2:4], ["file-user", "file-token"])
self.run_script(script, {**overrides, "USERNAME": "explicit-user", "TOKEN": "explicit-token"})
self.assertEqual(json.loads(self.credentials.read_text())[2:4], ["explicit-user", "explicit-token"])

def test_scenario_conversion_uses_custom_settings(self):
settings = self.work / "conversion" / "settings.json"
self.run_script("scenario2map.sh", {"SERVER_SETTINGS_FILE": str(settings)})
self.assertEqual(json.loads(settings.read_text()), self.example)
self.assertEqual(json.loads(self.calls.read_text()), ["--scenario2map", "freeplay"])


if __name__ == "__main__":
unittest.main()
Loading