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") + ''; +}