Everything you have used so far in this series, from Pods and Deployments to Services, ConfigMaps and Gateways, is a resource type that somebody added to the Kubernetes API. Some were compiled into the API server. Others, like the Gateway API, Argo CD Applications, KEDA ScaledObjects or cert-manager Certificates, were added from the outside with a CustomResourceDefinition (CRD). A CRD teaches the API server a new kind of object. From then on, kubectl get, RBAC, watches, validation, kubectl explain and etcd storage all work for it, without writing a line of server code.
In this lesson you define your own resource, a FeatureFlag, and let a .NET 10 API read it. The API server validates every flag with an OpenAPI schema and CEL rules, fills in defaults, prints custom columns, keeps status separate from spec, and serves the resource under two versions. The .NET side lists and watches the flags with the official KubernetesClient package, using a ServiceAccount that may only read flags in its own namespace. Changing a flag with kubectl patch changes the API's behavior within a second, without a restart.
We will cover the following topics:
- What a CRD is, and the difference between a custom resource and a controller
- The anatomy of a CRD: group, versions, scope, names, short names and categories
- OpenAPI v3 schema validation, defaults and strict field validation
- CEL validation rules with
x-kubernetes-validations, including immutable fields - additionalPrinterColumns, and the status and scale subresources
- Serving several versions with
served, storage, deprecated and the conversion strategy None - Reading and watching custom resources from .NET with KubernetesClient
- RBAC for custom resources, including aggregation into the built-in
view role
Technical Requirements
- A Kubernetes cluster and kubectl. The examples run on Docker Desktop's built-in Kubernetes with the kind provisioner (1 control-plane node and 3 workers, Kubernetes v1.36). A cluster created with
kind create cluster and the same layout behaves the same; there, load the image you build with kind load docker-image. - Docker and the .NET 10 SDK to build the sample API.
jq and curl for the test commands.- The RBAC and ServiceAccounts lessons. Roles, RoleBindings and
kubectl auth can-i are used here without being re-explained.
Core Concepts
Resources, Custom Resources and Controllers
The Kubernetes API is a collection of REST resources grouped by API group and version. deployments live in the group apps, version v1, at /apis/apps/v1/namespaces/<ns>/deployments. A CRD adds a new resource to that collection. After you apply a CRD for featureflags in the group flags.dotnetacademy.dev, the API server serves /apis/flags.dotnetacademy.dev/v1/namespaces/<ns>/featureflags and stores the objects in etcd like any built-in object.
A CRD on its own is only data. Nothing happens when you create a FeatureFlag except that it is validated and stored. Behavior comes from programs that read or watch these objects:
| Piece | What it is | Example in this lesson |
|---|
| CustomResourceDefinition | The type: name, schema, versions, columns | featureflags.flags.dotnetacademy.dev |
| Custom resource | One object of that type | FeatureFlag new-checkout |
| Consumer | A program that reads the objects and acts on them | The .NET flags-api |
| Controller / operator | A program that watches the objects and changes the cluster to match them | The next lesson builds one in .NET |
This split is why CRDs are so useful even without an operator. A FeatureFlag CRD gives you a typed, validated, RBAC-protected configuration store with a change feed, and every tool that speaks the Kubernetes API (kubectl, Argo CD, Helm, Lens) already understands it.
Anatomy of a CRD
The name of a CRD is always <plural>.<group>. The important fields:
| Field | Meaning |
|---|
spec.group | API group, usually a domain you own, e.g. flags.dotnetacademy.dev |
spec.scope | Namespaced or Cluster |
spec.names.kind / plural / singular | FeatureFlag, featureflags, featureflag |
spec.names.shortNames | Abbreviations for kubectl, e.g. ff |
spec.names.categories | Groups of resources: kubectl get flags lists every resource in the category flags. The category all would add it to kubectl get all |
spec.versions[] | One entry per version, each with its own schema, columns and subresources |
served / storage | Whether the version is offered by the API, and which single version is written to etcd |
subresources.status | Splits .status into its own endpoint |
subresources.scale | Lets kubectl scale and the HPA change a replica field |
spec.conversion.strategy | None (only apiVersion changes between versions) or Webhook |
Structural Schemas, Defaults and Unknown Fields
Every version must have a structural OpenAPI v3 schema: every field has a type, and objects list their properties. The API server uses it for three things:
- Validation.
minimum, maximum, pattern, enum, minLength, maxItems, required and so on are checked on every create and update. - Defaulting. A field with
default is filled in when it is missing, both when an object is written and when it is read from etcd. - Pruning. Fields that are not in the schema are dropped. Current kubectl versions ask the server for strict field validation by default (
--validate=strict), so a typo like rolloutPercnt is rejected with an error instead of being silently pruned.
kubectl explain reads the same schema, which is why the description fields are worth writing.
CEL Validation Rules
Some rules involve more than one field or compare the new object with the old one. Those are written in CEL (Common Expression Language) under x-kubernetes-validations. The API server evaluates them in-process. No webhook to run, deploy or keep available.
| Rule | Where | Meaning |
|---|
self.enabled || self.rolloutPercent == 0 | on spec | A disabled flag must have rolloutPercent 0 |
self == oldSelf | on spec.owner | A transition rule: owner may be set once, never changed |
self is the value of the field the rule is attached to. oldSelf is the previous value and only exists on updates. A rule that uses oldSelf is skipped on create. CEL rules run only after the plain schema checks pass, which is why you will see the message "some validation rules were not checked because the object was invalid".
The ValidatingAdmissionPolicy lesson uses the same language for rules across many resource types. CRD rules are the right place for rules that belong to the type itself.
Printer Columns
By default kubectl get on a custom resource shows only NAME and AGE. additionalPrinterColumns adds columns from JSONPath expressions. Each column has a type (string, integer, number, boolean, date) that controls formatting. A date column is printed as an age, like the built-in AGE.
The Status and Scale Subresources
Kubernetes objects split spec (what the user wants) from status (what a controller observed). With subresources.status: {} the API server enforces that split:
| Request | What it may change |
|---|
PUT/PATCH .../featureflags/<name> | spec and metadata. Changes to status are ignored |
PUT/PATCH .../featureflags/<name>/status | Only status. Changes to spec are ignored |
It also makes metadata.generation meaningful: generation increases only when the spec changes, not on status writes. Controllers compare it with status.observedGeneration to tell whether they have caught up. RBAC can grant the two endpoints separately, so users edit specs and only the controller writes status.
subresources.scale maps a replica count inside your spec to the standard Scale API, through specReplicasPath, statusReplicasPath and labelSelectorPath. Then kubectl scale and the HorizontalPodAutoscaler work on your custom resource. The next lesson's DotnetApp resource uses it; a feature flag has no replicas, so this lesson does not.
Versions and Conversion
APIs evolve. A CRD can serve several versions at once, for example a deprecated v1alpha1 next to v1. Exactly one version has storage: true; objects are written to etcd in that version. status.storedVersions records every version that has ever been used for storage, which matters when you later want to remove one.
With conversion.strategy: None, the API server converts between versions by changing only the apiVersion string. That is fine when the schemas are identical, as in a rename from v1alpha1 to v1. It is dangerous when they are not: each version validates with its own schema, so a loose old version becomes a back door for invalid data, as the step-by-step example shows. When the field layout really changes between versions, you need a conversion webhook.
deprecated: true with a deprecationWarning makes the API server return a warning header on every request to that version. kubectl prints it, and client libraries can log it.
Talking to Custom Resources from .NET
The official KubernetesClient NuGet package (version 21.0.8 in this lesson) has no generated classes for your types, but its CustomObjects API takes the group, version, namespace and plural as strings: ListNamespacedCustomObjectAsync<T>, WatchListNamespacedCustomObjectAsync, GetNamespacedCustomObjectAsync<T>, PatchNamespacedCustomObjectAsync and PatchNamespacedCustomObjectStatusAsync. You write the C# classes yourself, implementing IKubernetesObject<V1ObjectMeta> for the object and IItems<T> for the list. The watch returns each object untyped, as a JsonElement, which KubernetesJson.Deserialize<T> maps onto your class.
The standard way to keep a local copy is list, then watch: list once, remember the list's resourceVersion, and start the watch from it, so no event between the two calls is lost. When the server closes the watch, list again.
RBAC for Custom Resources
Custom resources are ordinary RBAC resources. A rule names the API group and the plural:
- apiGroups: ["flags.dotnetacademy.dev"]
resources: ["featureflags"] # or "featureflags/status"
verbs: ["get", "list", "watch"]
The built-in view, edit and admin ClusterRoles do not know about your type. They are aggregated roles: any ClusterRole labeled rbac.authorization.k8s.io/aggregate-to-view: "true" has its rules merged into view by the controller manager. Shipping such a ClusterRole with your CRD means every existing view binding immediately covers the new type.
Hands-On: Kubernetes Commands
List the resources an API group serves
Shows the plural, short names and subresources a CRD added.
kubectl api-resources --api-group=flags.dotnetacademy.dev
Read the schema of a custom resource
Works for every CRD in the cluster, including the ones Helm charts install.
kubectl explain featureflag.spec
kubectl explain featureflag.spec.rolloutPercent
List custom resources by short name and category
kubectl get ff -n crd-lab
kubectl get flags -n crd-lab
Write only the status
Without --subresource=status, status changes are silently ignored.
kubectl patch ff new-checkout -n crd-lab --subresource=status --type merge -p '{"status":{"reviewedBy":"alice"}}'
Ask for a specific version
The fully qualified resource name selects the version.
kubectl get featureflags.v1alpha1.flags.dotnetacademy.dev -n crd-lab
Check which versions exist and which are stored
kubectl get crd featureflags.flags.dotnetacademy.dev -o jsonpath='{range .spec.versions[*]}{.name} served={.served} storage={.storage}{"\n"}{end}stored: {.status.storedVersions}{"\n"}'
Check what a ServiceAccount may do with a custom resource
kubectl auth can-i watch featureflags.flags.dotnetacademy.dev -n crd-lab --as=system:serviceaccount:crd-lab:flags-api
Step-by-Step Example
The Scenario
A team wants feature flags without running a separate feature-flag service. Each flag is a Kubernetes object in the application's namespace, owned by a team, with a rollout percentage and the environments it applies to. The rules:
rolloutPercent is between 0 and 100 and defaults to 100.enabled defaults to true. A disabled flag must have rolloutPercent: 0.environments may only contain dev, staging and prod.owner is required, lowercase, and can never change.
A .NET 10 API, flags-api, keeps all flags of its namespace in memory and answers "is flag X on for user Y?". It must pick up changes immediately and must not be able to change flags.
Everything runs in a new namespace, crd-lab. The CRD itself and one ClusterRole are cluster-scoped and are removed in the last step.
Step 1 — Define the FeatureFlag CRD
Save this as 1-featureflag-crd.yaml:
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: featureflags.flags.dotnetacademy.dev
spec:
group: flags.dotnetacademy.dev
scope: Namespaced
names:
kind: FeatureFlag
listKind: FeatureFlagList
plural: featureflags
singular: featureflag
shortNames: ["ff"]
categories: ["flags"]
versions:
- name: v1
served: true
storage: true
additionalPrinterColumns:
- name: Enabled
type: boolean
jsonPath: .spec.enabled
- name: Rollout
type: integer
jsonPath: .spec.rolloutPercent
- name: Owner
type: string
jsonPath: .spec.owner
- name: Age
type: date
jsonPath: .metadata.creationTimestamp
schema:
openAPIV3Schema:
type: object
description: A feature flag that applications in this namespace read at runtime.
properties:
spec:
type: object
required: ["owner"]
x-kubernetes-validations:
- rule: "self.enabled || self.rolloutPercent == 0"
message: "rolloutPercent must be 0 while the flag is disabled"
properties:
enabled:
type: boolean
default: true
description: Master switch for the flag.
rolloutPercent:
type: integer
minimum: 0
maximum: 100
default: 100
description: Percentage of users who get the feature.
environments:
type: array
maxItems: 3
x-kubernetes-list-type: set
items:
type: string
enum: ["dev", "staging", "prod"]
description: Environments where the flag applies. Empty means all.
owner:
type: string
minLength: 3
pattern: "^[a-z0-9-]+$"
x-kubernetes-validations:
- rule: "self == oldSelf"
message: "owner is immutable"
description: Team that owns the flag.
status:
type: object
properties:
lastReviewed:
type: string
format: date-time
reviewedBy:
type: string
subresources:
status: {}
Apply it and wait for the API server to accept the names and start serving the resource:
kubectl create namespace crd-lab
kubectl apply -f 1-featureflag-crd.yaml
kubectl wait --for=condition=Established crd/featureflags.flags.dotnetacademy.dev --timeout=30s
namespace/crd-lab created
customresourcedefinition.apiextensions.k8s.io/featureflags.flags.dotnetacademy.dev created
customresourcedefinition.apiextensions.k8s.io/featureflags.flags.dotnetacademy.dev condition met
The API server now serves a new resource. Note the second entry: the status subresource is its own endpoint with its own verbs.
kubectl api-resources --api-group=flags.dotnetacademy.dev
kubectl get --raw /apis/flags.dotnetacademy.dev/v1 | jq -r '.resources[] | "\(.name) verbs=\(.verbs|join(","))"'
NAME SHORTNAMES APIVERSION NAMESPACED KIND
featureflags ff flags.dotnetacademy.dev/v1 true FeatureFlag
featureflags verbs=delete,deletecollection,get,list,patch,create,update,watch
featureflags/status verbs=get,patch,update
kubectl explain works from the schema. If it reports couldn't find resource right after the CRD was created, kubectl's discovery cache is a few seconds behind; run it again.
kubectl explain featureflag.spec
GROUP: flags.dotnetacademy.dev
KIND: FeatureFlag
VERSION: v1
FIELD: spec <Object>
FIELDS:
enabled <boolean>
Master switch for the flag.
environments <[]string>
Environments where the flag applies. Empty means all.
owner <string> -required-
Team that owns the flag.
rolloutPercent <integer>
Percentage of users who get the feature.
Step 2 — Create Flags and See the Defaults
Save three flags as 2-flags.yaml. dark-mode sets only the owner; legacy-search is switched off.
apiVersion: flags.dotnetacademy.dev/v1
kind: FeatureFlag
metadata:
name: new-checkout
namespace: crd-lab
spec:
rolloutPercent: 25
environments: ["dev", "staging"]
owner: payments-team
---
apiVersion: flags.dotnetacademy.dev/v1
kind: FeatureFlag
metadata:
name: dark-mode
namespace: crd-lab
spec:
owner: web-team
---
apiVersion: flags.dotnetacademy.dev/v1
kind: FeatureFlag
metadata:
name: legacy-search
namespace: crd-lab
spec:
enabled: false
rolloutPercent: 0
owner: search-team
kubectl apply -f 2-flags.yaml
kubectl get featureflags -n crd-lab
featureflag.flags.dotnetacademy.dev/new-checkout created
featureflag.flags.dotnetacademy.dev/dark-mode created
featureflag.flags.dotnetacademy.dev/legacy-search created
NAME ENABLED ROLLOUT OWNER AGE
dark-mode true 100 web-team 1s
legacy-search false 0 search-team 1s
new-checkout true 25 payments-team 1s
The columns come from additionalPrinterColumns. dark-mode shows true and 100 although the manifest did not set them: the API server applied the schema defaults before storing the object.
kubectl get ff dark-mode -n crd-lab -o jsonpath='{.spec}{"\n"}'
{"enabled":true,"owner":"web-team","rolloutPercent":100}
kubectl get flags -n crd-lab prints the same table through the category, and the object is also a plain JSON document at /apis/flags.dotnetacademy.dev/v1/namespaces/crd-lab/featureflags/<name>, which kubectl get --raw can fetch like any other REST path.
Step 3 — Let the API Server Reject Bad Flags
Each of the following files breaks one rule. First, three schema violations in one object, 3-invalid-flags.yaml:
apiVersion: flags.dotnetacademy.dev/v1
kind: FeatureFlag
metadata:
name: bad-schema
namespace: crd-lab
spec:
rolloutPercent: 150
environments: ["dev", "qa"]
owner: Payments_Team
kubectl apply -f 3-invalid-flags.yaml
The FeatureFlag "bad-schema" is invalid:
* spec.rolloutPercent: Invalid value: 150: spec.rolloutPercent in body should be less than or equal to 100
* spec.environments[1]: Unsupported value: "qa": supported values: "dev", "staging", "prod"
* spec.owner: Invalid value: "Payments_Team": spec.owner in body should match '^[a-z0-9-]+$'
* <nil>: Invalid value: null: some validation rules were not checked because the object was invalid; correct the existing errors to complete validation
All three errors come back at once. The last line tells you the CEL rules were skipped because the plain schema checks already failed.
Now a flag that passes the schema but breaks the cross-field CEL rule, 4-disabled-with-rollout.yaml:
apiVersion: flags.dotnetacademy.dev/v1
kind: FeatureFlag
metadata:
name: half-off
namespace: crd-lab
spec:
enabled: false
rolloutPercent: 50
owner: web-team
kubectl apply -f 4-disabled-with-rollout.yaml
The FeatureFlag "half-off" is invalid: spec: Invalid value: rolloutPercent must be 0 while the flag is disabled
The transition rule on owner turns it into a write-once field:
kubectl patch ff dark-mode -n crd-lab --type merge -p '{"spec":{"owner":"mobile-team"}}'
The FeatureFlag "dark-mode" is invalid: spec.owner: Invalid value: "mobile-team": owner is immutable
Two more files: 4b-no-owner.yaml leaves out owner, and 4c-typo.yaml has a valid owner but spells rolloutPercent as rolloutPercnt: 10.
kubectl apply -f 4b-no-owner.yaml
kubectl apply -f 4c-typo.yaml
The FeatureFlag "no-owner" is invalid:
* spec.owner: Required value
* <nil>: Invalid value: null: some validation rules were not checked because the object was invalid; correct the existing errors to complete validation
Error from server (BadRequest): error when creating "4c-typo.yaml": FeatureFlag in version "v1" cannot be handled as a FeatureFlag: strict decoding error: unknown field "spec.rolloutPercnt"
The typo is caught by strict field validation. With an older client, or with --validate=false, the API server would have pruned the unknown field and stored a flag with the default rolloutPercent: 100, the opposite of what the author meant.
Step 4 — Status Is a Separate Endpoint
Suppose a review tool records who last reviewed a flag in .status. Watch metadata.generation while you try three different writes:
kubectl get ff new-checkout -n crd-lab -o jsonpath='generation={.metadata.generation}{"\n"}'
kubectl patch ff new-checkout -n crd-lab --type merge -p '{"status":{"reviewedBy":"alice"}}'
kubectl get ff new-checkout -n crd-lab -o jsonpath='status={.status}{"\n"}'
generation=1
featureflag.flags.dotnetacademy.dev/new-checkout patched (no change)
status=
A normal patch cannot touch status: "no change", and the status is still empty. The status endpoint can:
kubectl patch ff new-checkout -n crd-lab --subresource=status --type merge -p '{"status":{"reviewedBy":"alice","lastReviewed":"2026-10-21T12:00:00Z"}}'
kubectl get ff new-checkout -n crd-lab -o jsonpath='generation={.metadata.generation} status={.status}{"\n"}'
featureflag.flags.dotnetacademy.dev/new-checkout patched
generation=1 status={"lastReviewed":"2026-10-21T12:00:00Z","reviewedBy":"alice"}
The status changed and the generation did not. Change the spec, and the generation moves while the status stays:
kubectl patch ff new-checkout -n crd-lab --type merge -p '{"spec":{"rolloutPercent":50}}'
kubectl get ff new-checkout -n crd-lab -o jsonpath='generation={.metadata.generation} status={.status}{"\n"}'
featureflag.flags.dotnetacademy.dev/new-checkout patched
generation=2 status={"lastReviewed":"2026-10-21T12:00:00Z","reviewedBy":"alice"}
Step 5 — Serve a Second Version, and the Conversion Trap
Older clients still send flags.dotnetacademy.dev/v1alpha1. A tempting shortcut is to add that version with a schema that accepts anything. This is 5-crd-two-versions-wrong.yaml: the same CRD as Step 1, with this entry appended to versions and the conversion strategy set:
- name: v1alpha1
served: true
storage: false
deprecated: true
deprecationWarning: "flags.dotnetacademy.dev/v1alpha1 FeatureFlag is deprecated; use flags.dotnetacademy.dev/v1"
schema:
openAPIV3Schema:
type: object
x-kubernetes-preserve-unknown-fields: true
conversion:
strategy: None
kubectl apply -f 5-crd-two-versions-wrong.yaml
kubectl get featureflags.v1alpha1.flags.dotnetacademy.dev -n crd-lab
customresourcedefinition.apiextensions.k8s.io/featureflags.flags.dotnetacademy.dev configured
Warning: flags.dotnetacademy.dev/v1alpha1 FeatureFlag is deprecated; use flags.dotnetacademy.dev/v1
NAME AGE
dark-mode 1s
legacy-search 1s
new-checkout 1s
The same three objects are visible through both versions, and the old version carries a deprecation warning. Its table has no custom columns, because those are defined per version. Now send an invalid flag through the old version, 5b-sneaky-v1alpha1.yaml:
apiVersion: flags.dotnetacademy.dev/v1alpha1
kind: FeatureFlag
metadata:
name: sneaky
namespace: crd-lab
spec:
rolloutPercent: 150
owner: Not_Valid
kubectl apply -f 5b-sneaky-v1alpha1.yaml
kubectl get ff -n crd-lab
Warning: flags.dotnetacademy.dev/v1alpha1 FeatureFlag is deprecated; use flags.dotnetacademy.dev/v1
featureflag.flags.dotnetacademy.dev/sneaky created
NAME ENABLED ROLLOUT OWNER AGE
dark-mode true 100 web-team 1s
legacy-search false 0 search-team 1s
new-checkout true 50 payments-team 1s
sneaky true 150 Not_Valid 0s
sneaky was validated against the loose v1alpha1 schema, converted with strategy None (only apiVersion changed) and stored as v1. Every v1 client, including the .NET API you are about to build, now receives a flag with a rollout of 150 percent and an owner that the v1 schema forbids. Remove it and fix the CRD.
With strategy None, every served version must have the same schema. A YAML anchor keeps the two copies identical. This is 5-crd-two-versions.yaml: in the v1 entry, the line schema: becomes schema: &featureflag-schema, and the v1alpha1 entry reuses it:
- name: v1alpha1
served: true
storage: false
deprecated: true
deprecationWarning: "flags.dotnetacademy.dev/v1alpha1 FeatureFlag is deprecated; use flags.dotnetacademy.dev/v1"
schema: *featureflag-schema
subresources:
status: {}
conversion:
strategy: None
kubectl delete ff sneaky -n crd-lab
kubectl apply -f 5-crd-two-versions.yaml
kubectl apply -f 5b-sneaky-v1alpha1.yaml
featureflag.flags.dotnetacademy.dev "sneaky" deleted from crd-lab namespace
customresourcedefinition.apiextensions.k8s.io/featureflags.flags.dotnetacademy.dev configured
Warning: flags.dotnetacademy.dev/v1alpha1 FeatureFlag is deprecated; use flags.dotnetacademy.dev/v1
The FeatureFlag "sneaky" is invalid:
* spec.owner: Invalid value: "Not_Valid": spec.owner in body should match '^[a-z0-9-]+$'
* spec.rolloutPercent: Invalid value: 150: spec.rolloutPercent in body should be less than or equal to 100
The old version is still served, but it now enforces the same rules. Reading through it returns the same data with a different apiVersion, and etcd still holds only v1:
kubectl get featureflags.v1alpha1.flags.dotnetacademy.dev dark-mode -n crd-lab -o jsonpath='{.apiVersion} {.spec}{"\n"}'
kubectl get crd featureflags.flags.dotnetacademy.dev -o jsonpath='{range .spec.versions[*]}{.name} served={.served} storage={.storage} deprecated={.deprecated}{"\n"}{end}stored: {.status.storedVersions}{"\n"}'
Warning: flags.dotnetacademy.dev/v1alpha1 FeatureFlag is deprecated; use flags.dotnetacademy.dev/v1
flags.dotnetacademy.dev/v1alpha1 {"enabled":true,"owner":"web-team","rolloutPercent":100}
v1 served=true storage=true deprecated=
v1alpha1 served=true storage=false deprecated=true
stored: ["v1"]
Because storedVersions contains only v1, you can later set served: false on v1alpha1 and remove it without migrating any stored objects.
Step 6 — The flags-api in .NET 10
Create the project and add the client library. System.IO.Hashing provides a fast, stable hash for putting users into rollout buckets.
dotnet new webapi -n FlagsApi -o flags-api --no-openapi
cd flags-api
dotnet add package KubernetesClient --version 21.0.8
dotnet add package System.IO.Hashing
FeatureFlag.cs describes the custom resource as C# classes. Only the fields the API needs are mapped; the JSON names match the CRD schema.
using System.Text.Json.Serialization;
using k8s;
using k8s.Models;
namespace FlagsApi;
public class FeatureFlag : IKubernetesObject<V1ObjectMeta>
{
public const string Group = "flags.dotnetacademy.dev";
public const string Version = "v1";
public const string Plural = "featureflags";
[JsonPropertyName("apiVersion")] public string ApiVersion { get; set; } = $"{Group}/{Version}";
[JsonPropertyName("kind")] public string Kind { get; set; } = "FeatureFlag";
[JsonPropertyName("metadata")] public V1ObjectMeta Metadata { get; set; } = new();
[JsonPropertyName("spec")] public FeatureFlagSpec Spec { get; set; } = new();
}
public class FeatureFlagSpec
{
[JsonPropertyName("enabled")] public bool Enabled { get; set; }
[JsonPropertyName("rolloutPercent")] public int RolloutPercent { get; set; }
[JsonPropertyName("environments")] public List<string> Environments { get; set; } = [];
[JsonPropertyName("owner")] public string Owner { get; set; } = "";
}
public class FeatureFlagList : IKubernetesObject<V1ListMeta>, IItems<FeatureFlag>
{
[JsonPropertyName("apiVersion")] public string ApiVersion { get; set; } = "";
[JsonPropertyName("kind")] public string Kind { get; set; } = "";
[JsonPropertyName("metadata")] public V1ListMeta Metadata { get; set; } = new();
[JsonPropertyName("items")] public IList<FeatureFlag> Items { get; set; } = [];
}
FlagCache.cs is a BackgroundService that implements list-then-watch. It starts the watch at the list's resourceVersion, so no change between the two calls is lost, and it re-lists whenever the watch ends or fails.
using System.Collections.Concurrent;
using System.Text.Json;
using k8s;
namespace FlagsApi;
// Keeps an in-memory copy of every FeatureFlag in the namespace: list once, then watch.
public class FlagCache(IKubernetes client, ILogger<FlagCache> logger, IConfiguration config) : BackgroundService
{
private readonly ConcurrentDictionary<string, FeatureFlag> _flags = new();
private readonly string _ns = config["POD_NAMESPACE"] ?? "default";
public IReadOnlyCollection<FeatureFlag> All => _flags.Values.OrderBy(f => f.Metadata.Name).ToList();
public FeatureFlag? Get(string name) => _flags.GetValueOrDefault(name);
public bool Synced { get; private set; }
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try
{
var list = await client.CustomObjects.ListNamespacedCustomObjectAsync<FeatureFlagList>(
FeatureFlag.Group, FeatureFlag.Version, _ns, FeatureFlag.Plural, cancellationToken: ct);
_flags.Clear();
foreach (var f in list.Items) _flags[f.Metadata.Name] = f;
Synced = true;
logger.LogInformation("Listed {Count} flags at resourceVersion {Rv}", list.Items.Count, list.Metadata.ResourceVersion);
await foreach (var (type, obj) in client.CustomObjects.WatchListNamespacedCustomObjectAsync(
FeatureFlag.Group, FeatureFlag.Version, _ns, FeatureFlag.Plural,
resourceVersion: list.Metadata.ResourceVersion, cancellationToken: ct))
{
// Custom objects arrive untyped; map them onto our FeatureFlag class.
var flag = KubernetesJson.Deserialize<FeatureFlag>(((JsonElement)obj).GetRawText());
if (type == WatchEventType.Deleted) _flags.TryRemove(flag.Metadata.Name, out _);
else _flags[flag.Metadata.Name] = flag;
logger.LogInformation("{Type} {Name} enabled={Enabled} rollout={Rollout}%",
type, flag.Metadata.Name, flag.Spec.Enabled, flag.Spec.RolloutPercent);
}
logger.LogInformation("Watch closed by the server, re-listing");
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { }
catch (Exception ex)
{
logger.LogWarning("Watch failed: {Message}. Retrying in 5s", ex.Message);
await Task.Delay(TimeSpan.FromSeconds(5), ct);
}
}
}
}
Program.cs wires it up. InClusterConfig() reads the ServiceAccount token that Kubernetes mounts into the Pod; outside the cluster the same code uses your kubeconfig. A user always lands in the same bucket for a flag, so raising the rollout from 25 to 50 percent keeps the first 25 percent of users in and adds new ones.
using System.IO.Hashing;
using System.Text;
using FlagsApi;
using k8s;
var builder = WebApplication.CreateBuilder(args);
var k8sConfig = KubernetesClientConfiguration.IsInCluster()
? KubernetesClientConfiguration.InClusterConfig()
: KubernetesClientConfiguration.BuildConfigFromConfigFile();
builder.Services.AddSingleton<IKubernetes>(new Kubernetes(k8sConfig));
builder.Services.AddSingleton<FlagCache>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<FlagCache>());
var app = builder.Build();
var environment = app.Configuration["APP_ENVIRONMENT"] ?? "dev";
app.MapGet("/flags", (FlagCache cache) => cache.All.Select(f => new
{
name = f.Metadata.Name,
f.Spec.Enabled,
f.Spec.RolloutPercent,
f.Spec.Environments,
f.Spec.Owner
}));
app.MapGet("/flags/{name}/evaluate", (string name, string user, FlagCache cache) =>
{
var flag = cache.Get(name);
if (flag is null) return Results.NotFound(new { name, error = "unknown flag" });
// Same user always lands in the same bucket (0-99), so a rollout is sticky per user.
var bucket = (int)(XxHash32.HashToUInt32(Encoding.UTF8.GetBytes($"{name}:{user}")) % 100);
var inEnvironment = flag.Spec.Environments.Count == 0 || flag.Spec.Environments.Contains(environment);
var on = flag.Spec.Enabled && inEnvironment && bucket < flag.Spec.RolloutPercent;
return Results.Ok(new { name, user, environment, bucket, on });
});
app.MapGet("/healthz/ready", (FlagCache cache) => cache.Synced ? Results.Ok("synced") : Results.StatusCode(503));
app.Run();
The image uses the chiseled ASP.NET Core runtime, which runs as a non-root user by default:
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY FlagsApi.csproj .
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app --no-restore
FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled
WORKDIR /app
COPY --from=build /app .
ENTRYPOINT ["dotnet", "FlagsApi.dll"]
docker build -t flags-api:1.0 .
Step 7 — A ServiceAccount That Can Only Read Flags
Save as 6-flags-api-rbac.yaml:
apiVersion: v1
kind: ServiceAccount
metadata:
name: flags-api
namespace: crd-lab
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: featureflag-reader
namespace: crd-lab
rules:
- apiGroups: ["flags.dotnetacademy.dev"]
resources: ["featureflags"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: flags-api-reads-flags
namespace: crd-lab
subjects:
- kind: ServiceAccount
name: flags-api
namespace: crd-lab
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: featureflag-reader
kubectl apply -f 6-flags-api-rbac.yaml
kubectl auth can-i list featureflags.flags.dotnetacademy.dev -n crd-lab --as=system:serviceaccount:crd-lab:flags-api
kubectl auth can-i watch featureflags.flags.dotnetacademy.dev -n crd-lab --as=system:serviceaccount:crd-lab:flags-api
kubectl auth can-i update featureflags.flags.dotnetacademy.dev -n crd-lab --as=system:serviceaccount:crd-lab:flags-api
kubectl auth can-i list featureflags.flags.dotnetacademy.dev -n default --as=system:serviceaccount:crd-lab:flags-api
kubectl auth can-i patch featureflags.flags.dotnetacademy.dev/status -n crd-lab --as=system:serviceaccount:crd-lab:flags-api
serviceaccount/flags-api created
role.rbac.authorization.k8s.io/featureflag-reader created
rolebinding.rbac.authorization.k8s.io/flags-api-reads-flags created
yes
yes
no
no
no
Read and watch in crd-lab, nothing else: no writes, no other namespaces, and no access to the status subresource, which RBAC treats as a separate resource.
Deploy the API with 7-flags-api.yaml. The Downward API passes the Pod's namespace to the app, and the readiness probe stays red until the first list has filled the cache.
apiVersion: apps/v1
kind: Deployment
metadata:
name: flags-api
namespace: crd-lab
spec:
replicas: 2
selector:
matchLabels:
app: flags-api
template:
metadata:
labels:
app: flags-api
spec:
serviceAccountName: flags-api
containers:
- name: api
image: flags-api:1.0
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
env:
- name: POD_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: APP_ENVIRONMENT
value: staging
readinessProbe:
httpGet:
path: /healthz/ready
port: 8080
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 128Mi
---
apiVersion: v1
kind: Service
metadata:
name: flags-api
namespace: crd-lab
spec:
selector:
app: flags-api
ports:
- port: 80
targetPort: 8080
kubectl apply -f 7-flags-api.yaml
kubectl rollout status deploy/flags-api -n crd-lab --timeout=120s
kubectl logs -n crd-lab -l app=flags-api --prefix | grep Listed
deployment.apps/flags-api created
service/flags-api created
Waiting for deployment "flags-api" rollout to finish: 0 of 2 updated replicas are available...
Waiting for deployment "flags-api" rollout to finish: 1 of 2 updated replicas are available...
deployment "flags-api" successfully rolled out
[pod/flags-api-79bfb78dd9-k8d48/api] Listed 3 flags at resourceVersion 611558
[pod/flags-api-79bfb78dd9-srx4j/api] Listed 3 flags at resourceVersion 611558
Step 8 — Evaluate Flags and Change Them Live
Forward a local port to the Service. Port 18091 is arbitrary; pick any free port.
kubectl port-forward -n crd-lab svc/flags-api 18091:80 >/dev/null &
curl -s localhost:18091/flags | jq -c '.[]'
{"name":"dark-mode","enabled":true,"rolloutPercent":100,"environments":[],"owner":"web-team"}
{"name":"legacy-search","enabled":false,"rolloutPercent":0,"environments":[],"owner":"search-team"}
{"name":"new-checkout","enabled":true,"rolloutPercent":50,"environments":["dev","staging"],"owner":"payments-team"}
new-checkout is at 50 percent since Step 4, and the API runs as staging. Users with a bucket below 50 get the feature:
for u in alice bob carol dave erin frank; do curl -s "localhost:18091/flags/new-checkout/evaluate?user=$u" | jq -c .; done
curl -s 'localhost:18091/flags/legacy-search/evaluate?user=carol' | jq -c .
curl -s 'localhost:18091/flags/dark-mode/evaluate?user=carol' | jq -c .
{"name":"new-checkout","user":"alice","environment":"staging","bucket":54,"on":false}
{"name":"new-checkout","user":"bob","environment":"staging","bucket":88,"on":false}
{"name":"new-checkout","user":"carol","environment":"staging","bucket":29,"on":true}
{"name":"new-checkout","user":"dave","environment":"staging","bucket":19,"on":true}
{"name":"new-checkout","user":"erin","environment":"staging","bucket":89,"on":false}
{"name":"new-checkout","user":"frank","environment":"staging","bucket":94,"on":false}
{"name":"legacy-search","user":"carol","environment":"staging","bucket":73,"on":false}
{"name":"dark-mode","user":"carol","environment":"staging","bucket":41,"on":true}
Roll the feature out to everyone. No redeploy, no restart:
kubectl patch ff new-checkout -n crd-lab --type merge -p '{"spec":{"rolloutPercent":100}}'
for u in alice bob; do curl -s "localhost:18091/flags/new-checkout/evaluate?user=$u" | jq -c .; done
featureflag.flags.dotnetacademy.dev/new-checkout patched
{"name":"new-checkout","user":"alice","environment":"staging","bucket":54,"on":true}
{"name":"new-checkout","user":"bob","environment":"staging","bucket":88,"on":true}
Restrict it to dev, and the staging API switches it off for everyone:
kubectl patch ff new-checkout -n crd-lab --type merge -p '{"spec":{"environments":["dev"]}}'
curl -s 'localhost:18091/flags/new-checkout/evaluate?user=alice' | jq -c .
featureflag.flags.dotnetacademy.dev/new-checkout patched
{"name":"new-checkout","user":"alice","environment":"staging","bucket":54,"on":false}
Add one flag (9-search-v2.yaml) and delete another:
apiVersion: flags.dotnetacademy.dev/v1
kind: FeatureFlag
metadata:
name: search-v2
namespace: crd-lab
spec:
rolloutPercent: 10
environments: ["staging", "prod"]
owner: search-team
kubectl apply -f 9-search-v2.yaml
kubectl delete ff legacy-search -n crd-lab
kubectl logs -n crd-lab -l app=flags-api --prefix --since=1m | grep -A1 FlagCache | grep -v -e '^--' -e 'info:'
curl -s localhost:18091/flags | jq -r '.[].name'
featureflag.flags.dotnetacademy.dev/search-v2 created
featureflag.flags.dotnetacademy.dev "legacy-search" deleted from crd-lab namespace
[pod/flags-api-79bfb78dd9-k8d48/api] Listed 3 flags at resourceVersion 611558
[pod/flags-api-79bfb78dd9-k8d48/api] Modified new-checkout enabled=True rollout=100%
[pod/flags-api-79bfb78dd9-k8d48/api] Modified new-checkout enabled=True rollout=100%
[pod/flags-api-79bfb78dd9-k8d48/api] Added search-v2 enabled=True rollout=10%
[pod/flags-api-79bfb78dd9-k8d48/api] Deleted legacy-search enabled=False rollout=0%
[pod/flags-api-79bfb78dd9-srx4j/api] (the same events for the second replica)
dark-mode
new-checkout
search-v2
Both replicas received every event over their watch: two Modified events for the two patches, one Added, one Deleted. The cache follows the cluster without polling.
Step 9 — Make the Built-in view Role Cover FeatureFlags
An auditor bound to the built-in view ClusterRole can read almost everything in the namespace, but not your new type:
kubectl create serviceaccount auditor -n crd-lab
kubectl create rolebinding auditor-view -n crd-lab --clusterrole=view --serviceaccount=crd-lab:auditor
kubectl auth can-i list featureflags.flags.dotnetacademy.dev -n crd-lab --as=system:serviceaccount:crd-lab:auditor
serviceaccount/auditor created
rolebinding.rbac.authorization.k8s.io/auditor-view created
no
Ship an aggregated ClusterRole with the CRD, 8-aggregate-to-view.yaml:
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: featureflags-view
labels:
rbac.authorization.k8s.io/aggregate-to-view: "true"
rules:
- apiGroups: ["flags.dotnetacademy.dev"]
resources: ["featureflags"]
verbs: ["get", "list", "watch"]
kubectl apply -f 8-aggregate-to-view.yaml
kubectl auth can-i list featureflags.flags.dotnetacademy.dev -n crd-lab --as=system:serviceaccount:crd-lab:auditor
kubectl auth can-i update featureflags.flags.dotnetacademy.dev -n crd-lab --as=system:serviceaccount:crd-lab:auditor
kubectl get clusterrole view -o json | jq -c '.rules[] | select(.apiGroups[0]=="flags.dotnetacademy.dev")'
clusterrole.rbac.authorization.k8s.io/featureflags-view created
yes
no
{"apiGroups":["flags.dotnetacademy.dev"],"resources":["featureflags"],"verbs":["get","list","watch"]}
The controller manager copied the rule into view. Every existing view binding in the cluster now covers FeatureFlags, read-only. Use aggregate-to-edit and aggregate-to-admin the same way for write access.
Step 10 — Clean Up, and What Deleting a CRD Does
Deleting a CRD deletes every object of that type in every namespace. There is no confirmation and no undo, so treat CRD deletion like dropping a database table.
kubectl get ff -n crd-lab --no-headers | wc -l
kubectl delete crd featureflags.flags.dotnetacademy.dev
kubectl get ff -n crd-lab
kubectl logs -n crd-lab -l app=flags-api --since=30s --tail=4
3
customresourcedefinition.apiextensions.k8s.io "featureflags.flags.dotnetacademy.dev" deleted
Error from server (NotFound): Unable to list "flags.dotnetacademy.dev/v1, Resource=featureflags": the server could not find the requested resource (get featureflags.flags.dotnetacademy.dev)
warn: FlagsApi.FlagCache[0]
Watch failed: Operation returned an invalid status code 'NotFound', response body 404 page not found
. Retrying in 5s
The three flags are gone, the API path no longer exists, and the .NET cache keeps retrying every five seconds, which is the behavior you want from a consumer: it recovers on its own when the CRD comes back. Finish the cleanup:
kubectl delete clusterrole featureflags-view
kubectl delete namespace crd-lab
kill %1 # the port-forward
clusterrole.rbac.authorization.k8s.io "featureflags-view" deleted
namespace "crd-lab" deleted
Summary
- A CRD adds a resource type to the Kubernetes API: REST endpoints, etcd storage, watches, RBAC and kubectl support come for free. Behavior comes from programs that read or watch the objects.
- The structural schema validates, defaults and prunes; strict field validation rejects typos. CEL rules add cross-field rules and transition rules such as
self == oldSelf, without a webhook. additionalPrinterColumns, shortNames and categories shape the kubectl experience.- The status subresource separates intent from observation, and
metadata.generation moves only with the spec. The scale subresource connects a replica field to kubectl scale and the HPA. - One version is the storage version, recorded in
status.storedVersions. With conversion None, give every served version the same schema, or an old version becomes a way around your validation. - From .NET, use KubernetesClient's
CustomObjects API with list-then-watch, and re-list when the watch ends. - Grant custom resources like any other resource, keep
/status separate, and ship an aggregate-to-view ClusterRole. - Deleting a CRD deletes all of its objects in every namespace.