Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Keep the Docker build context small. The Rust build happens inside the
# builder stage; host build artifacts and node_modules must not be shipped.
#
# NOTE: do not exclude `python/` — it is a Cargo workspace member, so cargo
# needs its manifest present to resolve the workspace even when we only build
# the master/server crates.
target/
**/node_modules/
crates/lance-context-master/ui/dist/
.git/
.github/
examples/
docs/
specs/
deploy/
.claude/
70 changes: 70 additions & 0 deletions test/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# syntax=docker/dockerfile:1

# Single multi-stage build for the lance-context test environment.
#
# Stages:
# ui -> builds the master's React/Vite admin UI into static assets
# builder -> compiles the Rust workspace binaries (master + server)
# master -> runtime image for lance-context-master (control-plane), bundles UI
# worker -> runtime image for lance-context-server (data-plane)
#
# Select a target with `--target master` / `--target worker`, which is what the
# accompanying docker-compose.yml does. Built once, the shared `builder` stage
# is cached across both runtime images.

# ---------------------------------------------------------------------------
# UI assets (only needed by the master image)
# ---------------------------------------------------------------------------
FROM node:20-bookworm-slim AS ui
WORKDIR /ui
COPY crates/lance-context-master/ui/package.json crates/lance-context-master/ui/package-lock.json ./
RUN npm ci
COPY crates/lance-context-master/ui/ ./
RUN npm run build
# Vite emits to ./dist

# ---------------------------------------------------------------------------
# Rust workspace build
# ---------------------------------------------------------------------------
FROM rust:1-bookworm AS builder
# Native deps mirror what CI installs: protoc for the lance protobufs, openssl
# for etcd TLS + object-store, make/perl for the openssl-sys vendored build.
RUN apt-get update && apt-get install -y --no-install-recommends \
protobuf-compiler libssl-dev pkg-config make perl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
COPY . .
# Build only the two service binaries we ship (skip python/examples).
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/build/target \
cargo build --release \
-p lance-context-master \
-p lance-context-server \
&& mkdir -p /out \
&& cp target/release/lance-context-master /out/ \
&& cp target/release/lance-context-server /out/

# ---------------------------------------------------------------------------
# Master runtime (control-plane + UI)
# ---------------------------------------------------------------------------
FROM debian:bookworm-slim AS master
RUN apt-get update && apt-get install -y --no-install-recommends \
libssl3 ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /out/lance-context-master /usr/local/bin/lance-context-master
COPY --from=ui /ui/dist /app/ui
ENV UI_DIR=/app/ui
ENV MASTER_PORT=8090
EXPOSE 8090
ENTRYPOINT ["lance-context-master"]

# ---------------------------------------------------------------------------
# Worker runtime (data-plane)
# ---------------------------------------------------------------------------
FROM debian:bookworm-slim AS worker
RUN apt-get update && apt-get install -y --no-install-recommends \
libssl3 ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /out/lance-context-server /usr/local/bin/lance-context-server
EXPOSE 3000
ENTRYPOINT ["lance-context-server"]
156 changes: 156 additions & 0 deletions test/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
# lance-context test environment
#
# A full control-plane + data-plane stack an AI agent (or human) can bring up to
# exercise the real HTTP surface end to end — including the record-list source
# selector (?source=fragments|wal|all) that today is only covered by #[ignore]d
# Rust tests because it needs a live etcd + object store.
#
# Topology:
# minio S3-compatible object store, backs the shared DATA_DIR
# minio-init one-shot: creates the bucket
# etcd durable scheduler queue / locks for the master
# worker-0 data-plane writer (owns MemWAL shard "worker-0"), port 3001
# worker-1 data-plane writer (owns MemWAL shard "worker-1"), port 3002
# master control-plane admin API + UI, port 8090
#
# All services share DATA_DIR=s3://lance-context on MinIO. Workers write rollout
# datasets there; the master discovers + reads them.
#
# Bring up: docker compose -f test/docker-compose.yml up --build --wait
# Smoke test: test/harness/smoke.sh
# Tear down: docker compose -f test/docker-compose.yml down -v

name: lance-context-test

x-s3-env: &s3-env
# lance object-store -> MinIO wiring
AWS_ACCESS_KEY_ID: minioadmin
AWS_SECRET_ACCESS_KEY: minioadmin
AWS_DEFAULT_REGION: us-east-1
AWS_ENDPOINT: http://minio:9000
AWS_ALLOW_HTTP: "true"
DATA_DIR: s3://lance-context

