Ops.Deployment Bridge
The Ops.Dsl Ecosystem series defines 22 sub-DSLs, ~12 of which emit Kubernetes manifests as their Cloud-tier output. Today those generators string-template YAML. This chapter shows the bridge generator pattern that lets every K8s-emitting Ops sub-DSL stop string-templating and start constructing typed Kubernetes.Dsl objects instead. One central serializer. One central CRD registry. One central versioning story. One central analyzer surface.
The example is Ops.Deployment because it's the simplest. Part 14 walks through all 12 K8s-emitting sub-DSLs end to end on a single service.
The pattern
Every Ops sub-DSL has the same shape:
- User code declares intent via attributes (
[DeploymentOrchestrator],[DeploymentApp],[DeploymentDependency]). - The Ops sub-DSL's existing source generator validates the intent (analyzers
OPS001–OPS003) and generates runtime objects that describe the deployment topology. - The bridge generator (new) reads the same attributes via
ForAttributeWithMetadataName, walks the user's class, and emits a*OpsDeployment.g.csfile that constructs typedKubernetes.DslPOCOs. - At runtime, user code calls
OrderServiceOpsDeployment.ToKubernetesManifests()and gets aIReadOnlyList<IKubernetesObject>that can be written viaKubernetesYamlWriter, applied viaKubectlClient.ApplyAsync, or composed into a contributor.
The bridge generator is additive. The Ops sub-DSL's existing generators keep working unchanged; the bridge runs alongside them and emits an extra file. Teams that want runtime ops semantics get them. Teams that want typed K8s manifests get those too. Teams that want both get both, from one source declaration.
Source attributes (verified from Ops.Deployment Part 5)
// User code: OrderServiceOps.cs
using Ops.Deployment;
[Deployment(Name = "order-api", Replicas = 3)]
[DeploymentApp(Image = "ghcr.io/acme/order-api:1.4.2", Port = 8080)]
[DeploymentDependency(Of = "postgres")]
public partial class OrderServiceOps { }// User code: OrderServiceOps.cs
using Ops.Deployment;
[Deployment(Name = "order-api", Replicas = 3)]
[DeploymentApp(Image = "ghcr.io/acme/order-api:1.4.2", Port = 8080)]
[DeploymentDependency(Of = "postgres")]
public partial class OrderServiceOps { }These four attribute uses are the entire user-facing surface. [Deployment], [DeploymentApp], [DeploymentDependency] are the verified attribute names from 05-deployment.md of the Ops.Dsl series.
What the existing Ops.Deployment generator emits
// <auto-generated/> by Ops.Deployment.Generators (existing, unchanged)
namespace Acme.Orders.Ops.Generated;
public static partial class OrderServiceOpsRuntime
{
public static DeploymentDescriptor Describe() => new()
{
Name = "order-api",
Replicas = 3,
Image = "ghcr.io/acme/order-api:1.4.2",
Port = 8080,
Dependencies = new[] { "postgres" },
OrderingHash = "...", // for OPS001 cycle detection
};
}// <auto-generated/> by Ops.Deployment.Generators (existing, unchanged)
namespace Acme.Orders.Ops.Generated;
public static partial class OrderServiceOpsRuntime
{
public static DeploymentDescriptor Describe() => new()
{
Name = "order-api",
Replicas = 3,
Image = "ghcr.io/acme/order-api:1.4.2",
Port = 8080,
Dependencies = new[] { "postgres" },
OrderingHash = "...", // for OPS001 cycle detection
};
}This is the existing Ops runtime artifact. Used by the deployment orchestrator at runtime to walk the dependency graph. Unchanged.
What the new bridge generator emits
// <auto-generated/> by Ops.Deployment.Bridge.Generator (NEW)
// Bridge: Ops.Deployment -> Kubernetes.Dsl
// Source: Acme.Orders.Ops.OrderServiceOps
namespace Acme.Orders.Ops.Generated;
using Kubernetes.Dsl;
using Kubernetes.Dsl.Api.Apps.V1;
using Kubernetes.Dsl.Api.Core.V1;
public static partial class OrderServiceOpsDeployment
{
public static IReadOnlyList<IKubernetesObject> ToKubernetesManifests() => new IKubernetesObject[]
{
new V1DeploymentBuilder()
.WithMetadata(m => m
.WithName("order-api")
.WithLabel("ops.dsl/source", "OrderServiceOps")
.WithLabel("app.kubernetes.io/name", "order-api")
.WithLabel("app.kubernetes.io/managed-by", "ops.deployment.bridge"))
.WithSpec(s => s
.WithReplicas(3)
.WithSelector(sel => sel.MatchLabels(("app", "order-api")))
.WithTemplate(t => t
.WithMetadata(m => m.WithLabel("app", "order-api"))
.WithSpec(ps => ps.WithContainer(c => c
.WithName("api")
.WithImage("ghcr.io/acme/order-api:1.4.2")
.WithPort(8080)
.WithResources(r => r
.WithRequests(Quantity.Cpu("100m"), Quantity.Memory("128Mi"))
.WithLimits (Quantity.Cpu("500m"), Quantity.Memory("512Mi")))))))
.Build().Value,
new V1ServiceBuilder()
.WithMetadata(m => m
.WithName("order-api")
.WithLabel("ops.dsl/source", "OrderServiceOps"))
.WithSpec(s => s
.WithSelector(("app", "order-api"))
.WithPort(80, IntOrString.From(8080))
.WithType(ServiceType.ClusterIP))
.Build().Value,
};
}// <auto-generated/> by Ops.Deployment.Bridge.Generator (NEW)
// Bridge: Ops.Deployment -> Kubernetes.Dsl
// Source: Acme.Orders.Ops.OrderServiceOps
namespace Acme.Orders.Ops.Generated;
using Kubernetes.Dsl;
using Kubernetes.Dsl.Api.Apps.V1;
using Kubernetes.Dsl.Api.Core.V1;
public static partial class OrderServiceOpsDeployment
{
public static IReadOnlyList<IKubernetesObject> ToKubernetesManifests() => new IKubernetesObject[]
{
new V1DeploymentBuilder()
.WithMetadata(m => m
.WithName("order-api")
.WithLabel("ops.dsl/source", "OrderServiceOps")
.WithLabel("app.kubernetes.io/name", "order-api")
.WithLabel("app.kubernetes.io/managed-by", "ops.deployment.bridge"))
.WithSpec(s => s
.WithReplicas(3)
.WithSelector(sel => sel.MatchLabels(("app", "order-api")))
.WithTemplate(t => t
.WithMetadata(m => m.WithLabel("app", "order-api"))
.WithSpec(ps => ps.WithContainer(c => c
.WithName("api")
.WithImage("ghcr.io/acme/order-api:1.4.2")
.WithPort(8080)
.WithResources(r => r
.WithRequests(Quantity.Cpu("100m"), Quantity.Memory("128Mi"))
.WithLimits (Quantity.Cpu("500m"), Quantity.Memory("512Mi")))))))
.Build().Value,
new V1ServiceBuilder()
.WithMetadata(m => m
.WithName("order-api")
.WithLabel("ops.dsl/source", "OrderServiceOps"))
.WithSpec(s => s
.WithSelector(("app", "order-api"))
.WithPort(80, IntOrString.From(8080))
.WithType(ServiceType.ClusterIP))
.Build().Value,
};
}The bridge generator reads the [Deployment], [DeploymentApp], [DeploymentDependency] attributes via Roslyn and emits one builder chain per resource. The output references typed Kubernetes.Dsl POCOs and goes through the same builder validation as hand-written contributors (Part 12). All the analyzers in Part 11 run against the generated code:
KUB040checks that the generatedV1ContainerhasResources.Limits(it does — the bridge sets them fromOps.Deploymentdefaults).KUB041checks for a liveness probe (a more complete bridge would add one).KUB060checks the Service selector matches a Pod template (it does — bridge generates both with consistent labels).
The user gets a free pass on these checks because the bridge generator is what produced the code, but if a future bridge change breaks one of them, the user sees the warning at compile time on their own service class. The diagnostic squiggle appears under [Deployment(...)] because the generator-mapped source location points back to the original attribute. The error surface is at the attribute, not in obj/Generated/.
The bridge generator implementation
// Ops.Deployment.Bridge.Generator/OpsDeploymentBridgeGenerator.cs
[Generator]
public sealed class OpsDeploymentBridgeGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var deployments = context.SyntaxProvider.ForAttributeWithMetadataName(
"Ops.Deployment.DeploymentAttribute",
predicate: (_, _) => true,
transform: (ctx, _) => ExtractDeploymentInfo(ctx));
context.RegisterSourceOutput(deployments, (spc, info) =>
{
if (info is null) return;
var source = EmitBridgeClass(info);
spc.AddSource($"{info.ClassName}OpsDeployment.g.cs",
SourceText.From(source, Encoding.UTF8));
});
}
private static DeploymentBridgeInfo? ExtractDeploymentInfo(GeneratorAttributeSyntaxContext ctx)
{
var classDecl = (ClassDeclarationSyntax)ctx.TargetNode;
var symbol = (INamedTypeSymbol)ctx.TargetSymbol;
var deployment = symbol.GetAttributes()
.FirstOrDefault(a => a.AttributeClass?.Name == "DeploymentAttribute");
var app = symbol.GetAttributes()
.FirstOrDefault(a => a.AttributeClass?.Name == "DeploymentAppAttribute");
if (deployment is null || app is null) return null;
return new DeploymentBridgeInfo(
ClassName: symbol.Name,
Namespace: symbol.ContainingNamespace.ToDisplayString(),
DeploymentName: GetStringArg(deployment, "Name") ?? symbol.Name.ToLowerInvariant(),
Replicas: GetIntArg(deployment, "Replicas") ?? 1,
Image: GetStringArg(app, "Image") ?? throw new InvalidOperationException("Image is required"),
Port: GetIntArg(app, "Port") ?? 8080,
Dependencies: symbol.GetAttributes()
.Where(a => a.AttributeClass?.Name == "DeploymentDependencyAttribute")
.Select(a => GetStringArg(a, "Of"))
.Where(s => s is not null)
.Cast<string>()
.ToList());
}
private static string EmitBridgeClass(DeploymentBridgeInfo info)
{
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated/>");
sb.AppendLine($"namespace {info.Namespace}.Generated;");
sb.AppendLine();
sb.AppendLine("using Kubernetes.Dsl;");
sb.AppendLine("using Kubernetes.Dsl.Api.Apps.V1;");
sb.AppendLine("using Kubernetes.Dsl.Api.Core.V1;");
sb.AppendLine();
sb.AppendLine($"public static partial class {info.ClassName}OpsDeployment");
sb.AppendLine("{");
sb.AppendLine(" public static IReadOnlyList<IKubernetesObject> ToKubernetesManifests() => new IKubernetesObject[]");
sb.AppendLine(" {");
sb.Append(EmitDeployment(info));
sb.AppendLine(",");
sb.Append(EmitService(info));
sb.AppendLine();
sb.AppendLine(" };");
sb.AppendLine("}");
return sb.ToString();
}
// EmitDeployment, EmitService: emit one builder chain each, ~30 lines each
}// Ops.Deployment.Bridge.Generator/OpsDeploymentBridgeGenerator.cs
[Generator]
public sealed class OpsDeploymentBridgeGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var deployments = context.SyntaxProvider.ForAttributeWithMetadataName(
"Ops.Deployment.DeploymentAttribute",
predicate: (_, _) => true,
transform: (ctx, _) => ExtractDeploymentInfo(ctx));
context.RegisterSourceOutput(deployments, (spc, info) =>
{
if (info is null) return;
var source = EmitBridgeClass(info);
spc.AddSource($"{info.ClassName}OpsDeployment.g.cs",
SourceText.From(source, Encoding.UTF8));
});
}
private static DeploymentBridgeInfo? ExtractDeploymentInfo(GeneratorAttributeSyntaxContext ctx)
{
var classDecl = (ClassDeclarationSyntax)ctx.TargetNode;
var symbol = (INamedTypeSymbol)ctx.TargetSymbol;
var deployment = symbol.GetAttributes()
.FirstOrDefault(a => a.AttributeClass?.Name == "DeploymentAttribute");
var app = symbol.GetAttributes()
.FirstOrDefault(a => a.AttributeClass?.Name == "DeploymentAppAttribute");
if (deployment is null || app is null) return null;
return new DeploymentBridgeInfo(
ClassName: symbol.Name,
Namespace: symbol.ContainingNamespace.ToDisplayString(),
DeploymentName: GetStringArg(deployment, "Name") ?? symbol.Name.ToLowerInvariant(),
Replicas: GetIntArg(deployment, "Replicas") ?? 1,
Image: GetStringArg(app, "Image") ?? throw new InvalidOperationException("Image is required"),
Port: GetIntArg(app, "Port") ?? 8080,
Dependencies: symbol.GetAttributes()
.Where(a => a.AttributeClass?.Name == "DeploymentDependencyAttribute")
.Select(a => GetStringArg(a, "Of"))
.Where(s => s is not null)
.Cast<string>()
.ToList());
}
private static string EmitBridgeClass(DeploymentBridgeInfo info)
{
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated/>");
sb.AppendLine($"namespace {info.Namespace}.Generated;");
sb.AppendLine();
sb.AppendLine("using Kubernetes.Dsl;");
sb.AppendLine("using Kubernetes.Dsl.Api.Apps.V1;");
sb.AppendLine("using Kubernetes.Dsl.Api.Core.V1;");
sb.AppendLine();
sb.AppendLine($"public static partial class {info.ClassName}OpsDeployment");
sb.AppendLine("{");
sb.AppendLine(" public static IReadOnlyList<IKubernetesObject> ToKubernetesManifests() => new IKubernetesObject[]");
sb.AppendLine(" {");
sb.Append(EmitDeployment(info));
sb.AppendLine(",");
sb.Append(EmitService(info));
sb.AppendLine();
sb.AppendLine(" };");
sb.AppendLine("}");
return sb.ToString();
}
// EmitDeployment, EmitService: emit one builder chain each, ~30 lines each
}The bridge generator is small (~200 lines for Ops.Deployment → V1Deployment + V1Service). It does not call into Builder.SourceGenerator.Lib because it's not generating builders — it's generating use sites of existing builders. The output is C# code that constructs typed objects using the builder chains that OpenApiV3SchemaEmitter already emitted (Part 6).
This is the two-stage SG pattern from Ddd.Entity.Dsl: SG #1 emits the typed POCOs and builders (Track B), SG #2 emits use sites of those builders driven by a different attribute set. Both SGs run in the same Roslyn pass; SG #2 sees SG #1's output via the metadata symbol table.
Where the bridge lives
The bridge generators ship in separate projects, one per Ops sub-DSL:
Net/FrenchExDev/Ops/
├── src/
│ ├── Ops.Deployment.Lib/
│ ├── Ops.Deployment.Generators/
│ ├── Ops.Deployment.Bridge.Lib/ (NEW: shared bridge primitives)
│ └── Ops.Deployment.Bridge.Generator/ (NEW: the bridge SG)
└── ...Net/FrenchExDev/Ops/
├── src/
│ ├── Ops.Deployment.Lib/
│ ├── Ops.Deployment.Generators/
│ ├── Ops.Deployment.Bridge.Lib/ (NEW: shared bridge primitives)
│ └── Ops.Deployment.Bridge.Generator/ (NEW: the bridge SG)
└── ...Each Ops sub-DSL with K8s output gets its own bridge project. The bridges share Ops.Deployment.Bridge.Lib for common helpers (label injection, the ops.dsl/source annotation, the ops.dsl/managed-by label, etc.). Adding K8s output to a 13th Ops sub-DSL is a new bridge project — no changes to the existing 12.
The round-trip caveat (again)
The bridge produces author intent, not cluster state. After kubectl apply, admission controllers will mutate the live object (defaults, finalizers, status, mutating webhooks, server-side patches). The same caveat from Part 7 applies: round-trip means bridge → YAML → bridge, not bridge → cluster → bridge.
The bridge generator does not query the cluster. It does not know what the cluster will fill in. It produces the YAML that the cluster reads. The cluster reads it, mutates it, persists it. The persisted form is opaque to the bridge.
This is consistent with the dev-side framing of Kubernetes.Dsl as a whole: it owns the author side. The runtime side (read cluster, reconcile, watch) belongs to KubernetesClient/csharp and to whatever operator framework the team uses.
Connecting the bridge output to a contributor
The bridge generator emits ToKubernetesManifests(), but it doesn't compose into a KubernetesBundleBuilder automatically. The user wires the two together in a contributor:
[KubernetesContributor(Name = "order-api-from-ops")]
public sealed class OrderApiOpsContributor : IKubernetesContributor
{
public void Contribute(KubernetesBundleBuilder bundle)
{
bundle.AddRange(OrderServiceOpsDeployment.ToKubernetesManifests());
}
}[KubernetesContributor(Name = "order-api-from-ops")]
public sealed class OrderApiOpsContributor : IKubernetesContributor
{
public void Contribute(KubernetesBundleBuilder bundle)
{
bundle.AddRange(OrderServiceOpsDeployment.ToKubernetesManifests());
}
}One line. The bridge produces the manifests; the contributor adds them to the bundle; the bundle is what gets serialized. The same Pipeline-with-stages composition that the rest of the framework uses.
What the bridge does not do
- Does not change the Ops sub-DSL's existing semantics.
Ops.Deployment.Generatorsis unchanged. The bridge runs alongside it. - Does not run runtime ops logic. The orchestrator (in
Ops.Deployment.Lib) still walks the deployment graph. The bridge only handles the K8s emission. - Does not own analyzers.
OPS001–OPS003are still owned byOps.Deployment.Analyzers. The bridge contributes new code that's analyzed byKUB001–KUB099. Two analyzer packs, two layers, no overlap (Part 11). - Does not implement deployment ordering at runtime. That's the orchestrator's job. The bridge produces the manifests; the orchestrator decides when to apply them.
The big picture
One source declaration. Two SGs. Two outputs. Same compile pass. Same analyzer pack. Same end-to-end story for every K8s-emitting Ops sub-DSL. Part 14 walks this through end-to-end on a service that uses all 12.
Previous: Part 12: Contributors, Bundles, and Helm/Kustomize Interop Next: Part 14: Composition Walkthrough — One Service, All 12 K8s-Emitting Ops Sub-DSLs