The nixos/nix build stage only knew cache.nixos.org, so every image build compiled the full logoscore CLI closure from source (~14 min in delivery-module CI). Both Logos caches are anonymously readable and already hold the closure, so wire them in as substituters. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Docker smoke tests for logoscore
These tests are opt-in: they spawn real docker containers running the logoscore daemon and drive them via the Python wrapper from the host. Without docker installed they skip cleanly; the rest of the suite stays green on CI runners that don't have docker available.
The image is a reusable CLI runtime
The logoscore:smoke-* image contains only the logoscore CLI plus
the modules the CLI itself ships with (capability_module,
package_manager_module). It does NOT bake in test_fullapi_cpp or
any other user module. User modules — the ones you're writing and
testing — are bind-mounted in at runtime.
Preferred: LogoscoreDockerDaemon
If you're testing a module from Python, use the helper that ships with
logoscore-py. It encapsulates the container lifecycle (volume mounts,
port wiring, client construction) so your tests don't have to:
from logoscore import LogoscoreDockerDaemon
with LogoscoreDockerDaemon(
image="logoscore:smoke-portable",
modules_dir="./my-module/result/modules", # host path
) as daemon:
client = daemon.client(binary="logoscore")
client.load_module("my_module")
print(client.call("my_module", "do_something", 42))
The helper picks a free host port, bind-mounts everything the daemon
needs (/config, /persistence, /user-modules), starts the
container, waits for state.json, and returns a LogoscoreClient
configured to dial the right port. Optional knobs: host_port=... to
pin a port, persistence_dir=... to restore a pre-seeded session,
codec="cbor" to pick a wire codec, extra_module_dirs=[...] /
extra_args=[...] to extend the daemon invocation.
Equivalent raw docker run
For reference / non-Python callers the same thing as a shell invocation:
docker run --rm -p 6000:6000 \
-v "$PWD/config":/config \
-v "$PWD/persistence":/persistence \
-v "$PWD/my-modules-install/modules":/user-modules:ro \
logoscore:smoke-portable \
daemon --config-dir /config \
--persistence-path /persistence \
--transport tcp --tcp-host 0.0.0.0 --tcp-port 6000 \
-m /opt/logoscore/modules \
-m /user-modules
The three mounts each have a specific purpose:
| Host dir | Container path | Read/write | Why |
|---|---|---|---|
./config |
/config |
rw | Daemon writes state.json here; your client reads it to discover host/port/instance_id. |
./persistence |
/persistence |
rw | Module state (--persistence-path). Pre-seed to restore a session; read back to inspect it. |
./my-modules/modules |
/user-modules |
ro | Compiled Qt plugins loaded via -m. Read-only because the daemon never mutates these. |
Port strategy
The container binds 6000 (core_service) and 6001
(capability_module) internally; the host maps a dynamically-picked
ephemeral port to each (-p $host_core:6000 -p $host_cap:6001). The
client dials those forwarded host ports from a per-module
client/config.json (written via LogoscoreClient.write_config) — one
entry per module, each with its own port — rather than the
container-internal ports the daemon wrote into its state.json. A single
tcp_port env override won't do here: the CLI applies it to every module
uniformly, which would collapse capability_module onto core_service's
port. Same pattern as
status-go tests-functional.
Result: parallel container-backed tests don't fight over port 6000 on the host, and you don't need to know which ports are free before you start.
Flavors
Two build flavors, to match how the daemon gets distributed:
| Flavor | Flake attr | Binary | Modules the user mounts in | Image size |
|---|---|---|---|---|
portable |
.#dockerBundlePortable |
…cli-bundle-dir (self-contained) |
.install-portable |
~600 MB |
dev |
.#dockerBundle |
logos-logoscore-cli.packages.…cli |
.install (nix-store rpaths) |
~3 GB |
portable is the default — it's the self-contained
bin/ + lib/ + modules/ tree that matches how released logoscore
binaries are distributed, so it's the most realistic smoke. dev
links against Qt/Boost/OpenSSL via nix-store rpaths (what the
logoscore-py dev shell itself uses) and is faster to iterate on when
you already have the nix cache warm, but requires copying /nix/store
into the image at build time.
The user-mounted modules must match the image flavor: .install
modules (rpath-linked into /nix/store) only work in the dev image
because its /nix/store is present; .install-portable modules
(self-contained shared-lib bundles) work in the portable image.
The smoke test driver picks the right one via
LOGOSCORE_TEST_MODULES_DIR (dev) or
LOGOSCORE_TEST_MODULES_DIR_PORTABLE (portable), both set by the
nix develop shell.
Setup
# Build one flavor (default: portable)
./build_smoke_image.sh
FLAVOR=dev ./build_smoke_image.sh
FLAVOR=both ./build_smoke_image.sh # builds both
# Run the suite (default: portable)
pytest tests/docker_smoke
pytest tests/docker_smoke --docker-flavor=dev
pytest tests/docker_smoke --docker-flavor=both # replays matrix twice
Tag convention: logoscore:smoke-dev / logoscore:smoke-portable.
Override with LOGOSCORE_DOCKER_IMAGE_FMT='myimg:{flavor}' if you
publish elsewhere.
The image is built by docker build from a multi-stage Dockerfile
whose first stage runs nix build inside a nixos/nix Linux
container. Because everything happens inside Docker, the host never
needs to cross-compile — Docker Desktop on macOS uses its native Linux
VM (linux/arm64 on Apple Silicon), same pattern as
status-go.
Build context: only the logos-logoscore-py repo. The flake pulls
logos-logoscore-cli and logos-test-modules from github at the
revisions this repo's flake.nix / flake.lock references. To
iterate on unpublished CLI changes, push them to a branch and bump
logos-logoscore-cli.url in flake.nix:
logos-logoscore-cli.url = "github:<you>/logos-logoscore-cli/<branch>";
then rebuild the image:
./tests/docker_smoke/build_smoke_image.sh
First build takes a few minutes while nix populates its store in the builder layer; subsequent builds are incremental thanks to Docker's layer cache and nix's content-addressed store.
What's covered
-
Every echo method on
test_fullapi_cppreplayed through the full wire stack — runs the matrix twice, once with--tcp-codec=jsonand once with--tcp-codec=cbor, so both codecs see every parameter / return type:tstr,bstr(canonical{"_bytes"}tag),int,uint,float64,bool,any, the typed arrays ([tstr]/[int]/[uint]/[float64]/[bool]),[any](LogosList),{tstr:any}(LogosMap),result, andvoid. -
Every typed event — one per event-legal type (
stringEvent,bytesEvent,intEvent, …mapEvent), fired via the module'sfire<X>Event(v)triggers, under each codec. Also validates thelogoscore watchsubprocess plumbing end-to-end over TCP. -
Two independent daemons running in two separate containers on two host ports, driven from one Python test. Confirms:
- distinct instance_ids in each container's
state.json - a module loaded on A isn't visible to B
- a call to A succeeds; the same call to B fails because the module isn't loaded there That covers the "one test talks to two daemons" need without needing a real multi-host setup.
- distinct instance_ids in each container's
-
Legacy smoke (
test_docker_tcp_status,test_docker_tcp_load_and_call) — kept as a minimal fallback. Useful when the matrix fixtures skip for environmental reasons; always worth running on top of anything else. -
TLS smoke (
test_docker_ssl_smoke.py) — one daemon inside docker listening ontcp_sslwith a self-signed cert generated at fixture time (viaopenssl req) and bind-mounted into the container. The host-side client dials over TLS with--no-verify-peer(because self-signed) and runs two scenarios: astatusround-trip (with arpc_error not in responseassertion so it doesn't false-positive on the fallback-to-state.json path when the RPC actually fails) and a load-module + method-call round-trip (proves the full payload path — method args → TLS → RPC → return → TLS → client).
Per-test skips show exactly which of (json, cbor, two-daemon, ssl)
you're missing when docker or openssl isn't present.
Building your module for the container
The daemon image is Linux. Your module's compiled plugin needs to be a
Linux .so with a glibc/Qt/OpenSSL ABI compatible with the image's
runtime. Modules built on macOS (dylibs) won't load; modules built on
Linux with a different glibc usually won't either.
Use the helper that builds inside the same nixos/nix base the daemon image was compiled in, guaranteeing ABI compatibility regardless of host OS:
from logoscore import LogoscoreDockerDaemon, build_modules_in_docker
modules_dir = build_modules_in_docker(
builds=[
# Each entry is (flake_ref, attr). ALL builds share one container
# run / one nix store, so common deps (logos-cpp-sdk, Qt, boost,
# openssl) get fetched once. Adding a second module costs only
# its own compile time, not another full closure download.
("github:user/my-module", "packages.x86_64-linux.install-portable"),
("github:user/my-module2", "packages.x86_64-linux.install-portable"),
],
output_dir="./build/modules",
)
with LogoscoreDockerDaemon(
image="logoscore:smoke-portable",
modules_dir=modules_dir,
) as daemon:
client = daemon.client(binary="logoscore")
client.load_module("my_module")
print(client.call("my_module", "do_something", 42))
Or via the shell wrapper at tests/docker_smoke/build_modules_in_docker.sh:
./tests/docker_smoke/build_modules_in_docker.sh ./build/modules \
'github:user/my-module#packages.x86_64-linux.install-portable' \
'github:user/my-module2#packages.x86_64-linux.install-portable'
Each attr must point at a derivation whose output contains a
modules/<name>/<plugin>.so + manifest.json tree. The standard
logos-module-builder .install-portable output produces exactly this.
Using LogoscoreDockerDaemon for TLS directly
from logoscore import LogoscoreDockerDaemon
with LogoscoreDockerDaemon(
image="logoscore:smoke-portable",
modules_dir="./my-modules/modules",
transport="tcp_ssl",
ssl_cert=Path("./cert.pem"), # host paths — mounted as /certs/*.pem
ssl_key=Path("./key.pem"),
) as daemon:
client = daemon.client(binary="logoscore") # auto-passes --no-verify-peer
client.status()
The helper bind-mounts the two PEM files as individual files into
/certs/cert.pem and /certs/key.pem (so they can live in different
host dirs) and flags the daemon with --transport=tcp_ssl --tcp-ssl-port=<internal> --ssl-cert=... --ssl-key=.... The daemon
itself always binds an additional LocalSocket listener for each module
on top of whatever the operator named, so module-to-module traffic
inside the daemon process group still uses the local socket — no
explicit local flag needed (and module-host subprocesses don't try
to bind a TLS listener with no cert).