commit a46c341b0cd74fbbf2075c3bec1a8f72cd792529
parent 4ba1739f51a97b26583712cd414c17129b9e9783
Author: James Eversole <james@eversole.co>
Date: Tue, 13 Oct 2026 18:31:08 -0500
Modernize Arboricx registry deployment with a precompiled native bundle
Diffstat:
4 files changed, 200 insertions(+), 16 deletions(-)
diff --git a/docs/build-and-run.md b/docs/build-and-run.md
@@ -94,6 +94,59 @@ resolve/update, fetch, bind and check remain separate from compilation. Offline
show selected dependencies, local evidence and structural changes without running
exported programs.
+## Deploy the Arboricx registry (Linux)
+
+The `arboricxServer` flake output builds the image `arboricx-server:latest`.
+It compiles `tricu-apps/arboricxServer.tri` and its explicit native dependencies
+with the packaged stable compiler at build time. Startup runs the compiled
+manifest from a read-only Nix store; it does not compile source, search
+`tricu.workspace`, or write a compiler cache into registry storage.
+
+```sh
+nix build .#arboricxServerTest --no-link
+nix build .#arboricxServer -o result-server
+
+docker load < result-server
+mkdir -p registry-store
+docker run -d --name arboricx-registry --restart unless-stopped \
+ --user "$(id -u):$(id -g)" --read-only \
+ --cap-drop ALL --security-opt no-new-privileges \
+ -p 127.0.0.1:8080:8080 \
+ --mount "type=bind,src=$PWD/registry-store,dst=/app/store" \
+ arboricx-server:latest
+curl --fail http://127.0.0.1:8080/_arboricx/health
+```
+
+The process listens on `0.0.0.0:8080` inside the container. The example publishes
+only host loopback; put a protected reverse proxy in front of it for remote
+access. The bind mount must be writable by the selected UID/GID. Preserve and
+back up `registry-store` across replacements; do not mount over `/nix/store`.
+Storage initialization and socket bind/listen failures exit nonzero. Monitor the
+HTTP health endpoint externally; the minimal image has no curl-based healthcheck.
+
+Endpoints include the landing page, `GET /_arboricx/health`,
+`POST /_arboricx/bundle` (201 with a SHA-256 hash),
+`GET /_arboricx/bundle/hash/HASH`, and
+`GET /_arboricx/package/NAME/VERSION`. Package indexes are read-only over HTTP;
+use [administrative publication](modules.md.html#exact-version-packages-from-trusted-registries)
+to populate them in the mounted root. Uploads store opaque bytes: hashing is not
+bundle validation or authorization.
+
+**Deployment limits:** this is a research registry, not a hardened public upload
+service. It has no built-in authentication, TLS, quotas, or request deadlines.
+Protect uploads at the proxy and apply connection/time/rate and storage limits.
+The current HTTP parser caps headers at 64 KiB and bodies at 1 MiB. The runner
+uses one Haskell capability and a 2 GiB managed-heap limit, not a total-process
+memory guarantee; set container resource limits appropriate to the workload.
+Retain the previous image and a storage backup for rollback.
+
+For native operation, build `.#arboricxServerRunner` and run
+`result/bin/arboricx-server` from a writable directory; it uses `./store` and the
+same fixed port. `.#arboricxServerBundle` exposes `server.arboricx`, `manifest`,
+and the indexed `runtime` store. The opt-in `arboricxServerTest` exercises the
+actual runner: startup failures, health, binary uploads/downloads, invalid paths,
+package lookup, and persistence across restart. It is not a container-engine test.
+
## Explicit compiler selection
For development or a pinned alternate compiler, import a caller-selected bundle:
diff --git a/flake.nix b/flake.nix
@@ -63,6 +63,39 @@
haskellPackages.callCabal2nix packageName tricuSource {}
);
+ # Compile once with the packaged stable compiler. Runtime needs neither
+ # source lookup nor a writable compiler/application cache.
+ arboricxServerBundle = pkgs.runCommand "arboricx-server-bundle" {
+ nativeBuildInputs = [ tricuPackage ];
+ } ''
+ mkdir workspace
+ ${pkgs.lib.concatMapStringsSep "\n" (name:
+ "cp ${./lib}/${name}.tri workspace/${name}.tri") [
+ "base" "prelude" "bytes" "conversions" "patterns" "io"
+ "parser" "http" "socket"
+ ]}
+ cp ${./lib/math/native.tri} workspace/native_math.tri
+ cp ${./lib/arboricx/server.tri} workspace/arboricx_server.tri
+ cp ${./tricu-apps/arboricxServer.tri} workspace/main.tri
+ mkdir -p $out
+ tricu arboricx compile --file workspace/main.tri --store build-store \
+ --output $out/server.arboricx --compile-timeout 900 \
+ +RTS -N1 -M8192m -RTS
+ tricu arboricx import --file $out/server.arboricx --store $out/runtime \
+ > import.log
+ sed -n 's/^Created module alias .* -> //p' import.log > $out/manifest
+ test "$(wc -l < $out/manifest)" -eq 1
+ grep -Eq '^[0-9a-f]{64}$' $out/manifest
+ '';
+
+ arboricxServerRunner = pkgs.writeShellScriptBin "arboricx-server" ''
+ exec ${tricuPackage}/bin/tricu arboricx run \
+ --module "$(${pkgs.coreutils}/bin/cat ${arboricxServerBundle}/manifest)" --name main \
+ --store ${arboricxServerBundle}/runtime \
+ --io --no-result --allow-read ./store --allow-write ./store \
+ +RTS -N1 -M2048m -RTS
+ '';
+
tricuBench =
hsLib.overrideCabal
(hsLib.doBenchmark (
@@ -349,20 +382,25 @@
};
};
+ # Opt-in: compiles and exercises the actual deployment entrypoint.
+ packages.arboricxServerTest = pkgs.runCommand "arboricx-server-test" {
+ nativeBuildInputs = [ pkgs.python3 ];
+ } ''
+ python3 ${./test/arboricx_deployment.py} ${arboricxServerRunner}/bin/arboricx-server
+ mkdir -p $out
+ '';
+ packages.arboricxServerBundle = arboricxServerBundle;
+ packages.arboricxServerRunner = arboricxServerRunner;
packages.arboricxServer = pkgs.dockerTools.buildImage {
- name = "arboricxServer";
+ name = "arboricx-server";
tag = "latest";
- copyToRoot = pkgs.runCommand "arboricxServer" {} ''
- mkdir -p $out/app/bin $out/app/lib $out/app/tricu-apps $out/app/store
- cp ${tricuStatic}/bin/tricu $out/app/bin/
- cp -r ${./lib}/* $out/app/lib/
- cp ${./tricu.workspace} $out/app/tricu.workspace
- cp ${./tricu-apps/arboricxServer.tri} $out/app/tricu-apps/arboricxServer.tri
+ copyToRoot = pkgs.runCommand "arboricx-server-root" {} ''
+ mkdir -p $out/app/store
'';
config = {
- Entrypoint = [ "/app/bin/tricu" "eval" "tricu-apps/arboricxServer.tri" "--io" "--allow-read" "./store" "--allow-write" "./store" "-f" "decode" ];
+ Entrypoint = [ "${arboricxServerRunner}/bin/arboricx-server" ];
WorkingDir = "/app";
ExposedPorts = { "8080/tcp" = {}; };
};
diff --git a/test/arboricx_deployment.py b/test/arboricx_deployment.py
@@ -0,0 +1,86 @@
+#!/usr/bin/env python3
+"""Haskell-only deployment runner smoke test (fixed port 8080 must be free)."""
+import hashlib
+from pathlib import Path
+import socket
+import subprocess
+import sys
+import tempfile
+import time
+import urllib.error
+import urllib.request
+
+
+def main():
+ runner = str(Path(sys.argv[1]).resolve())
+ opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
+
+ def request(path, data=None, status=200):
+ req = urllib.request.Request('http://127.0.0.1:8080' + path, data=data)
+ try:
+ response = opener.open(req, timeout=10)
+ except urllib.error.HTTPError as error:
+ response = error
+ with response:
+ assert response.status == status, (path, response.status)
+ return response.read()
+
+ with tempfile.TemporaryDirectory(prefix='arboricx-deployment-') as temporary:
+ root = Path(temporary)
+ # Startup failures must exit nonzero rather than return an ignored Result.
+ (root / 'store').write_text('not a directory')
+ failed = subprocess.run([runner], cwd=root, capture_output=True, timeout=30)
+ assert failed.returncode != 0, failed
+ (root / 'store').unlink()
+ with socket.socket() as occupied:
+ occupied.bind(('0.0.0.0', 8080))
+ occupied.listen()
+ failed = subprocess.run([runner], cwd=root, capture_output=True, timeout=30)
+ assert failed.returncode != 0, failed
+
+ payload = b'opaque registry bytes\x00\xff\n'
+ digest = hashlib.sha256(payload).hexdigest()
+ route = '/_arboricx/bundle/hash/' + digest
+ # Second start checks persistence without a compiler cache in the cwd.
+ for iteration in range(2):
+ with (root / 'server.log').open('w+') as log:
+ proc = subprocess.Popen([runner], cwd=root, stdout=log, stderr=log)
+ try:
+ for _ in range(100):
+ if proc.poll() is not None:
+ log.seek(0)
+ raise AssertionError(log.read())
+ try:
+ assert request('/_arboricx/health') == b'OK\n'
+ break
+ except (OSError, urllib.error.URLError):
+ time.sleep(.1)
+ else:
+ raise AssertionError('server startup timeout')
+ assert b'Arboricx Server' in request('/')
+ if iteration == 0:
+ reply = request('/_arboricx/bundle', payload, status=201)
+ assert reply.strip() == digest.encode(), reply
+ assert request(route) == payload
+ request('/_arboricx/bundle/hash/not-a-hash', status=400)
+ request('/_arboricx/bundle/hash/' + '0' * 64, status=404)
+ request('/_arboricx/package/../bad', status=400)
+ package = root / 'store/packages/example'
+ package.mkdir(parents=True, exist_ok=True)
+ record = b'arboricx.package-release.v1\nexample\t1.0\t' + b'a' * 64 + b'\t' + digest.encode() + b'\n'
+ (package / '1.0').write_bytes(record)
+ assert request('/_arboricx/package/example/1.0') == record
+ request('/_arboricx/package/example/missing', status=404)
+ finally:
+ proc.terminate()
+ try:
+ proc.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ proc.wait()
+ assert {p.name for p in root.iterdir()} == {'store', 'server.log'}
+ print('PASS: deployment startup failures, health, binary upload/download, path controls, packages, restart')
+
+
+if __name__ == '__main__':
+ main()
diff --git a/tricu-apps/arboricxServer.tri b/tricu-apps/arboricxServer.tri
@@ -1,14 +1,18 @@
-!import "io" !Local
-!import "arboricx.server" !Local
+!import io *
+!import arboricx_server as Registry
+!export main
-- Arboricx HTTP registry server.
--- Run with --allow-write ./store --allow-read ./store
+-- Build/deploy: nix build .#arboricxServer (see docs/build-and-run.md).
+-- The runner grants reads/writes only to ./store; dependencies are compiled
+-- in the explicit flat workspace prepared by flake.nix.
--
-- Endpoints:
-- GET / -> HTML landing page
-- GET /_arboricx/health -> "OK"
-- POST /_arboricx/bundle -> upload bundle, returns hash
-- GET /_arboricx/bundle/hash/:h -> download bundle by hash
+-- GET /_arboricx/package/:n/:v -> administratively published release
--
-- Example usage:
-- curl http://localhost:8080/
@@ -16,8 +20,11 @@
-- curl -X POST --data-binary @mybundle.arboricx http://localhost:8080/_arboricx/bundle
-- curl http://localhost:8080/_arboricx/bundle/hash/<hash>
-main = io (thenIO
- (putStrLn "Starting Arboricx server on 0.0.0.0:8080")
- (thenIO
- (void (ensureStore "./store"))
- (arboricxServer "./store" "0.0.0.0" 8080)))
+-- Initialization and bind/listen failures must terminate the process, not leave
+-- a deployment looking healthy after silently returning an error Result.
+main = io (onResult_
+ (do onOk_
+ Registry.ensureStore "./store"
+ Registry.arboricxServer "./store" "0.0.0.0" 8080)
+ abortIO
+ pure)