services:
minio:
image: minio/minio:latest
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
ports:
- "9000:9000"
- "9001:9001"
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:9000/minio/health/live"]
interval: 3s
timeout: 2s
retries: 20
volumes:
- minio-data:/data

minio-init:
image: minio/mc:latest
depends_on:
minio:
condition: service_healthy
entrypoint: >
/bin/sh -c "
mc alias set local http://minio:9000 minioadmin minioadmin &&
mc mb --ignore-existing local/lance-context &&
echo 'bucket ready'
"

etcd:
image: quay.io/coreos/etcd:v3.5.13
command:
- etcd
- --name=etcd0
- --data-dir=/etcd-data
- --advertise-client-urls=http://etcd:2379
- --listen-client-urls=http://0.0.0.0:2379
ports:
- "2379:2379"
healthcheck:
test: ["CMD", "etcdctl", "endpoint", "health"]
interval: 3s
timeout: 2s
retries: 20
volumes:
- etcd-data:/etcd-data

worker-0:
build:
context: ..
dockerfile: test/Dockerfile
target: worker
depends_on:
minio-init:
condition: service_completed_successfully
environment:
<<: *s3-env
INSTANCE_ID: worker-0
# Keep WAL un-merged by default so the ?source=wal path has data to show.
ROLLOUT_MERGE_AFTER_GENERATIONS: "0"
command: ["--host", "0.0.0.0", "--port", "3000", "--data-dir", "s3://lance-context"]
ports:
- "3001:3000"
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:3000/api/v1/health"]
interval: 3s
timeout: 2s
retries: 20

worker-1:
build:
context: ..
dockerfile: test/Dockerfile
target: worker
depends_on:
minio-init:
condition: service_completed_successfully
environment:
<<: *s3-env
INSTANCE_ID: worker-1
ROLLOUT_MERGE_AFTER_GENERATIONS: "0"
command: ["--host", "0.0.0.0", "--port", "3000", "--data-dir", "s3://lance-context"]
ports:
- "3002:3000"
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:3000/api/v1/health"]
interval: 3s
timeout: 2s
retries: 20

master:
build:
context: ..
dockerfile: test/Dockerfile
target: master
depends_on:
etcd:
condition: service_healthy
minio-init:
condition: service_completed_successfully
environment:
<<: *s3-env
ETCD_ENDPOINTS: http://etcd:2379
WORKER_ENDPOINTS: http://worker-0:3000,http://worker-1:3000
MASTER_HOST: 0.0.0.0
MASTER_PORT: "8090"
UI_DIR: /app/ui
# Fast sweeps so agents see merge/compaction react quickly in tests.
STATS_SCAN_INTERVAL_SECS: "10"
MERGE_WAL_INTERVAL_SECS: "0"
COMPACTION_INTERVAL_SECS: "0"
ports:
- "8090:8090"
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:8090/metrics"]
interval: 3s
timeout: 2s
retries: 30

volumes:
minio-data:
etcd-data:
109 changes: 109 additions & 0 deletions test/harness/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# lance-context test environment + harness

A Docker Compose stack that brings up the **full control-plane + data-plane** so an
AI agent (or human) can exercise the real HTTP surface end to end — the surface that
today is only covered by `#[ignore]`d Rust tests because they need a live etcd and
object store.

## Why this exists

The master's record-list endpoint (and its `?source=fragments|wal|all` selector shipped
in PR #170) can only be integration-tested against a running etcd + object store. Those
tests are marked `#[ignore]` and never run in CI, so the HTTP layer and UI had no
automated coverage. This harness closes that gap: `smoke.sh` makes real HTTP calls and
asserts the source-selector semantics.

## Topology

| Service | Role | Host port |
|------------|----------------------------------------|-----------|
| `minio` | S3-compatible object store (DATA_DIR) | 9000 (API), 9001 (console) |
| `etcd` | master scheduler queue / locks | 2379 |
| `worker-0` | data-plane writer, MemWAL shard `worker-0` | 3001 |
| `worker-1` | data-plane writer, MemWAL shard `worker-1` | 3002 |
| `master` | control-plane admin API + UI | 8090 |

