Building Providers
Defining the API
CRDs, APIExport, APIBinding, and permission claims.
A provider’s API surface is one or more CRDs that tenants can create in their own workspace. The plumbing is kcp’s: you publish an APIExport with the schemas you want to share, tenants create an APIBinding to pull those resources into their workspace, and from there they’re regular kubectl objects.
The kedge hub handles all the bootstrap for you. You just declare the schemas; the hub creates the APIExport, the bind grants, and the catalog metadata that drives the portal’s Enable button.
The flow, end to end
- You declare
CatalogEntry.spec.apiExport(third-party) or pass equivalent data throughBuiltinSpec(first-party). It includes the export name, the inline schemas, and any permission claims you need to read tenant data. - The hub reconciles: creates a per-provider workspace at
root:kedge:providers:<your-name>, applies theAPIResourceSchemaobjects there, creates theAPIExport, and installs aClusterRoleBindingsosystem:authenticatedusers are allowed to bind. - The tenant clicks Enable in the portal. The portal posts an
APIBindinginto the tenant’s workspace pointing at your export. - kcp materializes the bound resources. The tenant can now
kubectl get yourthingsin their workspace. - Your controllers watch through the export’s “virtual workspace” — the union of all bound tenants’ resources — and reconcile each one. (Out of scope for this page; the same kcp controller pattern any APIExport author uses.)
The CatalogEntry shape
apiVersion: providers.kedge.faros.sh/v1alpha1
kind: CatalogEntry
metadata:
name: my-provider
spec:
displayName: "My Provider"
description: "Manage Things."
category: "Custom"
apiExport:
# APIExport name — convention: <provider>.providers.kedge.faros.sh.
# This becomes the name of the kcp APIExport object.
name: "my-provider.providers.kedge.faros.sh"
# PermissionClaims tell kcp what your provider's controllers may
# access *in the tenant's workspace* beyond your own CRDs. Each
# claim shows up in the Enable dialog so the tenant explicitly
# accepts it before the binding is created.
permissionClaims:
- group: ""
resource: configmaps
verbs: [get, list, watch]
tenantScoped: true
- group: ""
resource: secrets
verbs: [get, list, watch]
tenantScoped: true
# Inline APIResourceSchema documents. The hub applies these into
# the per-provider workspace before creating the export. body is
# the full APIResourceSchema YAML as a string — easy to template
# from your CRD generator output.
schemas:
- groupResource: "things.my-provider.providers.kedge.faros.sh"
body: |
apiVersion: apis.kcp.io/v1alpha1
kind: APIResourceSchema
metadata:
name: v260529-abc.things.my-provider.providers.kedge.faros.sh
spec:
group: my-provider.providers.kedge.faros.sh
names:
kind: Thing
listKind: ThingList
plural: things
singular: thing
scope: Namespaced
versions:
- name: v1alpha1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
replicas:
type: integer
status:
type: object
properties:
phase: { type: string }
additionalPrinterColumns:
- name: Phase
type: string
jsonPath: .status.phase
# The UI/backend URL fields — covered in the UI and Virtual Workspace guides.
ui:
url: "http://my-provider-portal.my-namespace.svc:8080"
backend:
url: "http://my-provider-backend.my-namespace.svc:8080"
Schema versioning
APIResourceSchema names must be unique and immutable per kcp’s contract — once applied you can’t mutate the schema body in place. The convention is to prefix with a date or version suffix: v260529-abc.things.my-provider.providers.kedge.faros.sh. When you change the schema, ship a new name. The APIExport references the latest by name, and old bindings continue to work against the old schema.
In practice this means your CRD code-generation pipeline writes a new file per change, and you bump the schema name on every release. The first-party providers in the kedge repo follow this pattern; see config/kcp/apiresourceschema-edges.kedge.faros.sh.yaml for an example.
Permission claims
Permission claims are kcp’s way of letting an APIExport’s controllers read or write resources in the tenant workspace that the provider doesn’t own. Examples:
- A “secrets sync” provider needs
get/list/watchonSecretsto mirror them somewhere. - A “policy” provider needs
list/watchonPodsandDeploymentsto validate them. - A “cost” provider needs
listonNodesandPodsto compute usage.
Each claim names the resource + verbs. The portal surfaces them in the Enable dialog:
Enable my-provider in workspace
tenant-foo? It will be granted:
get, list, watchonconfigmapsget, list, watchonsecrets[Cancel] [Enable]
When the tenant clicks Enable, the portal sets each claim’s state: Accepted on the APIBinding; un-accepted claims become state: Rejected and kcp refuses to expose those resources to your controllers.
tenantScoped: true
Mark claims as tenantScoped if they’re scoped to the tenant’s own workspace (the common case). Untrusted/cross-workspace claims require an admin annotation on the CatalogEntry (kedge.faros.sh/accept-untrusted-claims) and shouldn’t be the default.
Don’t over-claim
A provider that claims */* will be rejected by careful tenants every time. Start with the narrowest set of resources you actually need; expand only when you have a concrete reason. Each new claim is friction at Enable time and a security review the tenant has to do.
What the tenant sees on Enable
The portal’s flow once the user clicks Enable on the catalog page:
- Fetches
/api/providersto get thepermissionClaimslist. - Shows the confirmation dialog with each claim laid out.
- On accept, POSTs an
APIBindinginto the tenant’s workspace:apiVersion: apis.kcp.io/v1alpha1 kind: APIBinding metadata: name: my-provider-binding spec: reference: export: path: "root:kedge:providers:my-provider" name: "my-provider.providers.kedge.faros.sh" permissionClaims: - { group: "", resource: configmaps, state: Accepted, ... } - kcp resolves the bind (the hub’s
ApplyBindGrantalready authorizedsystem:authenticatedto bind your export, so this succeeds). - The portal refreshes; the provider’s resources are now visible in the tenant’s workspace.
The disable flow is the inverse: delete the APIBinding and kcp cleans up the materialized resources.
Discovering bound providers from the portal
The portal lists APIBindings in the tenant workspace and matches spec.reference.export.path against the catalog to figure out which providers are enabled for the current tenant. That drives the side nav: enabled providers (with UIs) appear in the nav; unbound providers are visible only in the catalog page.
First-party variant
First-party providers don’t need a CatalogEntry YAML — the hub’s bootstrap creates one automatically from the BuiltinSpec registered in Go. The CRDs ship in the hub repo under config/crds/ and config/kcp/apiresourceschema-*.yaml, and the bootstrap applies them at startup before creating the export.
If your first-party provider needs CRDs, the conventional places to add them are:
config/crds/<your>.kedge.faros.sh_<resource>.yaml # for plain CRDs
config/kcp/apiresourceschema-<resource>.<group>.yaml # for the kcp schema
pkg/hub/bootstrap/crds/... # for the embedded copy the hub installer applies
The CI codegen pipeline (make codegen) keeps these in sync with the Go types under apis/<group>/v1alpha1/.
Beyond the basics
What this page doesn’t cover yet:
- Writing the controller: the standard kcp APIExport-controller pattern with the virtual-workspace client. The existing providers (
providers/kubernetesedges/controllers/) are good references. - Status subresource conventions: how kedge providers report
phase+conditions. - Webhook admission: optional, same shape as any kcp APIExport that ships webhooks.
The Virtual Workspace guide covers the other kind of HTTP surface — handlers mounted at /services/ that aren’t tied to CRDs.