Merge pull request #263765 from numinit/armagetronad-module

nixos/armagetronad: Add module with NixOS tests

Sandro bbabfca4 5f183589

Changed files
+544
nixos
doc
manual
release-notes
modules
tests
+2
nixos/doc/manual/release-notes/rl-2405.section.md
···
- [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).
- [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)
···
- [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
nixos/modules/module-list.nix
···
./services/editors/infinoted.nix
./services/finance/odoo.nix
./services/games/archisteamfarm.nix
./services/games/crossfire-server.nix
./services/games/deliantra-server.nix
./services/games/factorio.nix
···
./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
nixos/modules/services/games/armagetronad.nix
···
···
+
{ 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
nixos/tests/all-tests.nix
···
appliance-repart-image = runTest ./appliance-repart-image.nix;
apparmor = handleTest ./apparmor.nix {};
archi = handleTest ./archi.nix {};
atd = handleTest ./atd.nix {};
atop = handleTest ./atop.nix {};
atuin = handleTest ./atuin.nix {};
···
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
nixos/tests/armagetronad.nix
···
···
+
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}")
+
'';
+
+
})