e2e.nix
1# End to end on one NixOS VM, as on the forge host: soft-serve (as root,
2# like the forge role) with the bridge's post-receive hook, and the bridge,
3# runner (systemd launcher) and appview from nix/module.nix. A push runs a
4# workflow in a sandboxed transient unit as the ci user, pauses at a gate,
5# and finishes after the gate is approved through the runner's API.
6{ self, pkgs }:
7
8let
9 # test-only keys, generated at build time
10 keys = pkgs.runCommand "ci-test-keys" { nativeBuildInputs = [ pkgs.openssh pkgs.age ]; } ''
11 mkdir $out
12 ssh-keygen -q -t ed25519 -N "" -C ci-test -f $out/ssh-key
13 age-keygen -o $out/age-identity 2>/dev/null
14 '';
15 token = pkgs.writeText "token" "test-token-0123456789";
16 passwordHash = pkgs.runCommand "pw" { nativeBuildInputs = [ pkgs.mkpasswd ]; } ''
17 mkpasswd -m bcrypt -R 5 pw > $out
18 '';
19 workflow = pkgs.writeText "deploy.yml" ''
20 when: [{ event: push, branch: main }]
21 engine: devenv
22 steps: [build, deploy, record]
23 actions:
24 build:
25 command: |
26 echo "user=$(id -un)"
27 if touch /etc/ci-test 2>/dev/null; then echo etc-writable; else echo etc-readonly; fi
28 echo "identity-lines=$(grep -c ^AGE-SECRET-KEY- "$SECRETS_IDENTITY")"
29 echo "home=$HOME"
30 echo "unit=$(cat /proc/self/cgroup)"
31 # the appview holds 127.0.0.1:8477 on the host; a segment's own
32 # network namespace has the port free
33 timeout 2 ci bridge serve --listen 127.0.0.1:8477 --forge-host x --db "$CI_RUN_DIR/x.db" --spool "$CI_RUN_DIR/sp" 2>/dev/null || echo "port-isolated rc=$?"
34 ci-result ok --summary "built on $(cat /proc/sys/kernel/hostname)"
35 deploy:
36 command: echo deploying
37 gate: { prompt: "Deploy demo?" }
38 record:
39 command: |
40 git -c user.name=ci -c user.email=ci@test commit -q --allow-empty -m "recorded by ci"
41 git push -q origin HEAD:refs/heads/recorded
42 echo pushed
43 '';
44 hook = pkgs.writeShellScript "post-receive" ''
45 exec ${self.packages.${pkgs.stdenv.hostPlatform.system}.ci}/bin/ci bridge notify \
46 --spool /var/lib/ci-bridge/spool --server http://127.0.0.1:8475 --token-file /etc/ci/bridge-token
47 '';
48 node =
49 { pasta }:
50 { pkgs, ... }:
51 {
52 imports = [ self.nixosModules.default ];
53 virtualisation.memorySize = 2048;
54 environment.systemPackages = [ pkgs.git pkgs.jq pkgs.curl pkgs.openssh ];
55 environment.etc."ci/ssh-key" = {
56 source = "${keys}/ssh-key";
57 mode = "0600";
58 };
59 environment.etc."ci/age-identity" = {
60 source = "${keys}/age-identity";
61 mode = "0600";
62 };
63 # root-only, as the fleet uploads it: the bridge (user ci-bridge) must
64 # get it as a credential, and the forge's hook (root) reads the file
65 environment.etc."ci/bridge-token" = {
66 source = token;
67 mode = "0400";
68 };
69
70 # soft-serve as the forge runs it (root, /var/soft/data, a custom hook)
71 systemd.services.soft-serve = {
72 wantedBy = [ "multi-user.target" ];
73 after = [ "network.target" ];
74 environment = {
75 SOFT_SERVE_DATA_PATH = "/var/soft/data";
76 SOFT_SERVE_INITIAL_ADMIN_KEYS = builtins.readFile "${keys}/ssh-key.pub";
77 SOFT_SERVE_SSH_LISTEN_ADDR = ":23231";
78 };
79 preStart = ''
80 mkdir -p /var/soft/data/hooks
81 ln -sf ${hook} /var/soft/data/hooks/post-receive
82 '';
83 serviceConfig.ExecStart = "${pkgs.soft-serve}/bin/soft serve";
84 path = [ pkgs.git ];
85 };
86
87 services.ci = {
88 bridge = {
89 enable = true;
90 forgeHost = "git.test";
91 ownerDid = "did:plc:test";
92 tokenFile = "/etc/ci/bridge-token";
93 };
94 runner = {
95 enable = true;
96 shell = "bash";
97 inherit pasta;
98 pastaHostPorts = [ 23231 ];
99 cloneUrl = "ssh://git@localhost:23231/{repo}";
100 bridgeTokenFile = "${token}";
101 tokenFile = "${token}";
102 identityFile = "/etc/ci/age-identity";
103 sshKeyFile = "/etc/ci/ssh-key";
104 devenvCache = false;
105 };
106 appview = {
107 enable = true;
108 passwordHashFile = "${passwordHash}";
109 secureCookies = false;
110 };
111 };
112 };
113in
114pkgs.testers.runNixOSTest {
115 name = "ci-e2e";
116 # forge: the default configuration (what deploys); pastaforge: the same
117 # with each segment in its own network namespace
118 nodes.forge = node { pasta = false; };
119 nodes.pastaforge = node { pasta = true; };
120
121 testScript = ''
122 import json
123 auth = "-H 'Authorization: Bearer test-token-0123456789'"
124 api = f"curl -sf {auth} http://127.0.0.1:8476"
125
126 def dump(m):
127 print(m.execute(f"{api}/api/runs | jq '.[0] | .status, .state.status, .state.end'")[1])
128 print(m.execute(f"{api}/api/runs | jq -r '.[0].id' | xargs -I{{}} curl -s {auth} http://127.0.0.1:8476/api/runs/{{}}/log.txt")[1])
129 print(m.execute("tail -n 40 /var/lib/ci/runs/*/segment-*.log")[1])
130 print(m.execute("journalctl -u ci-runner -u 'ci-*-1.service' -u 'ci-*-2.service' -n 60 --no-pager")[1])
131
132 def wait_status(m, cmd, timeout):
133 try:
134 m.wait_until_succeeds(cmd, timeout=timeout)
135 except Exception:
136 dump(m)
137 raise
138
139 def flow(m, pasta):
140 m.wait_for_unit("soft-serve.service")
141 m.wait_for_open_port(23231)
142 for u in ["ci-bridge", "ci-runner", "ci-appview"]:
143 m.wait_for_unit(f"{u}.service")
144 m.wait_for_open_port(8477)
145 m.succeed("""
146 set -e
147 mkdir -p /tmp/w/.tangled/workflows && cd /tmp/w
148 git init -q -b main
149 cp ${workflow} .tangled/workflows/deploy.yml
150 git add -A && git -c user.name=t -c user.email=t@t commit -qm init
151 GIT_SSH_COMMAND='ssh -i /etc/ci/ssh-key -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/tmp/kh' \
152 git push -q ssh://git@localhost:23231/demo main
153 """)
154
155 with subtest(f"{m.name}: the push runs the workflow and pauses at the gate"):
156 wait_status(m, f"{api}/api/runs | jq -e '.[0].status == \"paused\"'", 180)
157 runs = json.loads(m.succeed(f"{api}/api/runs"))
158 run = runs[0]["id"]
159 assert runs[0]["state"]["gate"]["prompt"] == "Deploy demo?", runs[0]["state"]
160
161 with subtest(f"{m.name}: segments run sandboxed in ci.slice"):
162 text = m.succeed(f"{api}/api/runs/{run}/log.txt")
163 print(text)
164 assert "etc-readonly" in text, text
165 assert "identity-lines=1" in text, text
166 assert "ci.slice" in text and f"ci-{run}-1.service" in text, text
167 if pasta:
168 # its own network namespace: the host's port 8477 is free there
169 assert "port-isolated rc=124" in text, text
170 # caveat: uid 0 of pasta's user namespace, hostname pasta-<host>
171 assert "user=root" in text and "built on pasta-" in text, text
172 else:
173 assert "user=ci" in text, text
174 assert f"built on {m.name}" in text, text
175 assert "port-isolated rc=1" in text, text # shares the host network: 8477 is taken
176
177 if pasta:
178 # caveat: in pasta's user namespace root-owned files look unowned,
179 # and OpenSSH refuses its config: no SSH (so no deploy) under pasta
180 with subtest(f"{m.name}: dismissing the gate cancels the run"):
181 m.succeed(f"{api}/api/runs/{run}/gate -X POST -d '{{\"dismiss\":true,\"actor\":\"did:plc:test\"}}'")
182 wait_status(m, f"{api}/api/runs/{run} | jq -e '.status == \"cancelled\"'", 120)
183 return run
184
185 with subtest(f"{m.name}: approving the gate finishes the run, pushing with the ssh key"):
186 m.succeed(f"{api}/api/runs/{run}/gate -X POST -d '{{\"option\":1,\"actor\":\"did:plc:test\"}}'")
187 wait_status(m, f"{api}/api/runs/{run} | jq -e '.status == \"success\"'", 120)
188 assert "pushed" in m.succeed(f"{api}/api/runs/{run}/log.txt")
189 m.succeed("git --git-dir=/var/soft/data/repos/demo.git rev-parse --verify refs/heads/recorded")
190 return run
191
192 start_all()
193 run = flow(forge, pasta=False)
194 flow(pastaforge, pasta=True)
195
196 with subtest("records: pipeline, statuses and gate on the runner's /events"):
197 out = forge.succeed("${pkgs.sqlite}/bin/sqlite3 /var/lib/ci/runner.db 'select nsid, event from events order by created'")
198 assert "sh.tangled.pipeline|" in out, out
199 statuses = [l for l in out.splitlines() if l.startswith("sh.tangled.pipeline.status|")]
200 assert [json.loads(l.split("|", 1)[1])["status"] for l in statuses] == ["pending", "running", "success"], statuses
201 assert "run.rtw.ci.gate|" in out, out
202
203 with subtest("the appview is up and asks for a login"):
204 forge.succeed("curl -sf http://127.0.0.1:8477/healthz")
205 forge.succeed("curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8477/ | grep -q 303")
206
207 with subtest("a runner restart keeps finished state"):
208 forge.succeed("systemctl restart ci-runner.service")
209 forge.wait_for_open_port(8476)
210 forge.succeed(f"{api}/api/runs/{run} | jq -e '.status == \"success\"'")
211 '';
212}