Skip to content

Commit 1329a49

Browse files
committed
Add NixOS module, container integration test, and restructure flake
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.
1 parent 6f027ff commit 1329a49

5 files changed

Lines changed: 300 additions & 77 deletions

File tree

.github/workflows/nix-flake.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,10 @@ jobs:
6464
with:
6565
extra_nix_config: |
6666
trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= hackage-server.cachix.org-1:iw0iRh6+gsFIrxROFaAt5gKNgIHejKjIfyRdbpPYevY=
67-
substituters = https://cache.nixos.org/ https://hackage-server.cachix.org/
67+
# The following settings just affect Linux, but are harmless on macOS.
68+
extra-experimental-features = auto-allocate-uids cgroups
69+
auto-allocate-uids = true
70+
use-cgroups = true
6871
- uses: cachix/cachix-action@v17
6972
with:
7073
# https://nix.dev/tutorials/continuous-integration-github-actions#setting-up-github-actions

flake.nix

Lines changed: 1 addition & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -13,83 +13,8 @@
1313
imports = [
1414
inputs.haskell-flake.flakeModule
1515
inputs.flake-root.flakeModule
16+
./nix/flake-module.nix
1617
];
17-
perSystem = { self', system, lib, config, pkgs, ... }: {
18-
apps.default.program = pkgs.writeShellApplication {
19-
name = "run-hackage-server";
20-
runtimeInputs = [ config.packages.default ];
21-
text = ''
22-
if [ ! -d "state" ]; then
23-
hackage-server init --static-dir=datafiles --state-dir=state
24-
else
25-
echo "'state' state-dir already exists"
26-
fi
27-
hackage-server run \
28-
--static-dir=datafiles \
29-
--state-dir=state \
30-
--base-uri=http://127.0.0.1:8080 \
31-
--required-base-host-header=localhost:8080 \
32-
--user-content-uri=http://127.0.0.1:8080
33-
'';
34-
};
35-
apps.mirror-hackage-server.program = pkgs.writeShellApplication {
36-
name = "mirror-hackage-server";
37-
runtimeInputs = [ config.packages.default ];
38-
text = ''
39-
echo 'Copying packages from real Hackage Server into local Hackage Server.'
40-
echo 'This assumes the local Hackage Server uses default credentials;'
41-
echo 'otherwise, override in nix-default-servers.cfg'
42-
hackage-mirror nix-default-servers.cfg "$@"
43-
'';
44-
};
45-
packages.default = config.packages.hackage-server;
46-
haskellProjects.default = {
47-
basePackages = pkgs.haskell.packages.ghc912;
48-
settings = {
49-
hackage-server.check = false;
50-
51-
Cabal-syntax = { super, ... }:
52-
{ custom = _: super.Cabal-syntax_3_16_1_0; };
53-
Cabal = { super, ... }:
54-
{ custom = _: super.Cabal_3_16_1_0; };
55-
56-
sandwich.check = false;
57-
58-
threads.check = false;
59-
60-
unicode-data.check = false;
61-
};
62-
packages = {
63-
# https://community.flake.parts/haskell-flake/dependency#path
64-
# tls.source = "1.9.0";
65-
tar.source = "0.7.0.0";
66-
};
67-
devShell = {
68-
tools = hp: {
69-
inherit (pkgs)
70-
cabal-install
71-
ghc
72-
# https://github.com/haskell/hackage-server/pull/1219#issuecomment-1597140858
73-
# glibc
74-
icu67
75-
zlib
76-
openssl
77-
# cryptodev
78-
pkg-config
79-
brotli
80-
81-
gd
82-
libpng
83-
libjpeg
84-
fontconfig
85-
freetype
86-
expat
87-
;
88-
};
89-
hlsCheck.enable = false;
90-
};
91-
};
92-
};
9318
};
9419

