Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

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) { /* ... */ }
}

Five custom pieces:

  1. IntOrStringConverter — emits 8080 (integer) or http (string) depending on which form is set.
  2. QuantityConverter — emits 500m, 512Mi, 10Gi etc. as bare scalars (not strings).
  3. DurationConverter — converts TimeSpan back to Go duration syntax (30s, 5m, 1h30m).
  4. RawExtensionConverter — round-trips JsonElement so CRDs that embed arbitrary content don't lose fidelity.
  5. K8sStatusOmittingEmitter — drops the status: 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 }

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);
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);
    }
}

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;
}

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));
    }
}

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));
    }
}

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
    }
}

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);
    }
}

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));
}

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);
}

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

⬇ Download