tricu

An interpreted language for exploring Tree Calculus
Log | Files | Refs | README | LICENSE

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:
Mdocs/build-and-run.md | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Mflake.nix | 54++++++++++++++++++++++++++++++++++++++++++++++--------
Atest/arboricx_deployment.py | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtricu-apps/arboricxServer.tri | 23+++++++++++++++--------
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)