Install — standing the stack up from release tarballs
Four cloudcheflabs products make up this demo. No official Docker images are published, so the install path is to download each product's release tarball and build the image from it. You do not need a source checkout for any of it.
| Product | Role here | Release |
|---|---|---|
| ShannonStore | S3 object storage | cloudcheflabs/shannonstore-pack |
| NeorunBase | vector · Korean FTS · graph · OLTP serving | cloudcheflabs/neorunbase-pack |
| Ontul | federation · semantic layer · ontology · IAM · Flow | cloudcheflabs/ontul-pack |
| kiok | batch scheduler | cloudcheflabs/kiok-pack |
Apache Polaris (the Iceberg REST catalog) joins them, along with the source systems: ERP on PostgreSQL, the approval system on MySQL, a training-records SaaS over REST, and the embedding service.
What you need
A machine with at least 10 GB given to Docker, plus python3.11, psql
and the aws CLI. ANTHROPIC_API_KEY only if you want to run the agent. The
tarballs total about 1.2 GB and the built images take roughly 25 GB.
1. Fetch the distributions
Each product has a <name>-pack repository holding one rolling tag,
<name>-archive, so the current build is always at the same URL.
demo/infra/dist/fetch.sh
#!/usr/bin/env bash
##
## Download the component distributions.
##
## bash infra/dist/fetch.sh
##
## The four products are released through GitHub. Each has a `<name>-pack`
## repository holding a single rolling tag, `<name>-archive`, so the current
## build is always at the same URL. No official Docker images are published, so
## the images here are built from these tarballs.
##
## Files already present are not downloaded again. Together they are over a
## gigabyte, and there is no reason to fetch them every time the demo is rebuilt.
## To pick up a newer build, delete the tar.gz in question and run this again.
##
set -euo pipefail
cd "$(dirname "$0")"
VERSION="${COMPONENT_VERSION:-1.0.0}"
BASE="${COMPONENT_BASE_URL:-https://github.com/cloudcheflabs}"
for name in shannonstore neorunbase ontul kiok; do
f="${name}-${VERSION}.tar.gz"
if [ -s "$f" ]; then
printf ' have %s (%s)\n' "$f" "$(du -h "$f" | cut -f1)"
continue
fi
url="$BASE/${name}-pack/releases/download/${name}-archive/${f}"
printf ' fetching %s\n' "$url"
curl -fL -# --retry 3 --retry-delay 2 -o "$f.part" "$url" || {
rm -f "$f.part"
echo "failed: $url" >&2
exit 1
}
mv "$f.part" "$f"
printf ' got %s (%s)\n' "$f" "$(du -h "$f" | cut -f1)"
done
echo
echo "Distributions ready — infra/up.sh builds the images from these files."
내려받는 중 https://github.com/cloudcheflabs/shannonstore-pack/releases/download/shannonstore-archive/shannonstore-1.0.0.tar.gz
받음 shannonstore-1.0.0.tar.gz (112M)
...
배포본 준비 완료 — infra/up.sh 가 이 파일들로 이미지를 빌드합니다.
2. The image Dockerfiles
Three of the products have identically shaped tarballs — bin/, conf/, lib/,
admin-ui/ — so unpacking one into /app is the whole build, and one Dockerfile
serves all three.
demo/infra/dist/Dockerfile
# One release tarball, one component image. ShannonStore, NeorunBase and kiok are
# shaped identically — each tarball carries bin/, conf/, lib/ and admin-ui/ — so
# unpacking one into /app is the entire build, and one Dockerfile serves them all.
#
# The point is that no source repository is referenced. Whoever follows this demo
# has no checkout, only the release tarballs. The build context is closed around
# this directory too, so nothing but what fetch.sh downloaded can end up in the
# image.
FROM eclipse-temurin:17-jre-jammy
# curl for the healthchecks, jq for the setup script, netcat for the waits, and
# python3 for the tasks kiok runs.
RUN apt-get update && apt-get install -y --no-install-recommends \
curl jq python3 netcat-openbsd && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
ARG NAME
ARG VERSION=1.0.0
COPY ${NAME}-${VERSION}.tar.gz /tmp/dist.tar.gz
RUN tar -xzf /tmp/dist.tar.gz -C /tmp && \
mv /tmp/${NAME}-${VERSION}/* /app/ && \
rm -rf /tmp/dist.tar.gz /tmp/${NAME}-${VERSION} && \
chmod +x /app/bin/*.sh && \
mkdir -p /app/data /app/logs
# The same rule in all three products: with FOREGROUND set, start-*.sh execs the
# JVM so it becomes the container's main process. Without it the script starts a
# daemon, exits, and takes the container down with it.
ENV SHANNONSTORE_FOREGROUND=true \
NEORUNBASE_FOREGROUND=true \
KIOK_FOREGROUND=true \
SHANNONSTORE_LOG_PATH=/app/logs
kiok needs one extra thing: a switch for which role the container runs.
demo/infra/dist/Dockerfile.kiok
# kiok needs one thing the shared Dockerfile does not have: a switch for which
# role the container runs. Everything else is identical.
FROM eclipse-temurin:17-jre-jammy
RUN apt-get update && apt-get install -y --no-install-recommends \
curl jq python3 netcat-openbsd && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
ARG VERSION=1.0.0
COPY kiok-${VERSION}.tar.gz /tmp/dist.tar.gz
COPY kiok-entrypoint.sh /app/docker-entrypoint.sh
RUN tar -xzf /tmp/dist.tar.gz -C /tmp && \
mv /tmp/kiok-${VERSION}/* /app/ && \
rm -rf /tmp/dist.tar.gz /tmp/kiok-${VERSION} && \
chmod +x /app/bin/*.sh /app/docker-entrypoint.sh && \
mkdir -p /app/data /app/logs
ENV KIOK_FOREGROUND=true
EXPOSE 8080 19999 19998
ENTRYPOINT ["/app/docker-entrypoint.sh"]
demo/infra/dist/kiok-entrypoint.sh
#!/bin/bash
# One kiok image runs both master and worker. Compose only has to set KIOK_ROLE;
# everything else arrives as environment.
set -e
case "${KIOK_ROLE}" in
master) exec /app/bin/start-master.sh ;;
worker) exec /app/bin/start-worker.sh ;;
*) echo "KIOK_ROLE must be 'master' or 'worker' (got: '${KIOK_ROLE}')"; exit 1 ;;
esac
The Ontul image is the product distribution plus this pipeline's Python dependencies. Extraction runs as a Python UDF and the worker executes UDFs with its own system Python, so pdfplumber and friends have to be inside the container rather than in the client's virtualenv. Keeping them here instead of in the product image is the point: Ontul has no business shipping a PDF parser, and a real project builds its own job image for exactly this reason.
demo/infra/dist/Dockerfile.ontul
# The demo's Ontul image: the product distribution plus this pipeline's own
# Python dependencies.
#
# Extraction runs as a Python UDF, and the worker executes UDFs with its system
# python3 — so pdfplumber and friends have to be present in the container, not in
# the client's virtualenv. Keeping this here rather than in the product
# Dockerfile is the point: Ontul has no business shipping a PDF parser, and a
# real project builds its own job image for exactly this reason.
#
# Mirrors ../../../Dockerfile. If that file changes, this one follows.
FROM eclipse-temurin:17-jre-jammy
# Python 3.11, matching the client that submits the jobs.
#
# cloudpickle serialises a function by value, and a code object's layout is
# version-specific — a 3.11 client against jammy's 3.10 fails with "code expected
# at most 16 arguments, got 18", which names neither Python nor a version. It is
# the rule PySpark has: driver and executor run the same interpreter.
RUN apt-get update && apt-get install -y --no-install-recommends \
curl jq netcat-openbsd software-properties-common gnupg && \
add-apt-repository -y ppa:deadsnakes/ppa && \
apt-get update && apt-get install -y --no-install-recommends \
python3.11 python3.11-venv python3.11-distutils && \
python3.11 -m ensurepip --upgrade && \
rm -rf /var/lib/apt/lists/*
# The worker starts the UDF executor with $PYTHON3 when it is set.
ENV PYTHON3=/usr/bin/python3.11
# The pipeline's extraction dependencies. Pinned, because an extractor that
# silently starts returning different text changes every downstream answer and
# nothing about the pipeline reports it.
#
# pg8000 is a pure-Python Postgres client, for the one step that cannot go
# through Ontul: NeorunBase is not a JDBC catalog, so a DELETE against it is
# refused ("Not a JDBC catalog: nb") and the vector table has to be cleared over
# NeorunBase's own wire.
RUN python3.11 -m pip install --no-cache-dir \
pdfplumber==0.11.4 \
python-docx==1.1.2 \
openpyxl==3.1.5 \
boto3==1.35.36 \
cloudpickle==3.1.0 \
pyarrow==18.1.0 \
pg8000==1.31.2
WORKDIR /app
ARG VERSION=1.0.0
COPY ontul-${VERSION}.tar.gz /tmp/dist.tar.gz
RUN tar -xzf /tmp/dist.tar.gz -C /tmp && \
mv /tmp/ontul-${VERSION}/* /app/ && \
rm -rf /tmp/dist.tar.gz /tmp/ontul-${VERSION} && \
chmod +x /app/bin/*.sh && \
mkdir -p /data/spill /data/rocksdb /data/metadata /data/deps /data/drivers /data/logs
ENV ONTUL_FOREGROUND=true
ENV JAVA_OPTS="-Xmx2g -XX:+UseG1GC"
EXPOSE 8080 47470 19999 29999
The Python versions have to match
cloudpickle serialises a function by value, and a code object's layout is
version-specific. A 3.11 client against the container's 3.10 fails with
code expected at most 16 arguments, got 18 — a message that mentions
neither Python nor a version. It is the same rule PySpark has: driver and
executor run the same interpreter.
3. The compose files
Each product gets its own compose project. The names are kept distinct so that tearing down later removes only what this demo started.
ShannonStore — it owns the network
demo/infra/compose/shannonstore.yml
##
## ShannonStore — the demo's S3 storage, and the owner of the network.
##
## Topology is zk 1 · data 2 · api 1. There is no nginx; the S3 endpoint is
## api-server-1:8080 directly. Erasure coding is 1+1 (data+parity), which fits
## two data nodes.
##
## Every heap is stated explicitly because a JVM sizes its heap from what the
## container reports, and these containers see the whole host. Ten JVMs each
## deciding they may take a quarter of 11 GB is how a machine starts swapping
## while every individual setting still looks reasonable.
##
## What the setup container does is what fixes the startup order. ShannonStore
## holds no S3 credentials when it boots: it logs in as admin, mints an IAM key,
## creates the bucket with it, and writes the key to a volume. Polaris has to be
## started with that key and cannot be told about it later — so the ordering is a
## constraint, not a preference.
##
services:
zookeeper:
image: zookeeper:3.9.1
container_name: nrn-iceberg-zk
environment:
JVMFLAGS: "-Xmx192m"
networks: [neorun-iceberg-network]
volumes:
- zk-data:/data
- zk-datalog:/datalog
healthcheck:
test: ["CMD-SHELL", "echo ruok | nc localhost 2181 | grep imok || nc -z localhost 2181"]
interval: 3s
timeout: 5s
retries: 10
data-1:
build: &ss-build
context: ../dist
dockerfile: Dockerfile
args:
NAME: shannonstore
VERSION: "${COMPONENT_VERSION:-1.0.0}"
image: regdemo/shannonstore:${COMPONENT_VERSION:-1.0.0}
container_name: nrn-iceberg-data-1
hostname: data-1
command: ["/app/bin/start-data-node.sh", "-Dshannonstore.nio.port=9001", "-Dshannonstore.data.storage.dirs=data/node1-disk-a,data/node1-disk-b"]
environment:
SHANNONSTORE_ZK_CONNECT: zookeeper:2181
SHANNONSTORE_ADVERTISED_HOST: data-1
SHANNONSTORE_MASTER_KEY: ShannonStoreMasterKey1200303003X
SHANNONSTORE_LOG_MODE: RING_BUFFER
JAVA_OPTS: "-Xms192m -Xmx448m -XX:+UseG1GC -XX:MaxMetaspaceSize=192m"
depends_on:
zookeeper: { condition: service_healthy }
volumes:
- data1-data:/app/data
networks: [neorun-iceberg-network]
data-2:
build: *ss-build
image: regdemo/shannonstore:${COMPONENT_VERSION:-1.0.0}
container_name: nrn-iceberg-data-2
hostname: data-2
command: ["/app/bin/start-data-node.sh", "-Dshannonstore.nio.port=9002", "-Dshannonstore.data.storage.dirs=data/node2-disk-a,data/node2-disk-b"]
environment:
SHANNONSTORE_ZK_CONNECT: zookeeper:2181
SHANNONSTORE_ADVERTISED_HOST: data-2
SHANNONSTORE_MASTER_KEY: ShannonStoreMasterKey1200303003X
SHANNONSTORE_LOG_MODE: RING_BUFFER
JAVA_OPTS: "-Xms192m -Xmx448m -XX:+UseG1GC -XX:MaxMetaspaceSize=192m"
depends_on:
zookeeper: { condition: service_healthy }
volumes:
- data2-data:/app/data
networks: [neorun-iceberg-network]
api-server-1:
build: *ss-build
image: regdemo/shannonstore:${COMPONENT_VERSION:-1.0.0}
container_name: nrn-iceberg-api-1
hostname: api-server-1
command: ["/app/bin/start-api-server.sh", "-Dshannonstore.api.s3.port=8080", "-Dshannonstore.api.admin.port=8888", "-Dshannonstore.nio.port=9000", "-Dshannonstore.api.node.id=ss-api-1"]
environment:
SHANNONSTORE_ZK_CONNECT: zookeeper:2181
SHANNONSTORE_ADVERTISED_HOST: api-server-1
SHANNONSTORE_MASTER_KEY: ShannonStoreMasterKey1200303003X
SHANNONSTORE_LOG_MODE: RING_BUFFER
SHANNONSTORE_API_S3_EC_DATA_SHARDS: "1"
SHANNONSTORE_API_S3_EC_PARITY_SHARDS: "1"
JAVA_OPTS: "-Xms256m -Xmx640m -XX:+UseG1GC -XX:MaxMetaspaceSize=192m"
ports:
- "${S3_PORT:-28000}:8080"
depends_on:
zookeeper: { condition: service_healthy }
data-1: { condition: service_started }
data-2: { condition: service_started }
volumes:
- api1-data:/app/data
networks: [neorun-iceberg-network]
setup:
image: alpine/curl:latest
container_name: nrn-iceberg-setup
depends_on:
api-server-1: { condition: service_started }
entrypoint: "/bin/sh"
command:
- "-c"
- |
apk add --no-cache jq > /dev/null 2>&1
ADMIN=http://api-server-1:8888
echo "=== ShannonStore Setup ==="
echo "[1/3] waiting for the API..."
for i in $$(seq 1 90); do
curl -sf $$ADMIN/admin/health > /dev/null 2>&1 && { echo " ready"; break; }
[ $$i -eq 90 ] && { echo " ERROR: not up within 180s"; exit 1; }
sleep 2
done
echo "[2/3] logging in and minting an IAM access key..."
LOGIN_RES=$$(curl -sf -X POST $$ADMIN/admin/auth/login \
-H "Content-Type: application/json" -d '{"userId":"admin","password":"admin"}')
TOKEN=$$(echo $$LOGIN_RES | jq -r '.token')
[ -n "$$TOKEN" ] && [ "$$TOKEN" != "null" ] || { echo " ERROR: login failed"; exit 1; }
# The initial password works exactly once. If a rotation is demanded,
# rotate and log in again.
if [ "$$(echo $$LOGIN_RES | jq -r '.requirePasswordChange')" = "true" ]; then
curl -sf -X POST $$ADMIN/admin/auth/change-password \
-H "Content-Type: application/json" -H "Authorization: Bearer $$TOKEN" \
-d '{"oldPassword":"admin","newPassword":"password123"}'
LOGIN_RES=$$(curl -sf -X POST $$ADMIN/admin/auth/login \
-H "Content-Type: application/json" -d '{"userId":"admin","password":"password123"}')
TOKEN=$$(echo $$LOGIN_RES | jq -r '.token')
fi
ACCESS_KEY=""; SECRET_KEY=""
for attempt in 1 2 3 4 5 6 7 8 9 10; do
KEY_RES=$$(curl -sf -X POST $$ADMIN/admin/iam/keys \
-H "Content-Type: application/json" -H "Authorization: Bearer $$TOKEN" \
-d '{"userId":"admin"}' || true)
if [ -n "$$KEY_RES" ]; then
ACCESS_KEY=$$(echo $$KEY_RES | jq -r '.accessKey // empty')
SECRET_KEY=$$(echo $$KEY_RES | jq -r '.secretKey // empty')
fi
[ -n "$$ACCESS_KEY" ] && [ "$$ACCESS_KEY" != "null" ] && { echo " minted (attempt $$attempt)"; break; }
sleep 2
done
[ -n "$$ACCESS_KEY" ] && [ "$$ACCESS_KEY" != "null" ] || { echo " ERROR: could not mint an IAM key"; exit 1; }
# The key only works for S3 requests once it has synced across the cluster.
sleep 10
echo "[3/3] creating the iceberg-warehouse bucket..."
curl -sf -X POST $$ADMIN/admin/browser/buckets \
-H "Authorization: Bearer $$TOKEN" -H "Content-Type: application/json" \
-d '{"name":"iceberg-warehouse","versioning":false}'
echo "$$ACCESS_KEY" > /tmp/setup/access_key
echo "$$SECRET_KEY" > /tmp/setup/secret_key
echo "done" > /tmp/setup/ready
echo "=== done ==="
tail -f /dev/null
volumes:
- setup-data:/tmp/setup
healthcheck:
test: ["CMD", "test", "-f", "/tmp/setup/ready"]
interval: 2s
timeout: 5s
retries: 90
start_period: 10s
networks: [neorun-iceberg-network]
volumes:
setup-data:
api1-data:
data1-data:
data2-data:
zk-data:
zk-datalog:
networks:
neorun-iceberg-network:
driver: bridge
name: neorun-iceberg-network
What the setup container does is what fixes the startup order. ShannonStore
holds no S3 credentials when it boots: it logs in as admin, mints an IAM key,
creates the bucket with it, and writes the key to a volume. Polaris has to be
started with that key and cannot be told about it afterwards. The ordering is
a constraint, not a preference.
NeorunBase — the serving layer
demo/infra/compose/neorunbase.yml
##
## NeorunBase — the serving layer the agent actually queries.
##
## Coordinator 1 · datanode 2, sharing ShannonStore's ZooKeeper. A container
## costs about 200 MB here and there is no reason to run a second ensemble.
##
## The coordinator opens both the PostgreSQL wire protocol (5432) and admin HTTP
## (8080). That means psql can attach, and one step in this demo uses that path:
## NeorunBase is not a JDBC catalog, so clearing the vector table through Ontul
## is refused.
##
## The coordinator gets the larger heap. It plans, holds the catalog and runs the
## Iceberg sync; the datanodes serve shards and can live smaller.
##
services:
neorun-datanode-1:
build: &nb-build
context: ../dist
dockerfile: Dockerfile
args:
NAME: neorunbase
VERSION: "${COMPONENT_VERSION:-1.0.0}"
image: regdemo/neorunbase:${COMPONENT_VERSION:-1.0.0}
hostname: neorun-datanode-1
container_name: nrn-iceberg-datanode-1
command:
- "/app/bin/start-datanode.sh"
- "-Dneorunbase.zookeeper.server.list=zookeeper:2181"
- "-Dneorunbase.datanode.internal.port=7002"
- "-Dneorunbase.datanode.advertised.host=neorun-datanode-1"
- "-Dneorunbase.base.data.dir=data/datanode-1"
- "-Dneorunbase.datanode.data.dir=data/datanode-1/shards"
- "-Dneorunbase.log.mode=RING_BUFFER"
environment:
# The KMS master key. Coordinator and datanodes must carry the same value —
# a mismatch dies immediately in KMS initialisation with a SecurityException.
NEORUNBASE_MASTER_KEY: "${NEORUNBASE_MASTER_KEY:-NeorunBaseMasterKey120030300312345}"
JAVA_OPTS: "-Xms192m -Xmx448m -XX:+UseG1GC -XX:MaxMetaspaceSize=192m"
networks: [neorun-iceberg-network]
neorun-datanode-2:
build: *nb-build
image: regdemo/neorunbase:${COMPONENT_VERSION:-1.0.0}
hostname: neorun-datanode-2
container_name: nrn-iceberg-datanode-2
command:
- "/app/bin/start-datanode.sh"
- "-Dneorunbase.zookeeper.server.list=zookeeper:2181"
- "-Dneorunbase.datanode.internal.port=7003"
- "-Dneorunbase.datanode.advertised.host=neorun-datanode-2"
- "-Dneorunbase.base.data.dir=data/datanode-2"
- "-Dneorunbase.datanode.data.dir=data/datanode-2/shards"
- "-Dneorunbase.log.mode=RING_BUFFER"
environment:
# The KMS master key. Coordinator and datanodes must carry the same value —
# a mismatch dies immediately in KMS initialisation with a SecurityException.
NEORUNBASE_MASTER_KEY: "${NEORUNBASE_MASTER_KEY:-NeorunBaseMasterKey120030300312345}"
JAVA_OPTS: "-Xms192m -Xmx448m -XX:+UseG1GC -XX:MaxMetaspaceSize=192m"
networks: [neorun-iceberg-network]
neorun-coordinator-1:
build: *nb-build
image: regdemo/neorunbase:${COMPONENT_VERSION:-1.0.0}
hostname: neorun-coordinator-1
container_name: nrn-iceberg-coordinator-1
entrypoint: ["sh", "-c"]
command:
- >
/app/bin/start-coordinator.sh
-Dneorunbase.zookeeper.server.list=zookeeper:2181
-Dneorunbase.coordinator.pg.port=5432
-Dneorunbase.coordinator.advertised.host=neorun-coordinator-1
-Dneorunbase.admin.http.port=8080
-Dneorunbase.coordinator.internal.port=7100
-Dneorunbase.base.data.dir=data/coordinator-1
-Dneorunbase.log.mode=RING_BUFFER
$$ICEBERG_OPTS
environment:
# up.sh builds the Iceberg catalog settings and passes them in. The Polaris
# credentials are minted per bring-up, so they cannot be written into compose.
# That is also why the entrypoint is sh -c: a shell is needed to expand this
# variable into command-line arguments.
ICEBERG_OPTS: "${ICEBERG_OPTS_1:-}"
NEORUNBASE_MASTER_KEY: "${NEORUNBASE_MASTER_KEY:-NeorunBaseMasterKey120030300312345}"
JAVA_OPTS: "-Xms256m -Xmx768m -XX:+UseG1GC -XX:MaxMetaspaceSize=256m"
AWS_REGION: "${AWS_REGION:-us-east-1}"
ports:
- "${COORD1_PG_HOST_PORT:-5434}:5432"
- "${COORD1_ADMIN_HOST_PORT:-8084}:8080"
depends_on:
neorun-datanode-1: { condition: service_started }
neorun-datanode-2: { condition: service_started }
networks: [neorun-iceberg-network]
networks:
neorun-iceberg-network:
external: true
name: neorun-iceberg-network
Ontul
demo/infra/compose/ontul.yml
##
## Ontul for the regulation demo: zookeeper + 1 master + 1 worker.
##
## Reduced from the product's tests/docker-compose-ontul.yml (2 masters, 2
## workers, nginx). One master means no leader election to observe and no
## forwarding path to exercise; one worker means the scan does not visibly
## spread. Both still run the identical code path — embed_passage() is
## evaluated per Arrow batch by whichever worker owns the split, and owning
## every split is a valid case of owning some. What the reduction costs is the
## demonstration, not the behaviour. Raise the worker count when showing
## distribution is the point.
##
## Joins neorun-iceberg-network, which the ShannonStore slim compose creates.
## That matters more than it looks: Polaris hands clients an S3 endpoint of
## http://api-server-1:8080, a name that only resolves inside that network. An
## Ontul running on the host would take the catalog's word for it and then fail
## to reach the storage the catalog just described.
##
services:
ontul-zookeeper:
image: zookeeper:3.9.1
hostname: ontul-zookeeper
container_name: regdemo-ontul-zk
environment:
JVMFLAGS: "-Xmx256m"
networks: [neorun-iceberg-network]
healthcheck:
test: ["CMD-SHELL", "echo ruok | nc localhost 2181 | grep imok || nc -z localhost 2181"]
interval: 3s
timeout: 5s
retries: 20
ontul-master-1:
build:
context: ../dist
dockerfile: Dockerfile.ontul
args:
VERSION: "${COMPONENT_VERSION:-1.0.0}"
image: regdemo/ontul:${COMPONENT_VERSION:-1.0.0}
hostname: ontul-master-1
container_name: regdemo-ontul-master-1
volumes:
- ontul-m1-data:/data
entrypoint: ["sh", "-c"]
command:
- >
/app/bin/start-master.sh
-Dontul.zk.serverList=ontul-zookeeper:2181
-Dontul.master.host=ontul-master-1
-Dontul.master.admin.port=8080
-Dontul.master.flight.sql.port=47470
-Dontul.master.internal.port=19999
-Dontul.master.admin.ui.static.path=/app/admin-ui
-Dontul.base.data.dir=/data
-Dontul.python.path=/usr/bin/python3.11
-Dontul.log.output.name=ontul.log
-Dontul.kms.master.key.env=ONTUL_MASTER_KEY
environment:
ONTUL_FOREGROUND: "true"
ONTUL_MASTER_KEY: "regdemo-ontul-master-key-32chars"
JAVA_OPTS: "-Xms512m -Xmx1280m -XX:+UseG1GC -XX:MaxMetaspaceSize=256m"
ports:
- "${ONTUL_PORT:-8080}:8080" # Admin UI + REST
- "47470:47470" # Arrow Flight SQL
depends_on:
ontul-zookeeper:
condition: service_healthy
networks: [neorun-iceberg-network]
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:8080/admin/ready || exit 1"]
interval: 5s
timeout: 5s
retries: 40
start_period: 20s
ontul-worker-1:
build:
context: ../dist
dockerfile: Dockerfile.ontul
args:
VERSION: "${COMPONENT_VERSION:-1.0.0}"
image: regdemo/ontul:${COMPONENT_VERSION:-1.0.0}
hostname: ontul-worker-1
container_name: regdemo-ontul-worker-1
entrypoint: ["sh", "-c"]
command:
- >
/app/bin/start-worker.sh
-Dontul.zk.serverList=ontul-zookeeper:2181
-Dontul.worker.host=ontul-worker-1
-Dontul.worker.internal.port=29999
-Dontul.base.data.dir=/data
-Dontul.python.path=/usr/bin/python3.11
-Dontul.log.output.name=ontul.log
-Dontul.kms.master.key.env=ONTUL_MASTER_KEY
-Dontul.job.logs.memory.tail=2000
environment:
ONTUL_FOREGROUND: "true"
ONTUL_MASTER_KEY: "regdemo-ontul-master-key-32chars"
# Arrow allocates off-heap. The heap cap is deliberately below what the
# worker could use so the batch pipeline stays in direct memory, where
# embed_passage() hands its FixedSizeList straight to the vector.
# The worker gets more than the master, because of how much one worker
# carries at once here: eight streaming jobs resident (5 CDC + 2 graph +
# 1 approval) while the embedding batch pushes 800-odd chunks through as
# Arrow batches. At 1280m the worker died mid-index, and what it says when
# it goes is "Worker … failed and retry exhausted: null" — which points at
# nothing.
JAVA_OPTS: "-Xms768m -Xmx2560m -XX:+UseG1GC -XX:MaxMetaspaceSize=384m -XX:MaxDirectMemorySize=1024m"
depends_on:
# service_started, not service_healthy — this saves a minute, it does not
# avoid a deadlock. The master holds /admin/ready until a worker registers
# and then gives up after 60s and proceeds anyway
# (MasterServer.waitForClusterReady). Waiting for healthy therefore means
# the worker sits out that entire timeout for no reason: what it needs is
# ZooKeeper, which is already up before the master starts.
ontul-master-1:
condition: service_started
networks: [neorun-iceberg-network]
networks:
neorun-iceberg-network:
external: true
name: neorun-iceberg-network
volumes:
ontul-m1-data:
kiok
demo/infra/compose/kiok.yml
# kiok — the pipeline's scheduler.
#
# Until this existed, the dependency order between stages lived only inside a
# shell script (infra/index.sh). That is enough to run once but hard to call a
# pipeline: which stage failed and where, what needs re-running, how yesterday's
# run differed from today's — none of it is recorded anywhere. Moving to a DAG
# turns all of that into data.
#
# ZooKeeper is not started again; kiok shares Ontul's under a chroot. Splitting
# by chroot means the same ensemble is used without either seeing the other's
# nodes, and a container costs about 200 MB here.
#
# The heap is down at 256m for the same reason. This DAG has half a dozen tasks
# and every heavy thing happens on an Ontul worker — kiok keeps the order and
# records the results, so it holds no data. With few concurrent tasks, Serial
# costs less resident memory than G1.
services:
kiok-master:
build:
context: ../dist
dockerfile: Dockerfile.kiok
args: { VERSION: "${COMPONENT_VERSION:-1.0.0}" }
image: regdemo/kiok:${COMPONENT_VERSION:-1.0.0}
container_name: regdemo-kiok-master
hostname: kiok-master
ports: ["${KIOK_PORT:-18081}:8080"]
environment:
KIOK_ROLE: master
KIOK_MASTER_KEY: regdemo-kiok-master-key-32-chars
KIOK_MASTER_HOST: kiok-master
KIOK_ZK_SERVERLIST: ontul-zookeeper:2181/kiok
KIOK_MASTER_ADMIN_CONTEXT_PATH: /
JAVA_OPTS: "-Xmx256m -XX:MaxMetaspaceSize=192m"
volumes:
- kiok-master-data:/app/data
networks: [neorun-iceberg-network]
deploy:
resources:
limits: {memory: 512M}
healthcheck:
test: ["CMD", "curl", "-sf", "http://kiok-master:8080/healthz"]
interval: 5s
timeout: 5s
retries: 40
kiok-worker:
build:
context: ../dist
dockerfile: Dockerfile.kiok
args: { VERSION: "${COMPONENT_VERSION:-1.0.0}" }
image: regdemo/kiok:${COMPONENT_VERSION:-1.0.0}
container_name: regdemo-kiok-worker
hostname: kiok-worker
depends_on:
kiok-master: { condition: service_healthy }
environment:
KIOK_ROLE: worker
KIOK_MASTER_KEY: regdemo-kiok-master-key-32-chars
KIOK_WORKER_HOST: kiok-worker
KIOK_ZK_SERVERLIST: ontul-zookeeper:2181/kiok
JAVA_OPTS: "-Xmx256m -XX:MaxMetaspaceSize=192m"
volumes:
- kiok-worker-data:/app/data
networks: [neorun-iceberg-network]
deploy:
resources:
limits: {memory: 512M}
volumes:
kiok-master-data:
kiok-worker-data:
networks:
neorun-iceberg-network:
external: true
name: neorun-iceberg-network
The source systems
ERP (PostgreSQL), the approval system (MySQL), training records (REST) and the embedding service live in the demo's own root compose file.
demo/docker-compose.yml
##
## Regulation lakehouse demo — the sources.
##
## Only the sources live here. ShannonStore, Polaris, NeorunBase and Ontul are
## brought up from their own compose files by infra/up.sh, which also creates the
## catalog and hands every resolved endpoint to out/stack.env. Running this file
## alone gives you the four services below and nothing to point them at.
##
## The network is external on purpose: ShannonStore's compose owns it, and
## Polaris hands out an S3 endpoint (http://api-server-1:8080) that resolves
## nowhere else.
##
## make seed && bash infra/up.sh
##
name: regdemo
services:
# ── The embedding model. Standalone, and offline by construction ──────────
# Weights are baked into the image and HF_HUB_OFFLINE is set, so regulation
# text has no route out of this network — the guarantee is structural, not a
# policy someone has to remember. Indexing and search call this one process,
# which is what makes a stored vector and a query vector comparable at all.
# It refuses to serve if it cannot name the revision it loaded: a vector that
# cannot be pinned to a generation is worse than no vector, because it still
# looks like an answer.
embed-svc:
build:
context: ./infra/embed-svc
args:
EMBED_MODEL_ID: ${EMBED_MODEL_ID:-intfloat/multilingual-e5-base}
container_name: regdemo-embed-svc
ports: ["${EMBED_PORT:-8100}:8000"]
environment:
EMBED_DIM: "768"
EMBED_DEVICE: cpu
EMBED_MAX_BATCH: "64"
networks: [neorun-iceberg-network]
deploy:
resources:
limits: {memory: 1500M}
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request,json,sys; sys.exit(0 if json.load(urllib.request.urlopen('http://localhost:8000/health'))['ready'] else 1)"]
interval: 10s
retries: 60
# ── ERP. Federated live rather than copied: a leave balance is only worth
# quoting if it is the balance right now ────────────────────────────────
postgres-erp:
image: postgres:16-alpine
container_name: regdemo-erp
# 55432, not 5434 — NeorunBase's coordinator serves the PostgreSQL wire
# protocol on 5434 and would win the bind race silently.
ports: ["${ERP_PG_PORT:-55432}:5432"]
environment:
POSTGRES_DB: erp
POSTGRES_USER: erp
POSTGRES_PASSWORD: ${ERP_PASSWORD:-regdemo}
# wal_level=logical is what makes this an ERP a CDC pipeline can read. The
# default (replica) carries enough for a physical standby and not enough to
# reconstruct row changes, so Debezium fails at slot creation rather than
# part-way through — which is the better direction to fail in.
# One replication slot per synced table, so the ceiling is the table count.
command: ["postgres", "-c", "shared_buffers=96MB", "-c", "max_connections=50",
"-c", "wal_level=logical", "-c", "max_replication_slots=16",
"-c", "max_wal_senders=16"]
volumes:
- ./out/sql/erp_postgres.sql:/docker-entrypoint-initdb.d/10_erp.sql:ro
- erp-data:/var/lib/postgresql/data
networks: [neorun-iceberg-network]
deploy:
resources:
limits: {memory: 320M}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U erp -d erp"]
interval: 10s
retries: 30
# ── The approval system. Authoritative for effective dates — the approval, not
# the date the document prints, is what makes a regulation binding — and the
# CDC source
# Flow reacts to when an approval completes ─────────────────────────────
mysql-groupware:
image: mysql:8.0
container_name: regdemo-groupware
ports: ["${GROUPWARE_MYSQL_PORT:-33306}:3306"]
# Row-image binlog for CDC. The buffer pool is cut well below the default
# because this database holds 122 approval rows, not a workload; the memory
# is worth more to the JVMs sharing this machine.
command: >
--server-id=1 --log-bin=binlog --binlog-format=ROW --binlog-row-image=FULL
--innodb-buffer-pool-size=64M --performance-schema=OFF
environment:
MYSQL_DATABASE: groupware
MYSQL_USER: gw
MYSQL_PASSWORD: ${GW_PASSWORD:-regdemo}
MYSQL_ROOT_PASSWORD: ${GW_ROOT_PASSWORD:-regdemo-root}
volumes:
- ./out/sql/groupware_mysql.sql:/docker-entrypoint-initdb.d/10_groupware.sql:ro
- gw-data:/var/lib/mysql
networks: [neorun-iceberg-network]
deploy:
resources:
limits: {memory: 448M}
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-p${GW_ROOT_PASSWORD:-regdemo-root}"]
interval: 10s
retries: 40
# ── A source with an API and no database, reached through rest-operation.
# Not every system worth joining will hand you a JDBC URL ───────────────
mock-saas:
build: ./infra/mock-saas
container_name: regdemo-lms
ports: ["${LMS_PORT:-8200}:8000"]
volumes: ["./out/lms.json:/app/lms.json:ro"]
networks: [neorun-iceberg-network]
deploy:
resources:
limits: {memory: 256M}
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 10s
retries: 30
networks:
neorun-iceberg-network:
external: true
name: neorun-iceberg-network
volumes:
erp-data:
gw-data:
The embedding service bakes the model weights into its image and pins itself offline.
demo/infra/embed-svc/Dockerfile
FROM python:3.11-slim
# CPU-only torch: measured faster than MPS for this model+sequence shape, and it
# keeps the image off the CUDA wheels (several GB) that this demo never uses.
RUN pip install --no-cache-dir \
torch --index-url https://download.pytorch.org/whl/cpu \
&& pip install --no-cache-dir \
"sentence-transformers>=3" fastapi "uvicorn[standard]" pydantic
# Bake the weights into the image. Two reasons, and the second is the point:
# 1. container start does not wait on a 1.1GB download
# 2. with HF_HUB_OFFLINE=1 below, the running service physically cannot reach
# the internet — regulation text has no path out, by construction rather
# than by policy.
ARG EMBED_MODEL_ID=intfloat/multilingual-e5-base
RUN python -c "from sentence_transformers import SentenceTransformer as S; S('${EMBED_MODEL_ID}')"
ENV EMBED_MODEL_ID=${EMBED_MODEL_ID} \
EMBED_DIM=768 \
EMBED_DEVICE=cpu \
HF_HUB_OFFLINE=1 \
TRANSFORMERS_OFFLINE=1
WORKDIR /app
COPY app.py .
EXPOSE 8000
HEALTHCHECK --interval=10s --timeout=3s --retries=30 \
CMD python -c "import urllib.request,json,sys; \
sys.exit(0 if json.load(urllib.request.urlopen('http://localhost:8000/health'))['ready'] else 1)"
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
demo/infra/embed-svc/app.py
"""Embedding service — the single vector-space authority for the demo.
Indexing (Ontul worker UDF) and search (agent) both call this one process, so
the index and the query cannot be produced by different weights. That is the
whole reason it exists as a service rather than a library each side imports.
Runs offline by design: the model is baked into the image at build time and
HF_HUB_OFFLINE=1 is set, so regulation text has no path out of the network.
"""
from __future__ import annotations
import logging, os, time
from pathlib import Path
from typing import Literal
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("embed-svc")
MODEL_ID = os.environ.get("EMBED_MODEL_ID", "intfloat/multilingual-e5-base")
DIM = int(os.environ.get("EMBED_DIM", "768"))
NORMALIZE = os.environ.get("EMBED_NORMALIZE", "true").lower() == "true"
DEVICE = os.environ.get("EMBED_DEVICE", "cpu")
# e5 is trained asymmetrically. Applied here rather than by callers, because a
# forgotten prefix costs retrieval quality and raises nothing.
PASSAGE_PREFIX, QUERY_PREFIX = "passage: ", "query: "
MAX_BATCH = int(os.environ.get("EMBED_MAX_BATCH", "64"))
app = FastAPI(title="regdemo embed-svc")
_state: dict = {}
@app.on_event("startup")
def _load() -> None:
from sentence_transformers import SentenceTransformer
t0 = time.time()
st = SentenceTransformer(MODEL_ID, device=DEVICE)
dim = st.get_sentence_embedding_dimension()
if dim != DIM:
# The dimension is fixed in the NeorunBase VECTOR(n) column; serving a
# different one would produce rows the target table cannot hold.
raise RuntimeError(f"model dim={dim} but service declares {DIM}")
revision = _resolve_revision()
_state.update(st=st, revision=revision, load_seconds=time.time() - t0)
log.info("loaded %s rev=%s dim=%d device=%s in %.1fs",
MODEL_ID, revision or "?", dim, DEVICE, _state["load_seconds"])
def _resolve_revision() -> str:
"""The commit actually on disk.
Not read from the loaded model: `auto_model.name_or_path` echoes back the
name that was requested ("intfloat/multilingual-e5-base"), not the revision
it resolved to — so a service trusting it reports no revision at all and,
by design, refuses to serve. The Hub cache records the commit in two places;
refs/ is the branch pointer and snapshots/ is the materialised tree, and
either is authoritative for what these weights are.
"""
from huggingface_hub.constants import HF_HUB_CACHE
repo = Path(HF_HUB_CACHE) / ("models--" + MODEL_ID.replace("/", "--"))
refs = repo / "refs" / "main"
if refs.is_file():
rev = refs.read_text(encoding="utf-8").strip()
if rev:
return rev
snaps = repo / "snapshots"
if snaps.is_dir():
# A pinned build has exactly one; more than one means the image was
# rebuilt against a moving tag, which is precisely the drift worth
# refusing rather than picking a winner from.
found = sorted(d.name for d in snaps.iterdir() if d.is_dir())
if len(found) == 1:
return found[0]
log.error("%d snapshots present for %s: %s — cannot pin a revision",
len(found), MODEL_ID, found)
return ""
def _fingerprint() -> str:
rev = _state.get("revision") or ""
if not rev:
raise HTTPException(503, "model revision unresolved; refusing to serve "
"vectors that cannot be pinned to a generation")
return f"{MODEL_ID}@{rev}:{DIM}:{'l2' if NORMALIZE else 'raw'}:e5v1"
class EmbedRequest(BaseModel):
texts: list[str] = Field(..., min_length=1)
kind: Literal["passage", "query"] = "passage"
class EmbedResponse(BaseModel):
vectors: list[list[float]]
fingerprint: str
elapsed_ms: int
@app.get("/health")
def health() -> dict:
return {"ready": "st" in _state, "model": MODEL_ID, "dim": DIM, "device": DEVICE}
@app.get("/fingerprint")
def fingerprint() -> dict:
"""Registered against an embedding generation at index time and checked at
query time. Everything about the vector space that can drift is in here."""
return {"fingerprint": _fingerprint(), "model_id": MODEL_ID,
"revision": _state.get("revision"), "dim": DIM,
"normalize": NORMALIZE, "distance": "cosine", "prefix_scheme": "e5v1"}
@app.post("/embed", response_model=EmbedResponse)
def embed(req: EmbedRequest) -> EmbedResponse:
if "st" not in _state:
raise HTTPException(503, "model still loading")
if len(req.texts) > MAX_BATCH:
# Bounded so one caller cannot occupy the (single, CPU-bound) model for
# an unbounded time while others queue behind it.
raise HTTPException(413, f"batch of {len(req.texts)} exceeds {MAX_BATCH}")
prefix = PASSAGE_PREFIX if req.kind == "passage" else QUERY_PREFIX
t0 = time.time()
vecs = _state["st"].encode([prefix + t for t in req.texts],
batch_size=min(32, len(req.texts)),
normalize_embeddings=NORMALIZE,
show_progress_bar=False, convert_to_numpy=True)
return EmbedResponse(vectors=vecs.tolist(), fingerprint=_fingerprint(),
elapsed_ms=int((time.time() - t0) * 1000))
Why offline is not a preference here
You cannot send a corpus of internal regulations to a third-party API to find
out what it says. HF_HUB_OFFLINE=1 / TRANSFORMERS_OFFLINE=1 is a condition
of the demo existing at all. The service also refuses to serve unless it can
prove which revision it loaded — a vector whose model revision cannot be
named cannot be pinned to a generation.
Training records stand in for a SaaS with no database of its own. It is what Ontul's REST connector attaches to.
demo/infra/mock-saas/Dockerfile
FROM python:3.11-slim
RUN pip install --no-cache-dir fastapi "uvicorn[standard]"
WORKDIR /app
COPY app.py .
EXPOSE 8000
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
demo/infra/mock-saas/app.py
"""The training-records SaaS — a source with an API and no database.
Reached through Ontul's rest-operation connector, so the demo shows that a
system which never exposes a DB still joins with the rest. The payload is the
same file the seed generator wrote; this only serves it.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from fastapi import FastAPI, HTTPException, Query
app = FastAPI(title="mock LMS")
DATA = Path(os.environ.get("LMS_DATA", "/app/lms.json"))
_cache: dict = {}
def _load() -> dict:
if not _cache:
if not DATA.exists():
raise HTTPException(503, f"{DATA} not present — run the seed generator first")
_cache.update(json.loads(DATA.read_text(encoding="utf-8")))
return _cache
@app.get("/health")
def health() -> dict:
return {"ready": DATA.exists()}
@app.get("/courses")
def courses(doc_no: str | None = Query(None)) -> list[dict]:
rows = _load()["courses"]
return [c for c in rows if doc_no is None or c["doc_no"] == doc_no]
@app.get("/completions")
def completions(course_id: str | None = Query(None),
emp_no: str | None = Query(None)) -> list[dict]:
rows = _load()["completions"]
if course_id:
rows = [r for r in rows if r["course_id"] == course_id]
if emp_no:
rows = [r for r in rows if r["emp_no"] == emp_no]
return rows
@app.get("/outstanding")
def outstanding(course_id: str) -> dict:
"""Who has not completed a required course.
Answering "정보보안규정이 개정됐는데 교육 미이수자는?" still needs the
regulation graph and the org chart on top of this — which is the point of
having it as a separate source rather than another table.
"""
data = _load()
if not any(c["course_id"] == course_id for c in data["courses"]):
raise HTTPException(404, f"no such course: {course_id}")
done = {r["emp_no"] for r in data["completions"] if r["course_id"] == course_id}
return {"course_id": course_id, "completed": sorted(done)}
4. Bring it up
Every port, version, name and initial credential the stack uses is in one documented file. Nothing below reads a port from anywhere else, so changing a value here changes it everywhere — including the compose port bindings, which take their host side from these names.
demo/demo.properties
#
# Regulation demo — every setting the stack reads, in one place.
#
# Sourced by infra/up.sh before anything starts, and every value here is
# exported, so the pipeline jobs, the Flow scripts, the agent and the test
# suite all see the same numbers. Each line stays overridable from the
# environment (VAR="${VAR:-value}"), so a one-off run can override a single
# port without editing this file:
#
# ONTUL_PORT=9080 bash infra/up.sh
#
# Ports are host-side. Inside the compose network the components reach each
# other by service name on their internal ports, which are not configurable
# here because the release tarballs fix them.
#
# ── Component release ────────────────────────────────────────────────────────
# The version of the four cloudcheflabs tarballs infra/dist/fetch.sh downloads
# and infra/dist/Dockerfile* build into images. No official Docker images are
# published, so this is the only install path. Changing it means re-running
# fetch.sh — up.sh refuses to start if the matching tarballs are absent.
COMPONENT_VERSION="${COMPONENT_VERSION:-1.0.0}"
# Apache Polaris, pinned to the release chango ships and operates rather than
# :latest. A demo that runs on a different Polaris than the product does is
# testing a different product.
POLARIS_IMAGE="${POLARIS_IMAGE:-apache/polaris:1.4.1}"
# ── Docker topology ──────────────────────────────────────────────────────────
# The network belongs to ShannonStore's compose project; everything else joins
# it as external. That is why up.sh must start ShannonStore first.
NETWORK="${NETWORK:-neorun-iceberg-network}"
SETUP_CONTAINER="${SETUP_CONTAINER:-nrn-iceberg-setup}"
POLARIS_NAME="${POLARIS_NAME:-regdemo-polaris}"
# ── Iceberg catalog ──────────────────────────────────────────────────────────
# Polaris holds the catalog; the warehouse path resolves inside ShannonStore's
# S3. S3_INTERNAL is the in-network address — the host-side equivalent is
# S3_ENDPOINT_HOST below, and the two are not interchangeable.
CATALOG="${CATALOG:-regdemo_catalog}"
WAREHOUSE="${WAREHOUSE:-s3://iceberg-warehouse/}"
S3_INTERNAL="${S3_INTERNAL:-http://api-server-1:8080}"
# ── Host ports ───────────────────────────────────────────────────────────────
ONTUL_PORT="${ONTUL_PORT:-8080}" # Ontul admin API and admin UI
POLARIS_PORT="${POLARIS_PORT:-28181}" # Polaris Iceberg REST catalog
S3_PORT="${S3_PORT:-28000}" # ShannonStore S3 API
NB_PG_PORT="${NB_PG_PORT:-5434}" # NeorunBase, PostgreSQL wire protocol
NB_ADMIN_PORT="${NB_ADMIN_PORT:-8084}" # NeorunBase admin API
KIOK_PORT="${KIOK_PORT:-18081}" # kiok scheduler UI and REST API
EMBED_PORT="${EMBED_PORT:-8100}" # embedding service (in-network, offline)
LMS_PORT="${LMS_PORT:-8200}" # mock training SaaS (REST source)
ERP_PG_PORT="${ERP_PG_PORT:-55432}" # ERP PostgreSQL, the CDC source
GROUPWARE_MYSQL_PORT="${GROUPWARE_MYSQL_PORT:-33306}" # approval system MySQL
AGENT_WEB_PORT="${AGENT_WEB_PORT:-8900}" # the agent chat window
# ── Credentials ──────────────────────────────────────────────────────────────
# The initial admin password every component boots with. up.sh rotates each one
# immediately and writes the rotated values to out/stack.env, which is
# gitignored — nothing in this file is a live secret.
ADMIN_USER="${ADMIN_USER:-admin}"
ADMIN_PW_INITIAL="${ADMIN_PW_INITIAL:-admin}"
# The Anthropic API key the agent uses. Deliberately not defaulted and never
# written to a file: export it in the shell that runs the agent.
# export ANTHROPIC_API_KEY=sk-ant-...
ANTHROPIC_MODEL="${ANTHROPIC_MODEL:-claude-opus-5}"
# NeorunBase's compose file names its host ports after the coordinator it binds,
# so the values above are re-exported under the names that file expects rather
# than being written twice.
COORD1_PG_HOST_PORT="$NB_PG_PORT"
COORD1_ADMIN_HOST_PORT="$NB_ADMIN_PORT"
sources it, exports it, and starts the components in the only order that works.
demo/infra/up.sh
#!/usr/bin/env bash
##
## Bring up the whole demo stack, in the only order that works.
##
## The ordering is not stylistic. ShannonStore has to exist before Polaris,
## because Polaris is configured with credentials ShannonStore mints at startup
## and cannot be told about them later. Polaris has to hold a catalog before
## NeorunBase and Ontul start, because both read the catalog at boot and a
## missing warehouse is a startup failure rather than a retry. And the network
## belongs to ShannonStore's compose, so everything else joins as external and
## must follow it.
##
## This script references no source repository. All four products are built into
## images from the release tarballs infra/dist/fetch.sh downloaded — no official
## Docker images are published, so fetching the distribution and building it
## yourself is the only install path, and it is all anyone following this demo
## needs.
##
set -euo pipefail
DEMO="$(cd "$(dirname "$0")/.." && pwd)"
OVR="$DEMO/infra/compose"
DIST="$DEMO/infra/dist"
# Every port, name, version and credential the stack uses lives in one
# documented file. Sourced rather than parsed so a value can still be
# overridden from the environment for a single run.
# shellcheck source=../demo.properties
. "$DEMO/demo.properties"
export COMPONENT_VERSION POLARIS_IMAGE CATALOG WAREHOUSE
export ONTUL_PORT POLARIS_PORT S3_PORT NB_PG_PORT NB_ADMIN_PORT KIOK_PORT
export EMBED_PORT LMS_PORT ERP_PG_PORT GROUPWARE_MYSQL_PORT AGENT_WEB_PORT
export COORD1_PG_HOST_PORT COORD1_ADMIN_HOST_PORT ADMIN_USER ADMIN_PW_INITIAL ANTHROPIC_MODEL
STACK_ENV="$DEMO/out/stack.env"
log() { printf '\n\033[1m=== %s ===\033[0m\n' "$*"; }
step() { printf ' %s\n' "$*"; }
fail() { printf '\033[31mFAIL: %s\033[0m\n' "$*" >&2; exit 1; }
# Better to stop here than without them. Carry on and docker build dies at the
# COPY with "not found", which reads like a Docker problem.
for n in shannonstore neorunbase ontul kiok; do
[ -s "$DIST/$n-$COMPONENT_VERSION.tar.gz" ] || \
fail "$n-$COMPONENT_VERSION.tar.gz is missing — run 'bash infra/dist/fetch.sh' first"
done
[ -f "$DEMO/out/sql/erp_postgres.sql" ] || fail "corpus not generated — run 'make seed' first"
# ── 1. ShannonStore. Creates the network, mints S3 credentials, makes the bucket
log "1/7 ShannonStore (zk + data x2 + api x1)"
docker compose -p regdemo-ss --project-directory "$OVR" -f "$OVR/shannonstore.yml" up -d --build \
> /tmp/regdemo-ss.log 2>&1 || { tail -30 /tmp/regdemo-ss.log; fail "shannonstore up (see /tmp/regdemo-ss.log)"; }
step "waiting for setup to mint credentials and create the bucket..."
for i in $(seq 1 150); do
[ "$(docker inspect -f '{{.State.Health.Status}}' "$SETUP_CONTAINER" 2>/dev/null || echo x)" = healthy ] && break
[ "$i" = 150 ] && { docker logs --tail 40 "$SETUP_CONTAINER" 2>&1; fail "shannonstore setup never went healthy"; }
sleep 2
done
ACCESS_KEY=$(docker exec "$SETUP_CONTAINER" cat /tmp/setup/access_key 2>/dev/null | tr -d '\r\n')
SECRET_KEY=$(docker exec "$SETUP_CONTAINER" cat /tmp/setup/secret_key 2>/dev/null | tr -d '\r\n')
[ -n "$ACCESS_KEY" ] && [ -n "$SECRET_KEY" ] || fail "no S3 credentials came out of setup"
step "S3 ready — access key ${ACCESS_KEY:0:8}…, bucket iceberg-warehouse"
# ── 2. Polaris. Its own container, named for this demo so it never fights the
# shannon-polaris / nrn-perf-polaris that the product suites start.
log "2/7 Polaris (Iceberg REST catalog)"
docker rm -f "$POLARIS_NAME" >/dev/null 2>&1 || true
docker run -d --name "$POLARIS_NAME" --network "$NETWORK" -p "${POLARIS_PORT}:8181" \
-e POLARIS_BOOTSTRAP_CREDENTIALS=POLARIS,root,s3cr3t \
-e polaris.realm-context.realms=POLARIS \
-e quarkus.otel.sdk.disabled=true \
-e POLARIS_FEATURES_SKIP_CREDENTIAL_SUBSCOPING_INDIRECTION=true \
-e POLARIS_FEATURES_ALLOW_SETTING_S3_ENDPOINTS=true \
-e JAVA_OPTS_APPEND="-Xmx512m" \
-e AWS_ACCESS_KEY_ID="$ACCESS_KEY" -e AWS_SECRET_ACCESS_KEY="$SECRET_KEY" -e AWS_REGION=us-east-1 \
"$POLARIS_IMAGE" >/dev/null || fail "polaris did not start"
ptoken() {
curl -sf "http://localhost:${POLARIS_PORT}/api/catalog/v1/oauth/tokens" \
--user root:s3cr3t -H "Polaris-Realm: POLARIS" \
-d grant_type=client_credentials -d "scope=PRINCIPAL_ROLE:ALL" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])"
}
PT=""
for i in $(seq 1 60); do PT=$(ptoken 2>/dev/null || true); [ -n "$PT" ] && break; sleep 3; done
[ -n "$PT" ] || { docker logs --tail 30 "$POLARIS_NAME"; fail "polaris never issued a token"; }
step "polaris authenticated"
# ── 3. The catalog. Created before anything reads it.
log "3/7 Catalog '$CATALOG'"
curl -sf -H "Authorization: Bearer $PT" -H "Content-Type: application/json" -H "Polaris-Realm: POLARIS" \
"http://localhost:${POLARIS_PORT}/api/management/v1/catalogs" -d "{
\"catalog\": { \"name\": \"$CATALOG\", \"type\": \"INTERNAL\", \"readOnly\": false,
\"properties\": { \"default-base-location\": \"$WAREHOUSE\" },
\"storageConfigInfo\": { \"storageType\": \"S3\", \"endpoint\": \"$S3_INTERNAL\",
\"endpointInternal\": \"$S3_INTERNAL\", \"pathStyleAccess\": true,
\"allowedLocations\": [\"$WAREHOUSE\"], \"stsUnavailable\": true } } }" >/dev/null 2>&1 \
&& step "created" || step "already present"
curl -sf -H "Authorization: Bearer $PT" -H "Content-Type: application/json" -H "Polaris-Realm: POLARIS" \
-X PUT "http://localhost:${POLARIS_PORT}/api/management/v1/catalogs/${CATALOG}/catalog-roles/catalog_admin/grants" \
-d '{"type":"catalog","privilege":"CATALOG_MANAGE_CONTENT"}' >/dev/null 2>&1 || true
step "catalog_admin granted CATALOG_MANAGE_CONTENT"
# A principal that can actually use the catalog.
#
# On Polaris 1.4.x the bootstrap clientId is not registered as a PRINCIPAL, so
# catalog operations performed as 'root' are rejected with "TopLevelEntity of
# type PRINCIPAL does not exist" — the credentials authenticate and then cannot
# do anything. chango provisions a per-catalog admin principal for exactly this
# reason (PolarisClusterService.ensureCatalogAdminPrincipal); the demo does the
# same rather than inventing a second answer.
PRINCIPAL="${CATALOG}_admin"
PRINCIPAL_ROLE="${CATALOG}_admin_pr"
pol() { # pol <METHOD> <path> [body]
if [ -n "${3:-}" ]; then
curl -s -o /tmp/regdemo-pol.out -w '%{http_code}' -X "$1" \
-H "Authorization: Bearer $PT" -H 'Content-Type: application/json' -H 'Polaris-Realm: POLARIS' \
"http://localhost:${POLARIS_PORT}$2" -d "$3"
else
curl -s -o /tmp/regdemo-pol.out -w '%{http_code}' -X "$1" \
-H "Authorization: Bearer $PT" -H 'Polaris-Realm: POLARIS' \
"http://localhost:${POLARIS_PORT}$2"
fi
}
CODE=$(pol POST /api/management/v1/principals "{\"principal\":{\"name\":\"$PRINCIPAL\"}}")
if [ "${CODE:0:1}" = "2" ]; then
CAT_CLIENT_ID=$(python3 -c "import json;print(json.load(open('/tmp/regdemo-pol.out'))['credentials']['clientId'])")
CAT_CLIENT_SECRET=$(python3 -c "import json;print(json.load(open('/tmp/regdemo-pol.out'))['credentials']['clientSecret'])")
step "principal $PRINCIPAL created"
elif [ "$CODE" = "409" ]; then
# Polaris only ever vends a principal's secret once, at creation. Rotate to get
# a usable pair rather than guessing at one we were never given.
CODE=$(pol POST "/api/management/v1/principals/$PRINCIPAL/rotate")
[ "${CODE:0:1}" = "2" ] || fail "principal $PRINCIPAL exists and its secret could not be rotated (HTTP $CODE)"
CAT_CLIENT_ID=$(python3 -c "import json;print(json.load(open('/tmp/regdemo-pol.out'))['credentials']['clientId'])")
CAT_CLIENT_SECRET=$(python3 -c "import json;print(json.load(open('/tmp/regdemo-pol.out'))['credentials']['clientSecret'])")
step "principal $PRINCIPAL existed — secret rotated"
else
fail "could not create principal $PRINCIPAL (HTTP $CODE): $(head -c 200 /tmp/regdemo-pol.out)"
fi
pol POST /api/management/v1/principal-roles "{\"principalRole\":{\"name\":\"$PRINCIPAL_ROLE\"}}" >/dev/null
pol PUT "/api/management/v1/principals/$PRINCIPAL/principal-roles" \
"{\"principalRole\":{\"name\":\"$PRINCIPAL_ROLE\"}}" >/dev/null
pol PUT "/api/management/v1/principal-roles/$PRINCIPAL_ROLE/catalog-roles/$CATALOG" \
'{"catalogRole":{"name":"catalog_admin"}}' >/dev/null
step "$PRINCIPAL → $PRINCIPAL_ROLE → catalog_admin on $CATALOG"
# Prove it before anything depends on it: authenticate as the new principal and
# list namespaces. Creating the chain and assuming it works is how a catalog that
# rejects every operation gets discovered three stages later.
CT=$(curl -sf "http://localhost:${POLARIS_PORT}/api/catalog/v1/oauth/tokens" \
--user "$CAT_CLIENT_ID:$CAT_CLIENT_SECRET" -H 'Polaris-Realm: POLARIS' \
-d grant_type=client_credentials -d 'scope=PRINCIPAL_ROLE:ALL' \
| python3 -c "import sys,json;print(json.load(sys.stdin).get('access_token',''))") || true
[ -n "$CT" ] || fail "the catalog principal could not authenticate"
NSCODE=$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: Bearer $CT" -H 'Polaris-Realm: POLARIS' \
"http://localhost:${POLARIS_PORT}/api/catalog/v1/${CATALOG}/namespaces")
[ "${NSCODE:0:1}" = "2" ] || fail "the catalog principal cannot use the catalog (namespaces → HTTP $NSCODE)"
step "verified: the principal can list namespaces"
# ── 4. NeorunBase. Serves the vectors and the Korean FTS.
log "4/7 NeorunBase (coordinator + datanode x2)"
IC="-Dneorunbase.iceberg.catalog.type=rest \
-Dneorunbase.iceberg.catalog.rest.uri=http://${POLARIS_NAME}:8181/api/catalog \
-Dneorunbase.iceberg.catalog.rest.warehouse=${CATALOG} \
-Dneorunbase.iceberg.catalog.rest.security=OAUTH2 \
-Dneorunbase.iceberg.catalog.rest.client-id=${CAT_CLIENT_ID} \
-Dneorunbase.iceberg.catalog.rest.client-secret=${CAT_CLIENT_SECRET} \
-Dneorunbase.iceberg.catalog.rest.extra.properties=prefix=${CATALOG},header.Polaris-Realm=POLARIS,scope=PRINCIPAL_ROLE:ALL \
-Dneorunbase.iceberg.s3.endpoint=${S3_INTERNAL} \
-Dneorunbase.iceberg.s3.access.key=${ACCESS_KEY} \
-Dneorunbase.iceberg.s3.secret.key=${SECRET_KEY} \
-Dneorunbase.iceberg.s3.region=us-east-1 \
-Dneorunbase.iceberg.s3.path.style.access=true \
-Dneorunbase.iceberg.sync.interval.ms=8000 \
-Dneorunbase.iceberg.default.namespace=reg"
export ICEBERG_OPTS_1="$IC" AWS_REGION=us-east-1
export COORD1_PG_HOST_PORT=$NB_PG_PORT COORD1_ADMIN_HOST_PORT=$NB_ADMIN_PORT
docker compose -p regdemo-nb --project-directory "$OVR" -f "$OVR/neorunbase.yml" up -d --build \
> /tmp/regdemo-nb.log 2>&1 || { tail -30 /tmp/regdemo-nb.log; fail "neorunbase up (see /tmp/regdemo-nb.log)"; }
for i in $(seq 1 90); do
curl -sf "http://localhost:${NB_ADMIN_PORT}/admin/health" >/dev/null 2>&1 && break
[ "$i" = 90 ] && { docker logs --tail 40 nrn-iceberg-coordinator-1 2>&1 | tail -40; fail "neorunbase coordinator never answered"; }
sleep 2
done
step "coordinator up — pg :$NB_PG_PORT, admin :$NB_ADMIN_PORT"
# NeorunBase refuses connections on the PostgreSQL wire until the default admin
# password has been rotated, and rotation only happens over the admin HTTP API.
# A cluster that is "up" but unreachable by psql is not up for any purpose this
# demo has, so rotating belongs here rather than in a later script.
NB_PASSWORD="${NEORUNBASE_PASSWORD:-Regdemo12345}"
nb_token() {
curl -s -X POST "http://localhost:${NB_ADMIN_PORT}/admin/auth/login" \
-H 'Content-Type: application/json' -d "{\"username\":\"admin\",\"password\":\"$1\"}" \
| python3 -c "import sys,json;d=json.load(sys.stdin);print(d.get('token') or d.get('accessToken') or '')" 2>/dev/null
}
if [ -n "$(nb_token "$NB_PASSWORD")" ]; then
step "password already rotated"
else
NBT=$(nb_token admin)
[ -n "$NBT" ] || fail "neorunbase admin login failed with the default password"
curl -sf -X POST "http://localhost:${NB_ADMIN_PORT}/admin/auth/change-password" \
-H 'Content-Type: application/json' -H "Authorization: Bearer $NBT" \
-d "{\"oldPassword\":\"admin\",\"newPassword\":\"$NB_PASSWORD\"}" >/dev/null \
|| fail "neorunbase password rotation failed"
step "default password rotated"
fi
PGPASSWORD="$NB_PASSWORD" psql -h localhost -p "$NB_PG_PORT" -U admin -d neorunbase -t -A -c "SELECT 1" >/dev/null 2>&1 \
|| fail "neorunbase rotated its password but still refuses the wire protocol"
step "postgres wire accepts connections"
# ── 5. The sources: the embedding model, ERP, the approval system, and the SaaS
# with no database of its own.
log "5/7 Sources (embed-svc, ERP, groupware, LMS)"
S3_ACCESS_KEY="$ACCESS_KEY" S3_SECRET_KEY="$SECRET_KEY" \
docker compose -p regdemo --project-directory "$DEMO" -f "$DEMO/docker-compose.yml" up -d --build \
> /tmp/regdemo-src.log 2>&1 || { tail -30 /tmp/regdemo-src.log; fail "sources up (see /tmp/regdemo-src.log)"; }
for i in $(seq 1 120); do
curl -sf http://localhost:$EMBED_PORT/health 2>/dev/null | grep -q '"ready": *true' && break
[ "$i" = 120 ] && { docker logs --tail 30 regdemo-embed-svc 2>&1; fail "embed-svc never became ready"; }
sleep 2
done
FP=$(curl -sf http://localhost:$EMBED_PORT/fingerprint | python3 -c "import sys,json;print(json.load(sys.stdin)['fingerprint'])")
step "embedding model: $FP"
# ── 6. Ontul. Last, because it reads the catalog and the sources at boot.
log "6/7 Ontul (master 1 + worker 1)"
docker compose -p regdemo-ontul --project-directory "$OVR" -f "$OVR/ontul.yml" up -d --build \
> /tmp/regdemo-ontul.log 2>&1 || { tail -30 /tmp/regdemo-ontul.log; fail "ontul up (see /tmp/regdemo-ontul.log)"; }
for i in $(seq 1 90); do
curl -sf http://localhost:$ONTUL_PORT/admin/ready >/dev/null 2>&1 && break
[ "$i" = 90 ] && { docker logs --tail 40 regdemo-ontul-master-1 2>&1 | tail -40; fail "ontul master never became ready"; }
sleep 2
done
step "master ready on :8080"
# ── 7. kiok, the pipeline's scheduler.
#
# It shares Ontul's ZooKeeper under a chroot (/kiok) rather than starting one, so
# it comes after Ontul. This DAG has half a dozen tasks and every heavy thing
# happens on an Ontul worker — kiok keeps the order and records the results, so
# 256m of heap is plenty.
log "7/7 kiok (master + worker)"
# The chroot has to exist first. Curator does not create the chroot named in a
# connect string, so without it the master dies on startup with "NoNode for
# /kiok" — which reads as a ZooKeeper problem and only means nobody made it yet.
docker exec regdemo-ontul-zk \
bash -c 'echo "create /kiok \"\"" | /apache-zookeeper-*-bin/bin/zkCli.sh -server localhost:2181' \
> /dev/null 2>&1 || true
step "ZooKeeper chroot /kiok ready"
docker compose -p regdemo-kiok --project-directory "$OVR" -f "$OVR/kiok.yml" up -d --build \
> /tmp/regdemo-kiok.log 2>&1 || { tail -30 /tmp/regdemo-kiok.log; fail "kiok up (see /tmp/regdemo-kiok.log)"; }
for i in $(seq 1 90); do
curl -sf http://localhost:$KIOK_PORT/healthz >/dev/null 2>&1 && break
[ "$i" = 90 ] && { docker logs --tail 40 regdemo-kiok-master 2>&1 | tail -40; fail "kiok master never became ready"; }
sleep 2
done
step "kiok ready on :$KIOK_PORT"
# ── Hand the resolved stack to everything downstream, so no script re-derives it.
mkdir -p "$(dirname "$STACK_ENV")"
cat > "$STACK_ENV" <<ENVEOF
# Written by infra/up.sh. Credentials are minted per bring-up — do not commit.
export S3_ENDPOINT_INTERNAL=$S3_INTERNAL
export S3_ENDPOINT_HOST=http://localhost:$S3_PORT
export S3_ACCESS_KEY=$ACCESS_KEY
export S3_SECRET_KEY=$SECRET_KEY
export S3_REGION=us-east-1
export S3_WAREHOUSE=$WAREHOUSE
export POLARIS_URI_INTERNAL=http://${POLARIS_NAME}:8181/api/catalog
export POLARIS_URI_HOST=http://localhost:${POLARIS_PORT}/api/catalog
export POLARIS_CATALOG=$CATALOG
# The catalog principal, not the bootstrap root — root authenticates but cannot
# perform catalog operations on 1.4.x.
export POLARIS_CLIENT_ID=$CAT_CLIENT_ID
export POLARIS_CLIENT_SECRET=$CAT_CLIENT_SECRET
export POLARIS_ROOT_ID=root
export POLARIS_ROOT_SECRET=s3cr3t
export NEORUNBASE_PG=postgresql://admin@localhost:${NB_PG_PORT}/neorunbase
export NEORUNBASE_ADMIN=http://localhost:${NB_ADMIN_PORT}
export NEORUNBASE_PASSWORD=$NB_PASSWORD
export NEORUNBASE_INTERNAL_HOST=neorun-coordinator-1
export ONTUL_URL=http://localhost:$ONTUL_PORT
export EMBED_URL_INTERNAL=http://embed-svc:8000
export EMBED_URL_HOST=http://localhost:$EMBED_PORT
export EMBED_FINGERPRINT=$FP
# Split out so a job can record the generation's identity field by field. The
# fingerprint is the authority; these are its parts, taken from the same string
# the live endpoint reported rather than re-declared.
export EMBED_MODEL_ID=${FP%%@*}
export EMBED_MODEL_REVISION=$(printf '%s' "${FP#*@}" | cut -d: -f1)
export EMBED_GENERATION=${EMBED_GENERATION:-gen1}
export EMBED_DIM=$(printf '%s' "$FP" | cut -d: -f2)
# The source passwords, so nothing downstream has to guess them or repeat the
# compose defaults. They are the compose defaults unless overridden there.
export ERP_PASSWORD=${ERP_PASSWORD:-regdemo}
export GW_PASSWORD=${GW_PASSWORD:-regdemo}
export ERP_PG=postgresql://erp@localhost:$ERP_PG_PORT/erp
export GROUPWARE_MYSQL=mysql://gw@localhost:$GROUPWARE_MYSQL_PORT/groupware
export LMS_URL=http://localhost:$LMS_PORT
ENVEOF
log "Stack up"
cat <<SUMEOF
Ontul Admin UI http://localhost:$ONTUL_PORT
Polaris http://localhost:${POLARIS_PORT}
ShannonStore S3 http://localhost:$S3_PORT
NeorunBase psql -h localhost -p ${NB_PG_PORT} -U admin -d neorunbase
Embedding http://localhost:$EMBED_PORT $FP
ERP / approvals :$ERP_PG_PORT / :$GROUPWARE_MYSQL_PORT
LMS http://localhost:$LMS_PORT
kiok (scheduler) http://localhost:$KIOK_PORT
Resolved endpoints written to out/stack.env
Next: bash infra/register.sh (catalogs, connections, schema, IAM)
SUMEOF
=== 1/7 ShannonStore (zk + data x2 + api x1) ===
S3 ready — access key 7C546709…, bucket iceberg-warehouse
=== 2/7 Polaris (Iceberg REST catalog) ===
polaris authenticated
=== 3/7 Catalog 'regdemo_catalog' ===
regdemo_catalog_admin → regdemo_catalog_admin_pr → catalog_admin on regdemo_catalog
verified: the principal can list namespaces
=== 4/7 NeorunBase (coordinator + datanode x2) ===
coordinator up — pg :5434, admin :8084
default password rotated
postgres wire accepts connections
=== 5/7 Sources (embed-svc, ERP, groupware, LMS) ===
embedding model: intfloat/multilingual-e5-base@d128750…:768:l2:e5v1
=== 6/7 Ontul (master 1 + worker 1) ===
master ready on :8080
=== 7/7 kiok (master + worker) ===
ZooKeeper chroot /kiok 준비
kiok ready on :18081
=== Stack up ===
Ontul Admin UI http://localhost:8080/admin
Polaris http://localhost:28181
ShannonStore S3 http://localhost:28000
NeorunBase psql -h localhost -p 5434 -U admin -d neorunbase
Embedding http://localhost:8100
ERP / approvals :55432 / :33306
Training records http://localhost:8200
kiok (scheduler) http://localhost:18081
Why the odd-looking lines in that script are there
Tearing the whole thing down and rebuilding it from tarballs is what surfaced these. Each one was something an operator did by hand once and would not have remembered.
NEORUNBASE_MASTER_KEYused to arrive as anENVin the product Dockerfile. Without it in compose, the coordinator dies immediately in KMS initialisation with aSecurityException.- The coordinator's
$$ICEBERG_OPTS. Omit it and NeorunBase comes up without Iceberg, quietly. Nothing fails; the vector table is simply empty much later. - The ZooKeeper chroot
/kiok. Curator does not create the chroot named in a connect string. Without it the kiok master dies withNoNode for /kiok, which reads as a ZooKeeper problem and is not one. - Bringing kiok up at all. It was in no script. Someone started it by hand once and it kept running.
5. Tear down
demo/infra/down.sh
#!/usr/bin/env bash
##
## Tear down everything infra/up.sh started, and nothing else.
##
## The three product composes use distinct project names precisely so this can
## be specific. Volumes go with them: the Iceberg catalog lives in Polaris and
## the tables live in ShannonStore, so keeping one without the other leaves a
## catalog pointing at files that are gone, or files no catalog knows about.
##
set -uo pipefail
DEMO="$(cd "$(dirname "$0")/.." && pwd)"
OVR="$DEMO/infra/compose"
echo "=== tearing down the demo stack ==="
docker compose -p regdemo-ontul --project-directory "$OVR" -f "$OVR/ontul.yml" down -v 2>/dev/null
docker compose -p regdemo --project-directory "$DEMO" -f "$DEMO/docker-compose.yml" down -v 2>/dev/null
docker compose -p regdemo-kiok --project-directory "$OVR" -f "$OVR/kiok.yml" down -v 2>/dev/null
docker compose -p regdemo-nb --project-directory "$OVR" -f "$OVR/neorunbase.yml" down -v 2>/dev/null
docker rm -f regdemo-polaris 2>/dev/null
docker compose -p regdemo-ss --project-directory "$OVR" -f "$OVR/shannonstore.yml" down -v 2>/dev/null
rm -f "$DEMO/out/stack.env"
echo " done — out/stack.env removed; the corpus in out/ is kept"
The distinct project names are what make this specific: only what this demo started comes down. Volumes go with the containers — the Iceberg catalog lives in Polaris and the tables live in ShannonStore, so keeping one without the other leaves either a catalog pointing at files that are gone, or files no catalog knows about.
Next: the corpus — what this demo actually reads.