Files
datastore/Dockerfile
zeekayandHanzo Dev 3b44aea20f The warehouse maps the platform's real IAM clients, and the host owns the hanzo database
The rules named hanzo-cloud as the sink, but IAM provisions the event app
as hanzo-event and the o11y app as hanzo-o11y, and the cloud host is
hanzo-cloud. hanzo-event is now the sink, hanzo-cloud holds a new
`platform` role for the hanzo database it creates and owns, and the three
processes hold `observer`, the event plane's cross-org read, for the
reads no caller's token speaks for (an API key, a background job, a
fleet view). Without these the host was refused everything and the
sink's own reads failed.

`schema` may also keep the migration runner's ledger and lock
(hanzo.migration, hanzo.migration_lock_*), without which the o11y app
could not record that 0005 ran, and may create `observer`. `platform`
may grant SELECT on its own tables and write their row policies, so the
host confines the tables it reads as a person's org the same way.

The gtests and the integration test load the image's own directory
section instead of a copy, so the deployment's rules are what they test.
The integration test adds the host, the ledger, query cache isolation
and SET ROLE. contrib/authz is pinned to the v1.10.41 tag.


Co-authored-by: Hanzo Dev <[email protected]>
2026-09-24 21:18:12 -07:00

249 lines
14 KiB
Docker

