K8s YAML Serialization
The generated POCOs need to round-trip with K8s YAML — and K8s YAML has quirks that no off-the-shelf serializer handles correctly. Multi-doc streams. The apiVersion/kind discriminator. Status omission on write. IntOrString and Quantity converters. Strategic-merge semantics. This chapter walks through the hand-written KubernetesYamlReader and KubernetesYamlWriter, both built on YamlDotNet 16.3.0 with custom converters and a generated type registry.
What "round-trip" means here (the caveat)
Critical footgun, stated up front: K8s admission controllers mutate objects. Read(cluster) ≠ Write(builder) because the apiserver injects defaults, fills status, adds finalizers, sets creationTimestamp and resourceVersion, generates names from generateName, applies mutating-webhook patches.
Round-trip in this library means: Read(Write(builder)) == builder. Round-trip against a live cluster is out of scope. Users who need cluster reconciliation use KubernetesClient/csharp for the read side and Kubernetes.Dsl for the write side. The two libraries compose; they don't replace each other.
This bears repeating because every reader assumes otherwise. Kubernetes.Dsl is an author-side library. It writes the YAML you kubectl apply. It does not read what the cluster sent back.
The writer
// Kubernetes.Dsl.Yaml/KubernetesYamlWriter.cs (hand-written)
namespace Kubernetes.Dsl.Yaml;
public static class KubernetesYamlWriter
{
private static readonly ISerializer Serializer = new SerializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.WithTypeConverter(new IntOrStringConverter())
.WithTypeConverter(new QuantityConverter())
.WithTypeConverter(new DurationConverter())
.WithTypeConverter(new RawExtensionConverter())
.ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitNull)
.WithEventEmitter(next => new K8sStatusOmittingEmitter(next))
.DisableAliases()
.Build();
public static string Write(IKubernetesObject obj)
=> Serializer.Serialize(obj);
public static string WriteAll(IEnumerable<IKubernetesObject> objects)
{
var sb = new StringBuilder();
var first = true;
foreach (var o in objects)
{
if (!first) sb.AppendLine("---");
sb.Append(Serializer.Serialize(o));
first = false;
}
return sb.ToString();
}
public static void WriteHelmChart(KubernetesBundle bundle, string templatesDir) { /* ... */ }
public static void WriteKustomizeBase(KubernetesBundle bundle, string baseDir) { /* ... */ }
}// Kubernetes.Dsl.Yaml/KubernetesYamlWriter.cs (hand-written)
namespace Kubernetes.Dsl.Yaml;
public static class KubernetesYamlWriter
{
private static readonly ISerializer Serializer = new SerializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.WithTypeConverter(new IntOrStringConverter())
.WithTypeConverter(new QuantityConverter())
.WithTypeConverter(new DurationConverter())
.WithTypeConverter(new RawExtensionConverter())
.ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitNull)
.WithEventEmitter(next => new K8sStatusOmittingEmitter(next))
.DisableAliases()
.Build();
public static string Write(IKubernetesObject obj)
=> Serializer.Serialize(obj);
public static string WriteAll(IEnumerable<IKubernetesObject> objects)
{
var sb = new StringBuilder();
var first = true;
foreach (var o in objects)
{
if (!first) sb.AppendLine("---");
sb.Append(Serializer.Serialize(o));
first = false;
}
return sb.ToString();
}
public static void WriteHelmChart(KubernetesBundle bundle, string templatesDir) { /* ... */ }
public static void WriteKustomizeBase(KubernetesBundle bundle, string baseDir) { /* ... */ }
}Five custom pieces:
IntOrStringConverter— emits8080(integer) orhttp(string) depending on which form is set.QuantityConverter— emits500m,512Mi,10Gietc. as bare scalars (not strings).DurationConverter— convertsTimeSpanback to Go duration syntax (30s,5m,1h30m).RawExtensionConverter— round-tripsJsonElementso CRDs that embed arbitrary content don't lose fidelity.K8sStatusOmittingEmitter— drops thestatus:field on write because authors never write status.
Output example
# Emitted by KubernetesYamlWriter.Write(pod)
apiVersion: v1
kind: Pod
metadata:
name: order-api
namespace: orders
labels:
app.kubernetes.io/name: order-api
app.kubernetes.io/version: "1.4.2"
spec:
containers:
- name: api
image: ghcr.io/acme/order-api:1.4.2
ports:
- containerPort: 8080
resources:
requests: { cpu: 100m, memory: 128Mi }
limits: { cpu: 500m, memory: 512Mi }
livenessProbe:
httpGet: { path: /healthz, port: 8080 }# Emitted by KubernetesYamlWriter.Write(pod)
apiVersion: v1
kind: Pod
metadata:
name: order-api
namespace: orders
labels:
app.kubernetes.io/name: order-api
app.kubernetes.io/version: "1.4.2"
spec:
containers:
- name: api
image: ghcr.io/acme/order-api:1.4.2
ports:
- containerPort: 8080
resources:
requests: { cpu: 100m, memory: 128Mi }
limits: { cpu: 500m, memory: 512Mi }
livenessProbe:
httpGet: { path: /healthz, port: 8080 }Notice what's missing: status:, creationTimestamp:, resourceVersion:, uid:. None of those are author concerns. OmitNull handles all the never-set optional fields.
Multi-doc streams
var bundle = new[] { pod, service, configMap };
var yaml = KubernetesYamlWriter.WriteAll(bundle);var bundle = new[] { pod, service, configMap };
var yaml = KubernetesYamlWriter.WriteAll(bundle);apiVersion: v1
kind: Pod
metadata: { name: order-api }
spec: { ... }
---
apiVersion: v1
kind: Service
metadata: { name: order-api }
spec: { ... }
---
apiVersion: v1
kind: ConfigMap
metadata: { name: order-api-config }
data: { ... }apiVersion: v1
kind: Pod
metadata: { name: order-api }
spec: { ... }
---
apiVersion: v1
kind: Service
metadata: { name: order-api }
spec: { ... }
---
apiVersion: v1
kind: ConfigMap
metadata: { name: order-api-config }
data: { ... }This is what you kubectl apply -f against a real cluster. Standard K8s multi-doc YAML.
The reader
// Kubernetes.Dsl.Yaml/KubernetesYamlReader.cs (hand-written)
namespace Kubernetes.Dsl.Yaml;
public static class KubernetesYamlReader
{
private static readonly IDeserializer Deserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.WithTypeConverter(new IntOrStringConverter())
.WithTypeConverter(new QuantityConverter())
.WithTypeConverter(new DurationConverter())
.WithTypeConverter(new RawExtensionConverter())
.IgnoreUnmatchedProperties()
.Build();
public static IReadOnlyList<IKubernetesObject> ReadAll(string yaml)
{
var result = new List<IKubernetesObject>();
var yamlStream = new YamlStream();
yamlStream.Load(new StringReader(yaml));
foreach (var doc in yamlStream.Documents)
{
var (apiVersion, kind) = PeekDiscriminator(doc);
var type = KubernetesTypeRegistry.Resolve(apiVersion, kind)
?? throw new KubernetesYamlException(
$"Unknown ({apiVersion}, {kind}) - not in type registry. " +
"Did you generate the right schemas?");
// Re-serialize the document to a string and re-deserialize as the typed shape.
// This is the same two-phase technique GitLabCiYamlReader uses for its job extraction.
var serializer = new SerializerBuilder().Build();
var docText = serializer.Serialize(doc.RootNode);
var typed = (IKubernetesObject)Deserializer.Deserialize(docText, type)!;
result.Add(typed);
}
return result;
}
public static T Read<T>(string yaml) where T : IKubernetesObject
=> (T)Deserializer.Deserialize(yaml, typeof(T))!;
private static (string apiVersion, string kind) PeekDiscriminator(YamlDocument doc)
{
var root = (YamlMappingNode)doc.RootNode;
var apiVersion = ((YamlScalarNode)root.Children[new YamlScalarNode("apiVersion")]).Value!;
var kind = ((YamlScalarNode)root.Children[new YamlScalarNode("kind")]).Value!;
return (apiVersion, kind);
}
}// Kubernetes.Dsl.Yaml/KubernetesYamlReader.cs (hand-written)
namespace Kubernetes.Dsl.Yaml;
public static class KubernetesYamlReader
{
private static readonly IDeserializer Deserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.WithTypeConverter(new IntOrStringConverter())
.WithTypeConverter(new QuantityConverter())
.WithTypeConverter(new DurationConverter())
.WithTypeConverter(new RawExtensionConverter())
.IgnoreUnmatchedProperties()
.Build();
public static IReadOnlyList<IKubernetesObject> ReadAll(string yaml)
{
var result = new List<IKubernetesObject>();
var yamlStream = new YamlStream();
yamlStream.Load(new StringReader(yaml));
foreach (var doc in yamlStream.Documents)
{
var (apiVersion, kind) = PeekDiscriminator(doc);
var type = KubernetesTypeRegistry.Resolve(apiVersion, kind)
?? throw new KubernetesYamlException(
$"Unknown ({apiVersion}, {kind}) - not in type registry. " +
"Did you generate the right schemas?");
// Re-serialize the document to a string and re-deserialize as the typed shape.
// This is the same two-phase technique GitLabCiYamlReader uses for its job extraction.
var serializer = new SerializerBuilder().Build();
var docText = serializer.Serialize(doc.RootNode);
var typed = (IKubernetesObject)Deserializer.Deserialize(docText, type)!;
result.Add(typed);
}
return result;
}
public static T Read<T>(string yaml) where T : IKubernetesObject
=> (T)Deserializer.Deserialize(yaml, typeof(T))!;
private static (string apiVersion, string kind) PeekDiscriminator(YamlDocument doc)
{
var root = (YamlMappingNode)doc.RootNode;
var apiVersion = ((YamlScalarNode)root.Children[new YamlScalarNode("apiVersion")]).Value!;
var kind = ((YamlScalarNode)root.Children[new YamlScalarNode("kind")]).Value!;
return (apiVersion, kind);
}
}The two-phase technique (re-serialize the YAML node, re-deserialize as the typed shape) is borrowed from GitLab.Ci.Yaml's GitLabCiYamlReader. It's slower than a single-pass deserializer but it lets the reader work without knowing the types in advance — which is the whole point of multi-doc K8s bundles where each document might be a different type.
The discriminator and the type registry
The reader's job is impossible without KubernetesTypeRegistry. Multi-doc K8s YAML doesn't carry type tags; the only signal is the (apiVersion, kind) pair at the top of each document. The type registry, generated by the SG (Part 6), maps that pair to a Type:
// Generated by TypeRegistryEmitter (Part 6)
public static partial class KubernetesTypeRegistry
{
private static readonly Dictionary<(string, string), Type> _map = new()
{
[("v1", "Pod")] = typeof(Api.Core.V1.V1Pod),
[("v1", "Service")] = typeof(Api.Core.V1.V1Service),
[("apps/v1", "Deployment")] = typeof(Api.Apps.V1.V1Deployment),
[("argoproj.io/v1alpha1", "Rollout")] = typeof(Crds.ArgoProj.V1Alpha1.V1Alpha1Rollout),
// ... one row per x-kubernetes-group-version-kind
};
public static Type? Resolve(string apiVersion, string kind)
=> _map.TryGetValue((apiVersion, kind), out var t) ? t : null;
}// Generated by TypeRegistryEmitter (Part 6)
public static partial class KubernetesTypeRegistry
{
private static readonly Dictionary<(string, string), Type> _map = new()
{
[("v1", "Pod")] = typeof(Api.Core.V1.V1Pod),
[("v1", "Service")] = typeof(Api.Core.V1.V1Service),
[("apps/v1", "Deployment")] = typeof(Api.Apps.V1.V1Deployment),
[("argoproj.io/v1alpha1", "Rollout")] = typeof(Crds.ArgoProj.V1Alpha1.V1Alpha1Rollout),
// ... one row per x-kubernetes-group-version-kind
};
public static Type? Resolve(string apiVersion, string kind)
=> _map.TryGetValue((apiVersion, kind), out var t) ? t : null;
}The CRDs participate in the same registry. There's no second registry for "user CRDs" — every type that can appear in YAML is in _map.
Resolve returning null is a hard error. The reader throws KubernetesYamlException rather than degrade. The user's [KubernetesBundle] either includes the schema for that resource or it doesn't.
IntOrStringConverter
internal sealed class IntOrStringConverter : IYamlTypeConverter
{
public bool Accepts(Type type) => type == typeof(IntOrString) || type == typeof(IntOrString?);
public object? ReadYaml(IParser parser, Type type)
{
var scalar = parser.Consume<Scalar>();
if (int.TryParse(scalar.Value, out var i)) return IntOrString.From(i);
return IntOrString.From(scalar.Value);
}
public void WriteYaml(IEmitter emitter, object? value, Type type)
{
var v = (IntOrString)value!;
if (v.IsInt)
emitter.Emit(new Scalar(null, null, v.IntValue.ToString(), ScalarStyle.Plain, true, false));
else
emitter.Emit(new Scalar(null, null, v.StringValue, ScalarStyle.Plain, true, false));
}
}internal sealed class IntOrStringConverter : IYamlTypeConverter
{
public bool Accepts(Type type) => type == typeof(IntOrString) || type == typeof(IntOrString?);
public object? ReadYaml(IParser parser, Type type)
{
var scalar = parser.Consume<Scalar>();
if (int.TryParse(scalar.Value, out var i)) return IntOrString.From(i);
return IntOrString.From(scalar.Value);
}
public void WriteYaml(IEmitter emitter, object? value, Type type)
{
var v = (IntOrString)value!;
if (v.IsInt)
emitter.Emit(new Scalar(null, null, v.IntValue.ToString(), ScalarStyle.Plain, true, false));
else
emitter.Emit(new Scalar(null, null, v.StringValue, ScalarStyle.Plain, true, false));
}
}The plain scalar style emits 8080 rather than "8080". Critical: K8s validates targetPort: 8080 as an integer if quoted, and as a string-named-port if unquoted-and-non-numeric. Getting the YAML scalar style right is the difference between a working Service and a broken one.
QuantityConverter
internal sealed class QuantityConverter : IYamlTypeConverter
{
public bool Accepts(Type type) => type == typeof(Quantity) || type == typeof(Quantity?);
public object? ReadYaml(IParser parser, Type type)
{
var scalar = parser.Consume<Scalar>();
return Quantity.Parse(scalar.Value);
}
public void WriteYaml(IEmitter emitter, object? value, Type type)
{
var q = (Quantity)value!;
// Emit as a plain (unquoted) scalar so YAML parsers see "500m" not "\"500m\""
emitter.Emit(new Scalar(null, null, q.CanonicalForm, ScalarStyle.Plain, true, false));
}
}internal sealed class QuantityConverter : IYamlTypeConverter
{
public bool Accepts(Type type) => type == typeof(Quantity) || type == typeof(Quantity?);
public object? ReadYaml(IParser parser, Type type)
{
var scalar = parser.Consume<Scalar>();
return Quantity.Parse(scalar.Value);
}
public void WriteYaml(IEmitter emitter, object? value, Type type)
{
var q = (Quantity)value!;
// Emit as a plain (unquoted) scalar so YAML parsers see "500m" not "\"500m\""
emitter.Emit(new Scalar(null, null, q.CanonicalForm, ScalarStyle.Plain, true, false));
}
}DurationConverter
Go duration syntax (30s, 5m, 1h30m) ↔ TimeSpan. The converter parses 30s to TimeSpan.FromSeconds(30) and emits TimeSpan.FromSeconds(30) as 30s. Edge cases (negative durations, 0s, 1m30s500ms) are handled.
RawExtensionConverter
CRDs embed arbitrary YAML content via runtime.RawExtension (e.g., customResourceDefinition.spec.versions[*].schema.openAPIV3Schema). The converter reads the YAML node into a JsonElement via a temporary string round-trip:
internal sealed class RawExtensionConverter : IYamlTypeConverter
{
public bool Accepts(Type type) => type == typeof(JsonElement) || type == typeof(JsonElement?);
public object? ReadYaml(IParser parser, Type type)
{
var node = ConsumeNode(parser); // builds a YamlNode tree
var jsonText = ToJsonString(node);
using var doc = JsonDocument.Parse(jsonText);
return doc.RootElement.Clone();
}
public void WriteYaml(IEmitter emitter, object? value, Type type)
{
var elem = (JsonElement)value!;
EmitJsonElement(emitter, elem); // walks JsonElement and emits YAML events
}
}internal sealed class RawExtensionConverter : IYamlTypeConverter
{
public bool Accepts(Type type) => type == typeof(JsonElement) || type == typeof(JsonElement?);
public object? ReadYaml(IParser parser, Type type)
{
var node = ConsumeNode(parser); // builds a YamlNode tree
var jsonText = ToJsonString(node);
using var doc = JsonDocument.Parse(jsonText);
return doc.RootElement.Clone();
}
public void WriteYaml(IEmitter emitter, object? value, Type type)
{
var elem = (JsonElement)value!;
EmitJsonElement(emitter, elem); // walks JsonElement and emits YAML events
}
}Used heavily by CRDs. Without it, generated CRD types would lose the inner schema content.
Status omission on write
The K8s convention is that status is read from the cluster, never written by authors. The writer enforces this with a custom event emitter that drops status: keys:
internal sealed class K8sStatusOmittingEmitter : ChainedEventEmitter
{
public K8sStatusOmittingEmitter(IEventEmitter next) : base(next) { }
private bool _skipNextValue;
public override void Emit(ScalarEventInfo info, IEmitter emitter)
{
if (info.Source.Value is "status")
{
_skipNextValue = true;
return;
}
base.Emit(info, emitter);
}
public override void Emit(MappingStartEventInfo info, IEmitter emitter)
{
if (_skipNextValue) { _skipNextValue = false; return; }
base.Emit(info, emitter);
}
}internal sealed class K8sStatusOmittingEmitter : ChainedEventEmitter
{
public K8sStatusOmittingEmitter(IEventEmitter next) : base(next) { }
private bool _skipNextValue;
public override void Emit(ScalarEventInfo info, IEmitter emitter)
{
if (info.Source.Value is "status")
{
_skipNextValue = true;
return;
}
base.Emit(info, emitter);
}
public override void Emit(MappingStartEventInfo info, IEmitter emitter)
{
if (_skipNextValue) { _skipNextValue = false; return; }
base.Emit(info, emitter);
}
}The [YamlMember(SerializeAs = OmitOnWrite)] attribute on the generated Status properties is the high-level signal; the emitter is the low-level enforcement. Both are needed because some status fields are nested inside other types (e.g., V1NetworkPolicyStatus inside V1NetworkPolicy.Status).
Strategic merge patches and SSA
The writer does not implement strategic-merge patches. That's a server-side concern handled by kubectl apply --server-side. Kubernetes.Dsl produces complete object specifications; it does not produce patch deltas.
Track A's KubectlClient.ApplyAsync(obj, opts) (Part 10) defaults to --server-side --field-manager=kubernetes-dsl, which is the modern recommended way to manage resources from a typed client. The field manager string lets multiple controllers (yours, Helm's, Flux's, etc.) coexist on the same object without stomping each other's fields.
Helm chart and Kustomize base emission
Real teams have Helm charts. The series doesn't try to replace Helm — it emits into the Helm directory layout:
public static void WriteHelmChart(KubernetesBundle bundle, string templatesDir)
{
Directory.CreateDirectory(templatesDir);
foreach (var obj in bundle.Objects)
{
var fileName = $"{obj.Kind.ToLowerInvariant()}-{obj.Metadata.Name}.yaml";
var filePath = Path.Combine(templatesDir, fileName);
File.WriteAllText(filePath, KubernetesYamlWriter.Write(obj));
}
}
public static void WriteKustomizeBase(KubernetesBundle bundle, string baseDir)
{
Directory.CreateDirectory(baseDir);
var resourceList = new List<string>();
foreach (var obj in bundle.Objects)
{
var fileName = $"{obj.Kind.ToLowerInvariant()}-{obj.Metadata.Name}.yaml";
File.WriteAllText(Path.Combine(baseDir, fileName), KubernetesYamlWriter.Write(obj));
resourceList.Add(fileName);
}
var kustomization = new
{
apiVersion = "kustomize.config.k8s.io/v1beta1",
kind = "Kustomization",
resources = resourceList,
};
File.WriteAllText(Path.Combine(baseDir, "kustomization.yaml"),
KubernetesYamlWriter.Write((dynamic)kustomization));
}public static void WriteHelmChart(KubernetesBundle bundle, string templatesDir)
{
Directory.CreateDirectory(templatesDir);
foreach (var obj in bundle.Objects)
{
var fileName = $"{obj.Kind.ToLowerInvariant()}-{obj.Metadata.Name}.yaml";
var filePath = Path.Combine(templatesDir, fileName);
File.WriteAllText(filePath, KubernetesYamlWriter.Write(obj));
}
}
public static void WriteKustomizeBase(KubernetesBundle bundle, string baseDir)
{
Directory.CreateDirectory(baseDir);
var resourceList = new List<string>();
foreach (var obj in bundle.Objects)
{
var fileName = $"{obj.Kind.ToLowerInvariant()}-{obj.Metadata.Name}.yaml";
File.WriteAllText(Path.Combine(baseDir, fileName), KubernetesYamlWriter.Write(obj));
resourceList.Add(fileName);
}
var kustomization = new
{
apiVersion = "kustomize.config.k8s.io/v1beta1",
kind = "Kustomization",
resources = resourceList,
};
File.WriteAllText(Path.Combine(baseDir, "kustomization.yaml"),
KubernetesYamlWriter.Write((dynamic)kustomization));
}Templating placeholders ({{ .Values.image }}) are not generated by the writer. Users who want them write a contributor (Part 12) that emits them as literal strings. Kubernetes.Dsl's job ends at "produce YAML"; what shape that YAML takes downstream is the user's call.
Round-trip test
[Fact]
public void V1Pod_RoundTrips()
{
var pod = new V1PodBuilder()
.WithMetadata(m => m
.WithName("order-api")
.WithNamespace("orders"))
.WithSpec(s => s.WithContainer(c => c
.WithName("api")
.WithImage("ghcr.io/acme/order-api:1.4.2")
.WithPort(8080)))
.Build().Value;
var yaml = KubernetesYamlWriter.Write(pod);
var roundTripped = KubernetesYamlReader.Read<V1Pod>(yaml);
Assert.Equal(pod, roundTripped);
}[Fact]
public void V1Pod_RoundTrips()
{
var pod = new V1PodBuilder()
.WithMetadata(m => m
.WithName("order-api")
.WithNamespace("orders"))
.WithSpec(s => s.WithContainer(c => c
.WithName("api")
.WithImage("ghcr.io/acme/order-api:1.4.2")
.WithPort(8080)))
.Build().Value;
var yaml = KubernetesYamlWriter.Write(pod);
var roundTripped = KubernetesYamlReader.Read<V1Pod>(yaml);
Assert.Equal(pod, roundTripped);
}This test asserts Read(Write(pod)) == pod. It does not assert Read(cluster.GetPod("order-api")) == pod — that's the round-trip against a live cluster, which (again) is out of scope.
Previous: Part 6: Code Emission and Special Types Next: Part 8: Incremental Generator Performance — Surviving 600 Types × 5 Versions