ControlPlane API & Projector
The ControlPlane API and the Projector work together to give external clients — such as the Control Plane UI — read-only access to the platform's state. While the operators and custom resources remain the source of truth inside Kubernetes, these two components make that state available through a standard GraphQL API backed by a PostgreSQL database.
If you only manage the Control Plane through kubectl and Rover-CTL, you do not strictly need these components. They become essential when you want to offer a web-based UI or any external tooling that queries the platform state without direct Kubernetes API access.
How They Work Together
- The Projector runs inside the Kubernetes cluster as a read-only controller. It watches custom resources (teams, applications, API exposures, subscriptions, approvals) and continuously projects their current state into PostgreSQL. It never writes back to the cluster.
- The ControlPlane API is a read-only GraphQL server that queries the same PostgreSQL database. It provides paginated, filterable access to all projected resources.
Neither component modifies the Kubernetes state. All mutations continue to flow through Rover-CTL, Rover Server, or kubectl.
Prerequisites
Before enabling the ControlPlane API and Projector, ensure you have:
- A running PostgreSQL instance — in production use a managed database service; the
databasecomponent provides one for local development (see below) - The Control Plane operators installed and running (see Installation)
Both components connect to the same database through the controlplane-db Secret. The Projector creates and manages the schema automatically on startup — no manual migration is needed.
Enabling in the local overlay
The local overlay includes the ControlPlane API, Projector, and an in-cluster PostgreSQL instance out of the box. No additional steps are needed — just deploy using the local overlay as described in the Quickstart:
kubectl apply -k install/overlays/local
The local overlay also:
- Includes the
databasecomponent, which deploys a PostgreSQL pod with default credentials into thecontrolplane-systemnamespace - Provides a pre-configured ControlPlane API config with the external listener's JWT auth set to
mockand the GraphQL Playground enabled - Sets all images to the
latesttag withIfNotPresentpull policy
Enabling in the default overlay
The default overlay (install/overlays/default/kustomization.yaml) does not include the ControlPlane API and Projector by default — the relevant resource and image entries are present but commented out. The simplest way to enable them is to uncomment these lines directly in your copy of the overlay:
resources:
# ...existing controllers...
# Uncomment to enable the controlplane-api and projector:
- https://github.com/telekom/controlplane//controlplane-api/config/default/?timeout=120&ref=v0.18.0
- https://github.com/telekom/controlplane//projector/config/default/?timeout=120&ref=v0.18.0
# Uncomment to deploy an in-cluster PostgreSQL (development only):
components:
- ../../components/database
images:
# ...existing images...
- name: ghcr.io/telekom/controlplane/controlplane-api
newTag: v0.18.0
- name: ghcr.io/telekom/controlplane/projector
newTag: v0.18.0
Alternatively, if you prefer to keep the upstream overlay untouched, create a custom overlay in your own repository that builds on the bundle — the same pattern used for optional capabilities and other customisations. This bundle-based workflow requires Control Plane v0.22.0 or newer.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: controlplane-system
resources:
- https://github.com/telekom/controlplane//install/bundle/?ref=v0.22.0
- https://github.com/telekom/controlplane//controlplane-api/config/default/?timeout=120&ref=v0.22.0
- https://github.com/telekom/controlplane//projector/config/default/?timeout=120&ref=v0.22.0
configMapGenerator:
- name: controlplane-env
literals:
- FEATURE_PUBSUB_ENABLED=false
- FEATURE_PERMISSION_ENABLED=false
- FEATURE_AI_GATEWAY_ENABLED=false
images:
- name: ghcr.io/telekom/controlplane/controlplane-api
newTag: v0.22.0
- name: ghcr.io/telekom/controlplane/projector
newTag: v0.22.0
Database
Both components expect a Kubernetes Secret named controlplane-db with a url key containing the PostgreSQL connection string. For production, create this Secret with your managed database credentials:
apiVersion: v1
kind: Secret
metadata:
name: controlplane-db
namespace: controlplane-system
stringData:
url: postgres://user:password@your-db-host:5432/controlplane?sslmode=require
By default, the database (/controlplane above) must already exist. To let the Projector create it on startup instead, set DB_CREATE: "true" in the optional projector-env ConfigMap. The database user then needs the CREATEDB permission and a database with the user's name.
For development or testing, you can include the database component instead, which deploys an in-cluster PostgreSQL and creates the Secret automatically:
components:
- https://github.com/telekom/controlplane//install/components/database/?ref=v0.18.0
The database component uses hardcoded credentials and is intended for development only. Always use a managed database service with secure credentials in production.
ControlPlane API configuration
The ControlPlane API reads its configuration from a ConfigMap named controlplane-api-config. When the custom overlay consumes the remote bundle, patch that generated ConfigMap:
patches:
- target:
kind: ConfigMap
name: controlplane-api-config
path: controlplane-api-config.yaml
Then create controlplane-api-config.yaml next to your overlay as a ConfigMap patch, with the configuration under data.config.yaml. This preserves the generated name hash and rolling restart behavior.
Security modes
The API authenticates incoming requests on its external listener. The listeners.external.jwt.mode field controls how:
| Mode | JWT required | Signature validated | Use case |
|---|---|---|---|
jwt | Yes | Yes | Production — full JWT validation against trusted issuers |
mock | Yes | No | Local development / integration testing — JWT parsed but signature is not checked |
The default mode is jwt (secure by default). A deployment in jwt mode with no trustedIssuers will panic at startup — fail-closed by design.
Production configuration
database:
url: ${DATABASE_URL}
listeners:
external:
address: ":8443"
jwt:
mode: jwt
trustedIssuers:
- https://your-idp.example.com/realms/controlplane
The DATABASE_URL variable is injected from the controlplane-db Secret by the deployment manifest.
mode: jwt requires at least one entry in trustedIssuers. The application will panic at startup if this is missing — fail-closed by design.
Development / local overlay configuration
database:
url: ${DATABASE_URL}
listeners:
external:
address: ":8443"
jwt:
mode: mock
graphql:
playgroundEnabled: true
mode: mock accepts any JWT without validating its signature. Never use this in production.
Testing configuration
listeners:
external:
address: ":8443"
jwt:
mode: mock
# No trustedIssuers needed — signatures are not validated
mode: mock accepts JWTs without validating signatures. Only use for automated integration tests with controlled tokens.
Keycloak integration
To issue JWTs compatible with the ControlPlane API, configure Keycloak with the following protocol mappers:
Client setup
- Create a confidential client in your Keycloak realm.
- Enable Service Accounts for machine-to-machine authentication.
- Add the protocol mappers below.
Required protocol mappers
| Mapper name | Mapper type | Token claim name | Example value |
|---|---|---|---|
env | Hardcoded claim | env | production |
clientId | User Session Note | clientId | acme--platform--api-gateway |
scope | Hardcoded claim | scope | tardis:admin:all |
The clientId claim uses the format <group>--<team>--<service> and determines team-level access scoping. The scope claim controls the caller type (tardis:team:all, tardis:group:all, tardis:admin:all).
When the tardis:* value (e.g. tardis:admin:all) is modeled as a Keycloak client scope, its Include in token scope setting must be enabled. If it is off, Keycloak omits the value from the scope claim and the caller is rejected or falls back to the default scope.
Legacy tardis:hub:* scopes remain accepted at runtime as deprecated aliases for the corresponding tardis:group:* scopes.
The Organization API's POST /hubs/{hub}/teams endpoint requires write access and accepts group or admin scopes. For compatibility, tardis:hub:all is accepted as the legacy alias of tardis:group:all; team scopes cannot create teams.
Example JWT payload
{
"exp": 1719320400,
"iat": 1719316800,
"iss": "https://keycloak.example.com/realms/controlplane",
"sub": "service-account-my-client",
"env": "production",
"clientId": "acme--platform--api-gateway",
"scope": "tardis:admin:all"
}
Running multiple environments in one cluster
By default, one ControlPlane API and one Projector serve all environments from one database. To isolate environments, deploy one pair per environment, each with its own database:
| Setting | Component | Purpose |
|---|---|---|
ENVIRONMENT_FILTER | Projector | Projects only resources of this environment. |
DATABASE_URL | Both | The environment's database. |
KUBERNETES_ENVIRONMENT | ControlPlane API | The environment that mutations write to. |
Example
Create a controlplane-db-<environment> Secret with a url key and one overlay per environment. Only the environment name (qa) changes between overlays:
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
nameSuffix: -qa
resources:
- https://github.com/telekom/controlplane//controlplane-api/config/default/?ref=<version>
- https://github.com/telekom/controlplane//projector/config/default/?ref=<version>
labels:
- includeSelectors: true
pairs:
app.kubernetes.io/instance: qa
fields:
- path: spec/selector/matchLabels
create: true
kind: ServiceMonitor
group: monitoring.coreos.com
# env/0 is DATABASE_URL in both Deployments.
patches:
- target:
kind: Deployment
name: projector-controller-manager
patch: |-
- op: replace
path: /spec/template/spec/containers/0/env/0/valueFrom/secretKeyRef/name
value: controlplane-db-qa
- op: add
path: /spec/template/spec/containers/0/env/-
value: { name: ENVIRONMENT_FILTER, value: qa }
- target:
kind: Deployment
name: controlplane-api
patch: |-
- op: replace
path: /spec/template/spec/containers/0/env/0/valueFrom/secretKeyRef/name
value: controlplane-db-qa
- op: add
path: /spec/template/spec/containers/0/env/-
value: { name: KUBERNETES_ENVIRONMENT, value: qa }
- target:
kind: Ingress
patch: |-
- op: replace
path: /spec/rules/0/host
value: controlplane-api-qa.example.com
# nameSuffix does not rename the Certificate's DNS names or its Secret.
replacements:
- source:
kind: Service
name: controlplane-api
fieldPath: metadata.name
targets:
- select:
kind: Certificate
fieldPaths:
- spec.dnsNames.0
- spec.dnsNames.1
options:
delimiter: "."
index: 0
- source:
kind: Certificate
fieldPath: metadata.name
targets:
- select:
kind: Certificate
fieldPaths:
- spec.secretName
- select:
kind: Deployment
name: controlplane-api
fieldPaths:
- spec.template.spec.volumes.[name=cert-volume].secret.secretName
Keep in mind:
-
Use this overlay for every environment, including the first. Otherwise Services, metrics, and certificates mix environments.
-
Provide the API configuration once per environment. The suffix renames the ConfigMap to
controlplane-api-config-<environment>. Add one entry per environment; all can use the same file, becauseKUBERNETES_ENVIRONMENToverrideskubernetes.environment:kustomization.yaml (parent of environments/)resources:- environmentsconfigMapGenerator:- name: controlplane-api-config-devnamespace: controlplane-systembehavior: replacefiles:- config.yaml=controlplane-api.yaml- name: controlplane-api-config-qanamespace: controlplane-systembehavior: replacefiles:- config.yaml=controlplane-api.yaml -
Point in-cluster callers, such as the organization server (
CPAPI_ENDPOINT), to their environment's Service, for examplehttps://controlplane-api-qa.controlplane-system.svc.cluster.local:9443/graphql/query. -
Set the token's
envclaim to the instance's environment (see Keycloak integration). The zone realms that the Control Plane creates, includingrover, do not add it (see Token Claims).
The ControlPlane API does not check the token's env claim yet. Separate hostnames are not an authorization boundary.
Verify the deployment
After installation, verify that both components are running:
kubectl get pods -n controlplane-system | grep -E "projector|controlplane-api"
Check the Projector logs to confirm it is watching resources and syncing them into the database:
kubectl logs -l app.kubernetes.io/name=projector -n controlplane-system --tail=50
If the GraphQL Playground is enabled, you can access it via port-forward:
kubectl port-forward svc/controlplane-api 8443:443 -n controlplane-system
# Open https://localhost:8443/graphql in your browser
When enabled, the Playground page at /graphql is publicly accessible. The Playground sends operations to /graphql/query, which always requires a JWT bearer token, including for introspection. In the Playground's Headers tab, add:
{ "Authorization": "Bearer <your-token>" }
For local development with mode: mock, any JWT with valid claims works — the signature is not validated.
Troubleshooting
| Symptom | Likely cause | Resolution |
|---|---|---|
| Projector pod not starting | Database not reachable | Check that the controlplane-db Secret exists and the connection string is correct. The readiness probe at /readyz includes a database check. |
Projector logs show ErrDependencyMissing | Resources synced out of order | This is normal — the Projector retries automatically when a parent resource has not been synced yet. |
| ControlPlane API returns empty results | Projector not running, or JWT scoping | Confirm the Projector is syncing. Otherwise check that the caller's JWT has the expected team/group claims. |
| GraphQL Playground not accessible | Playground disabled in config | Set graphql.playgroundEnabled: true in the ControlPlane API configuration. |
| Playground queries return 401 | Missing or invalid Authorization header | Add { "Authorization": "Bearer <token>" } in the Playground Headers tab. With mode: mock, any JWT with valid claims works. |
403 Missing field 'env' | The token has no env claim | Add a hardcoded env claim to the client's realm. See Keycloak integration. |
403 No valid scope found | The token's scope claim has no tardis:<team|group|admin>:<read|obfuscated|all> value | Add the client scope with Include in token scope enabled, and assign it as Default or request it in the token request. |
| A Projector projects nothing | ENVIRONMENT_FILTER does not match the cp.ei.telekom.de/environment label | Fix the filter or the label. With a filter set, unlabeled resources are ignored. |
Next steps
- Architecture: ControlPlane API & Projector — Understand the CQRS pattern, data model, and team isolation internals
- Operations & Monitoring — General observability guidance for the Control Plane
- Components — Overview of all platform components