diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..9b229ef8 --- /dev/null +++ b/.github/workflows/tests.yml @@ -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 diff --git a/README.md b/README.md index a96c1b9c..fe1eb830 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/docker/files/docker-entrypoint-rootless.sh b/docker/files/docker-entrypoint-rootless.sh index 2b91698a..07328945 100755 --- a/docker/files/docker-entrypoint-rootless.sh +++ b/docker/files/docker-entrypoint-rootless.sh @@ -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 @@ -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 @@ -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" \ ) diff --git a/docker/files/docker-entrypoint.sh b/docker/files/docker-entrypoint.sh index fd9537bc..9cc983a9 100755 --- a/docker/files/docker-entrypoint.sh +++ b/docker/files/docker-entrypoint.sh @@ -14,6 +14,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" if [[ ! -f $CONFIG/rconpw ]]; then @@ -21,9 +24,9 @@ if [[ ! -f $CONFIG/rconpw ]]; then 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 @@ -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" \ @@ -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" \ ) diff --git a/docker/files/docker-update-mods.sh b/docker/files/docker-update-mods.sh index 47dac482..37d20516 100755 --- a/docker/files/docker-update-mods.sh +++ b/docker/files/docker-update-mods.sh @@ -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) @@ -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 diff --git a/docker/files/scenario.sh b/docker/files/scenario.sh index 412379d4..1e65e380 100755 --- a/docker/files/scenario.sh +++ b/docker/files/scenario.sh @@ -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 @@ -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" diff --git a/docker/files/scenario2map.sh b/docker/files/scenario2map.sh index 7f40d3a2..c996535e 100755 --- a/docker/files/scenario2map.sh +++ b/docker/files/scenario2map.sh @@ -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 diff --git a/docker/files/server-config.sh b/docker/files/server-config.sh new file mode 100644 index 00000000..a24d286d --- /dev/null +++ b/docker/files/server-config.sh @@ -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 diff --git a/tests/test_config_paths.py b/tests/test_config_paths.py new file mode 100644 index 00000000..92915890 --- /dev/null +++ b/tests/test_config_paths.py @@ -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()