Skip to content

Commit 09a33de

Browse files
Frandoclaude
andauthored
feat!: replace LabOpts with Lab::builder() pattern (#31)
Replaces LabOpts with a LabBuilder accessed via Lab::builder(). Lab::new() remains as sugar for Lab::builder().build(). TOML config loading moves to LabBuilder::config() which stores the config and applies it during build. ```rust // Simple let lab = Lab::new().await?; // With options let lab = Lab::builder() .outdir(OutDir::Exact(path)) .label("my-test") .build().await?; // From TOML config let lab = Lab::builder() .outdir_from_env() .config(parsed_toml) .build().await?; ``` ## Breaking changes * Removed: `LabOpts`. Use `Lab::builder()` instead. * Removed: `Lab::with_opts(opts)`. Use `Lab::builder().build()` with the same chain methods. * Removed: `Lab::from_config(cfg)`. Use `Lab::builder().config(cfg).build()`. * Removed: `Lab::from_config_with_builder(cfg, builder)`. Use `Lab::builder().config(cfg).build()`. * Added: `LabBuilder` with all the methods from LabOpts plus `.config()` and `.build()`. * Added: `Lab::builder()` constructor. Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
1 parent e001d5c commit 09a33de

9 files changed

Lines changed: 97 additions & 69 deletions

File tree

patchbay-cli/src/native.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,9 @@ pub async fn inspect_command(input: PathBuf, work_dir: PathBuf) -> Result<()> {
121121
check_caps()?;
122122

123123
let (topo, is_sim) = load_topology_for_inspect(&input)?;
124-
let lab = patchbay_runner::Lab::from_config(topo.clone())
124+
let lab = patchbay_runner::Lab::builder()
125+
.config(topo.clone())
126+
.build()
125127
.await
126128
.with_context(|| format!("build lab config from {}", input.display()))?;
127129

patchbay-cli/tests/fixtures/counter/tests/counter.rs

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,11 @@ fn init() {
1414
#[tokio::test(flavor = "current_thread")]
1515
async fn udp_counter() -> anyhow::Result<()> {
1616
let outdir = testdir::testdir!();
17-
let lab = patchbay::Lab::with_opts(
18-
patchbay::LabOpts::default()
19-
.outdir(patchbay::OutDir::Nested(outdir))
20-
.label("udp-counter"),
21-
)
22-
.await?;
17+
let lab = patchbay::Lab::builder()
18+
.outdir(patchbay::OutDir::Nested(outdir))
19+
.label("udp-counter")
20+
.build()
21+
.await?;
2322
let dc = lab.add_router("dc").build().await?;
2423
let sender = lab
2524
.add_device("sender")

patchbay-runner/src/sim/runner.rs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use std::{
88
};
99

1010
use anyhow::{anyhow, bail, Context, Result};
11-
use patchbay::{config::LabConfig, Lab, LabOpts};
11+
use patchbay::{config::LabConfig, Lab};
1212
use patchbay_utils::assets::{
1313
parse_binary_overrides, resolve_binary_source_path, BinaryOverride, PathResolveMode,
1414
};
@@ -810,10 +810,11 @@ async fn execute_single_sim(
810810
let setup = setup_topology_summary(&setup_base, Some(&topo));
811811

812812
// ── Build lab ────────────────────────────────────────────────────────
813-
let opts = LabOpts::default()
813+
let lab = Lab::builder()
814814
.outdir(patchbay::OutDir::Exact(run_work_dir.to_path_buf()))
815-
.label(sim_name);
816-
let lab = Lab::from_config_with_opts(topo, opts)
815+
.label(sim_name)
816+
.config(topo)
817+
.build()
817818
.await
818819
.context("step=configure-lab")?;
819820

patchbay/src/lab.rs

Lines changed: 59 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
//! High-level lab API: [`Lab`], [`LabOpts`], [`Ix`], topology types.
1+
//! High-level lab API: [`Lab`], [`LabBuilder`], [`Ix`], topology types.
22
33
use std::{
44
collections::HashMap,
@@ -20,6 +20,7 @@ use tracing::{debug, debug_span};
2020

2121
pub use crate::qdisc::LinkLimits;
2222
use crate::{
23+
config::LabConfig,
2324
core::{
2425
self, CoreConfig, DeviceData, DownstreamPool, NetworkCore, NodeId, RouterData,
2526
RA_DEFAULT_ENABLED, RA_DEFAULT_INTERVAL_SECS, RA_DEFAULT_LIFETIME_SECS,
@@ -432,7 +433,7 @@ impl LabInner {
432433
/// A virtual network lab running in Linux network namespaces.
433434
///
434435
/// `Lab` is cheaply cloneable. All methods take `&self` and use interior
435-
/// mutability. Use [`Lab::new`] or [`Lab::with_opts`] to create a lab,
436+
/// mutability. Use [`Lab::new`] or [`Lab::builder()`] to create a lab,
436437
/// then build routers and devices with [`Lab::add_router`] and
437438
/// [`Lab::add_device`].
438439
///
@@ -473,32 +474,33 @@ pub struct Lab {
473474
pub(crate) inner: Arc<LabDropGuard>,
474475
}
475476

476-
/// Options for constructing a [`Lab`].
477+
/// Builder for constructing a [`Lab`] with custom configuration.
477478
///
478-
/// Use the builder methods to configure output directory and label, then pass
479-
/// to [`Lab::with_opts`].
479+
/// Use [`Lab::builder()`] to obtain a `LabBuilder`, configure output directory
480+
/// and label with the builder methods, then call [`.build()`](LabBuilder::build)
481+
/// to create the lab.
480482
///
481483
/// # Example
482484
/// ```no_run
483-
/// # use patchbay::{Lab, LabOpts, OutDir};
485+
/// # use patchbay::{Lab, LabBuilder, OutDir};
484486
/// # #[tokio::main(flavor = "current_thread")]
485487
/// # async fn main() -> anyhow::Result<()> {
486-
/// let lab = Lab::with_opts(
487-
/// LabOpts::default()
488-
/// .outdir(OutDir::Nested("/tmp/patchbay-out".into()))
489-
/// .label("my-test"),
490-
/// )
491-
/// .await?;
488+
/// let lab = Lab::builder()
489+
/// .outdir(OutDir::Nested("/tmp/patchbay-out".into()))
490+
/// .label("my-test")
491+
/// .build()
492+
/// .await?;
492493
/// # Ok(())
493494
/// # }
494495
/// ```
495496
#[derive(Default)]
496-
pub struct LabOpts {
497+
pub struct LabBuilder {
497498
outdir: Option<OutDir>,
498499
label: Option<String>,
499500
ipv6_dad_mode: Ipv6DadMode,
500501
ipv6_provisioning_mode: Ipv6ProvisioningMode,
501502
allow_real_root: bool,
503+
config: Option<LabConfig>,
502504
}
503505

504506
/// Where the lab writes event logs and state files.
@@ -563,7 +565,7 @@ impl Ipv6Profile {
563565
}
564566
}
565567

566-
impl LabOpts {
568+
impl LabBuilder {
567569
/// Sets the output directory for event log and state files.
568570
pub fn outdir(mut self, outdir: OutDir) -> Self {
569571
self.outdir = Some(outdir);
@@ -608,13 +610,30 @@ impl LabOpts {
608610
self
609611
}
610612

613+
/// Loads a TOML lab configuration. The config is applied during
614+
/// [`build`](Self::build), creating all routers and devices defined
615+
/// in the file. Builder options (outdir, label, etc.) take precedence
616+
/// over config defaults.
617+
pub fn config(mut self, cfg: LabConfig) -> Self {
618+
self.config = Some(cfg);
619+
self
620+
}
621+
611622
/// Applies a deployment profile that sets both DAD and v6 provisioning mode.
612623
pub fn ipv6_profile(mut self, profile: Ipv6Profile) -> Self {
613624
let (dad, provisioning) = profile.modes();
614625
self.ipv6_dad_mode = dad;
615626
self.ipv6_provisioning_mode = provisioning;
616627
self
617628
}
629+
630+
/// Builds the [`Lab`] with the configured options.
631+
///
632+
/// If a TOML [`config`](Self::config) was set, all routers and devices
633+
/// defined in the config are created during build.
634+
pub async fn build(self) -> Result<Lab> {
635+
Lab::build(self).await
636+
}
618637
}
619638

620639
impl Lab {
@@ -643,26 +662,32 @@ impl Lab {
643662
anyhow::bail!(
644663
"refusing to run as real root (not inside a user namespace). \
645664
Call patchbay::init_userns() before creating a Lab, or use \
646-
LabOpts::default().allow_real_root() to bypass this check."
665+
Lab::builder().allow_real_root() to bypass this check."
647666
);
648667
}
649668
}
650669
Ok(())
651670
}
652671

672+
/// Returns a [`LabBuilder`] for configuring and constructing a [`Lab`].
673+
pub fn builder() -> LabBuilder {
674+
LabBuilder::default()
675+
}
676+
653677
/// Creates a new lab with default address ranges and IX settings.
654678
///
655679
/// Reads `PATCHBAY_OUTDIR` from the environment for event output.
656-
/// Use [`Lab::with_opts`] for explicit configuration.
680+
/// Use [`Lab::builder()`] for explicit configuration.
657681
pub async fn new() -> Result<Self> {
658-
Self::with_opts(LabOpts::default().outdir_from_env()).await
682+
Self::builder().outdir_from_env().build().await
659683
}
660684

661-
/// Creates a new lab with the given options.
662-
pub async fn with_opts(opts: LabOpts) -> Result<Self> {
685+
/// Internal constructor used by [`LabBuilder::build`].
686+
async fn build(mut opts: LabBuilder) -> Result<Self> {
663687
if !opts.allow_real_root {
664688
Self::refuse_real_root()?;
665689
}
690+
let config = opts.config.take();
666691
let pid = std::process::id();
667692
let pid_tag = pid % 9999 + 1;
668693
let lab_seq = LAB_COUNTER.fetch_add(1, Ordering::Relaxed);
@@ -778,6 +803,11 @@ impl Lab {
778803
gw_v6: cfg.ix_gw_v6,
779804
});
780805

806+
// Apply TOML config if provided.
807+
if let Some(cfg) = config {
808+
lab.apply_config(cfg).await?;
809+
}
810+
781811
Ok(lab)
782812
}
783813

@@ -822,22 +852,13 @@ impl Lab {
822852
/// Parses `lab.toml`, builds the network, and returns a ready-to-use lab.
823853
pub async fn load(path: impl AsRef<Path>) -> Result<Self> {
824854
let text = std::fs::read_to_string(path).context("read lab config")?;
825-
let cfg: crate::config::LabConfig = toml::from_str(&text).context("parse lab config")?;
826-
Self::from_config(cfg).await
827-
}
828-
829-
/// Builds a `Lab` from a parsed config, creating all namespaces and links.
830-
pub async fn from_config(cfg: crate::config::LabConfig) -> Result<Self> {
831-
Self::from_config_with_opts(cfg, LabOpts::default().outdir_from_env()).await
855+
let cfg: LabConfig = toml::from_str(&text).context("parse lab config")?;
856+
Self::builder().outdir_from_env().config(cfg).build().await
832857
}
833858

834-
/// Builds a `Lab` from a parsed config with explicit options.
835-
pub async fn from_config_with_opts(
836-
cfg: crate::config::LabConfig,
837-
opts: LabOpts,
838-
) -> Result<Self> {
839-
let lab = Self::with_opts(opts).await?;
840-
859+
/// Applies a parsed TOML config to an already-built lab, creating all
860+
/// routers and devices defined in the config.
861+
async fn apply_config(&self, cfg: LabConfig) -> Result<()> {
841862
// Region latency pairs from TOML config are ignored in the new region API.
842863
// TODO: support regions in TOML config via add_region / link_regions.
843864

@@ -865,12 +886,12 @@ impl Lab {
865886
for name in ready {
866887
let rcfg = pending.remove(name).unwrap();
867888
let upstream = {
868-
let inner = lab.inner.core.lock().unwrap();
889+
let inner = self.inner.core.lock().unwrap();
869890
rcfg.upstream
870891
.as_deref()
871892
.and_then(|n| inner.router_id_by_name(n))
872893
};
873-
let mut rb = lab
894+
let mut rb = self
874895
.add_router(&rcfg.name)
875896
.nat(rcfg.nat)
876897
.ip_support(rcfg.ip_support)
@@ -953,7 +974,7 @@ impl Lab {
953974
.ok_or_else(|| {
954975
anyhow!("device '{}' iface '{}' missing 'gateway'", dev_name, ifname)
955976
})?;
956-
let router_id = lab
977+
let router_id = self
957978
.inner
958979
.core
959980
.lock()
@@ -999,7 +1020,7 @@ impl Lab {
9991020
result
10001021
};
10011022
for dev in dev_data {
1002-
let mut builder = lab.add_device(&dev.name);
1023+
let mut builder = self.add_device(&dev.name);
10031024
for (ifname, router_id, impair) in dev.ifaces {
10041025
let config = IfaceConfig::routed(router_id);
10051026
let config = if let Some(cond) = impair {
@@ -1015,7 +1036,7 @@ impl Lab {
10151036
builder.build().await?;
10161037
}
10171038

1018-
Ok(lab)
1039+
Ok(())
10191040
}
10201041

10211042
// ── Builder methods (sync — just populate data structures) ──────────

patchbay/src/lib.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -241,9 +241,9 @@ pub use iface::{Iface, IfaceConfig};
241241
pub use ipnet::Ipv4Net;
242242
pub use lab::{
243243
ConntrackTimeouts, DefaultRegions, Firewall, FirewallConfig, FirewallConfigBuilder, IpSupport,
244-
Ipv6DadMode, Ipv6Profile, Ipv6ProvisioningMode, Ix, Lab, LabOpts, LinkCondition, LinkDirection,
245-
LinkLimits, Nat, NatConfig, NatConfigBuilder, NatFiltering, NatMapping, NatV6Mode, OutDir,
246-
Region, RegionLink, TestGuard,
244+
Ipv6DadMode, Ipv6Profile, Ipv6ProvisioningMode, Ix, Lab, LabBuilder, LinkCondition,
245+
LinkDirection, LinkLimits, Nat, NatConfig, NatConfigBuilder, NatFiltering, NatMapping,
246+
NatV6Mode, OutDir, Region, RegionLink, TestGuard,
247247
};
248248
pub use metrics::MetricsBuilder;
249249
pub use router::{Router, RouterBuilder, RouterIface, RouterPreset};

patchbay/src/router.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -851,7 +851,7 @@ impl RouterPreset {
851851
/// Returns the recommended IPv6 profile for this preset.
852852
///
853853
/// All presets return [`Ipv6Profile::Realistic`](crate::lab::Ipv6Profile). Use
854-
/// [`LabOpts::ipv6_profile`](crate::LabOpts::ipv6_profile) with [`Ipv6Profile::Deterministic`](crate::lab::Ipv6Profile::Deterministic) to
854+
/// [`LabBuilder::ipv6_profile`](crate::LabBuilder::ipv6_profile) with [`Ipv6Profile::Deterministic`](crate::lab::Ipv6Profile::Deterministic) to
855855
/// override for fast, reproducible tests.
856856
pub fn recommended_ipv6_profile(self) -> crate::lab::Ipv6Profile {
857857
crate::lab::Ipv6Profile::Realistic

patchbay/src/tests/devtools.rs

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@ use crate::consts;
1919
#[ignore]
2020
async fn simple_lab_for_e2e() -> Result<()> {
2121
check_caps()?;
22-
let lab = Lab::with_opts(LabOpts::default().outdir_from_env().label("e2e-test")).await?;
22+
let lab = Lab::builder()
23+
.outdir_from_env()
24+
.label("e2e-test")
25+
.build()
26+
.await?;
2327

2428
// DC router (public, no NAT).
2529
let dc = lab.add_router("dc").build().await?;
@@ -178,12 +182,11 @@ async fn run_sync_preserves_async_events() -> Result<()> {
178182
let tmp =
179183
std::env::temp_dir().join(format!("patchbay-test-sync-events-{}", std::process::id()));
180184
let _ = std::fs::create_dir_all(&tmp);
181-
let lab = Lab::with_opts(
182-
LabOpts::default()
183-
.outdir(OutDir::Exact(tmp.clone()))
184-
.label("sync-events"),
185-
)
186-
.await?;
185+
let lab = Lab::builder()
186+
.outdir(OutDir::Exact(tmp.clone()))
187+
.label("sync-events")
188+
.build()
189+
.await?;
187190

188191
let dc = lab.add_router("dc").build().await?;
189192
let dev = lab.add_device("dev").iface("eth0", dc.id()).build().await?;

patchbay/src/tests/lifecycle.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ default_via = "eth0"
5353
gateway = "dc1"
5454
"#;
5555
let parsed: config::LabConfig = toml::from_str(cfg)?;
56-
let lab = Lab::from_config(parsed).await?;
56+
let lab = Lab::builder().config(parsed).build().await?;
5757
assert!(lab.device_by_name("fetcher-0").is_some());
5858
assert!(lab.device_by_name("fetcher-1").is_some());
5959
assert!(lab.device_by_name("fetcher").is_none());
@@ -231,7 +231,10 @@ async fn add_device_after_build() -> Result<()> {
231231
#[tokio::test(flavor = "current_thread")]
232232
async fn drop_guard_flushes_with_outstanding_handles() -> Result<()> {
233233
let outdir = testdir::testdir!();
234-
let lab = Lab::with_opts(LabOpts::default().outdir(OutDir::Exact(outdir.clone()))).await?;
234+
let lab = Lab::builder()
235+
.outdir(OutDir::Exact(outdir.clone()))
236+
.build()
237+
.await?;
235238
let guard = lab.test_guard();
236239
let dc = lab.add_router("dc").build().await?;
237240
let dev = lab.add_device("dev").iface("eth0", dc.id()).build().await?;

patchbay/tests/vm_smoke.rs

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
use std::net::{IpAddr, SocketAddr};
55

66
use anyhow::{Context, Result};
7-
use patchbay::{Lab, LabOpts, Nat, OutDir};
7+
use patchbay::{Lab, Nat, OutDir};
88
use testdir::testdir;
99
use tokio::io::{AsyncReadExt, AsyncWriteExt};
1010

@@ -18,12 +18,11 @@ async fn tcp_through_nat() -> Result<()> {
1818
let outdir = testdir!();
1919
eprintln!("testdir: {}", outdir.display());
2020

21-
let lab = Lab::with_opts(
22-
LabOpts::default()
23-
.outdir(OutDir::Exact(outdir.clone()))
24-
.label("vm-smoke"),
25-
)
26-
.await?;
21+
let lab = Lab::builder()
22+
.outdir(OutDir::Exact(outdir.clone()))
23+
.label("vm-smoke")
24+
.build()
25+
.await?;
2726

2827
let dc = lab.add_router("dc").build().await?;
2928
let home = lab.add_router("home").nat(Nat::Home).build().await?;

0 commit comments

Comments
 (0)