Three planes, one responsibility each
An internal platform holds up if every request follows the same path: someone decides it, someone executes it, someone records the outcome. In this article I call the three roles the control plane, the execution plane and the data layer. Mind the name: it is a working convention, not a standard, and «control plane» has a precise meaning inside Kubernetes. Here I use it by analogy, to reason about who can change what.
The rule that holds the design together is that only the control plane receives requests from developers, and only it writes the desired state towards the execution plane. Nobody opens a terminal on a cluster to «fix it on the fly»: if an environment is needed, it goes through a request, a policy and a generated manifest. The rest of the article shows how this translates into practice, with executed examples: a small control plane that produces Kubernetes manifests, a manifest validator and a CloudEvents event with its routing.
The control plane: deciding before executing
The control plane contains the parts developers interact with and those that decide whether a request is admitted:
- Portal and API: the entry point, with authentication and rate limits; the portal (usually Backstage, which article 4 covers) is one of the possible interfaces, next to the command line and direct APIs.
- Workflow engine: coordinates the steps of a request (validate, check the policies, create the resources, verify) and remembers its state; Temporal and Argo Workflows are two well-known choices.
- Policy engine: decides whether the request complies with the organization's rules, for example with OPA or Kyverno.
- Service catalog: knows what exists and who owns it, the topic of article 6.
The four items do not carry the same weight in the first year. The catalog and the policies are what make the platform governable, while the workflow engine is justified only when a request has several steps that can fail halfway and must be resumed: with a single step, a CI job is enough. As for policies, the two tools mentioned have different paths: OPA has been a CNCF graduated project since 2021-01-29 and Kyverno since 2026-03-16 (project pages, consulted on 2026-11-13). Which one to use depends on where the rule must apply: the DevSecOps series goes into it in the article on policy as code.
To make the boundary between deciding and executing concrete, the script below is a control plane in miniature. It receives an environment request in JSON, applies three rules (team name, known environment, CPU ceiling) and, if the request passes, produces the desired state as Kubernetes manifests; otherwise it rejects it and produces nothing. The three rules and the 16-core threshold are examples, chosen for the case.
"""Miniature control plane: validates a request and produces the desired state."""
import json
import re
import sys
import yaml
AMBIENTI = {"dev", "staging", "production"}
NOME_VALIDO = re.compile(r"^[a-z][a-z0-9-]{1,40}$")
CPU_MASSIMA = 16 # cores a single namespace may request: example threshold
def controlla(r):
"""Returns the list of policy violations: empty = request allowed."""
violazioni = []
if not NOME_VALIDO.match(r["team"]):
violazioni.append(f"invalid team name: {r['team']!r}")
if r["ambiente"] not in AMBIENTI:
violazioni.append(f"unknown environment: {r['ambiente']!r}")
if r["cpu"] > CPU_MASSIMA:
violazioni.append(f"requested cpu {r['cpu']} above the maximum {CPU_MASSIMA}")
return violazioni
def stato_desiderato(r):
ns = f"team-{r['team']}"
etichette = {"platform.example/team": r["team"], "platform.example/env": r["ambiente"]}
return [
{"apiVersion": "v1", "kind": "Namespace",
"metadata": {"name": ns, "labels": etichette}},
{"apiVersion": "v1", "kind": "ResourceQuota",
"metadata": {"name": "quota", "namespace": ns},
"spec": {"hard": {"requests.cpu": str(r["cpu"] // 2), "limits.cpu": str(r["cpu"]),
"requests.memory": f"{r['cpu'] * 1}Gi", "limits.memory": f"{r['cpu'] * 2}Gi",
"pods": "50"}}},
{"apiVersion": "v1", "kind": "LimitRange",
"metadata": {"name": "default-containers", "namespace": ns},
"spec": {"limits": [{"type": "Container",
"defaultRequest": {"cpu": "100m", "memory": "128Mi"},
"default": {"cpu": "500m", "memory": "512Mi"}}]}},
{"apiVersion": "networking.k8s.io/v1", "kind": "NetworkPolicy",
"metadata": {"name": "default-deny-ingress", "namespace": ns},
"spec": {"podSelector": {}, "policyTypes": ["Ingress"]}},
{"apiVersion": "networking.k8s.io/v1", "kind": "NetworkPolicy",
"metadata": {"name": "allow-same-team", "namespace": ns},
"spec": {"podSelector": {}, "policyTypes": ["Ingress"],
"ingress": [{"from": [{"namespaceSelector": {
"matchLabels": {"platform.example/team": r["team"]}}}]}]}},
]
richiesta = json.loads(sys.argv[1])
problemi = controlla(richiesta)
if problemi:
print("REJECTED:", "; ".join(problemi), file=sys.stderr)
sys.exit(1)
print(yaml.safe_dump_all(stato_desiderato(richiesta), sort_keys=False), end="")
Two choices in the code matter more than the rest. The controlla function returns the list of all violations instead of stopping at the first, because whoever receives a rejection must be able to fix it in one go. And stato_desiderato applies nothing: it returns data, which another component (the execution plane) will bring to the cluster. Separating them makes it possible to test the rules without a cluster and to regenerate the manifests when the template changes.
The script prints the YAML on standard output and, on rejection, exits with code 1 and writes the error on standard error. With the valid request (team checkout, environment production, 16 cores) I obtained the five manifests shown further down; with {"team":"Checkout_Team","ambiente":"prod","cpu":64} the output was:
# python3 richiesta_ambiente.py '{"team":"Checkout_Team","ambiente":"prod","cpu":64}'
# REJECTED: invalid team name: 'Checkout_Team'; unknown environment: 'prod'; requested cpu 64 above the maximum 16
# (exit code: 1)
The execution plane: where things really happen
The execution plane is the Kubernetes clusters with their build and release tools. A GitOps reconciler such as Argo CD or Flux reads the desired state (here, the manifests generated by the script) and brings it to the cluster, correcting differences over time. The architectural advantage is that the control plane can stop without stopping the services: the clusters keep running the last known state, and only new requests are blocked. It is a property of the reconciliation design, not a measurement I made.
Here are the manifests the script produced for the valid request. They contain a namespace with labels, a resource quota, a default limit for containers and two network policies. I validated them with kubeconform 0.8.0 against the Kubernetes 1.37.0 schemas in -strict mode, which also rejects unknown fields.
apiVersion: v1
kind: Namespace
metadata:
name: team-checkout
labels:
platform.example/team: checkout
platform.example/env: production
---
apiVersion: v1
kind: ResourceQuota
metadata:
name: quota
namespace: team-checkout
spec:
hard:
requests.cpu: '8'
limits.cpu: '16'
requests.memory: 16Gi
limits.memory: 32Gi
pods: '50'
---
apiVersion: v1
kind: LimitRange
metadata:
name: default-containers
namespace: team-checkout
spec:
limits:
- type: Container
defaultRequest:
cpu: 100m
memory: 128Mi
default:
cpu: 500m
memory: 512Mi
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-ingress
namespace: team-checkout
spec:
podSelector: {}
policyTypes:
- Ingress
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-same-team
namespace: team-checkout
spec:
podSelector: {}
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
platform.example/team: checkout
# Output (kubeconform -strict -summary -kubernetes-version 1.37.0):
# Summary: 5 resources found in 1 file - Valid: 5, Invalid: 0, Errors: 0, Skipped: 0
Four details of these manifests come from the Kubernetes documentation and are the ones people trip over. First: a ResourceQuota on CPU and memory makes the cluster reject pods that do not declare requests and limits, and that is why a LimitRange with default values appears next to it. Second: a NetworkPolicy only has an effect if the cluster's network (the CNI plugin) enforces it, otherwise it is accepted and ignored. Third: network policies do not conflict but add up, so default-deny-ingress (selects all pods and admits nothing) together with allow-same-team (admits traffic from the namespaces carrying the team's label) produce «only the team gets in». Fourth: the order of the policies does not matter.
To find out whether the validator really sees errors, I spoiled the file by replacing ingress: with ingres: in one policy. The output:
# kubeconform -strict -summary -kubernetes-version 1.37.0 sbagliato.yaml
# sbagliato.yaml - NetworkPolicy allow-same-team is invalid: problem validating schema. Check JSON formatting: jsonschema validation failed with 'https://raw.githubusercontent.com/yannh/kubernetes-json-schema/master/v1.37.0-standalone-strict/networkpolicy-networking-v1.json#' - at '/spec': additional properties 'ingres' not allowed
# Summary: 5 resources found in 1 file - Valid: 4, Invalid: 1, Errors: 0, Skipped: 0
# (exit code: 1)
The schema check finds typos and wrong fields, not errors of intent: a CPU ceiling that is too high or a network policy that is too permissive pass validation. For those you need the policy engine, which is the other half of the control plane. Note also that kubeconform downloads the schemas from a third-party repository (yannh/kubernetes-json-schema): in a closed pipeline you use a local copy, with the -schema-location option.
The data layer: what happened
The data layer gathers what is needed to explain the state of the platform: metrics, logs, traces and an event log (who asked for what, with which outcome). For the first three the Observability series is the reference, starting from the fundamentals and from OpenTelemetry. Here the event log matters, because it is what lets several components react without calling each other.
CloudEvents is the CNCF specification for describing an event in a uniform way. In version 1.0.2 there are four required attributes: id, source, specversion and type, all non-empty strings. The type is used for routing, and the specification recommends (SHOULD) prefixing it with a reversed domain name. The script builds a completed-release event, validates it and routes it to whoever has subscribed with a pattern.
import hashlib
import json
from datetime import datetime
from fnmatch import fnmatch
OBBLIGATORI = ("id", "source", "specversion", "type")
def nuovo_evento(tipo, sorgente, soggetto, dati, quando):
corpo = json.dumps([tipo, sorgente, soggetto, dati, quando], sort_keys=True)
return {
"specversion": "1.0",
"id": hashlib.sha256(corpo.encode()).hexdigest()[:12], # same event, same id
"source": sorgente,
"type": tipo,
"subject": soggetto,
"time": quando,
"datacontenttype": "application/json",
"data": dati,
}
def valida(e):
errori = [f"missing required attribute {a!r}" for a in OBBLIGATORI if not e.get(a)]
if e.get("specversion") not in (None, "1.0"):
errori.append("specversion must be '1.0'")
if "." not in e.get("type", "."):
errori.append("type: the specification recommends a reverse-DNS prefix")
if "time" in e:
try:
datetime.fromisoformat(e["time"])
except ValueError:
errori.append("time is not an RFC 3339 timestamp")
return errori
def destinatari(e, iscrizioni):
return sorted(nome for nome, schema in iscrizioni.items() if fnmatch(e["type"], schema))
ok = nuovo_evento(
"io.example.platform.deployment.completed", "/ci/checkout", "checkout@v2.3.1",
{"environment": "production", "durata_s": 312}, "2026-11-13T09:30:00Z")
print(json.dumps(ok, indent=2, sort_keys=True))
print("valid:", valida(ok) == [])
rotto = {"type": "deployment-completed", "time": "yesterday"}
print("broken event:", valida(rotto))
iscrizioni = {
"audit": "io.example.platform.*",
"dora-metrics": "io.example.platform.deployment.*",
"alerts": "io.example.platform.incident.*",
}
print("recipients:", destinatari(ok, iscrizioni))
# Output (python3 evento.py):
{
"data": {
"durata_s": 312,
"environment": "production"
},
"datacontenttype": "application/json",
"id": "790ece950cf4",
"source": "/ci/checkout",
"specversion": "1.0",
"subject": "checkout@v2.3.1",
"time": "2026-11-13T09:30:00Z",
"type": "io.example.platform.deployment.completed"
}
valid: True
broken event: ["missing required attribute 'id'", "missing required attribute 'source'", "missing required attribute 'specversion'", 'type: the specification recommends a reverse-DNS prefix', 'time is not an RFC 3339 timestamp']
recipients: ['audit', 'dora-metrics']
The id is computed from the content, so the same event always has the same identifier and a consumer can discard duplicates: the specification requires the source plus id pair to be unique for each distinct event, and on queues with «at least once» delivery duplicates really do arrive. The second event, deliberately broken, shows the full list of errors; the third result shows that the release event reaches audit and dora-metrics but not alerts. The time value with the trailing «Z» requires Python 3.11 or later to be read by fromisoformat. (The key durata_s is the duration in seconds: I left it in Italian because it takes part in the hash that produces the id, and changing it would change the printed identifier.)
The broker that carries the events (Kafka, NATS or something else) does not appear in the listing, because the choice depends on the volume and on who runs it, and because the script only does the matching between type and subscription. If consumers are few and the volume is low, a direct webhook is enough: the broker is added when a slow consumer must not slow down whoever produces.
Service mesh: when it is really needed
A service mesh adds encryption, identity and metrics to every communication between services without changing the code. Linkerd's documentation describes automatic mTLS between the pods injected into the mesh, and Istio's treats mutual authentication as part of its security model. That is why they are the two names you meet first.
One fact to check before choosing them concerns licensing and distribution. Istio has been a CNCF graduated project since 2023-07-12 (project page, consulted on 2026-11-13). Linkerd remains open source, but its releases page states that since February 2024 the project no longer publishes stable release artifacts: stable releases are the responsibility of vendor distributions (for example Buoyant Enterprise for Linkerd), while the project publishes edge releases. Whoever picks Linkerd for «something light to run» must therefore decide whether to use the edge releases, pay for a distribution or build it themselves. The cost a mesh adds is always the same one: one more component on every network path, to be updated and to be understood when something does not answer. If the only goal is to encrypt traffic between a few services, first evaluate whether the certificates managed by your ingress and by the services' libraries are enough.
Scaling without multiplying control planes
With more clusters (per region, per environment, for different workloads) the control plane stays one and the execution planes grow. Three choices weigh more than the others:
- Isolation between teams: namespaces, quotas and network policies, as in the manifests above, before thinking about separate clusters per team.
- A single catalog: the list of clusters and of what they host lives in one place, not in the memory of whoever created them.
- Desired state in Git: if the control plane stops, the clusters still hold the last verified state.
The real question of scale is not «how many clusters» but «how many things do I have to change when I add one»: every new cluster must be born from a template (the same one that produced the manifests above), otherwise the fragmentation of article 1 reproduces itself one level further down. Article 8 returns to multi-cloud as a strategic choice.
What to build first
Start from the smallest control plane that gives a result: one request, one policy, one manifest, a single cluster. Add the workflow engine when a request has several steps that can fail halfway, the broker when consumers become more than two, the mesh when you have a network security requirement that the ingress certificates do not cover. This is my own indication, not a research finding, and it holds for small teams: in large organizations some of these parts already exist for other reasons.
What you can do now
You can tell apart who decides, who executes and who records, and why separating them makes the deciding part testable. You can generate and validate Kubernetes manifests with a policy in front, and you recognize the four traps of quota, network, sum of policies and order. You can build a valid CloudEvents event and route it, and you know which questions to ask a service mesh before adopting it. The next article goes down to the developer's path: the golden paths.
Frequently asked questions
What are the three layers of an Internal Developer Platform?
In this series: the control plane, which receives requests and decides with policies; the execution plane, which brings the desired state to the clusters; the data layer, which records metrics, logs, traces and events. It is a working convention, not a standard.
What does the control plane do?
It receives developers' requests, checks the organization's policies and produces the desired state, for example the Kubernetes manifests of an environment. It applies nothing by itself: the execution plane does that.
Why separate the control plane from the execution plane?
Because the rules can be tested without a cluster and because, with a GitOps reconciler, the clusters keep running the last known state even if the control plane stops: only new requests are blocked.
Is a service mesh needed in an IDP?
Not at the start. It is needed when you have a security or service-to-service traffic requirement that the ingress certificates do not cover. Since February 2024 the Linkerd project no longer publishes stable releases: you have to choose the edge releases or a vendor's distribution.
The same series also includes infrastructure as code with Terraform and Pulumi and the comparison between on-prem and cloud AI.
Sources
- Kubernetes: Network Policies - effect only with a network plugin that enforces them, sum of policies, default deny, consulted on 2026-11-13
- Kubernetes: Resource Quotas - names of the quota fields and behavior with pods without requests, LimitRange, consulted on 2026-11-13
- CloudEvents Specification v1.0.2 - required attributes and the recommendation on the type prefix, consulted on 2026-11-13
- Linkerd: Releases and Versions - since February 2024 no stable artifacts from the project, consulted on 2026-11-13
- CNCF: Open Policy Agent and CNCF: Kyverno - dates of the move to graduated, consulted on 2026-11-13
- kubeconform - manifest validator used for the schema check (version 0.8.0), consulted on 2026-11-13
The scripts were run twice with Python 3.14 and PyYAML 6, with identical output; validation with kubeconform 0.8.0 against the 1.37.0 schemas. The examples do not describe any real infrastructure.

Commenti
Caricamento commenti...
Sign in to comment