diff options
-rw-r--r-- | .envrc | 4 | ||||
-rw-r--r-- | Makefile | 12 | ||||
-rw-r--r-- | src/curth0.scm | 258 | ||||
-rw-r--r-- | src/machines.scm | 374 | ||||
-rw-r--r-- | tests/unit-tests.scm | 10 |
5 files changed, 655 insertions, 3 deletions
@@ -0,0 +1,4 @@ +#!/bin/sh +set -eu + +export GUILE_LOAD_PATH="$PWD/src" @@ -1,12 +1,18 @@ .POSIX: -check: +check-guile: + guile tests/unit-tests.scm + +check-shellcheck: sh aux/assert-shellcheck.sh + +check-todos: sh aux/workflow/assert-todos.sh - sh aux/assert-terraform.sh + +check: check-guile check-shellcheck check-todos clean: - rm -rf public/ + rm -rf public/ *.log dev-check: check diff --git a/src/curth0.scm b/src/curth0.scm new file mode 100644 index 0000000..13a3ec8 --- /dev/null +++ b/src/curth0.scm @@ -0,0 +1,258 @@ +(define-module (curth0) + #:use-module ((language tree-il) #:prefix tree-il:) + #:use-module ((srfi srfi-1) #:prefix s1:) + #:use-module ((srfi srfi-64) #:prefix t:) + #:export (-> + ->>)) + + +(define (expand l) + (tree-il:tree-il->scheme (macroexpand l))) + +(define-syntax -> + (syntax-rules () + ((_) #f) + ((_ x) x) + ((_ x (f . (head ...))) (f x head ...)) + ((_ x f) (f x)) + ((_ x (f . (head ...)) rest ...) (-> (f x head ...) rest ...)) + ((_ x f rest ...) (-> (f x) rest ...)))) + +(define-syntax ->> + (syntax-rules () + ((_) #f) + ((_ x) x) + ((_ x (f ...)) (f ... x)) + ((_ x f) (f x)) + ((_ x (f ...) rest ...) (->> (f ... x) rest ...)) + ((_ x f rest ...) (->> (f x) rest ...)))) + +(define (test-thread-macro) + (t:test-group "-> and ->>" + (t:test-equal '#f (expand '(->))) + (t:test-equal '#f (expand '(->>))) + + (t:test-equal '1 (expand '(-> 1))) + (t:test-equal '1 (expand '(->> 1))) + + (t:test-equal '(f 1) (expand '(-> (f 1)))) + (t:test-equal '(f 1) (expand '(->> (f 1)))) + + (t:test-equal '(f 1 2 3) (expand '(-> 1 (f 2 3)))) + (t:test-equal '(f 2 3 1) (expand '(->> 1 (f 2 3)))) + + (t:test-equal '(f 1) (expand '(-> 1 f))) + (t:test-equal '(f 1) (expand '(->> 1 f))) + + (t:test-equal '(f2 (f1 1)) (expand '(-> 1 f1 f2))) + (t:test-equal '(f2 (f1 1)) (expand '(->> 1 f1 f2))) + + (t:test-equal '(f2 (f1 1)) (expand '(-> 1 (f1) f2))) + (t:test-equal '(f2 (f1 1)) (expand '(->> 1 (f1) f2))) + + (t:test-equal '(f2 (f1 1)) (expand '(-> 1 f1 (f2)))) + (t:test-equal '(f2 (f1 1)) (expand '(->> 1 f1 (f2)))) + + (t:test-equal '(f2 (f1 1)) (expand '(-> 1 (f1) (f2)))) + (t:test-equal '(f2 (f1 1)) (expand '(->> 1 (f1) (f2)))) + + (t:test-equal '(f1 1 (f2)) (expand '(-> 1 (f1 (f2))))) + (t:test-equal '(f1 (f2) 1) (expand '(->> 1 (f1 (f2))))) + + (t:test-equal '(f6 (f5 (f4 (f3 (f1 1 (f2)))) 0 1 2)) + (expand '(-> 1 (f1 (f2)) f3 (f4) (f5 0 1 2) f6))) + (t:test-equal '(f6 (f5 0 1 2 (f4 (f3 (f1 (f2) 1))))) + (expand '(->> 1 (f1 (f2)) f3 (f4) (f5 0 1 2) f6))))) + +(define consumable-chars + '(#\space #\tab)) + +(define (extract-single-line-indentation chars) + (s1:fold (lambda (curr acc) + (let* ((prev (s1:first acc)) + (halted? (s1:second acc)) + (n (s1:third acc)) + (non-blank? (not (member curr consumable-chars))) + (changed? (and (not (null? prev)) + (not (equal? curr prev))))) + (cond + (halted? acc) + (non-blank? (list prev #t n)) + (changed? (list prev #t n)) + (#:else (list curr #f (+ 1 n)))))) + (list #nil #f 0) + chars)) + +(define (test-extract-single-line-indentation) + (t:test-group "extract-single-line-indentation" + (t:test-equal '(#nil #f 0) + (extract-single-line-indentation + '())) + + (t:test-equal '(#nil #t 0) + (extract-single-line-indentation + '(#\a #\b #\c))) + + (t:test-equal '(#\space #t 1) + (extract-single-line-indentation + '(#\space #\b #\c))) + + (t:test-equal '(#\space #t 5) + (extract-single-line-indentation + '(#\space #\space #\space #\space #\space #\b #\c))) + + (t:test-equal '(#\space #f 5) + (extract-single-line-indentation + '(#\space #\space #\space #\space #\space))) + + (t:test-equal '(#\space #f 1) + (extract-single-line-indentation + '(#\space))) + + (t:test-equal '(#\tab #t 3) + (extract-single-line-indentation + '(#\tab #\tab #\tab #\b #\c))) + + (t:test-equal '(#\tab #f 3) + (extract-single-line-indentation + '(#\tab #\tab #\tab))) + + (t:test-equal '(#\tab #f 1) + (extract-single-line-indentation + '(#\tab))) + + (t:test-equal '(#\space #t 1) + (extract-single-line-indentation + '(#\space #\tab))) + + (t:test-equal '(#\space #t 2) + (extract-single-line-indentation + '(#\space #\space #\tab #\space))) + + (t:test-equal '(#\tab #t 3) + (extract-single-line-indentation + '(#\tab #\tab #\tab #\space))) + + (t:test-equal '(#\tab #t 3) + (extract-single-line-indentation + '(#\tab #\tab #\tab #\a #\tab))) + + (t:test-equal '(#\tab #t 3) + (extract-single-line-indentation + '(#\tab #\tab #\tab #\a #\space))))) + +(define (extract-line-indentations lines) + (map (compose (lambda (triple) + (list (s1:first triple) + (s1:third triple))) + extract-single-line-indentation + string->list) + lines)) + +(define (test-extract-line-indentations) + (t:test-group "extract-line-indentations" + (t:test-equal '() + (extract-line-indentations + '())) + + (t:test-equal '((#nil 0)) + (extract-line-indentations + '(""))) + + (t:test-equal '((#nil 0) (#nil 0) (#nil 0)) + (extract-line-indentations + '("" "" ""))) + + (t:test-equal '((#nil 0) (#nil 0) (#nil 0)) + (extract-line-indentations + '("a" "b" "c"))) + + (t:test-equal '((#\space 1) (#\space 2) (#\space 3)) + (extract-line-indentations + '(" " " " " "))))) + +(define (maximum-indentation lines) + (let* ((line-indentations (extract-line-indentations lines)) + (chars (map s1:first line-indentations)) + (different-indents? (not (= 1 (length (s1:delete-duplicates chars)))))) + (if different-indents? + 0 + (apply min (map s1:third line-indentations))))) + +(define (trim-indentation s) + (let* ((lines (string-split s #\newline)) + (trim-n (maximum-indentation + (filter (lambda (s) (not (equal? "" s))) + lines)))) + (string-join + (map (lambda (line) + (if (equal? "" line) + line + (substring line trim-n))) + lines) + "\n"))) + +(define (non-quote-chars? chars) + (let ((non-quote-chars (filter (lambda (c) + (not (equal? #\" c))) + chars))) + (if (< 0 (length non-quote-chars)) + non-quote-chars + #f))) + +(define (multiline-string-reader _char port) + "FIXME: + - remove the need to indent the last line with the rest of the content. + How does Perl, Ruby, Python, sh do it?" + (let ((multiline? #f) + (chars '())) + (do ((curr (read-char port) + (read-char port))) + ((equal? #\newline curr)) + (set! chars (cons curr chars))) + (when (equal? #\- (car chars)) + (set! multiline? #t) + (set! chars (cdr chars))) + (let ((non-quote-chars (non-quote-chars? chars))) + (when non-quote-chars + (error + (format + #f + "Invalid characters at the beginning of the multiline reader: ~s" + (reverse non-quote-chars))))) + (let* ((quote-count (+ 1 (length chars))) + (quote-n 0) + (output '())) + (while #t + (let ((curr (read-char port))) + (when (eof-object? curr) + (error "EOF while reading #\"\"# multiline string")) + (set! output (cons curr output)) + (set! quote-n + (cond + ((and (>= quote-n quote-count) + (equal? #\# curr)) + (break)) + ((equal? #\" curr) (+ 1 quote-n)) + (#:else 0))))) + (let ((s (list->string + (reverse + (s1:drop output (+ 1 quote-count)))))) + (if multiline? + (trim-indentation s) + s))))) + +(read-hash-extend #\" multiline-string-reader) + +(define test-fns + (list + test-thread-macro + test-extract-single-line-indentation + test-extract-line-indentations)) + +(define (unit-tests) + (t:test-begin "curth0-tests") + (for-each (lambda (fn) (fn)) test-fns) + (let ((n-fail (t:test-runner-fail-count (t:test-runner-get)))) + (t:test-end) + n-fail)) diff --git a/src/machines.scm b/src/machines.scm new file mode 100644 index 0000000..3121938 --- /dev/null +++ b/src/machines.scm @@ -0,0 +1,374 @@ +(use-modules (gnu) + (curth0) + + (gnu packages ssh) + + (gnu services certbot) + (gnu services mcron) + (gnu services mail) + (gnu services networking) + (gnu services ssh) + (gnu services web)) + +;; +;; Implicit dependencies, to be automated: +;; - /srv and /opt directories: +;; # mkdir -p /srv/http /opt/secrets +;; # chown -R andreh:users /opt /srv +;; # chmod -R 755 /opt /srv +;; - create /opt/secrets/borg-passphrase.txt +;; $ pass generate VPS/$SERVER/borg/passphrase.txt 999 +;; $ pass show VPS/$SERVER/borg/passphrase | ssh $SERVER 'cat - > /opt/secrets/borg-passphrase.txt' +;; - create the SSH key +;; $ ssh-keygen +;; - *manually* add that to the authorized_keys on rsync.net: +;; $ scp $R:.ssh/authorized_keys src/rsync.net/ +;; $ # FIXME: add 'restrict,command="..."' to the authorized_keys entry +;; $ ssh $SERVER cat .ssh/id_rsa.pub >> authorized_keys +;; $ scp src/rsync.net/authorized_keys $R:.ssh/ +;; - copy borg key after the first backup: +;; $ FIXME +;; - generate DKIM key +;; $ guix shell openssl -- openssl genrsa -out /opt/secrets/dkim.arrobaponto.org.key 1024 +;; $ guix shell openssl -- openssl rsa -in /opt/secrets/dkim.arrobaponto.org.key -pubout -out /opt/secrets/dkim.arrobaponto.org.pub +;; - manually load /etc/profile-extra, /etc/bashrc-extra and /etc/ps1.sh +;; to ~/.bashrc and ~root/.bashrc +;; + +;; +;; Friendly reminder that I've actually installed and configure email + XMPP o +;; a Debian server in Vultr! I just deleted the snapshot that I made in case +;; of restoring, and I deleted it just now to save money and stop paying for it. +;; + +;; +;; TODO (FIXME): +;; - maddy (spamassasin? fail2ban? rspamd? blacklistd?) +;; - dns (knot) +;; - prosody? matrix-conduit? Read again HN comments on Dino 0.3 release. Maybe manage both for a while. +;; - httpd? +;; + +;; +;; FIXME: +;; - resize machine +;; + + +(define profile-extra + (plain-file "profile-extra" #"""- + R='16686@ch-s010.rsync.net' + export BORG_REMOTE_PATH='borg1' + export BORG_PASSPHRASE_FD='/opt/secrets/borg-passphrase.txt' +"""#)) + +(define bashrc-extra + (plain-file "bashrc-extra" #"""- + alias l='ls -lahF --color' +"""#)) + +(define ps1.sh + (plain-file "ps1.sh" #"""- + end="\033[0m" + + gray() { + printf "\033[0;90m$1$end" + } + + yellow() { + printf "\033[1;33m$1$end" + } + + red() { + printf "\033[1;31m$1$end" + } + + redl() { + printf "\033[0;31m$1$end" + } + + error_marker() { + STATUS=$? + if [ "$STATUS" != 0 ]; then + red " (!! $STATUS !!) " + fi + } + + export PS1='`error_marker`'$(gray '\T')' '$(yellow '\w/')'\n'$(redl '\\u@\h')'\$ ' +"""#)) + +(define with-email.sh + (plain-file "with-email.sh" #"""- + #!/bin/sh + set -u + + while getopts 's:' flag; do + case "$flag" in + s) + SUBJECT="$OPTARG" + ;; + *) + exit 2 + ;; + esac + done + shift $((OPTIND - 1)) + + now() { + date '+%Y-%m-%dT%H:%M:%S%Z' + } + + OUT="$(echo 'mkstemp(template)' | m4 -D template="${TMPDIR:-/tmp}/m4-tmpname")" + printf 'Running command: %s\nStarting at: %s\n\n' "$*" "$(now)" >> "$OUT" + ("$@" 2>&1) >> "$OUT" + STATUS="$?" + printf '\nFinished at: %s\n' "$(now)" >> "$OUT" + + mail \ + -a 'Content-Type: text/plain; charset=UTF-8' \ + -a 'From:cron@arrobaponto.org' \ + -s "(exit status: $STATUS) - ${SUBJECT:-NO SUBJECT}" \ + eu@euandre.org < "$OUT" + + cat "$OUT" +"""#)) + +(define backup.sh + (plain-file "backup.sh" #"""- + #!/bin/sh + set -eux + . /etc/profile-extra + + finish() { + STATUS=$? + printf '\n>>>\n>>> exit status: %s\n>>>\n\n' "$STATUS" >&2 + } + trap finish EXIT + + borg init -e repokey-blake2 "$R:toph-borg" ||: + borg key export "$R:toph-borg" /opt/secrets/borg-key.txt + + FIXME: more users + borg create \ + --exclude /root/.cache/ \ + --exclude /home/andreh/.cache/ \ + --stats \ + --compression lzma,9 \ + "R$:toph-borg::{hostname}-{now}-${1:-cronjob}" \ + /root/ \ + /home/andreh/ \ + /etc/letsencrypt/ \ + /var/lib/certbot/ \ + /var/lib/letsencrypt \ + /opt/secrets/ \ + /srv/ + + borg check \ + --verbose \ + "$R:toph-borg" + + borg prune \ + --verbose \ + --list \ + --keep-within=6m \ + --keep-weekly=52 \ + --keep-monthly=24 \ + "$R:toph-borg" +"""#)) + +(define vi.exrc + (plain-file "vi.exrc" #"""- + " set number + set autoindent + set tabstop=8 + set shiftwidth=8 + set ruler + set showmode + set showmatch +"""#)) + +(define opensmtpd.conf + (plain-file "opensmtpd.conf" #"""- + table aliases file:/etc/aliases + table creds { } + + pki mail.arrobaponto.org cert "/etc/letsencrypt/live/arrobaponto.org/fullchain.pem" + pki mail.arrobaponto.org key "/etc/letsencrypt/live/arrobaponto.org/privkey.pem" + + listen on eth0 + + filter check_dyndns phase connect match rdns regex { '.*\.dyn\..*', '.*\.dsl\..*' } junk + filter check_rdns phase connect match !rdns junk + filter check_fcrdns phase connect match !fcrdns junk + + accept from any for domain "arrobaponto.org" alias <aliases> deliver to maildir + accept for local alias <aliases> deliver to maildir + + accept for any relay +"""#)) + + +(define cronjobs + (list + #~(job "0 30 * * 0" "guix gc -d 1m -F 10G") + ;; FIXME: wat!? There is a /root/dead.letter file!? + ;; #~(job "* * * * *" "sh /etc/with-email.sh -s '[CRON] toph: xablau' -- seq 10 20 >&1") + #~(job "* * * * *" "whoami") + #~(job "* * * * *" "seq 20 30 >&2"))) + + +(define users + '()) + +(define user-accounts + '()) +(define authorized-keys) +`(("andreh" ,(plain-file "id_rsa.pub" "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDnUv7iWOejQNa3fZ6v4lkHT6qFRp2+NuzIpFJ2Vy7eP58XZoiz6HJPcCU8Hf95JXwaXEwS4S7mXdw1x60hd8JIe058Ek6MZSSVQmlLfocGsAYj1wTrLmnQ8+PV0IeQlNj1aytBI1fL+v3IPt+JdLt6b+g3vwcEUU9efzxx2E0KZ5GIpb2meiCQ6ha+tcd7XqegB53eQj/h/coE2zLJodpaJ3xbj894pE/OJCNC0+4d0Sv7oHhY7QoLYldTQbSgPyhyfl4iZpJf6OEPZxK2cJaB+cbeoBB6aGNyU+CIJToM+uAJJ7H7EpvxfcnfJQ1PuY5szTdvFbW820euiUEKEW69mW4uaFNPSc6D4Z8tZ5hXQIqBD40irULhF0CYNkIILmyNV/KJIZ5HkbQ1q+UrCFHJyvuH/3aCTjj9OSfE7xHPQ3xd3Xw8vvj0Mjie09xFbbcklBTw5WRzH7cw8c+Q0O69kZZ8b+ykcdzWTeZeWNdnzptNqnMjfheig90rUIJ7DN0c+53jCUcGpWJxJhcYF9Uk1RNHmSE5+VzK1y+20t0grVFX90nApm4Tl35QPrX7Qxp9C81cWiUB8xCAE6jYrmd4x+P/3wSQfc1Xg0Eg3QjJB+6JD7cbyDJpzDR3ja+CLZCAr9I0B4rDKD2d6et/z67iXPnZUWMyZ8RVVZPFbBMOTw== openpgp:0xF727046D"))) + (list + (user-account + (name "andreh") + (comment "EuAndreh") + (group "users") + (supplementary-groups '("wheel")))) + +(define toph + (operating-system + (locale "fr_FR.utf8") + (timezone "America/Sao_Paulo") + (host-name "toph") + (users (append + (list + (user-account + (name "andreh") + (comment "EuAndreh") + (group "users") + (supplementary-groups '("wheel")))) + %base-user-accounts)) + (sudoers-file (plain-file "sudoers" #"""- + root ALL=(ALL) ALL + %wheel ALL=NOPASSWD: ALL + """#)) + (packages + (append (map (compose list specification->package+output symbol->string) + '(nss-certs ; required for guix pull + git-minimal + borg + m4)) + %base-packages)) + (services + (append + (list + (service openssh-service-type + (openssh-configuration + (port-number 38123) + (openssh openssh-sans-x) + (password-authentication? #f) + (permit-root-login #f) + (subsystems '()) + (extra-content #"""- + ClientAliveInterval 30 + ClientAliveCountMax 20 + """#) + (authorized-keys + `(("andreh" ,(plain-file "id_rsa.pub" "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDnUv7iWOejQNa3fZ6v4lkHT6qFRp2+NuzIpFJ2Vy7eP58XZoiz6HJPcCU8Hf95JXwaXEwS4S7mXdw1x60hd8JIe058Ek6MZSSVQmlLfocGsAYj1wTrLmnQ8+PV0IeQlNj1aytBI1fL+v3IPt+JdLt6b+g3vwcEUU9efzxx2E0KZ5GIpb2meiCQ6ha+tcd7XqegB53eQj/h/coE2zLJodpaJ3xbj894pE/OJCNC0+4d0Sv7oHhY7QoLYldTQbSgPyhyfl4iZpJf6OEPZxK2cJaB+cbeoBB6aGNyU+CIJToM+uAJJ7H7EpvxfcnfJQ1PuY5szTdvFbW820euiUEKEW69mW4uaFNPSc6D4Z8tZ5hXQIqBD40irULhF0CYNkIILmyNV/KJIZ5HkbQ1q+UrCFHJyvuH/3aCTjj9OSfE7xHPQ3xd3Xw8vvj0Mjie09xFbbcklBTw5WRzH7cw8c+Q0O69kZZ8b+ykcdzWTeZeWNdnzptNqnMjfheig90rUIJ7DN0c+53jCUcGpWJxJhcYF9Uk1RNHmSE5+VzK1y+20t0grVFX90nApm4Tl35QPrX7Qxp9C81cWiUB8xCAE6jYrmd4x+P/3wSQfc1Xg0Eg3QjJB+6JD7cbyDJpzDR3ja+CLZCAr9I0B4rDKD2d6et/z67iXPnZUWMyZ8RVVZPFbBMOTw== openpgp:0xF727046D")))))) + (service dhcp-client-service-type) + (service mcron-service-type + (mcron-configuration + (jobs cronjobs))) + (simple-service 'extra-etc-file etc-service-type + `(("backup.sh" ,backup.sh) + ("profile-extra" ,profile-extra) + ("bashrc-extra" ,bashrc-extra) + ("ps1.sh" ,ps1.sh) + ("vi.exrc" ,vi.exrc) + ("with-email.sh" ,with-email.sh))) + (service certbot-service-type + (certbot-configuration + (email "eu@euandre.org") + (certificates + (list + (certificate-configuration + (domains '("arrobaponto.org")) + (deploy-hook + (program-file + "nginx-deploy-hook" + #~(let ((pid (call-with-input-file + "/var/run/nginx/pid" + read))) + (kill pid SIGHUP))))))))) + (service nginx-service-type + (nginx-configuration + (run-directory "/var/run/nginx") + (server-blocks + (list + (nginx-server-configuration + (server-name '("arrobaponto.org")) + (listen '("[::]:443 ssl http2" "443 ssl http2")) + (root "/srv/http/arrobaponto.org") + (ssl-certificate "/etc/letsencrypt/live/arrobaponto.org/fullchain.pem") + (ssl-certificate-key "/etc/letsencrypt/live/arrobaponto.org/privkey.pem") + (raw-content '(#"""- + autoindex on; + add_header Strict-Transport-Security 'max-age=86400; includeSubdomains' always; + """#))))))) + (service mail-aliases-service-type + '(("webmaster" "andreh") + ("abuse" "andreh") + ("root" "andreh") + ("postmaster" "andreh"))) + (service opensmtpd-service-type + (opensmtpd-configuration + (config-file opensmtpd.conf)))) + (modify-services + %base-services + (guix-service-type + config => (guix-configuration + (inherit config) + (authorized-keys + (append + (list + (local-file "/etc/guix/signing-key.pub")) + %default-authorized-guix-keys))))))) + (bootloader + (bootloader-configuration + (bootloader grub-bootloader) + (targets '("/dev/vda")))) + (swap-devices + (list + (swap-space + (target (uuid "30122738-f2f6-4f93-a37c-62023f56c73b"))))) + (file-systems + (append + (list + (file-system + (mount-point "/") + (device + (uuid "455682d0-7ce7-4144-9728-a6d07beb049a" + 'btrfs)) + (type "btrfs"))) + %base-file-systems)))) + + + +(list + (machine + (operating-system toph) + (environment managed-host-environment-type) + (configuration (machine-ssh-configuration + (host-name "toph") + (system "x86_64-linux") + (user "andreh") + (port 38123) + (host-key "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILoz1gFl6chY91vQ5SrZXSP5yHqRI3TdYy2ccEDpS7Z4"))))) + +;; toph -> euandre.org +;; kuvira -> euandreh.xyz +;; ??? -> arrobaponto.org +;; asami -> discussions.site +;; zhu-li -> mediator.ht +;; lily -> hinarioespirita.org ; musician +;; kyoshi -> standardify.sh ; standardtized warriors +;; suyin -> rsync.net ; city with a metal shell +;; ??? -> amber.ht +;; yangchen -> multipatch.xyz +;; mai -> mailbug.xyz diff --git a/tests/unit-tests.scm b/tests/unit-tests.scm new file mode 100644 index 0000000..4312a27 --- /dev/null +++ b/tests/unit-tests.scm @@ -0,0 +1,10 @@ +(use-modules (curth0)) + +(define (run-tests) + (let ((fns (list (@@ (curth0) unit-tests)))) + (for-each (lambda (fn) + (when (not (= 0 (fn))) + (exit 1))) + fns))) + +(run-tests) |