Driver Extensibility
FluentDocker’s extensibility model lets drivers expose custom interfaces and builder extensions without kernel changes. This enables driver-specific features (Podman pods, Docker Swarm, etc.) to integrate cleanly with the fluent API.
Step by Step
This is an advanced guide. Complete Architecture before implementing custom extensions.
- Foundation: Architecture Overview, Interface Resolution, Driver-Aware Builders
- Implementation: Writing Custom Extensions, Real-World Example: Multi-Driver Deployment
- Reference: Extension Conventions, DriverPackBase Helper
Architecture Overview
┌──────────────────────────────┐
│ Extension Methods │
│ .UsePod("my-pod") │
│ .UseSwarmMode(replicas: 3) │
└──────────┬───────────────────┘
│ casts to
┌──────────▼───────────────────┐
│ IDriverScopedBuilder │
│ .Kernel + .DriverId │
└──────────┬───────────────────┘
│ calls
┌──────────▼───────────────────┐
│ TryDriver<T>() / │
│ RequireDriver<T>() │
└──────────┬───────────────────┘
│ delegates to
┌───────────────────▼──────────────────┐
│ FluentDockerKernel.SysCtl(id, Type) │
└───────────────────┬──────────────────┘
│ resolves via
┌───────────────────▼──────────────────┐
│ IDriverInterfaceResolver │
│ on DriverPack or Driver │
└──────────────────────────────────────┘
Key principle: Builders use the common IContainerBuilder API by default. Extension methods detect the active driver at configuration time and apply driver-specific behavior only when the driver supports it.
Interface Resolution
IDriverInterfaceResolver
Any driver or driver pack can implement IDriverInterfaceResolver to expose arbitrary interfaces:
public interface IDriverInterfaceResolver
{
bool TryResolve(Type interfaceType, out object implementation);
IReadOnlyCollection<Type> GetSupportedInterfaces();
}
The kernel uses a cascading resolution strategy when you call SysCtl(driverId, Type):
| Step | Check | Fallback |
|---|---|---|
| 1 | IDriverInterfaceResolver on driver pack |
Continue |
| 2 | Driver pack’s SysCtl(driverId, Type) |
Continue |
| 3 | IDriverInterfaceResolver on driver |
Continue |
| 4 | Direct cast (driver is T) |
Throw |
This means any interface registered with the driver pack or driver is discoverable without kernel changes.
SysCtl Overloads
// Type-safe — throws InterfaceNotSupportedException if not found
var driver = kernel.SysCtl<IContainerDriver>("docker");
// Type-based — useful for runtime resolution
object driver = kernel.SysCtl("docker", typeof(IContainerDriver));
// Non-throwing — returns false if interface not supported
if (kernel.TrySysCtl<IPodmanPodDriver>("podman", out var podDriver))
{
await podDriver.CreatePodAsync(context, "my-pod");
}
TrySysCtl<T>() returns false when the interface is not supported, but still throws DriverNotFoundException if the driver ID itself is invalid. This distinction is intentional: a missing driver is a configuration error, while an unsupported interface is a feature check.
Driver-Aware Builders
IDriverScopedBuilder
All internal builders (ContainerBuilder, NetworkBuilder, VolumeBuilder, ComposeBuilder, ImageBuilder) implement IDriverScopedBuilder:
public interface IDriverScopedBuilder
{
FluentDockerKernel Kernel { get; }
string DriverId { get; }
}
Inside any UseContainer(...), UseNetwork(...), etc. lambda, the builder you receive carries the kernel and driver context from the enclosing WithinDriver() scope.
RequireDriver and TryDriver
Two extension methods on IDriverScopedBuilder simplify driver-specific resolution:
// Throws InterfaceNotSupportedException if not available
T RequireDriver<T>(this IDriverScopedBuilder builder) where T : class;
// Returns null if not available
T TryDriver<T>(this IDriverScopedBuilder builder) where T : class;
Use TryDriver<T>() for optional features with graceful fallback. Use RequireDriver<T>() when the feature is mandatory (e.g., your extension only makes sense with a specific driver).
Writing Custom Extensions
Step 1: Define the Interface
Create a driver-specific interface in the driver’s namespace:
// FluentDocker/Drivers/Podman/IPodmanPodDriver.cs
public interface IPodmanPodDriver
{
Task<CommandResponse<PodCreateResult>> CreatePodAsync(
DriverContext context, string name,
CancellationToken cancellationToken = default);
Task<CommandResponse<Unit>> RemovePodAsync(
DriverContext context, string name, bool force = false,
CancellationToken cancellationToken = default);
Task<CommandResponse<IList<PodInfo>>> ListPodsAsync(
DriverContext context,
CancellationToken cancellationToken = default);
}
Step 2: Register in the Driver Pack
In the driver pack’s InitializeAsync, register the implementation:
public class PodmanCliDriverPack : DriverPackBase
{
protected override async Task OnInitializeAsync(
DriverContext context, CancellationToken ct)
{
// Standard interfaces
RegisterDriver<IContainerDriver>(new PodmanContainerDriver(...));
RegisterDriver<IImageDriver>(new PodmanImageDriver(...));
// Podman-specific interface
RegisterDriver<IPodmanPodDriver>(new PodmanPodDriver(...));
}
}
DriverPackBase provides RegisterDriver<T>() backed by a dictionary, which automatically implements IDriverInterfaceResolver.
Step 3: Write Builder Extensions
Create extension methods in Drivers/<driver>/BuilderExtensions:
// FluentDocker/Drivers/Podman/BuilderExtensions/PodmanContainerExtensions.cs
public static class PodmanContainerExtensions
{
/// <summary>
/// Associates this container with a Podman pod.
/// No-op if the current driver does not support pods.
/// </summary>
public static IContainerBuilder UsePod(
this IContainerBuilder builder, string podName)
{
if (builder is IDriverScopedBuilder scoped)
{
var podDriver = scoped.TryDriver<IPodmanPodDriver>();
if (podDriver != null)
{
builder.WithLabel("io.podman.pod", podName);
}
}
return builder;
}
}
Pattern: Check builder is IDriverScopedBuilder, then TryDriver<T>(). Always return the builder for chaining. Gracefully no-op when the driver doesn’t support the feature.
Step 4: Use It
using FluentDocker.Drivers.Podman.BuilderExtensions;
await new Builder()
.WithinDriver("podman", kernel)
.UseContainer(c => c
.UseImage("redis:7-alpine")
.UsePod("cache-pod") // Podman-specific
.ExposePort(6379, 6379)
.WaitForPort("6379/tcp"))
.BuildAsync();
When run against a Docker driver, UsePod() simply does nothing and the container is created normally.
Real-World Example: Multi-Driver Deployment
This example deploys a web stack on Docker and a cache cluster in a Podman pod, using a single Builder with two driver scopes. It demonstrates common builder calls that work on both drivers alongside Podman-specific extensions.
using FluentDocker.Builders;
using FluentDocker.Kernel;
using FluentDocker.Drivers.Podman.BuilderExtensions;
// ── Kernel with both drivers ──────────────────────────────────
var kernel = await FluentDockerKernel.Create()
.WithDockerCli("docker", d => d
.AsDefault())
.WithPodmanCli("podman", d => d
.AsDefault())
.BuildAsync();
// ── Single builder, two driver scopes ─────────────────────────
var deployment = await new Builder()
// ── Docker scope: standard web stack ──────────────────────
.WithinDriver("docker", kernel)
.UseNetwork(n => n
.WithName("web-net")
.WithSubnet("172.20.0.0/16"))
.UseContainer(c => c
.UseImage("postgres:16-alpine")
.WithName("db")
.WithNetwork("web-net")
.WithEnvironment("POSTGRES_PASSWORD", "secret")
.ExposePort(5432, 5432)
.WaitForPort("5432/tcp"))
.UseContainer(c => c
.UseImage("myapp:latest")
.WithName("api")
.WithNetwork("web-net")
.WithEnvironment("DB_HOST", "db")
.ExposePort(8080, 80)
.WaitForHttp("80/tcp", "/health"))
// ── Podman scope: cache cluster in a pod ──────────────────
.WithinDriver("podman") // reuses same kernel
.UseContainer(c => c
.UseImage("redis:7-alpine")
.WithName("cache-primary")
.UsePod("cache-pod") // Podman: shared network namespace
.ExposePort(6379, 6379)
.WaitForPort("6379/tcp"))
.UseContainer(c => c
.UseImage("redis:7-alpine")
.WithName("cache-replica")
.UsePod("cache-pod") // Same pod as primary
.WithCommand("redis-server", "--replicaof", "localhost", "6379"))
.BuildAsync();
// ── Access results by driver scope ────────────────────────────
var dockerServices = deployment.ForDriver("docker");
var podmanServices = deployment.ForDriver("podman");
// ── Cleanup ───────────────────────────────────────────────────
await deployment.DisposeAllAsync();
kernel.Dispose();
What’s Happening
| Line | Common or Specific | Notes |
|---|---|---|
UseImage(...) |
Common | Works on any driver |
WithName(...) |
Common | Works on any driver |
WithNetwork(...) |
Common | Works on any driver |
ExposePort(...) |
Common | Works on any driver |
WaitForPort(...) |
Common | Works on any driver |
WaitForHttp(...) |
Common | Works on any driver |
.UsePod("cache-pod") |
Podman-specific | No-ops on Docker |
.WithinDriver("podman") |
Scope switch | Builder chains across drivers |
deployment.ForDriver(...) |
Common | Filter results by driver scope |
The common builder calls (UseImage, WithName, ExposePort, WaitForPort) work identically across Docker and Podman. The Podman-specific .UsePod() extension applies only when the active driver supports IPodmanPodDriver; when the same container builder runs under Docker, the call is a no-op.
Extension Conventions
When writing driver-specific extensions, follow these conventions:
- Namespace:
FluentDocker.Drivers.<Driver>.BuilderExtensions - Return type: Always return the builder interface for chaining
- Fallback: Use
TryDriver<T>()and no-op when unsupported, unless the extension only makes sense for that driver - Naming: Use verbs that describe the intent (
UsePod,EnableSwarmMode,WithSecurityProfile) - Documentation: Document no-op behavior in the XML summary
DriverPackBase Helper
For new driver packs, DriverPackBase provides a dictionary-backed implementation of IDriverInterfaceResolver:
public abstract class DriverPackBase : IDriverInterfaceResolver
{
// Register an interface implementation during initialization
protected void RegisterDriver<T>(T driver) where T : class;
// IDriverInterfaceResolver — automatically implemented
bool TryResolve(Type interfaceType, out object implementation);
IReadOnlyCollection<Type> GetSupportedInterfaces();
// Protected helpers for subclass use
protected object ResolveSysCtl(string driverId, Type interfaceType);
protected bool TryResolveSysCtl<T>(out T instance) where T : class;
}
Register your driver interfaces via RegisterDriver<T>() during initialization. Existing driver packs like DockerCliDriverPack implement IDriverInterfaceResolver directly (they don’t inherit DriverPackBase), but new packs can use the base class to avoid boilerplate.
Summary
| Concept | Purpose |
|---|---|
IDriverInterfaceResolver |
Lets drivers expose arbitrary interfaces |
TrySysCtl<T>() |
Non-throwing interface check on the kernel |
IDriverScopedBuilder |
Gives builder lambdas access to kernel + driver context |
RequireDriver<T>() |
Resolves a driver interface (throws if missing) |
TryDriver<T>() |
Resolves a driver interface (returns null if missing) |
DriverPackBase |
Optional helper for new driver packs |
| Extension methods | Driver-specific fluent API that gracefully no-ops |