Skip to content

Commit 5b10270

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 23111c7 commit 5b10270

5 files changed

Lines changed: 302 additions & 76 deletions

File tree

.github/workflows/nix-flake.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,15 @@ jobs:
6565
extra_nix_config: |
6666
trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= hackage-server.cachix.org-1:iw0iRh6+gsFIrxROFaAt5gKNgIHejKjIfyRdbpPYevY=
6767
substituters = https://cache.nixos.org/ https://hackage-server.cachix.org/
68+
# The following settings just affect Linux, but are harmless on macOS.
69+
extra-experimental-features = nix-command flakes auto-allocate-uids cgroups
70+
# Let's just pretend we have KVM. It's a container test so
71+
# actually we don't even need KVM --- I think it's a mistake
72+
# that that the test derivation is even requesting this
73+
# feature.
74+
extra-system-features = uid-range kvm
75+
auto-allocate-uids = true
76+
use-cgroups = true
6877
- uses: cachix/cachix-action@v17
6978
with:
7079
# 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: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
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+
};
11+
12+
perSystem = { system, lib, config, pkgs, ... }:
13+
{
14+
checks = lib.optionalAttrs pkgs.stdenv.isLinux {
15+
nixos-test = import ./test.nix {
16+
hackage-server = config.packages.hackage-server;
17+
inherit pkgs;
18+
};
19+
};
20+
21+
apps.default.program = pkgs.writeShellApplication {
22+
name = "run-hackage-server";
23+
runtimeInputs = [ config.packages.default ];
24+
text = ''
25+
if [ ! -d "state" ]; then
26+
hackage-server init --static-dir=datafiles --state-dir=state
27+
else
28+
echo "'state' state-dir already exists"
29+
fi
30+
hackage-server run \
31+
--static-dir=datafiles \
32+
--state-dir=state \
33+
--base-uri=http://127.0.0.1:8080 \
34+
--required-base-host-header=localhost:8080 \
35+
--user-content-uri=http://127.0.0.1:8080
36+
'';
37+
};
38+
39+
apps.mirror-hackage-server.program = pkgs.writeShellApplication {
40+
name = "mirror-hackage-server";
41+
runtimeInputs = [ config.packages.default ];
42+
text = ''
43+
echo 'Copying packages from real Hackage Server into local Hackage Server.'
44+
echo 'This assumes the local Hackage Server uses default credentials;'
45+
echo 'otherwise, override in nix-default-servers.cfg'
46+
hackage-mirror nix-default-servers.cfg "$@"
47+
'';
48+
};
49+
50+
packages.default = config.packages.hackage-server;
51+
52+
haskellProjects.default = {
53+
basePackages = pkgs.haskell.packages.ghc912;
54+
# Only include files relevant to the Haskell build so that
55+
# changes to nix/, flake.nix, etc. don't trigger a rebuild.
56+
projectRoot = lib.fileset.toSource {
57+
root = ../.;
58+
fileset = let
59+
haskell = f: lib.hasSuffix ".hs" f.name;
60+
in lib.fileset.unions [
61+
../cabal.project
62+
../hackage-server.cabal
63+
../LICENSE
64+
(lib.fileset.fileFilter haskell ../src)
65+
(lib.fileset.fileFilter haskell ../exes)
66+
(lib.fileset.fileFilter haskell ../benchmarks)
67+
../tests # includes .hs, golden files, test tarballs, etc.
68+
../datafiles
69+
../libstemmer_c
70+
../src/Distribution/Server/Util/NLP/LICENSE
71+
];
72+
};
73+
settings = {
74+
hackage-server.check = false;
75+
76+
Cabal-syntax = { super, ... }:
77+
{ custom = _: super.Cabal-syntax_3_16_1_0; };
78+
Cabal = { super, ... }:
79+
{ custom = _: super.Cabal_3_16_1_0; };
80+
81+
sandwich.check = false;
82+
83+
threads.check = false;
84+
85+
unicode-data.check = false;
86+
};
87+
packages = {
88+
# https://community.flake.parts/haskell-flake/dependency#path
89+
# tls.source = "1.9.0";
90+
tar.source = "0.7.0.0";
91+
};
92+
devShell = {
93+
tools = hp: {
94+
inherit (pkgs)
95+
cabal-install
96+
ghc
97+
# https://github.com/haskell/hackage-server/pull/1219#issuecomment-1597140858
98+
# glibc
99+
icu67
100+
zlib
101+
openssl
102+
# cryptodev
103+
pkg-config
104+
brotli
105+
106+
gd
107+
libpng
108+
libjpeg
109+
fontconfig
110+
freetype
111+
expat
112+
;
113+
};
114+
hlsCheck.enable = false;
115+
};
116+
};
117+
};
118+
}

