Unverified Commit bbabfca4 authored by Sandro Jäckel's avatar Sandro Jäckel Committed by GitHub
Browse files

Merge pull request #263765 from numinit/armagetronad-module

nixos/armagetronad: Add module with NixOS tests
parents 5f183589 a5c305d1
Loading
Loading
Loading
Loading
+2 −0
Original line number Diff line number Diff line
@@ -93,6 +93,8 @@ The pre-existing [services.ankisyncd](#opt-services.ankisyncd.enable) has been m

- [Clevis](https://github.com/latchset/clevis), a pluggable framework for automated decryption, used to unlock encrypted devices in initrd. Available as [boot.initrd.clevis.enable](#opt-boot.initrd.clevis.enable).

- [armagetronad](https://wiki.armagetronad.org), a mid-2000s 3D lightcycle game widely played at iD Tech Camps. You can define multiple servers using `services.armagetronad.<server>.enable`.

- [TuxClocker](https://github.com/Lurkki14/tuxclocker), a hardware control and monitoring program. Available as [programs.tuxclocker](#opt-programs.tuxclocker.enable).

- [ALVR](https://github.com/alvr-org/alvr), a VR desktop streamer. Available as [programs.alvr](#opt-programs.alvr.enable)
+1 −0
Original line number Diff line number Diff line
@@ -512,6 +512,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
+268 −0
Original line number Diff line number Diff line
{ 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}";
  getStateDirectory = serverName: "armagetronad/${serverName}";
  getServerRoot = serverName: "/var/lib/${getStateDirectory 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 which 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 the configured 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:
                <https://wiki.armagetronad.org/index.php?title=Console_Commands>
                or `armagetronad-dedicated --doc` for a list.

                This attrset is used to populate `settings_custom.cfg`; see:
                <https://wiki.armagetronad.org/index.php/Configuration_Files>
              '';
              example = literalExpression ''
                {
                  CYCLE_RUBBER = 40;
                }
              '';
            };

            roundSettings = mkOption {
              type = settingsFormat.type;
              default = { };
              description = lib.mdDoc ''
                Armagetron Advanced server per-round configuration. Refer to:
                <https://wiki.armagetronad.org/index.php?title=Console_Commands>
                or `armagetronad-dedicated --doc` for a list.

                This attrset is used to populate `everytime.cfg`; see:
                <https://wiki.armagetronad.org/index.php/Configuration_Files>
              '';
              example = literalExpression ''
                {
                  SAY = [
                    "Hosted on NixOS"
                    "https://nixos.org"
                    "iD Tech High Rubber rul3z!! Happy New Year 2008!!1"
                  ];
                }
              '';
            };
          };
        });
      };
    };
  };

  config = mkIf (enabledServers != { }) {
    systemd.tmpfiles.settings = mkMerge (mapAttrsToList
      (serverName: serverCfg:
        let
          serverId = nameToId serverName;
          serverRoot = getServerRoot serverName;
          serverInfo = (
            {
              SERVER_IP = serverCfg.host;
              SERVER_PORT = serverCfg.port;
              SERVER_NAME = serverCfg.name;
            } // (lib.optionalAttrs (serverCfg.dns != null) { SERVER_DNS = serverCfg.dns; })
          );
          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
        {
          "10-armagetronad-${serverId}" = {
            "${serverRoot}/data" = {
              d = {
                group = serverId;
                user = serverId;
                mode = "0750";
              };
            };
            "${serverRoot}/settings" = {
              d = {
                group = serverId;
                user = serverId;
                mode = "0750";
              };
            };
            "${serverRoot}/var" = {
              d = {
                group = serverId;
                user = serverId;
                mode = "0750";
              };
            };
            "${serverRoot}/resource" = {
              d = {
                group = serverId;
                user = serverId;
                mode = "0750";
              };
            };
            "${serverRoot}/input" = {
              "f+" = {
                group = serverId;
                user = serverId;
                mode = "0640";
              };
            };
            "${serverRoot}/settings/server_info.cfg" = {
              "L+" = {
                argument = "${serverInfoCfg}";
              };
            };
            "${serverRoot}/settings/settings_custom.cfg" = {
              "L+" = {
                argument = "${customSettingsCfg}";
              };
            };
            "${serverRoot}/settings/everytime.cfg" = {
              "L+" = {
                argument = "${everytimeSettingsCfg}";
              };
            };
          };
        }
      )
      enabledServers
    );

    systemd.services = mkMerge (mapAttrsToList
      (serverName: serverCfg:
        let
          serverId = nameToId serverName;
        in
        {
          "armagetronad-${serverName}" = {
            description = "Armagetron Advanced Dedicated Server for ${serverName}";
            wants = [ "basic.target" ];
            after = [ "basic.target" "network.target" "multi-user.target" ];
            wantedBy = [ "multi-user.target" ];
            serviceConfig =
              let
                serverRoot = getServerRoot serverName;
              in
              {
                Type = "simple";
                StateDirectory = getStateDirectory serverName;
                ExecStart = "${lib.getExe serverCfg.package} --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
    );
  };
}
+1 −0
Original line number Diff line number Diff line
@@ -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 {};
+272 −0
Original line number Diff line number Diff line
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"
              ];
            };
          };
          trunk = {
            enable = true;
            name = "Smoke Test trunk Server";
            package = pkgs.armagetronad."0.4".dedicated;
            port = 4536;
            settings = {
              SERVER_OPTIONS = "0.4 server made to run smoke tests.";
              CYCLE_RUBBER = 20;
              SIZE_FACTOR = 0.5;
            };
            roundSettings = {
              SAY = [
                "NixOS Smoke Test 0.4 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),
        Server(server, 'trunk', 'server', 4536, 'NixOS Smoke Test 0.4 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}")
    '';

})