diff --git a/flake.lock b/flake.lock index 070635fe..34bf5217 100644 --- a/flake.lock +++ b/flake.lock @@ -45,11 +45,11 @@ ] }, "locked": { - "lastModified": 1674771137, - "narHash": "sha256-Zpk1GbEsYrqKmuIZkx+f+8pU0qcCYJoSUwNz1Zk+R00=", + "lastModified": 1675295133, + "narHash": "sha256-dU8fuLL98WFXG0VnRgM00bqKX6CEPBLybhiIDIgO45o=", "owner": "hercules-ci", "repo": "flake-parts", - "rev": "7c7a8bce3dffe71203dcd4276504d1cb49dfe05f", + "rev": "bf53492df08f3178ce85e0c9df8ed8d03c030c9f", "type": "github" }, "original": { @@ -110,15 +110,12 @@ } }, "mission-control": { - "inputs": { - "nixpkgs": "nixpkgs" - }, "locked": { - "lastModified": 1671493916, - "narHash": "sha256-7uvy37mfprmI3fbBw9E+baV1KZHR5zKfSNfPlSiliqo=", + "lastModified": 1675195908, + "narHash": "sha256-nQv35C7svZFluwy2uoZFxwPqiT16XoKAoMlIQYmvg3A=", "owner": "Platonic-Systems", "repo": "mission-control", - "rev": "9acdaa469ebd3c2d6816f8a30c0c217a0da59fe2", + "rev": "feb06872ac4dc977f70f6388c87d36fc3c3c3693", "type": "github" }, "original": { @@ -135,11 +132,11 @@ ] }, "locked": { - "lastModified": 1674752624, - "narHash": "sha256-ejhmcqHZ6E5L+YA8WX1z4KkiDYK3AsXc9i3ohGMFOKA=", + "lastModified": 1674862884, + "narHash": "sha256-Q3yExefODBrrziRnCYETrJgSn42BOR7ZsL8pu3q5D/w=", "owner": "mic92", "repo": "nix-update", - "rev": "f570df2044c41ffa44d7d86a4c2d7ae3dc59b4eb", + "rev": "42e248829ed8d51dbc7da941c97cb907ee86d7eb", "type": "github" }, "original": { @@ -150,11 +147,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1671249438, - "narHash": "sha256-5e+CcnbZA3/i2BRXbnzRS52Ly67MUNdZR+Zpbb2C65k=", + "lastModified": 1675614288, + "narHash": "sha256-i3Rc/ENnz62BcrSloeVmAyPicEh4WsrEEYR+INs9TYw=", "owner": "nixos", "repo": "nixpkgs", - "rev": "067bfc6c90a301572cec7da48f09c447a9a8eae0", + "rev": "d25de6654a34d99dceb02e71e6db516b3b545be6", "type": "github" }, "original": { @@ -166,16 +163,16 @@ }, "nixpkgs_2": { "locked": { - "lastModified": 1674807565, - "narHash": "sha256-zOLE1YXf2RhYhtNv4n8C0xPaSjduchLlCxZaAeoAvxU=", + "lastModified": 1675545634, + "narHash": "sha256-TbQeQcM5TA/wIho6xtzG+inUfiGzUXi8ewwttiQWYJE=", "owner": "nixos", "repo": "nixpkgs", - "rev": "f80ac848e3d6f0c12c52758c0f25c10c97ca3b62", + "rev": "0591d6b57bfeb55dfeec99a671843337bc2c3323", "type": "github" }, "original": { "owner": "nixos", - "ref": "nixpkgs-unstable", + "ref": "nixos-unstable", "repo": "nixpkgs", "type": "github" } @@ -187,7 +184,7 @@ "flake-root": "flake-root", "mission-control": "mission-control", "nix-update": "nix-update", - "nixpkgs": "nixpkgs_2", + "nixpkgs": "nixpkgs", "statix": "statix", "treefmt-nix": "treefmt-nix" } @@ -232,12 +229,15 @@ } }, "treefmt-nix": { + "inputs": { + "nixpkgs": "nixpkgs_2" + }, "locked": { - "lastModified": 1674470002, - "narHash": "sha256-Tk1VaMeBTMMGEZeqv3TEwrTAdR9fYb3EH/TPI27AdKk=", + "lastModified": 1675588998, + "narHash": "sha256-CLeFLmah0mxNp/EIW0PMG3YutKxVIIs4B0f5oJhwe8E=", "owner": "numtide", "repo": "treefmt-nix", - "rev": "d5ed9a1e6793f99b2e179d5dec9639e48ef22db7", + "rev": "70e03145e26c2f3199f4320ecd9fd343f1129c60", "type": "github" }, "original": { diff --git a/modules/clients/execution/default.nix b/modules/clients/execution/default.nix index b55f4659..7cfca445 100644 --- a/modules/clients/execution/default.nix +++ b/modules/clients/execution/default.nix @@ -2,5 +2,6 @@ imports = [ ./erigon.nix ./geth.nix + ./nethermind.nix ]; } diff --git a/modules/clients/execution/nethermind.nix b/modules/clients/execution/nethermind.nix new file mode 100644 index 00000000..09fe35c0 --- /dev/null +++ b/modules/clients/execution/nethermind.nix @@ -0,0 +1,308 @@ +{ + config, + lib, + pkgs, + ... +}: let + inherit + (builtins) + isBool + isList + isNull + toString + ; + inherit + (lib) + boolToString + concatStringsSep + filterAttrs + findFirst + flatten + hasPrefix + literalExpression + mapAttrs' + mapAttrsRecursive + mapAttrsToList + mdDoc + mkEnableOption + mkIf + mkMerge + mkOption + nameValuePair + optionalAttrs + optionals + types + zipAttrsWith + ; + + # capture config for all configured netherminds + eachNethermind = config.services.ethereum.nethermind; + + # submodule options + nethermindOpts = { + options = { + enable = mkEnableOption (mdDoc "Nethermind Ethereum Node."); + + package = mkOption { + type = types.package; + default = pkgs.nethermind; + defaultText = literalExpression "pkgs.nethermind"; + description = mdDoc "Package to use as Nethermind."; + }; + + args = { + baseDbPath = mkOption { + type = types.nullOr types.path; + default = null; + description = mdDoc "Configures the path of the Nethermind's database folder."; + }; + + config = mkOption { + type = types.nullOr types.str; + default = null; + example = "mainnet"; + description = mdDoc "Determines the configuration file of the network on which Nethermind will be running."; + }; + + configsDirectory = mkOption { + type = types.nullOr types.path; + default = null; + description = mdDoc "Changes the source directory of your configuration files."; + }; + + log = mkOption { + type = types.enum [ + "OFF" + "TRACE" + "DEBUG" + "INFO" + "WARN" + "ERROR" + ]; + default = "INFO"; + description = mdDoc "Changes the logging level."; + }; + + loggerConfigSource = mkOption { + type = types.nullOr types.str; + default = null; + description = mdDoc "Changes the path of the NLog.config file."; + }; + + modules = { + # https://docs.nethermind.io/nethermind/ethereum-client/configuration/network + Network = { + DiscoveryPort = mkOption { + type = types.port; + default = 30303; + description = mdDoc "UDP port number for incoming discovery connections."; + }; + + P2PPort = mkOption { + type = types.port; + default = 30303; + description = mdDoc "TPC/IP port number for incoming P2P connections."; + }; + }; + + # https://docs.nethermind.io/nethermind/ethereum-client/configuration/jsonrpc + JsonRpc = { + Enabled = mkOption { + type = types.bool; + default = true; + description = mdDoc "Defines whether the JSON RPC service is enabled on node startup."; + }; + + Port = mkOption { + type = types.port; + default = 8545; + description = mdDoc "Port number for JSON RPC calls."; + }; + + WebSocketsPort = mkOption { + type = types.port; + default = 8545; + description = mdDoc "Port number for JSON RPC web sockets calls."; + }; + + EngineHost = mkOption { + type = types.str; + default = "127.0.0.1"; + description = mdDoc "Host for JSON RPC calls."; + }; + + EnginePort = mkOption { + type = types.port; + default = 8551; + description = mdDoc "Port for Execution Engine calls."; + }; + + JwtSecretFile = mkOption { + type = types.nullOr types.str; + default = null; + description = mdDoc "Path to file with hex encoded secret for jwt authentication."; + example = "/var/run/geth/jwtsecret"; + }; + }; + + # https://docs.nethermind.io/nethermind/ethereum-client/configuration/healthchecks + HealthChecks = { + Enabled = mkOption { + type = types.bool; + default = true; + description = mdDoc "If 'true' then Health Check endpoints is enabled at /health."; + }; + }; + + # https://docs.nethermind.io/nethermind/ethereum-client/configuration/metrics + Metrics = { + Enabled = mkOption { + type = types.bool; + default = true; + description = mdDoc "If 'true',the node publishes various metrics to Prometheus Pushgateway at given interval."; + }; + + ExposePort = mkOption { + type = types.nullOr types.port; + default = null; + description = mdDoc "If 'true' then Health Check endpoints is enabled at /health"; + }; + }; + }; + }; + + extraArgs = mkOption { + type = types.listOf types.str; + description = mdDoc "Additional arguments to pass to Nethermind."; + default = []; + }; + + openFirewall = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc "Open ports in the firewall for any enabled networking services"; + }; + + service = { + supplementaryGroups = mkOption { + default = []; + type = types.listOf types.str; + description = mdDoc "Additional groups for the systemd service e.g. sops-nix group for secret access."; + }; + }; + }; + }; +in { + ###### interface + + options = { + services.ethereum.nethermind = mkOption { + type = types.attrsOf (types.submodule nethermindOpts); + default = {}; + description = mdDoc "Specification of one or more Nethermind instances."; + }; + }; + + ###### implementation + + config = mkIf (eachNethermind != {}) { + # configure the firewall for each service + networking.firewall = let + openFirewall = filterAttrs (_: cfg: cfg.openFirewall) eachNethermind; + perService = + mapAttrsToList + ( + _: cfg: + with cfg.args; { + allowedUDPPorts = [modules.Network.DiscoveryPort]; + allowedTCPPorts = + [modules.Network.P2PPort modules.JsonRpc.EnginePort] + ++ (optionals modules.JsonRpc.Enabled [modules.JsonRpc.Port modules.JsonRpc.WebSocketsPort]) + ++ (optionals modules.Metrics.Enabled && (modules.Metrics.ExposePort != null) [modules.Metrics.ExposePort]); + } + ) + openFirewall; + in + zipAttrsWith (name: flatten) perService; + + # create a service for each instance + systemd.services = + mapAttrs' ( + nethermindName: let + serviceName = "nethermind-${nethermindName}"; + + modulesLib = import ../../lib.nix {inherit lib pkgs;}; + inherit (modulesLib) mkArgs baseServiceConfig; + in + cfg: let + scriptArgs = let + # custom arg reducer for nethermind + argReducer = value: + if (isList value) + then concatStringsSep "," value + else if (isBool value) + then boolToString value + else toString value; + + # remove modules from arguments + pathReducer = path: let + arg = concatStringsSep "." (lib.lists.remove "modules" path); + in "--${arg}"; + + # custom arg formatter for nethermind + argFormatter = { + opt, + path, + value, + argReducer, + pathReducer, + }: let + arg = pathReducer path; + in "${arg} ${argReducer value}"; + + jwtSecret = + if cfg.args.modules.JsonRpc.JwtSecretFile != null + then "--JsonRpc.JwtSecretFile %d/jwtsecret" + else ""; + + # generate flags + args = mkArgs { + inherit pathReducer argReducer argFormatter; + inherit (cfg) args; + opts = nethermindOpts.options.args; + }; + + # filter out certain args which need to be treated differently + specialArgs = ["--JsonRpc.JwtSecretFile"]; + isNormalArg = name: (findFirst (arg: hasPrefix arg name) null specialArgs) == null; + + filteredArgs = builtins.filter isNormalArg args; + in '' + --datadir %S/${serviceName} \ + ${jwtSecret} \ + ${concatStringsSep " \\\n" filteredArgs} \ + ${lib.escapeShellArgs cfg.extraArgs} + ''; + in + nameValuePair serviceName (mkIf cfg.enable { + after = ["network.target"]; + wantedBy = ["multi-user.target"]; + description = "Nethermind Node (${nethermindName})"; + + # create service config by merging with the base config + serviceConfig = mkMerge [ + baseServiceConfig + { + User = serviceName; + StateDirectory = serviceName; + ExecStart = "${cfg.package}/bin/Nethermind.Runner ${scriptArgs}"; + } + (mkIf (cfg.args.modules.JsonRpc.JwtSecretFile != null) { + LoadCredential = "jwtsecret:${cfg.args.modules.JsonRpc.JwtSecretFile}"; + }) + ]; + }) + ) + eachNethermind; + }; +} diff --git a/modules/lib.nix b/modules/lib.nix index a8b8f2a1..33613477 100644 --- a/modules/lib.nix +++ b/modules/lib.nix @@ -27,10 +27,24 @@ arg = concatStringsSep "." path; in "--${arg}"; + defaultArgFormatter = { + opt, + path, + value, + argReducer ? defaultArgReducer, + pathReducer ? defaultPathReducer, + }: let + arg = pathReducer path; + in + if (opt.type == types.bool && value) + then "${arg}" + else "${arg} ${argReducer value}"; + mkArg = { path, opt, args, + argFormatter ? defaultArgFormatter, argReducer ? defaultArgReducer, pathReducer ? defaultPathReducer, }: let @@ -40,23 +54,20 @@ in assert assertMsg (isOption opt) "opt must be an option"; if (hasValue || hasDefault) - then let - arg = pathReducer path; - in - if (opt.type == types.bool && value) - then "${arg}" - else "${arg} ${argReducer value}" + then (argFormatter {inherit opt path value argReducer pathReducer;}) else ""; mkArgs = { opts, args, + argFormatter ? defaultArgFormatter, + argReducer ? defaultArgReducer, pathReducer ? defaultPathReducer, }: collect (v: (isString v) && v != "") ( mapAttrsRecursiveCond (as: !isOption as) - (path: opt: mkArg {inherit path opt args pathReducer;}) + (path: opt: mkArg {inherit path opt args argFormatter argReducer pathReducer;}) opts ); diff --git a/nix/checks.nix b/nix/checks.nix index e8a9b877..1443802b 100644 --- a/nix/checks.nix +++ b/nix/checks.nix @@ -13,11 +13,13 @@ in { }: let # load package derivations inherit (packagesModule.perSystem psArgs) packages; + # import integration tests + integrationTests = import ./../tests {inherit self' inputs pkgs;}; in { checks = { - nix-lint = - pkgs.runCommand "nix-lint" { + statix = + pkgs.runCommand "statix" { nativeBuildInputs = with pkgs; [statix]; } '' cp --no-preserve=mode -r ${self} source @@ -26,6 +28,8 @@ in { touch $out ''; } + # add integration tests for our custom nixosModules + // integrationTests # merge in the package derivations to force a build of all packages during a `nix flake check` // packages; }; diff --git a/packages/clients/execution/nethermind/default.nix b/packages/clients/execution/nethermind/default.nix index 034ffae2..39d47f4e 100644 --- a/packages/clients/execution/nethermind/default.nix +++ b/packages/clients/execution/nethermind/default.nix @@ -31,6 +31,7 @@ buildDotnetModule rec { runtimeDeps = [ rocksdb + snappy ]; patches = [ diff --git a/tests/default.nix b/tests/default.nix new file mode 100644 index 00000000..2ea2f442 --- /dev/null +++ b/tests/default.nix @@ -0,0 +1,9 @@ +{ + self', + inputs, + pkgs, +}: let + makeTest = import (inputs.nixpkgs + "/nixos/tests/make-test-python.nix"); +in { + nethermind-integration-tests = import ./nethermind.nix {inherit self' makeTest pkgs;}; +} diff --git a/tests/nethermind.nix b/tests/nethermind.nix new file mode 100644 index 00000000..64e3558c --- /dev/null +++ b/tests/nethermind.nix @@ -0,0 +1,52 @@ +{ + self', + makeTest, + pkgs, +}: let + imports = [../modules/clients/execution/nethermind.nix]; + + jwtSecret = pkgs.writeText "jwt-secret" "315228a30b238d15df0bedd570a3e1d21bb3f92588168a26127c2090497cf4b6"; +in + (makeTest { + name = "nethermind"; + + nodes = { + basicConf = _: { + inherit imports; + + # see: https://docs.nethermind.io/nethermind/first-steps-with-nethermind/system-requirements + virtualisation.cores = 2; + virtualisation.memorySize = 8192; + + services.ethereum.nethermind.sepolia = { + enable = true; + package = self'.packages.nethermind; + args = { + config = "sepolia"; + modules.JsonRpc.JwtSecretFile = "${jwtSecret}"; + modules.Metrics.Enabled = true; + modules.Metrics.ExposePort = 1313; + }; + }; + }; + }; + + testScript = '' + start_all() + + with subtest("Minimal (settings = null) config test"): + basicConf.wait_for_unit("nethermind-sepolia.service") + + # TODO: Finish properly these tests once PR is merged in upstream https://github.com/NethermindEth/nethermind/pull/4320 + # basicConf.wait_for_open_port(30303) + # basicConf.wait_for_open_port(8545) + + # out = basicConf.succeed("systemctl status nethermind-sepolia.service") + # print(out) + ''; + } + { + inherit pkgs; + inherit (pkgs) system; + }) + .test