From 1dc5eb13b0fe25ce66928869821997d8202d240d Mon Sep 17 00:00:00 2001 From: Morgan Jones Date: Sat, 25 Nov 2023 13:06:11 -0800 Subject: [PATCH] nixos/armagetronad: add module with tests --- nixos/modules/module-list.nix | 1 + nixos/modules/services/games/armagetronad.nix | 221 +++++++++++++++ nixos/tests/all-tests.nix | 1 + nixos/tests/armagetronad.nix | 254 ++++++++++++++++++ 4 files changed, 477 insertions(+) create mode 100644 nixos/modules/services/games/armagetronad.nix create mode 100644 nixos/tests/armagetronad.nix diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index 71498e397cb6..af314370e883 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -509,6 +509,7 @@ ./services/editors/infinoted.nix ./services/finance/odoo.nix ./services/games/archisteamfarm.nix + ./services/games/armagetronad.nix ./services/games/crossfire-server.nix ./services/games/deliantra-server.nix ./services/games/factorio.nix diff --git a/nixos/modules/services/games/armagetronad.nix b/nixos/modules/services/games/armagetronad.nix new file mode 100644 index 000000000000..64b8cb23057e --- /dev/null +++ b/nixos/modules/services/games/armagetronad.nix @@ -0,0 +1,221 @@ +{ config, lib, pkgs, ... }: +let + inherit (lib) mkEnableOption mkIf mkOption mkMerge literalExpression; + inherit (lib) mapAttrsToList filterAttrs unique recursiveUpdate types; + + mkValueStringArmagetron = with lib; v: + if isInt v then toString v + else if isFloat v then toString v + else if isString v then v + else if true == v then "1" + else if false == v then "0" + else if null == v then "" + else throw "unsupported type: ${builtins.typeOf v}: ${(lib.generators.toPretty {} v)}"; + + settingsFormat = pkgs.formats.keyValue { + mkKeyValue = lib.generators.mkKeyValueDefault + { + mkValueString = mkValueStringArmagetron; + } " "; + listsAsDuplicateKeys = true; + }; + + cfg = config.services.armagetronad; + enabledServers = lib.filterAttrs (n: v: v.enable) cfg.servers; + nameToId = serverName: "armagetronad-${serverName}"; +in +{ + options = { + services.armagetronad = { + servers = mkOption { + description = lib.mdDoc "Armagetron server definitions."; + default = { }; + type = types.attrsOf (types.submodule { + options = { + enable = mkEnableOption (lib.mdDoc "armagetronad"); + package = lib.mkPackageOptionMD pkgs "armagetronad-dedicated" { + example = '' + pkgs.armagetronad."0.2.9-sty+ct+ap".dedicated + ''; + extraDescription = '' + Ensure that you use a derivation whose evaluation contains the path `bin/armagetronad-dedicated`. + ''; + }; + host = mkOption { + type = types.str; + default = "0.0.0.0"; + description = lib.mdDoc "Host to listen on. Used for SERVER_IP."; + }; + port = mkOption { + type = types.port; + default = 4534; + description = lib.mdDoc "Port to listen on. Used for SERVER_PORT."; + }; + dns = mkOption { + type = types.nullOr types.str; + default = null; + description = lib.mdDoc "DNS address to use for this server. Optional."; + }; + openFirewall = mkOption { + type = types.bool; + default = true; + description = lib.mdDoc "Set to true to open a UDP port for Armagetron Advanced."; + }; + name = mkOption { + type = types.str; + description = "The name of this server."; + }; + settings = mkOption { + type = settingsFormat.type; + default = { }; + description = lib.mdDoc '' + Armagetron Advanced server rules configuration. Refer to: + + or `armagetronad-dedicated --doc` for a list. + + This attrset is used to populate `settings_custom.cfg`; see: + + ''; + example = literalExpression '' + { + CYCLE_RUBBER = 40; + } + ''; + }; + roundSettings = mkOption { + type = settingsFormat.type; + default = { }; + description = lib.mdDoc '' + Armagetron Advanced server per-round configuration. Refer to: + + or `armagetronad-dedicated --doc` for a list. + + This attrset is used to populate `everytime.cfg`; see: + + ''; + example = literalExpression '' + { + SAY = [ + "Hosted on NixOS" + "https://nixos.org" + "iD Tech High Rubber rul3z!! Happy New Year 2008!!1" + ]; + } + ''; + }; + }; + }); + }; + }; + }; + + config = mkIf (enabledServers != { }) { + systemd.services = mkMerge (mapAttrsToList + (serverName: serverCfg: + let + serverId = nameToId serverName; + serverInfo = ( + { + SERVER_IP = serverCfg.host; + SERVER_PORT = serverCfg.port; + SERVER_NAME = serverCfg.name; + } // ( + if serverCfg.dns != null then { SERVER_DNS = serverCfg.dns; } + else { } + ) + ); + customSettings = serverCfg.settings; + everytimeSettings = serverCfg.roundSettings; + + serverInfoCfg = settingsFormat.generate "server_info.${serverName}.cfg" serverInfo; + customSettingsCfg = settingsFormat.generate "settings_custom.${serverName}.cfg" customSettings; + everytimeSettingsCfg = settingsFormat.generate "everytime.${serverName}.cfg" everytimeSettings; + in + { + "armagetronad@${serverName}" = { + description = "Armagetron Advanced Dedicated Server for ${serverName}"; + wants = [ "basic.target" ]; + after = [ "basic.target" "network.target" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = + let + stateDirectory = "armagetronad/${serverName}"; + serverRoot = "/var/lib/${stateDirectory}"; + preStart = pkgs.writeShellScript "armagetronad-${serverName}-prestart.sh" '' + owner="${serverId}:${serverId}" + + # Create the config directories. + for dirname in data settings var resource; do + dir="${serverRoot}/$dirname" + mkdir -p "$dir" + chmod u+rwx,g+rx,o-rwx "$dir" + chown "$owner" "$dir" + done + + # Link in the config files if present and non-trivial. + ln -sf ${serverInfoCfg} "${serverRoot}/settings/server_info.cfg" + ln -sf ${customSettingsCfg} "${serverRoot}/settings/settings_custom.cfg" + ln -sf ${everytimeSettingsCfg} "${serverRoot}/settings/everytime.cfg" + + # Create an input file for sending commands to the server. + input="${serverRoot}/input" + truncate -s0 "$input" + chmod u+rw,g+r,o-rwx "$input" + chown "$owner" "$input" + ''; + in + { + Type = "simple"; + StateDirectory = stateDirectory; + ExecStartPre = preStart; + ExecStart = "${serverCfg.package}/bin/armagetronad-dedicated --daemon --input ${serverRoot}/input --userdatadir ${serverRoot}/data --userconfigdir ${serverRoot}/settings --vardir ${serverRoot}/var --autoresourcedir ${serverRoot}/resource"; + Restart = "on-failure"; + CapabilityBoundingSet = ""; + LockPersonality = true; + NoNewPrivileges = true; + PrivateDevices = true; + PrivateTmp = true; + PrivateUsers = true; + ProtectClock = true; + ProtectControlGroups = true; + ProtectHome = true; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectProc = "invisible"; + ProtectSystem = "strict"; + RestrictNamespaces = true; + RestrictSUIDSGID = true; + User = serverId; + Group = serverId; + }; + }; + }) + enabledServers + ); + + networking.firewall.allowedUDPPorts = + unique (mapAttrsToList (serverName: serverCfg: serverCfg.port) (filterAttrs (serverName: serverCfg: serverCfg.openFirewall) enabledServers)); + + users.users = mkMerge (mapAttrsToList + (serverName: serverCfg: + { + ${nameToId serverName} = { + group = nameToId serverName; + description = "Armagetron Advanced dedicated user for server ${serverName}"; + isSystemUser = true; + }; + }) + enabledServers + ); + + users.groups = mkMerge (mapAttrsToList + (serverName: serverCfg: + { + ${nameToId serverName} = { }; + }) + enabledServers + ); + }; +} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 81bd36cf0e34..413882bcaa7a 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -128,6 +128,7 @@ in { appliance-repart-image = runTest ./appliance-repart-image.nix; apparmor = handleTest ./apparmor.nix {}; archi = handleTest ./archi.nix {}; + armagetronad = handleTest ./armagetronad.nix {}; atd = handleTest ./atd.nix {}; atop = handleTest ./atop.nix {}; atuin = handleTest ./atuin.nix {}; diff --git a/nixos/tests/armagetronad.nix b/nixos/tests/armagetronad.nix new file mode 100644 index 000000000000..d518673203a2 --- /dev/null +++ b/nixos/tests/armagetronad.nix @@ -0,0 +1,254 @@ +import ./make-test-python.nix ({ pkgs, ...} : + +let + user = "alice"; + + client = + { pkgs, ... }: + + { imports = [ ./common/user-account.nix ./common/x11.nix ]; + hardware.opengl.driSupport = true; + virtualisation.memorySize = 256; + environment = { + systemPackages = [ pkgs.armagetronad ]; + variables.XAUTHORITY = "/home/${user}/.Xauthority"; + }; + test-support.displayManager.auto.user = user; + }; + +in { + name = "armagetronad"; + meta = with pkgs.lib.maintainers; { + maintainers = [ numinit ]; + }; + + enableOCR = true; + + nodes = + { + server = { + services.armagetronad.servers = { + high-rubber = { + enable = true; + name = "Smoke Test High Rubber Server"; + port = 4534; + settings = { + SERVER_OPTIONS = "High Rubber server made to run smoke tests."; + CYCLE_RUBBER = 40; + SIZE_FACTOR = 0.5; + }; + roundSettings = { + SAY = [ + "NixOS Smoke Test Server" + "https://nixos.org" + ]; + }; + }; + sty = { + enable = true; + name = "Smoke Test sty+ct+ap Server"; + package = pkgs.armagetronad."0.2.9-sty+ct+ap".dedicated; + port = 4535; + settings = { + SERVER_OPTIONS = "sty+ct+ap server made to run smoke tests."; + CYCLE_RUBBER = 20; + SIZE_FACTOR = 0.5; + }; + roundSettings = { + SAY = [ + "NixOS Smoke Test sty+ct+ap Server" + "https://nixos.org" + ]; + }; + }; + }; + }; + + client1 = client; + client2 = client; + }; + + testScript = let + xdo = name: text: let + xdoScript = pkgs.writeText "${name}.xdo" text; + in "${pkgs.xdotool}/bin/xdotool ${xdoScript}"; + in + '' + import shlex + import threading + from collections import namedtuple + + class Client(namedtuple('Client', ('node', 'name'))): + def send(self, *keys): + for key in keys: + self.node.send_key(key) + + def send_on(self, text, *keys): + self.node.wait_for_text(text) + self.send(*keys) + + Server = namedtuple('Server', ('node', 'name', 'address', 'port', 'welcome', 'attacker', 'victim', 'coredump_delay')) + + # Clients and their in-game names + clients = ( + Client(client1, 'Arduino'), + Client(client2, 'SmOoThIcE') + ) + + # Server configs. + servers = ( + Server(server, 'high-rubber', 'server', 4534, 'NixOS Smoke Test Server', 'SmOoThIcE', 'Arduino', 8), + Server(server, 'sty', 'server', 4535, 'NixOS Smoke Test sty+ct+ap Server', 'Arduino', 'SmOoThIcE', 8) + ) + + """ + Runs a command as the client user. + """ + def run(cmd): + return "su - ${user} -c " + shlex.quote(cmd) + + screenshot_idx = 1 + + """ + Takes screenshots on all clients. + """ + def take_screenshots(screenshot_idx): + for client in clients: + client.node.screenshot(f"screen_{client.name}_{screenshot_idx}") + return screenshot_idx + 1 + + # Wait for the servers to come up. + start_all() + for srv in servers: + srv.node.wait_for_unit(f"armagetronad@{srv.name}") + srv.node.wait_until_succeeds(f"ss --numeric --udp --listening | grep -q {srv.port}") + + # Make sure console commands work through the named pipe we created. + for srv in servers: + srv.node.succeed( + f"echo 'say Testing!' >> /var/lib/armagetronad/{srv.name}/input" + ) + srv.node.succeed( + f"echo 'say Testing again!' >> /var/lib/armagetronad/{srv.name}/input" + ) + srv.node.wait_until_succeeds( + f"journalctl -u armagetronad@{srv.name} -e | grep -q 'Admin: Testing!'" + ) + srv.node.wait_until_succeeds( + f"journalctl -u armagetronad@{srv.name} -e | grep -q 'Admin: Testing again!'" + ) + + """ + Sets up a client, waiting for the given barrier on completion. + """ + def client_setup(client, servers, barrier): + client.node.wait_for_x() + + # Configure Armagetron. + client.node.succeed( + run("mkdir -p ~/.armagetronad/var"), + run(f"echo 'PLAYER_1 {client.name}' >> ~/.armagetronad/var/autoexec.cfg") + ) + for idx, srv in enumerate(servers): + client.node.succeed( + run(f"echo 'BOOKMARK_{idx+1}_ADDRESS {srv.address}' >> ~/.armagetronad/var/autoexec.cfg"), + run(f"echo 'BOOKMARK_{idx+1}_NAME {srv.name}' >> ~/.armagetronad/var/autoexec.cfg"), + run(f"echo 'BOOKMARK_{idx+1}_PORT {srv.port}' >> ~/.armagetronad/var/autoexec.cfg") + ) + + # Start Armagetron. + client.node.succeed(run("ulimit -c unlimited; armagetronad >&2 & disown")) + client.node.wait_until_succeeds( + run( + "${xdo "create_new_win-select_main_window" '' + search --onlyvisible --name "Armagetron Advanced" + windowfocus --sync + windowactivate --sync + ''}" + ) + ) + + # Get through the tutorial. + client.send_on('Language Settings', 'ret') + client.send_on('First Setup', 'ret') + client.send_on('Welcome to Armagetron Advanced', 'ret') + client.send_on('round 1', 'esc') + client.send_on('Menu', 'up', 'up', 'ret') + client.send_on('We hope you', 'ret') + client.send_on('Armagetron Advanced', 'ret') + client.send_on('Play Game', 'ret') + + # Online > LAN > Network Setup > Mates > Server Bookmarks + client.send_on('Multiplayer', 'down', 'down', 'down', 'down', 'ret') + + barrier.wait() + + # Get to the Server Bookmarks screen on both clients. This takes a while so do it asynchronously. + barrier = threading.Barrier(3, timeout=120) + for client in clients: + threading.Thread(target=client_setup, args=(client, servers, barrier)).start() + barrier.wait() + + # Main testing loop. Iterates through each server bookmark and connects to them in sequence. + # Assumes that the game is currently on the Server Bookmarks screen. + for srv in servers: + screenshot_idx = take_screenshots(screenshot_idx) + + # Connect both clients at once, one second apart. + for client in clients: + client.send('ret') + client.node.sleep(1) + + # Wait for clients to connect + for client in clients: + srv.node.wait_until_succeeds( + f"journalctl -u armagetronad@{srv.name} -e | grep -q '{client.name}.*entered the game'" + ) + + # Wait for the match to start + srv.node.wait_until_succeeds( + f"journalctl -u armagetronad@{srv.name} -e | grep -q 'Admin: {srv.welcome}'" + ) + srv.node.wait_until_succeeds( + f"journalctl -u armagetronad@{srv.name} -e | grep -q 'Admin: https://nixos.org'" + ) + srv.node.wait_until_succeeds( + f"journalctl -u armagetronad@{srv.name} -e | grep -q 'Go (round 1 of 10)'" + ) + + # Wait a bit + srv.node.sleep(srv.coredump_delay) + + # Turn the attacker player's lightcycle left + attacker = next(client for client in clients if client.name == srv.attacker) + victim = next(client for client in clients if client.name == srv.victim) + attacker.send('left') + screenshot_idx = take_screenshots(screenshot_idx) + + # Wait for coredump. + srv.node.wait_until_succeeds( + f"journalctl -u armagetronad@{srv.name} -e | grep -q '{attacker.name} core dumped {victim.name}'" + ) + screenshot_idx = take_screenshots(screenshot_idx) + + # Disconnect both clients from the server + for client in clients: + client.send('esc') + client.send_on('Menu', 'up', 'up', 'ret') + srv.node.wait_until_succeeds( + f"journalctl -u armagetronad@{srv.name} -e | grep -q '{client.name}.*left the game'" + ) + + # Next server. + for client in clients: + client.send_on('Server Bookmarks', 'down') + + # Stop the servers + for srv in servers: + srv.node.succeed( + f"systemctl stop armagetronad@{srv.name}" + ) + srv.node.wait_until_fails(f"ss --numeric --udp --listening | grep -q {srv.port}") + ''; + +})