From 07689e1a9eaa38cf09e657666b44d8e7173676bd Mon Sep 17 00:00:00 2001 From: yukkop Date: Mon, 31 Aug 2026 10:51:38 +0000 Subject: [PATCH] feat: p4 on hectic-lab --- nixos/module/hectic/service/p4d.nix | 355 +++++++++++++++++++++++++ nixos/system/hectic-lab/default.nix | 2 +- nixos/system/hectic-lab/hectic-lab.nix | 8 +- test/default.nix | 6 +- test/nixos/default.nix | 56 ++++ 5 files changed, 422 insertions(+), 5 deletions(-) create mode 100644 nixos/module/hectic/service/p4d.nix create mode 100644 test/nixos/default.nix diff --git a/nixos/module/hectic/service/p4d.nix b/nixos/module/hectic/service/p4d.nix new file mode 100644 index 00000000..0ad119e0 --- /dev/null +++ b/nixos/module/hectic/service/p4d.nix @@ -0,0 +1,355 @@ +{ ... }: +{ + pkgs, + lib, + config, + ... +}: let + cfg = config.hectic.services.p4d; + + boolString = value: if value then "1" else "0"; + + serviceUser = cfg.user; + serviceGroup = cfg.group; + packageExe = if cfg.package != null then "${cfg.package}/bin/p4d" else "/run/current-system/sw/bin/false"; + clientExe = if cfg.clientPackage != null then "${cfg.clientPackage}/bin/p4" else "/run/current-system/sw/bin/false"; + + journalFile = "${cfg.journalDir}/journal"; + logFile = "${cfg.dataDir}/logs/p4d.log"; + sslPort = if cfg.ssl.enable then "ssl:${cfg.listenAddress}:${toString cfg.port}" else "${cfg.listenAddress}:${toString cfg.port}"; + bootstrapPort = "${cfg.bootstrap.listenAddress}:${toString cfg.bootstrap.port}"; + + typemapLines = [ + "binary+l //....uasset" + "binary+l //....umap" + "binary+l //....upk" + "binary+l //....udk" + "binary+l //....ubulk" + "binary+l //....uexp" + "binary+l //....utoc" + "binary+l //....ucas" + "binary+w //....exe" + "binary+w //....dll" + "binary+w //....lib" + "binary+w //....app" + "binary+w //....dylib" + "binary+w //....stub" + "binary+w //....ipa" + "binary+w //....pdb" + ] ++ cfg.typemap.extraLines; + + typemapSpec = pkgs.writeText "p4d-unreal-typemap.txt" ( + lib.concatStringsSep "\n" ([ "Typemap:" ] ++ map (line: "\t${line}") typemapLines) + "\n" + ); + + mainArgs = [ + "-r" cfg.dataDir + "-p" sslPort + "-J" journalFile + "-L" logFile + ] ++ lib.optional (!cfg.caseSensitive) "-C1"; + + bootstrapArgs = [ + "-r" cfg.dataDir + "-p" bootstrapPort + "-J" journalFile + "-L" "${cfg.dataDir}/logs/bootstrap.log" + ] ++ lib.optional (!cfg.caseSensitive) "-C1"; + + initScript = pkgs.writeShellScript "p4d-init" '' + set -eu + + export P4ROOT=${lib.escapeShellArg cfg.dataDir} + export P4JOURNAL=${lib.escapeShellArg journalFile} + export P4SSLDIR=${lib.escapeShellArg cfg.ssl.dir} + + marker_file="$P4ROOT/.hectic-p4d-initialized" + typemap_target="$P4ROOT/unreal-engine.typemap" + server_id_file="$P4ROOT/server.id" + + ${pkgs.coreutils}/bin/install -d -m 0750 "$P4ROOT" + ${pkgs.coreutils}/bin/install -d -m 0750 ${lib.escapeShellArg cfg.journalDir} + ${pkgs.coreutils}/bin/install -d -m 0750 ${lib.escapeShellArg cfg.checkpointDir} + ${pkgs.coreutils}/bin/install -d -m 0750 "$P4ROOT/logs" + ${pkgs.coreutils}/bin/install -d -m 0700 ${lib.escapeShellArg cfg.ssl.dir} + + if [ ! -f "$server_id_file" ]; then + umask 077 + ${pkgs.coreutils}/bin/printf '%s\n' ${lib.escapeShellArg cfg.serverId} > "$server_id_file" + fi + + if [ "${boolString cfg.typemap.enable}" = "1" ]; then + ${pkgs.coreutils}/bin/install -m 0640 ${lib.escapeShellArg typemapSpec} "$typemap_target" + fi + + if [ "${boolString cfg.ssl.enable}" = "1" ] && { + [ ! -f "$P4SSLDIR/privatekey.txt" ] || [ ! -f "$P4SSLDIR/certificate.txt" ]; + }; then + ${packageExe} -r "$P4ROOT" -Gc + fi + + if [ ! -f "$marker_file" ] && [ "${boolString cfg.unicode}" = "1" ] && [ ! -e "$P4ROOT/db.counters" ]; then + ${packageExe} ${lib.escapeShellArgs ([ "-r" cfg.dataDir ] ++ lib.optional (!cfg.caseSensitive) "-C1" ++ [ "-xi" ])} + fi + + if [ ! -f "$marker_file" ] && [ "${boolString cfg.bootstrap.enable}" = "1" ]; then + temp_pid_file="$P4ROOT/bootstrap.pid" + + cleanup() { + if [ -f "$temp_pid_file" ]; then + pid=$(cat "$temp_pid_file") + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + rm -f "$temp_pid_file" + fi + } + + trap cleanup EXIT INT TERM + + ${packageExe} ${lib.escapeShellArgs bootstrapArgs} & + pid=$! + ${pkgs.coreutils}/bin/printf '%s\n' "$pid" > "$temp_pid_file" + + ready=0 + tries=0 + while [ "$tries" -lt 30 ]; do + if ${clientExe} -p ${lib.escapeShellArg bootstrapPort} info >/dev/null 2>&1; then + ready=1 + break + fi + tries=$((tries + 1)) + sleep 1 + done + + if [ "$ready" -ne 1 ]; then + printf 'p4d bootstrap server failed to become ready on %s\n' ${lib.escapeShellArg bootstrapPort} >&2 + exit 1 + fi + + ${lib.optionalString (cfg.securityLevel != null) '' + ${clientExe} -p ${lib.escapeShellArg bootstrapPort} configure set security=${toString cfg.securityLevel} + ''} + + if [ "${boolString cfg.typemap.enable}" = "1" ]; then + ${clientExe} -p ${lib.escapeShellArg bootstrapPort} typemap -i < "$typemap_target" + fi + + ${clientExe} -p ${lib.escapeShellArg bootstrapPort} admin stop || true + wait "$pid" 2>/dev/null || true + rm -f "$temp_pid_file" + trap - EXIT INT TERM + fi + + if [ ! -f "$marker_file" ]; then + : > "$marker_file" + fi + ''; +in { + options.hectic.services.p4d = { + enable = lib.mkEnableOption "Perforce Helix Core p4d server"; + + package = lib.mkOption { + type = with lib.types; nullOr package; + default = null; + defaultText = lib.literalExpression "pkgs.p4d"; + description = '' + Package providing the `p4d` executable. Left null by default because + nixpkgs marks Perforce packages unfree. + ''; + }; + + clientPackage = lib.mkOption { + type = with lib.types; nullOr package; + default = null; + defaultText = lib.literalExpression "pkgs.p4"; + description = '' + Package providing the `p4` client executable used for optional local + bootstrap tasks like typemap installation and security configuration. + ''; + }; + + user = lib.mkOption { + type = lib.types.str; + default = "p4d"; + description = "System user running the p4d service."; + }; + + group = lib.mkOption { + type = lib.types.str; + default = "p4d"; + description = "System group running the p4d service."; + }; + + dataDir = lib.mkOption { + type = lib.types.str; + default = "/var/lib/p4d"; + description = "Persistent P4ROOT directory for metadata and archives."; + }; + + journalDir = lib.mkOption { + type = lib.types.str; + default = "/var/lib/p4d/journal"; + description = "Directory storing the live journal file."; + }; + + checkpointDir = lib.mkOption { + type = lib.types.str; + default = "/var/lib/p4d/checkpoints"; + description = "Directory reserved for checkpoints and offline backups."; + }; + + listenAddress = lib.mkOption { + type = lib.types.str; + default = "0.0.0.0"; + description = "Address p4d listens on for normal client traffic."; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 1666; + description = "TCP port exposed by p4d."; + }; + + openFirewall = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Whether to open the p4d TCP port in the firewall."; + }; + + serverId = lib.mkOption { + type = lib.types.str; + default = "master"; + description = "Perforce server identifier written to P4ROOT/server.id."; + }; + + unicode = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Initialize Unicode mode on first boot. Irreversible after init."; + }; + + caseSensitive = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Whether the server remains case-sensitive. Linux default stays true. + Set false only when tooling requires case-insensitive behavior. + ''; + }; + + securityLevel = lib.mkOption { + type = with lib.types; nullOr (ints.between 0 5); + default = 4; + description = '' + Optional `p4 configure set security=` value applied during local + bootstrap. Set null to skip automatic security tuning. + ''; + }; + + ssl = { + enable = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Whether p4d listens with SSL enabled."; + }; + + dir = lib.mkOption { + type = lib.types.str; + default = "/var/lib/p4d/ssl"; + description = "Directory holding p4d SSL certificate and key material."; + }; + }; + + bootstrap = { + enable = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Run one-time local bootstrap tasks on first start: optional security + level configuration and Unreal typemap installation. + ''; + }; + + listenAddress = lib.mkOption { + type = lib.types.str; + default = "127.0.0.1"; + description = "Loopback address used by the local temporary bootstrap server."; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 1667; + description = "Loopback-only port used by the temporary bootstrap server."; + }; + }; + + typemap = { + enable = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Generate the Unreal-focused typemap spec and optionally install it + during bootstrap. + ''; + }; + + extraLines = lib.mkOption { + type = with lib.types; listOf str; + default = [ ]; + description = "Extra lines appended to the generated Perforce typemap spec."; + }; + }; + }; + + config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = cfg.package != null; + message = "hectic.services.p4d.package must be set, for example pkgs.p4d."; + } + { + assertion = !cfg.bootstrap.enable || cfg.clientPackage != null; + message = "hectic.services.p4d.clientPackage must be set when bootstrap.enable is true."; + } + ]; + + users.groups.${serviceGroup} = { }; + users.users.${serviceUser} = { + isSystemUser = true; + group = serviceGroup; + home = cfg.dataDir; + createHome = true; + }; + + systemd.tmpfiles.rules = [ + "d ${cfg.dataDir} 0750 ${serviceUser} ${serviceGroup} - -" + "d ${cfg.journalDir} 0750 ${serviceUser} ${serviceGroup} - -" + "d ${cfg.checkpointDir} 0750 ${serviceUser} ${serviceGroup} - -" + "d ${cfg.dataDir}/logs 0750 ${serviceUser} ${serviceGroup} - -" + "d ${cfg.ssl.dir} 0700 ${serviceUser} ${serviceGroup} - -" + ]; + + systemd.services.p4d = { + description = "Perforce Helix Core p4d server"; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + wantedBy = [ "multi-user.target" ]; + preStart = builtins.readFile initScript; + serviceConfig = { + Type = "simple"; + User = serviceUser; + Group = serviceGroup; + WorkingDirectory = cfg.dataDir; + ExecStart = "${packageExe} ${lib.escapeShellArgs mainArgs}"; + Restart = "on-failure"; + RestartSec = "5s"; + TimeoutStopSec = "30s"; + KillSignal = "SIGTERM"; + KillMode = "mixed"; + UMask = "0077"; + }; + }; + + networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ]; + }; +} diff --git a/nixos/system/hectic-lab/default.nix b/nixos/system/hectic-lab/default.nix index 3276a8c2..89010ee6 100644 --- a/nixos/system/hectic-lab/default.nix +++ b/nixos/system/hectic-lab/default.nix @@ -13,7 +13,7 @@ in self.lib.nixpkgs-lib.nixosSystem { inherit system; overlays = [ self.overlays.default ]; config.allowUnfreePredicate = pkg: - builtins.elem (inputs.nixpkgs.lib.getName pkg) [ "steamcmd" "steam-unwrapped" ]; + builtins.elem (inputs.nixpkgs.lib.getName pkg) [ "steamcmd" "steam-unwrapped" "p4d" "p4" ]; }; modules = [ { networking.hostName = name; } diff --git a/nixos/system/hectic-lab/hectic-lab.nix b/nixos/system/hectic-lab/hectic-lab.nix index 6663a7a3..86e695b8 100644 --- a/nixos/system/hectic-lab/hectic-lab.nix +++ b/nixos/system/hectic-lab/hectic-lab.nix @@ -8,7 +8,6 @@ config, pkgs, lib, - modulesPath, ... }: with builtins; @@ -99,6 +98,13 @@ in { workshopItems = [ ]; mods = [ ]; }; + services.p4d = { + enable = true; + package = pkgs.p4d; + clientPackage = pkgs.p4; + openFirewall = true; + bootstrap.enable = false; + }; services.gitea-runner-controller = { # NOTE(yukkop): ephemeral Hetzner VM runners (1 VM = 1 job). # Runbook: infra/gitea-runners/runbook.md "Ephemeral VM runner cutover". diff --git a/test/default.nix b/test/default.nix index 72dbdaab..4b294394 100644 --- a/test/default.nix +++ b/test/default.nix @@ -1,3 +1,3 @@ -{ system, inputs, self, pkgs, flake }: - (import ./package { inherit system inputs self pkgs; }) - +{ system, inputs, self, pkgs, flake }: + (import ./package { inherit system inputs self pkgs; }) // + (import ./nixos { inherit system inputs self pkgs flake; }) diff --git a/test/nixos/default.nix b/test/nixos/default.nix new file mode 100644 index 00000000..b65216b1 --- /dev/null +++ b/test/nixos/default.nix @@ -0,0 +1,56 @@ +{ system, inputs, self, pkgs, ... }: +let + lib = inputs.nixpkgs.lib; + + mkSystem = module: + lib.nixosSystem { + inherit system; + modules = [ + inputs.sops-nix.nixosModules.sops + self.nixosModules.hectic + module + ]; + }; + + fakeP4d = pkgs.writeShellScriptBin "p4d" "exit 0"; + fakeP4 = pkgs.writeShellScriptBin "p4" "exit 0"; + + enabledCfg = (mkSystem { + hectic.services.p4d = { + enable = true; + package = fakeP4d; + }; + }).config; + + bootstrapCfg = (mkSystem { + hectic.services.p4d = { + enable = true; + package = fakeP4d; + clientPackage = fakeP4; + bootstrap.enable = true; + openFirewall = true; + caseSensitive = false; + }; + }).config; +in { + p4d-module-basic = + assert enabledCfg.users.users.p4d.isSystemUser; + assert enabledCfg.users.users.p4d.group == "p4d"; + assert enabledCfg.systemd.services.p4d.serviceConfig.User == "p4d"; + assert enabledCfg.systemd.services.p4d.serviceConfig.Group == "p4d"; + assert enabledCfg.systemd.services.p4d.serviceConfig.ExecStart == "${fakeP4d}/bin/p4d -r /var/lib/p4d -p ssl:0.0.0.0:1666 -J /var/lib/p4d/journal/journal -L /var/lib/p4d/logs/p4d.log"; + pkgs.runCommand "p4d-module-basic" {} '' + touch "$out" + ''; + + p4d-module-bootstrap = + assert lib.any (rule: rule == "d /var/lib/p4d/ssl 0700 p4d p4d - -") bootstrapCfg.systemd.tmpfiles.rules; + assert bootstrapCfg.networking.firewall.allowedTCPPorts == [ 1666 ]; + assert bootstrapCfg.systemd.services.p4d.preStart != ""; + assert lib.hasInfix "configure set security=4" bootstrapCfg.systemd.services.p4d.preStart; + assert lib.hasInfix "typemap -i" bootstrapCfg.systemd.services.p4d.preStart; + assert lib.hasInfix "-C1" bootstrapCfg.systemd.services.p4d.serviceConfig.ExecStart; + pkgs.runCommand "p4d-module-bootstrap" {} '' + touch "$out" + ''; +}