9520
nixConfig = {

nix/flake-module.nix

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
{ withSystem, ... }:
2+
3+
{
4+
flake.nixosModules.default = { config, lib, pkgs, ... }:
5+
let
6+
pkg = withSystem pkgs.stdenv.hostPlatform.system ({ config, ... }: config.packages.hackage-server);
7+
in {
8+
imports = [ ./nixos-module.nix ];
9+
services.hackage-server.package = lib.mkDefault pkg;
10+
services.hackage-server.datafilesDir = lib.mkDefault (
11+
# The Cabal data-files are installed under an ABI-specific path
12+
# like share/ghc-X.Y.Z/<abi-hash>/hackage-server-0.6/
13+
# We use a derivation to resolve the glob at build time.
14+
pkgs.runCommand "hackage-server-datafiles" {} ''
15+
datadir=$(dirname $(find ${pkg.data}/share -name templates -type d | head -1))
16+
if [ -z "$datadir" ]; then
17+
echo "Could not find hackage-server data files in ${pkg.data}" >&2
18+
exit 1
19+
fi
20+
ln -s "$datadir" $out
21+
''
22+
);
23+
};
24+
25+
perSystem = { system, lib, config, pkgs, ... }:
26+
{
27+
checks = lib.optionalAttrs pkgs.stdenv.isLinux {
28+
nixos-test = import ./test.nix {
29+
hackage-server = config.packages.hackage-server;
30+
inherit pkgs;
31+
};
32+
};
33+
34+
apps.default.program = pkgs.writeShellApplication {
35+
name = "run-hackage-server";
36+
runtimeInputs = [ config.packages.default ];
37+
text = ''
38+
if [ ! -d "state" ]; then
39+
hackage-server init --static-dir=datafiles --state-dir=state
40+
else
41+
echo "'state' state-dir already exists"
42+
fi
43+
hackage-server run \
44+
--static-dir=datafiles \
45+
--state-dir=state \
46+
--base-uri=http://127.0.0.1:8080 \
47+
--required-base-host-header=localhost:8080 \
48+
--user-content-uri=http://127.0.0.1:8080
49+
'';
50+
};
51+
52+
apps.mirror-hackage-server.program = pkgs.writeShellApplication {
53+
name = "mirror-hackage-server";
54+
runtimeInputs = [ config.packages.default ];
55+
text = ''
56+
echo 'Copying packages from real Hackage Server into local Hackage Server.'
57+
echo 'This assumes the local Hackage Server uses default credentials;'
58+
echo 'otherwise, override in nix-default-servers.cfg'
59+
hackage-mirror nix-default-servers.cfg "$@"
60+
'';
61+
};
62+
63+
packages.default = config.packages.hackage-server;
64+
65+
haskellProjects.default = {
66+
basePackages = pkgs.haskell.packages.ghc912;
67+
# Only include files relevant to the Haskell build so that
68+
# changes to nix/, flake.nix, etc. don't trigger a rebuild.
69+
projectRoot = lib.fileset.toSource {
70+
root = ../.;
71+
fileset = let
72+
haskell = f: lib.hasSuffix ".hs" f.name;
73+
in lib.fileset.unions [
74+
../cabal.project
75+
../hackage-server.cabal
76+
../LICENSE
77+
(lib.fileset.fileFilter haskell ../src)
78+
(lib.fileset.fileFilter haskell ../exes)
79+
(lib.fileset.fileFilter haskell ../benchmarks)
80+
../tests # includes .hs, golden files, test tarballs, etc.
81+
../datafiles
82+
../libstemmer_c
83+
../src/Distribution/Server/Util/NLP/LICENSE
84+
];
85+
};
86+
settings = {
87+
hackage-server.check = false;
88+
89+
Cabal-syntax = { super, ... }:
90+
{ custom = _: super.Cabal-syntax_3_16_1_0; };
91+
Cabal = { super, ... }:
92+
{ custom = _: super.Cabal_3_16_1_0; };
93+
94+
sandwich.check = false;
95+
96+
threads.check = false;
97+
98+
unicode-data.check = false;
99+
};
100+
packages = {
101+
# https://community.flake.parts/haskell-flake/dependency#path
102+
# tls.source = "1.9.0";
103+
tar.source = "0.7.0.0";
104+
};
105+
devShell = {
106+
tools = hp: {
107+
inherit (pkgs)
108+
cabal-install
109+
ghc
110+
# https://github.com/haskell/hackage-server/pull/1219#issuecomment-1597140858
111+
# glibc
112+
icu67
113+
zlib
114+
openssl
115+
# cryptodev
116+
pkg-config
117+
brotli
118+
119+
gd
120+
libpng
121+
libjpeg
122+
fontconfig
123+
freetype
124+
expat
125+
;
126+
};
127+
hlsCheck.enable = false;
128+
};
129+
};
130+
};
131+
}

nix/nixos-module.nix

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
{ config, lib, pkgs, ... }:
2+
3+
let
4+
cfg = config.services.hackage-server;
5+
pkg = cfg.package;
6+
in
7+
{
8+
options.services.hackage-server = {
9+
enable = lib.mkEnableOption "hackage-server, a Haskell package repository";
10+
11+
package = lib.mkPackageOption pkgs "hackage-server" { };
12+
13+
baseUri = lib.mkOption {
14+
type = lib.types.str;
15+
example = "https://hackage.example.org";
16+
description = "The server's public base URI.";
17+
};
18+
19+
userContentUri = lib.mkOption {
20+
type = lib.types.str;
21+
example = "https://hackage-content.example.org";
22+
description = ''
23+
The server's public user content base URI, used for untrusted
24+
content to defeat XSS-style attacks.
25+
'';
26+
};
27+
28+
requiredBaseHostHeader = lib.mkOption {
29+
type = lib.types.str;
30+
example = "hackage-origin.example.org";
31+
description = ''
32+
Required Host header value for incoming requests. This may be
33+
an internal hostname if the server is behind a reverse proxy.
34+
'';
35+
};
36+
37+
stateDir = lib.mkOption {
38+
type = lib.types.path;
39+
default = "/var/lib/hackage-server";
40+
description = "Directory for the server's persistent state.";
41+
};
42+
43+
datafilesDir = lib.mkOption {
44+
type = lib.types.path;
45+
description = ''
46+
Directory containing HTML templates, static files, and TUF keys.
47+
'';
48+
};
49+
50+
port = lib.mkOption {
51+
type = lib.types.port;
52+
default = 8080;
53+
description = "TCP port to listen on.";
54+
};
55+
56+
ip = lib.mkOption {
57+
type = lib.types.str;
58+
default = "127.0.0.1";
59+
description = "IPv4 address to bind.";
60+
};
61+
62+
user = lib.mkOption {
63+
type = lib.types.str;
64+
default = "hackage";
65+
description = "User account under which hackage-server runs.";
66+
};
67+
68+
group = lib.mkOption {
69+
type = lib.types.str;
70+
default = "hackage";
71+
description = "Group under which hackage-server runs.";
72+
};
73+
};
74+
75+
config = lib.mkIf cfg.enable {
76+
77+
users.users.${cfg.user} = {
78+
isSystemUser = true;
79+
group = cfg.group;
80+
home = cfg.stateDir;
81+
description = "Hackage Server service user";
82+
};
83+
84+
users.groups.${cfg.group} = { };
85+
86+
systemd.tmpfiles.rules = [
87+
"d ${cfg.stateDir} 0750 ${cfg.user} ${cfg.group} -"
88+
"d ${cfg.stateDir}/state 0750 ${cfg.user} ${cfg.group} -"
89+
];
90+
91+
systemd.services.hackage-server = {
92+
description = "Hackage Server";
93+
after = [ "network-online.target" ];
94+
wants = [ "network-online.target" ];
95+
wantedBy = [ "multi-user.target" ];
96+
97+
preStart = ''
98+
if [ ! -d "${cfg.stateDir}/state/db" ]; then
99+
${lib.getExe pkg} init \
100+
--state-dir="${cfg.stateDir}/state" \
101+
--static-dir="${cfg.datafilesDir}"
102+
fi
103+
'';
104+
105+
serviceConfig = {
106+
Type = "simple";
107+
User = cfg.user;
108+
Group = cfg.group;
109+
Restart = "on-failure";
110+
RestartSec = 3;
111+
TimeoutStopSec = 120;
112+
LimitNOFILE = 1073741824;
113+
WorkingDirectory = cfg.stateDir;
114+
115+
ExecStart = lib.concatStringsSep " " [
116+
(lib.getExe pkg)
117+
"run"
118+
"--ip=${cfg.ip}"
119+
"--port=${toString cfg.port}"
120+
"--base-uri=${cfg.baseUri}"
121+
"--user-content-uri=${cfg.userContentUri}"
122+
"--required-base-host-header=${cfg.requiredBaseHostHeader}"
123+
"--state-dir=${cfg.stateDir}/state"
124+
"--static-dir=${cfg.datafilesDir}"
125+
"--tmp-dir=${cfg.stateDir}/state/tmp"
126+
];
127+
};
128+
};
129+
};
130+
}

nix/test.nix

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{ hackage-server, pkgs, ... }:
2+
3+
pkgs.testers.runNixOSTest {
4+
name = "hackage-server";
5+
6+
containers.machine = { pkgs, ... }: {
7+
imports = [ ./nixos-module.nix ];
8+
9+
services.hackage-server = {
10+
enable = true;
11+
package = hackage-server;
12+
datafilesDir = pkgs.runCommand "hackage-server-datafiles" {} ''
13+
datadir=$(dirname $(find ${hackage-server.data}/share -name templates -type d | head -1))
14+
ln -s "$datadir" $out
15+
'';
16+
baseUri = "http://localhost:8080";
17+
userContentUri = "http://localhost:8080";
18+
requiredBaseHostHeader = "localhost:8080";
19+
port = 8080;
20+
};
21+
22+
environment.systemPackages = [ pkgs.curl ];
23+
};
24+
25+
testScript = ''
26+
machine.start()
27+
machine.wait_for_unit("hackage-server.service")
28+
machine.wait_for_open_port(8080)
29+
30+
# Smoke test
31+
machine.succeed("curl -fsS --max-time 10 http://localhost:8080/")
32+
machine.succeed("curl -fsS --max-time 10 http://localhost:8080/users/.json")
33+
'';
34+
}

0 commit comments

Comments
 (0)