All services share `DATA_DIR=s3://lance-context` on MinIO. Workers write rollout
datasets there; the master discovers and reads them. MemWAL self-merge is disabled
(`ROLLOUT_MERGE_AFTER_GENERATIONS=0`) so appended rows stay **pending in the WAL** —
which is exactly what makes `?source=fragments` vs `wal` vs `all` observably different.

## Prerequisites

- Docker with Compose v2 (`docker compose`)
- `curl` and `jq` on the host (for `smoke.sh`)

## Usage

```bash
# Build images and bring the stack up (waits for all healthchecks):
test/harness/up.sh

# Run the end-to-end smoke test (asserts source-selector semantics):
test/harness/smoke.sh

# Tear down (removes volumes for a clean slate):
test/harness/down.sh
```

### Containerless mode (sandboxes without a working Docker runtime)

Some environments have a Docker daemon but a locked-down kernel that forbids
`unshare`/netlink, so no container can actually start (`docker run` fails with
`failed to register layer: unshare: operation not permitted`). For those, the
same stack runs as **plain host processes** — etcd (static binary, auto-downloaded),
`lance-context-server`, and `lance-context-master`, with a local-filesystem
`DATA_DIR` instead of MinIO:

```bash
test/harness/native-up.sh --smoke # build if needed, start stack, run smoke.sh
test/harness/native-down.sh --purge # stop everything + wipe state
```

State lives under `$HARNESS_DIR` (default `/tmp/lance-harness`). This mode needs
`cargo` (to build the two binaries) and network access to fetch the etcd release
on first run. It asserts the exact same `?source=` semantics as the Docker path.

`up.sh --no-build` skips the image build if images already exist.
`down.sh --keep-volumes` retains MinIO + etcd data across runs.

## What `smoke.sh` asserts

1. Create a rollout store on `worker-0` and append 3 records (they land in MemWAL,
un-merged).
2. Wait for the master to discover the experiment.
3. `GET .../records?source=fragments` → **0** rows (base table only, nothing merged yet).
4. `GET .../records?source=wal` → **3** rows (the pending generations).
5. `GET .../records?source=all` → **3** rows (base ∪ WAL union).
6. Response JSON echoes the resolved `source`.
7. No `source` param defaults to `fragments`.
8. An unknown `source` value returns HTTP `400`.

## Manual poking (for agents)

```bash
# Create a store + append on a worker:
curl -X POST localhost:3001/api/v1/rollouts \
-H 'Content-Type: application/json' -d '{"name":"exp1"}'
curl -X POST localhost:3001/api/v1/rollouts/exp1/records \
-H 'Content-Type: application/json' \
-d '{"records":[{"id":"a","rollout_id":"r","role":"assistant","content":"hi"}]}'

# Browse via the master under each source:
curl 'localhost:8090/api/v1/experiments/exp1/records?source=fragments' | jq
curl 'localhost:8090/api/v1/experiments/exp1/records?source=wal' | jq
curl 'localhost:8090/api/v1/experiments/exp1/records?source=all' | jq

# List experiments / open the UI:
curl localhost:8090/api/v1/experiments | jq
open http://localhost:8090 # admin UI (Fragments / WAL / All tabs)
```

## Notes / limitations

- The images build the Rust workspace from source (protoc + openssl), so the first
`up.sh` is slow (a full release build). Subsequent runs reuse the BuildKit cache.
- The master's WAL-merge and compaction sweeps are disabled in this stack
(`MERGE_WAL_INTERVAL_SECS=0`, `COMPACTION_INTERVAL_SECS=0`) so WAL rows stay pending
and the source split is deterministic. Enqueue merges/compactions manually via the
`/api/v1/tasks` endpoint if you want to test the scheduler.
20 changes: 20 additions & 0 deletions test/harness/down.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Tear down the lance-context test environment.
#
# Usage: test/harness/down.sh [--keep-volumes]
#
# By default removes containers AND volumes (fresh state next `up`). Pass
# --keep-volumes to retain the MinIO bucket + etcd data across runs.
set -euo pipefail

HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
COMPOSE_FILE="${HERE}/../docker-compose.yml"

VOL_FLAG="-v"
if [[ "${1:-}" == "--keep-volumes" ]]; then
VOL_FLAG=""
fi

echo ">> tearing down lance-context test stack"
# shellcheck disable=SC2086
docker compose -f "${COMPOSE_FILE}" down ${VOL_FLAG}
Loading