nix/nixos-module.nix

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
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+
# The Cabal data-files are installed under an ABI-specific path
46+
# like share/ghc-X.Y.Z/<abi-hash>/hackage-server-0.6/
47+
# We use a derivation to resolve the glob at build time.
48+
default = pkgs.runCommand "hackage-server-datafiles" {} ''
49+
templatesDir=$(find ${pkg.data or pkg}/share -name templates -type d | head -1)
50+
if [ -z "$templatesDir" ]; then
51+
echo "Could not find hackage-server data files in ${pkg.data or pkg}" >&2
52+
exit 1
53+
fi
54+
datadir=$(dirname "$templatesDir")
55+
ln -s "$datadir" $out
56+
'';
57+
defaultText = lib.literalMD "the data files of {option}`package`";
58+
description = ''
59+
Directory containing HTML templates, static files, and TUF keys.
60+
'';
61+
};
62+
63+
port = lib.mkOption {
64+
type = lib.types.port;
65+
default = 8080;
66+
description = "TCP port to listen on.";
67+
};
68+
69+
ip = lib.mkOption {
70+
type = lib.types.str;
71+
default = "127.0.0.1";
72+
description = "IPv4 address to bind.";
73+
};
74+
75+
user = lib.mkOption {
76+
type = lib.types.str;
77+
default = "hackage";
78+
description = "User account under which hackage-server runs.";
79+
};
80+
81+
group = lib.mkOption {
82+
type = lib.types.str;
83+
default = "hackage";
84+
description = "Group under which hackage-server runs.";
85+
};
86+
};
87+
88+
config = lib.mkIf cfg.enable {
89+
90+
users.users.${cfg.user} = {
91+
isSystemUser = true;
92+
group = cfg.group;
93+
home = cfg.stateDir;
94+
description = "Hackage Server service user";
95+
};
96+
97+
users.groups.${cfg.group} = { };
98+
99+
systemd.tmpfiles.rules = [
100+
"d ${cfg.stateDir} 0750 ${cfg.user} ${cfg.group} -"
101+
"d ${cfg.stateDir}/state 0750 ${cfg.user} ${cfg.group} -"
102+
"d ${cfg.stateDir}/state/tmp 0750 ${cfg.user} ${cfg.group} -"
103+
];
104+
105+
systemd.services.hackage-server = {
106+
description = "Hackage Server";
107+
after = [ "network-online.target" ];
108+
wants = [ "network-online.target" ];
109+
wantedBy = [ "multi-user.target" ];
110+
111+
preStart = ''
112+
if [ ! -d "${cfg.stateDir}/state/db" ]; then
113+
${lib.getExe pkg} init \
114+
--state-dir="${cfg.stateDir}/state" \
115+
--static-dir="${cfg.datafilesDir}"
116+
fi
117+
'';
118+
119+
serviceConfig = {
120+
Type = "simple";
121+
User = cfg.user;
122+
Group = cfg.group;
123+
Restart = "on-failure";
124+
RestartSec = 3;
125+
TimeoutStopSec = 120;
126+
LimitNOFILE = 1073741824;
127+
WorkingDirectory = cfg.stateDir;
128+
129+
ExecStart = lib.concatStringsSep " " [
130+
(lib.getExe pkg)
131+
"run"
132+
"--ip=${cfg.ip}"
133+
"--port=${toString cfg.port}"
134+
"--base-uri=${cfg.baseUri}"
135+
"--user-content-uri=${cfg.userContentUri}"
136+
"--required-base-host-header=${cfg.requiredBaseHostHeader}"
137+
"--state-dir=${cfg.stateDir}/state"
138+
"--static-dir=${cfg.datafilesDir}"
139+
"--tmp-dir=${cfg.stateDir}/state/tmp"
140+
];
141+
};
142+
};
143+
};
144+
}

nix/test.nix

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
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+
baseUri = "http://localhost:8080";
13+
userContentUri = "http://localhost:8080";
14+
requiredBaseHostHeader = "localhost:8080";
15+
port = 8080;
16+
};
17+
18+
environment.systemPackages = [ pkgs.curl ];
19+
};
20+
21+
testScript = ''
22+
machine.start()
23+
machine.wait_for_unit("hackage-server.service")
24+
machine.wait_for_open_port(8080)
25+
26+
# Smoke test
27+
machine.succeed("curl -fsS --max-time 10 http://localhost:8080/")
28+
machine.succeed("curl -fsS --max-time 10 http://localhost:8080/users/.json")
29+
'';
30+
}

0 commit comments

Comments
 (0)