Skip to content
Architecture

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:

ComponentRole
LabKitFoundational native libraries: logging, metrics, tracing, config
LabBenchComposition layer / service chassis that wires the rest together
Runway / FairwayArtifact generation and delivery (Helm chart generation)
CaproniLocal 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 in config/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 (via prost-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 set can 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), and scaffold and build before they render or compile. Findings carry a severity — an error (a missing required field or an impossible value, e.g. a port out of range or a listener on the reserved probe port) fails check; a warning is 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. --group seeds canonical_group, the owning GitLab group path that scaffold, build, and register all read; --project-name seeds project_name, the repository name scaffold and build share, 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 -C to 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’s project_name, defaulting to the bench name. After rendering the repo, it git inits the repo root, trusts the rendered .mise.toml, and runs mise install for the pinned toolchain, so bench build works next with no manual setup. That makes mise a requirement for scaffold. It doesn’t install pre-commit hooks; run the rendered scripts/prepare-dev-env.sh for those. Set BENCH_SKIP_PREPARE_DEV_ENV to 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 into chart/<assembly>. Two are committed: the Fairway manifest at .runway/<assembly>/fairway.yaml and a Caproni fragment at caproni/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’s canonical_group; with it unset the fragments are skipped. The repository is named by the spec’s project_name, the same value scaffold renders, defaulting the same way. Before compiling, build scaffolds what’s missing: the repo skeleton when .copier-answers.yml is absent, and a skeleton for each handler whose source directory is absent or empty. A directory holding other files but no lib.rs (Rust) or .go source (Go) fails the build instead of being rendered into. Scaffolding needs canonical_group set. build also compiles each gRPC endpoint’s proto_file (relative to the handler’s source root) and fails if the file doesn’t declare the endpoint’s service_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-run prints the branch, target file, and repos.yaml entry it would create, then exits without calling the GitLab API. --group registers under a different group than the spec’s canonical_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 single pub(crate) entry point that main.rs dispatches to. Moved so far: build, check, init, register, scaffold. The rest are still free functions in main.rs.
  • cli/src/build/* — the runtime-specific assembly builders ( go, rust), plus deploy (generates the deployment files above) and image (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)  →  cli

A 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

Last updated on