# Hanzo Datastore — the image this repo publishes, built FROM SOURCE.
#
# Why not docker/server/Dockerfile: that one installs prebuilt .deb packages from
# packages.hanzo.ai, an apt repository we have never stood up. The hostname is a
# rename artifact — the global rebrand rewrote the upstream package host into it,
# so the Dockerfile reads as if a repo exists and the build only discovers
# otherwise at `apt-get install`. Until we run a package repo, source is the ONLY
# path that yields OUR 26.7 rather than somebody else's binary, so this is the
# one builder the pipeline uses. docker/server/* is left alone: it is upstream
# machinery for a distribution channel we do not operate, and rewriting it would
# just conflict on the next upstream merge.
#
# Cost: hours, not minutes. That is normal for this codebase — a job still
# running after two hours is compiling, not hung.
#
# Versions are pinned to what ci/docker/fasttest/Dockerfile provisions, because
# that is the toolchain this tree is actually known to compile under:
# cmake/tools.cmake hard-fails below clang 21, and the Rust contribs want the
# exact pinned nightly. Bump these together with that file, never alone.
ARG UBUNTU_VERSION=24.04
ARG LLVM_VERSION=21
ARG RUST_TOOLCHAIN=nightly-2026-03-22
FROM ubuntu:${UBUNTU_VERSION} AS builder
ARG LLVM_VERSION
ARG RUST_TOOLCHAIN
ENV DEBIAN_FRONTEND=noninteractive
# Retry apt: a transient mirror 5xx hours into a build is an expensive way to
# learn the network blipped.
RUN printf 'Acquire::Retries "5";\n' > /etc/apt/apt.conf.d/99-datastore-build \
&& apt-get update \
&& apt-get install --yes --no-install-recommends \
ca-certificates curl gnupg lsb-release wget git \
cmake ninja-build python3 xxd zstd \
nasm yasm \
&& rm -rf /var/lib/apt/lists/*
# clang from apt.llvm.org — Ubuntu 24.04 ships clang 18, below the hard floor in
# cmake/tools.cmake. The key is pinned by hash so a compromised or swapped key
# fails here rather than silently signing a different toolchain.
RUN wget -nv -O /tmp/llvm.key https://apt.llvm.org/llvm-snapshot.gpg.key \
&& echo "5ffc7c9a9299ce774f81cada703e23ebba5bdfb0345b6c3b667b3ead7aa21c75ef62ccd74f7a8f2aa0cbe158d3068bbe /tmp/llvm.key" | sha384sum -c \
&& gpg --dearmor -o /etc/apt/trusted.gpg.d/llvm.gpg /tmp/llvm.key \
&& rm /tmp/llvm.key \
&& CODENAME="$(lsb_release --codename --short)" \
&& echo "deb https://apt.llvm.org/${CODENAME}/ llvm-toolchain-${CODENAME}-${LLVM_VERSION} main" \
> /etc/apt/sources.list.d/llvm.list \
&& apt-get update \
&& apt-get install --yes --no-install-recommends \
clang-${LLVM_VERSION} lld-${LLVM_VERSION} \
llvm-${LLVM_VERSION}-dev libclang-${LLVM_VERSION}-dev libclang-rt-${LLVM_VERSION}-dev \
`# llvm-N (not just -dev) carries llvm-ar/ranlib/objcopy/nm/strip.` \
`# cmake/tools.cmake resolves each by versioned name and raises` \
`# FATAL_ERROR when one is missing, so omitting this package fails the` \
`# build at configure — cheap to install, expensive to debug.` \
llvm-${LLVM_VERSION} \
&& rm -rf /var/lib/apt/lists/* \
# cmake resolves the linker as plain `ld.lld`; the apt package only ships the
# versioned name.
&& ln -sf /usr/bin/lld-${LLVM_VERSION} /usr/bin/ld.lld \
# Debian's llvm packaging exports import checks for targets it does not ship
# (mlir, bolt, merge-fdata); cmake aborts on the missing files. Upstream
# carries this same workaround.
&& sed -i '/_IMPORT_CHECK_FILES_FOR_\(mlir-\|llvm-bolt\|merge-fdata\|MLIR\)/ {s|^|#|}' \
/usr/lib/llvm-${LLVM_VERSION}/lib/cmake/llvm/LLVMExports-*.cmake
# Rust: several contribs (corrosion-driven) are Rust, so a missing toolchain is
# a configure-time failure. The nightly is pinned because those crates use
# unstable features that move.
ENV RUSTUP_HOME=/rust/rustup CARGO_HOME=/rust/cargo
ENV PATH=/rust/cargo/bin:${PATH}
RUN curl -fsSL -o /tmp/rustup-init \
"https://static.rust-lang.org/rustup/archive/1.28.1/x86_64-unknown-linux-gnu/rustup-init" \
&& chmod +x /tmp/rustup-init \
&& /tmp/rustup-init -y --no-modify-path --default-toolchain "${RUST_TOOLCHAIN}" \
&& rm /tmp/rustup-init \
&& rustup component add rust-src \
&& rustup target add x86_64-unknown-linux-gnu
COPY . /src
WORKDIR /src
# Submodules are fetched HERE, not by the CI checkout: the shared reusable
# (hanzoai/ci .hanzo/workflows/build.yml) runs a bare actions/checkout@v4 with no
# submodule support and exposes no knob for it, so a build that assumed a
# populated contrib/ would fail on an empty directory 135 times over. Doing it in
# the builder keeps this image buildable from any checkout, shallow or not.
#
# update-submodules.sh is upstream's own script and does two things, both
# required: shallow-fetches each pinned SHA, then DELETES the third-party CMake
# files, without which the build picks up vendored cmake instead of this tree's.
RUN git config --global --add safe.directory /src \
&& ./contrib/update-submodules.sh --max-procs 16
# ENABLE_TESTS=1 only defines the gtest targets; ninja builds the two named below.
# unit_tests_dbms (the whole suite) is a large fraction of the tree and is never
# built here. unit_tests_iam is: it is the IAM gtests alone, a few translation
# units linked against libraries the server build has already produced, and it
# runs as a gate before the image exists. The IAM user directory decides who can
# read which organization's rows, so an image whose token verification is broken
# must not be pushed. Everything else stays default ON so the image we ship has
# the same capability surface the .deb would have: dropping ENABLE_DATASTORE_ALL
# would silently remove keeper, which a deployment can depend on and which no
# error message would explain.
#
# COMPILER_CACHE=disabled, because a missing ccache is a hard FATAL_ERROR here,
# not a warning: FAIL_ON_UNSUPPORTED_OPTIONS_COMBINATION defaults ON, which makes
# RECONFIGURE_MESSAGE_LEVEL fatal, and cmake/ccache.cmake takes that path when it
# finds neither ccache nor sccache. `disabled` is the honest statement rather than
# apt-installing one: nothing in this Dockerfile or in hanzoai/ci mounts a cache,
# so a compiler cache would start cold on every run and cache nothing for the next.
# Install one only together with a cache mount that outlives the layer.
#
# PARALLEL_*_JOBS, because cmake/limit_jobs.cmake sizes them with
# cmake_host_system_information, which reports the NODE and not this container's
# cgroup. On the runner fleet that reads 64311 MB / 8 cores while the pod is
# capped at 26 GiB / 6 CPU, so it derives 64311/MAX_LINKER_MEMORY(5000) = 12 link
# jobs, clamps to 8 cores, and runs 8 concurrent links budgeted at 40 GB inside a
# 26 GiB pod, so both are pinned here rather than derived.
#
# COMPILE_JOBS is 6, the pod's CPU limit. It was briefly 3, on a wrong reading of
# a failure that looked like memory and was not — recorded because the wrong
# answer is the instructive one. Run #7 at 6 jobs stopped mid-line on
# [11637/15284] with no error and no FAILED:, and the runner pod showed
# restarts=0 with an empty lastState, which is the signature of a kill inside the
# pod's own dockerd that the kubelet never sees. It died in
# FunctionsConversion_impl*.cpp, the cluster upstream split into impl00..impl27
# because one TU would not build, so "6 concurrent heavy TUs blew the cgroup" fit
# the evidence. It was wrong. Run #8 at 3 jobs died the same silent way but
# EARLIER, at [9688/15284], in rocksdb TUs that are not memory-hungry at all.
#
# What the two runs share is not a target or a translation unit, it is a clock:
# 190.5 and 193.5 minutes. That is act_runner's 3h job timeout plus teardown
# (git-runner-config: "runner.timeout allows 3h"). A timeout kills the container
# and truncates the log exactly like an OOM does, which is why the shape is
# indistinguishable without comparing wall time across runs. Halving parallelism
# only bought less work per unit of the same fixed budget.
#
# THIS BUILD CANNOT FIT THAT BUDGET, and no value on the line below changes it.
# At 6 jobs ninja reached 76% in 173.6 min, so a full build needs ~228 min plus
# ~17 min of preamble = ~245 min against a 180 min cap: 1.36x over, a 65 minute
# shortfall, on a pod capped at 6 CPU. A BuildKit cache mount to resume across
# runs does not rescue it either — run #7 and run #8 landed on git-runner-6 and
# git-runner-18, and each runner has its own dockerd, so a cached /build is a
# 1-in-20 coincidence rather than a mechanism. Closing this needs a longer job
# timeout or a larger dedicated runner, which is a platform decision and is
# written down here instead of being papered over with smaller job counts.
RUN cmake -S /src -B /build -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_C_COMPILER=clang-${LLVM_VERSION} \
-DCMAKE_CXX_COMPILER=clang++-${LLVM_VERSION} \
-DENABLE_TESTS=1 \
-DCOMPILER_CACHE=disabled \
-DPARALLEL_COMPILE_JOBS=6 \
-DPARALLEL_LINK_JOBS=1 \
&& ninja -C /build datastore unit_tests_iam \
&& (cd /tmp && /build/src/unit_tests_iam) \
&& llvm-strip-${LLVM_VERSION} /build/programs/datastore
FROM ubuntu:${UBUNTU_VERSION} AS runtime
ARG DEBIAN_FRONTEND=noninteractive
# uid/gid 101 fixed on purpose and created before any package: a rootless
# container cannot chown its mounted volume, so the owner has to be predictable
# from the outside. Same pair upstream uses, so an existing volume's ownership
# still matches.
RUN groupadd -r datastore --gid=101 \
&& useradd -r -g datastore --uid=101 --home-dir=/var/lib/datastore --shell=/bin/bash datastore \
&& apt-get update \
&& apt-get install --yes --no-install-recommends \
ca-certificates locales tzdata \
&& rm -rf /var/lib/apt/lists/* /var/cache/debconf /tmp/* \
&& locale-gen en_US.UTF-8
COPY --from=builder /build/programs/datastore /usr/bin/datastore
# The binary is multi-call: it dispatches on argv[0], and the build already emits
# these as symlinks beside it. Only the ones the entrypoint and day-to-day
# operation actually invoke are recreated here — the full set is a package
# concern, not a container one.
RUN for tool in server client local keeper extract-from-config disks su benchmark; do \
ln -sf /usr/bin/datastore "/usr/bin/datastore-$tool"; \
done
COPY --from=builder /src/programs/server/config.xml /etc/datastore-server/config.xml
COPY --from=builder /src/programs/server/users.xml /etc/datastore-server/users.xml
COPY --from=builder /src/docker/server/docker_related_config.xml /etc/datastore-server/config.d/
# System-log retention overlay — bounds every system.*_log table with a 3-day TTL
# and caps text_log at `warning`. 2026-07: system.text_log reached ~115 GB with
# no TTL and filled the data disk, taking down api.hanzo.ai. It ships in the
# image so a fresh instance is bounded before anyone remembers to configure it.
COPY --from=builder /src/docker/server/datastore-log-retention.xml /etc/datastore-server/config.d/
# Hanzo IAM user directory: callers authenticate with an IAM access token, no
# stored passwords. Baked with the binary that understands it, because an older
# binary refuses to start on an unknown user directory type.
COPY --from=builder /src/docker/server/datastore-iam.xml /etc/datastore-server/config.d/
# Its three bootstrap roles (superadmin, schema, platform) are users-config roles, so they exist
# before any SQL runs; the schema's own migrations create the rest.
COPY --from=builder /src/docker/server/datastore-iam-roles.xml /etc/datastore-server/users.d/
COPY --from=builder /src/docker/server/entrypoint.sh /entrypoint.sh
# ugo+Xrw, not chown: OpenShift and friends start the container as an arbitrary
# uid that is in no group we control, and it still has to write here.
RUN mkdir -p /var/lib/datastore /var/log/datastore-server /etc/datastore-server/config.d /etc/datastore-server/users.d /etc/datastore-client /docker-entrypoint-initdb.d \
&& chmod ugo+Xrw -R /var/lib/datastore /var/log/datastore-server /etc/datastore-server /etc/datastore-client \
&& chmod +x /entrypoint.sh
# Smoke the binary at build time. A server that cannot answer a trivial query is
# a broken image, and finding that out here costs one layer instead of one
# rollout. It also pins the version into the build log.
#
# The last two lines: a session that did not log in with an IAM token has no
# organization (currentOrg() is NULL, so `org = currentOrg()` policies show it
# nothing), and the baked config.d merges into a config that names the issuer.
RUN datastore-local -q 'SELECT version()' \
&& datastore-local -q 'SELECT count() > 0 FROM system.build_options' \
&& datastore-local -q 'SELECT throwIf(currentOrg() IS NOT NULL)' \
&& test "$(datastore-extract-from-config --config-file /etc/datastore-server/config.xml --key user_directories.iam.issuer)" = https://hanzo.id
# Start the server once on the image's own configuration. Every config.d and users.d file has to
# load, the IAM user directory has to be active with its bootstrap roles present, and a native
# login with something that is not a token has to be refused as an authentication failure (it is,
# before any key is fetched, so this needs no network). The server's data and logs are removed
# before the layer is committed, so the image carries none.
RUN datastore-server --config-file=/etc/datastore-server/config.xml > /tmp/smoke.log 2>&1 & server=$!; \
for i in $(seq 1 60); do datastore-client -q 'SELECT 1' > /dev/null 2>&1 && break; sleep 1; done; \
ok=1; \
test "$(datastore-client -q "SELECT type FROM system.user_directories WHERE type = 'iam'")" = iam || ok=0; \
test "$(datastore-client -q "SELECT arraySort(groupArray(name)) FROM system.roles WHERE name IN ('platform', 'schema', 'superadmin')")" = "['platform','schema','superadmin']" || ok=0; \
if datastore-client --jwt not.a.token -q 'SELECT 1' 2> /tmp/smoke.jwt; then ok=0; fi; \
grep -q AUTHENTICATION_FAILED /tmp/smoke.jwt || ok=0; \
kill "$server"; wait "$server"; \
if [ "$ok" != 1 ]; then cat /tmp/smoke.log /tmp/smoke.jwt; exit 1; fi; \
rm -rf /var/lib/datastore/* /var/log/datastore-server/* /tmp/smoke.log /tmp/smoke.jwt
# 8123 HTTP, 9000 native wire protocol, 9009 interserver replication.
EXPOSE 8123 9000 9009
VOLUME /var/lib/datastore
ENV LANG=en_US.UTF-8 \
TZ=UTC \
DATASTORE_CONFIG=/etc/datastore-server/config.xml
ENTRYPOINT ["/entrypoint.sh"]