From 1329a4953afb2bffdc528001e8bc06c3ab2c312f Mon Sep 17 00:00:00 2001 From: John Ericson Date: Thu, 4 Jun 2026 12:41:12 -0400 Subject: [PATCH] Add NixOS module, container integration test, and restructure flake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If we are to refactor Hackage, we want to have more tests to be more confident that we are not making mistakes. This commit reworks the Nix and makes a new end-to-end NixOS container test. Even if we aren't refactoring hackage, more tests don't hurt. (NixOS Container tests are an (exciting!) new variant of the older NixOS VM test concept, and share most of the same infrastructure. I had to use them here because the current GitHub Actions runner doesn't support KVM, but they are nicer in general (quicker, lighter logs, etc.).) The NixOS module (`nix/nixos-module.nix`) provides `services.hackage-server` with Claude's guess at usual options (`baseUri`, `userContentUri`, `port`, `stateDir`, etc.), automatic `init` on first start, and a systemd service matching production deployment conventions. It seems good to me — certainly good enough for testing purposes. The container test (`nix/test.nix`) spins up a NixOS container with the module enabled and does a curl smoke test. This is the first time we have an automated test of the full deployment stack (systemd → init → server → HTTP), not just the Haskell code in isolation. The flake is restructured so that `flake.nix` is a thin wrapper and all the actual configuration lives in `nix/flake-module.nix`. This also introduces `lib.fileset` to whitelist only Haskell-relevant source files, so that editing nix files, documentation, etc. does not trigger a full Haskell rebuild. That was very useful when editing the container test and other nix code — don't want to blow up my debug cycle by waiting for Hackage to rebuild each time! No Haskell source code is modified. The running server, when deployed as it is today, should be entirely the same. --- .github/workflows/nix-flake.yml | 5 +- flake.nix | 77 +------------------ nix/flake-module.nix | 131 ++++++++++++++++++++++++++++++++ nix/nixos-module.nix | 130 +++++++++++++++++++++++++++++++ nix/test.nix | 34 +++++++++ 5 files changed, 300 insertions(+), 77 deletions(-) create mode 100644 nix/flake-module.nix create mode 100644 nix/nixos-module.nix create mode 100644 nix/test.nix diff --git a/.github/workflows/nix-flake.yml b/.github/workflows/nix-flake.yml index 608c6ae94..0dd3845ca 100644 --- a/.github/workflows/nix-flake.yml +++ b/.github/workflows/nix-flake.yml @@ -64,7 +64,10 @@ jobs: with: extra_nix_config: | trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= hackage-server.cachix.org-1:iw0iRh6+gsFIrxROFaAt5gKNgIHejKjIfyRdbpPYevY= - substituters = https://cache.nixos.org/ https://hackage-server.cachix.org/ + # The following settings just affect Linux, but are harmless on macOS. + extra-experimental-features = auto-allocate-uids cgroups + auto-allocate-uids = true + use-cgroups = true - uses: cachix/cachix-action@v17 with: # https://nix.dev/tutorials/continuous-integration-github-actions#setting-up-github-actions diff --git a/flake.nix b/flake.nix index 7bb4852da..795cc2728 100644 --- a/flake.nix +++ b/flake.nix @@ -13,83 +13,8 @@ imports = [ inputs.haskell-flake.flakeModule inputs.flake-root.flakeModule + ./nix/flake-module.nix ]; - perSystem = { self', system, lib, config, pkgs, ... }: { - apps.default.program = pkgs.writeShellApplication { - name = "run-hackage-server"; - runtimeInputs = [ config.packages.default ]; - text = '' - if [ ! -d "state" ]; then - hackage-server init --static-dir=datafiles --state-dir=state - else - echo "'state' state-dir already exists" - fi - hackage-server run \ - --static-dir=datafiles \ - --state-dir=state \ - --base-uri=http://127.0.0.1:8080 \ - --required-base-host-header=localhost:8080 \ - --user-content-uri=http://127.0.0.1:8080 - ''; - }; - apps.mirror-hackage-server.program = pkgs.writeShellApplication { - name = "mirror-hackage-server"; - runtimeInputs = [ config.packages.default ]; - text = '' - echo 'Copying packages from real Hackage Server into local Hackage Server.' - echo 'This assumes the local Hackage Server uses default credentials;' - echo 'otherwise, override in nix-default-servers.cfg' - hackage-mirror nix-default-servers.cfg "$@" - ''; - }; - packages.default = config.packages.hackage-server; - haskellProjects.default = { - basePackages = pkgs.haskell.packages.ghc912; - settings = { - hackage-server.check = false; - - Cabal-syntax = { super, ... }: - { custom = _: super.Cabal-syntax_3_16_1_0; }; - Cabal = { super, ... }: - { custom = _: super.Cabal_3_16_1_0; }; - - sandwich.check = false; - - threads.check = false; - - unicode-data.check = false; - }; - packages = { - # https://community.flake.parts/haskell-flake/dependency#path - # tls.source = "1.9.0"; - tar.source = "0.7.0.0"; - }; - devShell = { - tools = hp: { - inherit (pkgs) - cabal-install - ghc - # https://github.com/haskell/hackage-server/pull/1219#issuecomment-1597140858 - # glibc - icu67 - zlib - openssl - # cryptodev - pkg-config - brotli - - gd - libpng - libjpeg - fontconfig - freetype - expat - ; - }; - hlsCheck.enable = false; - }; - }; - }; }; nixConfig = { diff --git a/nix/flake-module.nix b/nix/flake-module.nix new file mode 100644 index 000000000..bf1ca4279 --- /dev/null +++ b/nix/flake-module.nix @@ -0,0 +1,131 @@ +{ withSystem, ... }: + +{ + flake.nixosModules.default = { config, lib, pkgs, ... }: + let + pkg = withSystem pkgs.stdenv.hostPlatform.system ({ config, ... }: config.packages.hackage-server); + in { + imports = [ ./nixos-module.nix ]; + services.hackage-server.package = lib.mkDefault pkg; + services.hackage-server.datafilesDir = lib.mkDefault ( + # The Cabal data-files are installed under an ABI-specific path + # like share/ghc-X.Y.Z//hackage-server-0.6/ + # We use a derivation to resolve the glob at build time. + pkgs.runCommand "hackage-server-datafiles" {} '' + datadir=$(dirname $(find ${pkg.data}/share -name templates -type d | head -1)) + if [ -z "$datadir" ]; then + echo "Could not find hackage-server data files in ${pkg.data}" >&2 + exit 1 + fi + ln -s "$datadir" $out + '' + ); + }; + + perSystem = { system, lib, config, pkgs, ... }: + { + checks = lib.optionalAttrs pkgs.stdenv.isLinux { + nixos-test = import ./test.nix { + hackage-server = config.packages.hackage-server; + inherit pkgs; + }; + }; + + apps.default.program = pkgs.writeShellApplication { + name = "run-hackage-server"; + runtimeInputs = [ config.packages.default ]; + text = '' + if [ ! -d "state" ]; then + hackage-server init --static-dir=datafiles --state-dir=state + else + echo "'state' state-dir already exists" + fi + hackage-server run \ + --static-dir=datafiles \ + --state-dir=state \ + --base-uri=http://127.0.0.1:8080 \ + --required-base-host-header=localhost:8080 \ + --user-content-uri=http://127.0.0.1:8080 + ''; + }; + + apps.mirror-hackage-server.program = pkgs.writeShellApplication { + name = "mirror-hackage-server"; + runtimeInputs = [ config.packages.default ]; + text = '' + echo 'Copying packages from real Hackage Server into local Hackage Server.' + echo 'This assumes the local Hackage Server uses default credentials;' + echo 'otherwise, override in nix-default-servers.cfg' + hackage-mirror nix-default-servers.cfg "$@" + ''; + }; + + packages.default = config.packages.hackage-server; + + haskellProjects.default = { + basePackages = pkgs.haskell.packages.ghc912; + # Only include files relevant to the Haskell build so that + # changes to nix/, flake.nix, etc. don't trigger a rebuild. + projectRoot = lib.fileset.toSource { + root = ../.; + fileset = let + haskell = f: lib.hasSuffix ".hs" f.name; + in lib.fileset.unions [ + ../cabal.project + ../hackage-server.cabal + ../LICENSE + (lib.fileset.fileFilter haskell ../src) + (lib.fileset.fileFilter haskell ../exes) + (lib.fileset.fileFilter haskell ../benchmarks) + ../tests # includes .hs, golden files, test tarballs, etc. + ../datafiles + ../libstemmer_c + ../src/Distribution/Server/Util/NLP/LICENSE + ]; + }; + settings = { + hackage-server.check = false; + + Cabal-syntax = { super, ... }: + { custom = _: super.Cabal-syntax_3_16_1_0; }; + Cabal = { super, ... }: + { custom = _: super.Cabal_3_16_1_0; }; + + sandwich.check = false; + + threads.check = false; + + unicode-data.check = false; + }; + packages = { + # https://community.flake.parts/haskell-flake/dependency#path + # tls.source = "1.9.0"; + tar.source = "0.7.0.0"; + }; + devShell = { + tools = hp: { + inherit (pkgs) + cabal-install + ghc + # https://github.com/haskell/hackage-server/pull/1219#issuecomment-1597140858 + # glibc + icu67 + zlib + openssl + # cryptodev + pkg-config + brotli + + gd + libpng + libjpeg + fontconfig + freetype + expat + ; + }; + hlsCheck.enable = false; + }; + }; + }; +} diff --git a/nix/nixos-module.nix b/nix/nixos-module.nix new file mode 100644 index 000000000..f8416d374 --- /dev/null +++ b/nix/nixos-module.nix @@ -0,0 +1,130 @@ +{ config, lib, pkgs, ... }: + +let + cfg = config.services.hackage-server; + pkg = cfg.package; +in +{ + options.services.hackage-server = { + enable = lib.mkEnableOption "hackage-server, a Haskell package repository"; + + package = lib.mkPackageOption pkgs "hackage-server" { }; + + baseUri = lib.mkOption { + type = lib.types.str; + example = "https://hackage.example.org"; + description = "The server's public base URI."; + }; + + userContentUri = lib.mkOption { + type = lib.types.str; + example = "https://hackage-content.example.org"; + description = '' + The server's public user content base URI, used for untrusted + content to defeat XSS-style attacks. + ''; + }; + + requiredBaseHostHeader = lib.mkOption { + type = lib.types.str; + example = "hackage-origin.example.org"; + description = '' + Required Host header value for incoming requests. This may be + an internal hostname if the server is behind a reverse proxy. + ''; + }; + + stateDir = lib.mkOption { + type = lib.types.path; + default = "/var/lib/hackage-server"; + description = "Directory for the server's persistent state."; + }; + + datafilesDir = lib.mkOption { + type = lib.types.path; + description = '' + Directory containing HTML templates, static files, and TUF keys. + ''; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 8080; + description = "TCP port to listen on."; + }; + + ip = lib.mkOption { + type = lib.types.str; + default = "127.0.0.1"; + description = "IPv4 address to bind."; + }; + + user = lib.mkOption { + type = lib.types.str; + default = "hackage"; + description = "User account under which hackage-server runs."; + }; + + group = lib.mkOption { + type = lib.types.str; + default = "hackage"; + description = "Group under which hackage-server runs."; + }; + }; + + config = lib.mkIf cfg.enable { + + users.users.${cfg.user} = { + isSystemUser = true; + group = cfg.group; + home = cfg.stateDir; + description = "Hackage Server service user"; + }; + + users.groups.${cfg.group} = { }; + + systemd.tmpfiles.rules = [ + "d ${cfg.stateDir} 0750 ${cfg.user} ${cfg.group} -" + "d ${cfg.stateDir}/state 0750 ${cfg.user} ${cfg.group} -" + ]; + + systemd.services.hackage-server = { + description = "Hackage Server"; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + wantedBy = [ "multi-user.target" ]; + + preStart = '' + if [ ! -d "${cfg.stateDir}/state/db" ]; then + ${lib.getExe pkg} init \ + --state-dir="${cfg.stateDir}/state" \ + --static-dir="${cfg.datafilesDir}" + fi + ''; + + serviceConfig = { + Type = "simple"; + User = cfg.user; + Group = cfg.group; + Restart = "on-failure"; + RestartSec = 3; + TimeoutStopSec = 120; + LimitNOFILE = 1073741824; + WorkingDirectory = cfg.stateDir; + + ExecStart = lib.concatStringsSep " " [ + (lib.getExe pkg) + "run" + "--ip=${cfg.ip}" + "--port=${toString cfg.port}" + "--base-uri=${cfg.baseUri}" + "--user-content-uri=${cfg.userContentUri}" + "--required-base-host-header=${cfg.requiredBaseHostHeader}" + "--state-dir=${cfg.stateDir}/state" + "--static-dir=${cfg.datafilesDir}" + "--tmp-dir=${cfg.stateDir}/state/tmp" + ]; + }; + }; + }; +} diff --git a/nix/test.nix b/nix/test.nix new file mode 100644 index 000000000..8496d99a6 --- /dev/null +++ b/nix/test.nix @@ -0,0 +1,34 @@ +{ hackage-server, pkgs, ... }: + +pkgs.testers.runNixOSTest { + name = "hackage-server"; + + containers.machine = { pkgs, ... }: { + imports = [ ./nixos-module.nix ]; + + services.hackage-server = { + enable = true; + package = hackage-server; + datafilesDir = pkgs.runCommand "hackage-server-datafiles" {} '' + datadir=$(dirname $(find ${hackage-server.data}/share -name templates -type d | head -1)) + ln -s "$datadir" $out + ''; + baseUri = "http://localhost:8080"; + userContentUri = "http://localhost:8080"; + requiredBaseHostHeader = "localhost:8080"; + port = 8080; + }; + + environment.systemPackages = [ pkgs.curl ]; + }; + + testScript = '' + machine.start() + machine.wait_for_unit("hackage-server.service") + machine.wait_for_open_port(8080) + + # Smoke test + machine.succeed("curl -fsS --max-time 10 http://localhost:8080/") + machine.succeed("curl -fsS --max-time 10 http://localhost:8080/users/.json") + ''; +}