LabBench Architecture
This document describes how LabBench is structured — the system it composes, the
domain model it encodes, the principles that constrain it, and the code that
implements the bench CLI today. For positioning, non-goals, and roadmap see
docs/README.md; for an operational quick-start see the root
README.md.
System context
LabBench is one component of Theseus, GitLab’s initiative to moduralize its production platform. LabBench does not reimplement platform domains; it composes them:
| Component | Role |
|---|---|
| LabKit | Foundational native libraries: logging, metrics, tracing, config |
| LabBench | Composition layer / service chassis that wires the rest together |
| Runway / Fairway | Artifact generation and delivery (Helm chart generation) |
| Caproni | Local Kubernetes development environment |
The three-layer domain model
Every domain LabBench composes has the same shape, and the chassis owns two of the three layers:
- Inbound — request handling, AuthZ, health checks, RED metrics.
- Business logic — the developer-owned handler. The only layer a domain author writes.
- Outbound — connection pooling, retry, circuit breaking.
Some of what the chassis is intended to own is still unimplemented. RED metrics are not emitted in either direction ( inbound, outbound), and circuit breaking has a schema but no implementation ( overload policy). Phase 1 scope is tracked under DM-4 inbound and DM-6 outbound.
flowchart LR
subgraph Domain["Lab Bench Domain (single SOA endpoint)"]
Inbound["<b>Inbound</b><br/>(LabKit with support from Runway)<br/><br/>mTLS · API+PubSub Routing · AuthN+Z<br/>Load Shedding · Config/Feature Flags · Health · Metrics"]
Logic["<b>Business Logic</b><br/>(Team Member implements + Static Analysis)<br/><br/>Developer-defined handlers + in-process services<br/>(cache, secrets, tracing, etc.)"]
Outbound["<b>Outbound</b><br/>(LabKit with support from Runway)<br/><br/>Connection Pooling · Outbound Backoff · Metrics<br/>HTTPS / gRPC / Native (Pg, ClickHouse)"]
Inbound --> Logic --> Outbound
end
style Domain fill:#c9dcf0,stroke:#333,stroke-width:2px
style Inbound fill:#f0997b,stroke:#333,stroke-width:1px,color:#4a1b0c
style Logic fill:#9fe1cb,stroke:#333,stroke-width:1px,color:#04342c
style Outbound fill:#f0997b,stroke:#333,stroke-width:1px,color:#4a1b0c
The chassis is language-matched: a Go chassis composes LabKit Go, a Rust chassis composes labkit-rs. The manifest and proto schema stay language-neutral; only the runtime chassis matches the domain’s language. There is no FFI for library features.
Inbound Layer
Supports HTTP, gRPC, and NATS interfaces. It decodes the request and calls the defined handler in the business logic. It handles authorization and metrics.
Business Logic Layer
This should contain only the pure business logic and is the responsibility of the developer. All Inbound and Outbound requirements are taken care of by the chassis.
Outbound Layer
Handles all backend requirements such as persistent storage and in-memory data stores.
The bench / assembly / domain model
The spec that bench reads and validates is a hierarchy of three entities: a
bench holds assemblies, and an assembly composes domains. Each
is defined in
the glossary, which also covers why
“service” is reserved for the deployed unit an assembly produces.
An assembly is a build-time composition, not a source location: the same domain can belong to several assemblies. This is why the assembly, not the bench, is the unit of deployment, and why the bench is the unit of source. See LB-D5: Assembly Runtime.
A repo usually holds one bench.yaml, which is what -C defaults to. A repo
with more than one bench names each file explicitly with a repeated -C
(-C a.bench.yaml -C b.bench.yaml). Only check reads the whole set; every
other command takes a single bench and rejects more than one -C, since
rendering or building several benches into one repo is not supported yet.
Checking the set validates two invariants that span it. Bench names must be
unique. Assemblies may be shared between benches, keyed on their stable id,
because an assembly is a build-time composition, not a source location: two
benches can compose the same assembly. A repeated assembly name is an error only
when it is not the same assembly, a different id, a diverging definition under
one id, or one id under two names. These findings surface only when the
files are checked together, since no single-bench command sees its peers.
Design principles
- Compose, don’t reimplement. Library features (logging, metrics, tracing, config, feature flags, mTLS, secrets, health checks) are native LabKit, per Theseus ADR 007. LabBench wires them in; it does not rebuild them.
- Language-matched chassis. A Go chassis composes LabKit Go; a Rust chassis composes labkit-rs. The manifest and proto schema stay language-neutral (protobuf); only the runtime chassis matches the domain’s language. No FFI for library features.
- Owning the “if”. LabBench’s value is the opinionated structure and defaults — removing open customization in favor of configuration — that turn a pile of good components into one paved path.
Code architecture
LabBench is a Cargo workspace (Cargo.toml) with two crates.
config — schema and validation (bench-config)
Schema. The protobuf definitions under
config/gitlab/bench/v1/(bench.proto,assembly.proto,domain.proto,inbound.proto,outbound.proto,handler.proto,common.proto) plus custom field options inconfig/gitlab/options/v1/options.proto. The schema is the source of truth; it is language-neutral so both Go and Rust chassis can consume it.Generated types. config/build.rs invokes
protoc(viaprost-build/pbjson-build) at build time to generate the Rust types. Nothing generated is checked in.Validation. config/src/lib.rs reads/writes the spec across formats (JSON, TOML, YAML, textproto) and validates it against the schema’s rules, returning structured
ValidationErrors.Reading and writing answer “can this config be loaded at all?” — they only parse and serialize, so an incomplete spec round-trips freely and
bench setcan build one field at a time.validate()answers the separate question “is this a complete, runnable spec?” and never runs on I/O. It runs when a command is about to consume the spec:check(the explicit gate, which CI runs with--strict), andscaffoldandbuildbefore they render or compile. Findings carry a severity — anerror(a missing required field or an impossible value, e.g. a port out of range or a listener on the reserved probe port) failscheck; awarningis advisory and only fails under--strict. See Decouple config validation from I/O.
cli — the bench binary (bench-cli)
cli/src/main.rs is a clap-based CLI built on top of the
config crate. You point it at a spec file — bench.yaml by default — and it
edits and validates that spec for you. The subcommands are what you’d expect:
init— create a new spec. Identity-creation commands reject names the schema would fault rather than writing a spec that only fails later.--groupseedscanonical_group, the owning GitLab group path thatscaffold,build, andregisterall read;--project-nameseedsproject_name, the repository namescaffoldandbuildshare, defaulting to the bench name.assembly create/domain create— add entities to it.set/get/show— read and mutate paths within the spec.check— validate the spec against the schema. Accepts multiple-Cto validate a set of benches together and check cross-bench name uniqueness.convert— round-trip the spec between formats (JSON, TOML, YAML, textproto).scaffold— render the spec into a working project via the copier template. The repo is named by the spec’sproject_name, defaulting to the bench name. After rendering the repo, itgit inits the repo root, trusts the rendered.mise.toml, and runsmise installfor the pinned toolchain, sobench buildworks next with no manual setup. That makes mise a requirement forscaffold. It doesn’t install pre-commit hooks; run the renderedscripts/prepare-dev-env.shfor those. SetBENCH_SKIP_PREPARE_DEV_ENVto skip the mise steps (the CLI smoke tests do).build— compile an assembly from its scaffolded sources and generate its deployment files. Two go to gitignored directories: the image manifest into the build tree, and the Helm chart Fairway renders intochart/<assembly>. Two are committed: the Fairway manifest at.runway/<assembly>/fairway.yamland a Caproni fragment atcaproni/fragments/caproni.<assembly>.yaml. The fragment deploys the chart just rendered, reached through the Caproni environment’s clone of the repository; the published chart rides along commented out, for deploying without building first. The owning GitLab group, which the clone URL and that published reference are both built from, comes from the spec’scanonical_group; with it unset the fragments are skipped. The repository is named by the spec’sproject_name, the same valuescaffoldrenders, defaulting the same way. Before compiling,buildscaffolds what’s missing: the repo skeleton when.copier-answers.ymlis absent, and a skeleton for each handler whose source directory is absent or empty. A directory holding other files but nolib.rs(Rust) or.gosource (Go) fails the build instead of being rendered into. Scaffolding needscanonical_groupset.buildalso compiles each gRPC endpoint’sproto_file(relative to the handler’s source root) and fails if the file doesn’t declare the endpoint’sservice_name.register— open an infra-mgmt MR registering the service with the Release Platform. A one-off imperative action, not persisted in the spec.--dry-runprints the branch, target file, and repos.yaml entry it would create, then exits without calling the GitLab API.--groupregisters under a different group than the spec’scanonical_group.
--version reports the binary’s own version, which init also stamps into the
spec’s bench_version marker. Only a release build carries a real version; any
other build reports 0.0.0-dev.
The crate is being pulled apart into layers so main.rs holds only clap wiring
and exit-code mapping (see
issue #12):
cli/src/cmd/*— one module per subcommand, each exposing a singlepub(crate)entry point thatmain.rsdispatches to. Moved so far:build,check,init,register,scaffold. The rest are still free functions inmain.rs.cli/src/build/*— the runtime-specific assembly builders (go,rust), plusdeploy(generates the deployment files above) andimage(packages the OCI image on--release).
Proto → Rust pipeline
config/gitlab/**/*.proto → build.rs (prost + pbjson) → generated Rust types
│ │
└── buf lint / buf breaking (CI gate) config/src/lib.rs (validation) → cliA schema change flows through buf lint (style) and buf breaking (contract,
against main) in CI before it can merge. See
docs/design/schema-change-process.md.
References
- Glossary — bench, assembly, domain, service
- Decision records — architectural decisions recorded as
LB-D<n>: - LabBench SOA Architecture (full decision index)