API registrations

An ApiRegistration declares that an API group exists, scoped to this deployment’s own platform+region — “vault.example.com”, “pki.example.com”, “compute.example.com”. Kayra never implements the API it names: whatever team owns it runs its own service (using infrapi/lib, like any other app, to ask Kayra’s own /apis/authorization.<domain>/v1/check for RBAC) and is notified of changes through Reconciler — agnostic of however that team’s own API gateway (APISIX, Envoy, Kong, …) actually routes requests to it. Ported from the legacy Postgres stacks table. Repository and route code lives in internal/catalog, backed by store.

type ApiRegistration struct {
	Name, Platform, Region       string
	Active, Upstream, Downstream bool
	Owner, Description           string
	DependsOn                    []ApiRegistrationRef
	SyncID, Mode                 string
}

Managing one#

Full CRUD at /apis/catalog.example.com/v1/apiregistrations, gated by SysadminGate — same trust tier as the cluster RBAC objects.

kayra-cli

kayra-cli create catalog apiregistrations -d '{
    "name": "compute.example.com",
    "short_name": "compute",
    "active": true,
    "owner": "sre-a"
  }'

curl

curl -s -X POST http://localhost:8080/apis/catalog.example.com/v1/apiregistrations \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{
    "name": "compute.example.com",
    "short_name": "compute",
    "active": true,
    "owner": "sre-a"
  }'

A registration’s id is its long name — the full API group URN it is reachable under (compute.example.com), a lowercase DNS name of at least two labels; a single-label name is refused (400). It’s the only name anything else ever refers to it by: a subscription’s api, a /v1/check request’s api, a Role rule’s apis, a depends_on entry, every URL below. short_name is optional — a single label (compute), unique within this platform/region (409 otherwise) — an alias for display and step templates ({{ .Subscription.ShortName }}) only: it never addresses the registration.

Every route addresses a registration by name alone — /apis/catalog.example.com/v1/apiregistrations/compute.example.com, never .../:platform/:region/compute — because this instance already knows its own INFRAPI_APP_PLATFORM/_REGION. Create silently stamps them onto the record regardless of what (if anything) the request body sends; list, get, update and delete can only ever reach a registration in that same scope.

kayra-cli

kayra-cli get catalog apiregistrations compute.example.com
kayra-cli update catalog apiregistrations compute.example.com -d '{"name": "compute.example.com", "owner": "sre-b"}'
kayra-cli delete catalog apiregistrations compute.example.com

curl

curl -s http://localhost:8080/apis/catalog.example.com/v1/apiregistrations/compute.example.com \
  -H "Authorization: Bearer $TOKEN"
curl -s -X PUT http://localhost:8080/apis/catalog.example.com/v1/apiregistrations/compute.example.com \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"name": "compute.example.com", "owner": "sre-b"}'
curl -s -X DELETE http://localhost:8080/apis/catalog.example.com/v1/apiregistrations/compute.example.com \
  -H "Authorization: Bearer $TOKEN"

Dependencies#

DependsOn replaces the legacy model’s separate “dependencies” table: a list of {platform, region, name} refs, each validated to exist on create/update. Unlike the registration’s own identity, a dependency may cross platform/region — the legacy model allowed a dependency graph spanning every platform/region a single Postgres database served, and that part carries over unchanged.

{"name": "app", "depends_on": [{"platform": "sandbox", "region": "eu-west-1", "name": "compute.example.com"}]}

Deleting a registration is refused (409) while another registration’s DependsOn, or any subscription, still references it.

Triggering reconcile#

SyncID and Mode exist to drive subscription reconciliation — they have no effect on the registration itself. Reconciler never reconciles a subscriber against a registration whose SyncID is empty — there’s nothing yet to catch up to — so POST /apis/catalog.example.com/v1/apiregistrations mints one automatically when the request doesn’t name one, same hashicorp/go-uuid generator PUT .../sync uses. A request that does name an explicit sync_id keeps it as given.

The shorthand:

kayra-cli

kayra-cli update catalog apiregistrations compute.example.com/sync -d '{}'

curl

curl -s -X PUT http://localhost:8080/apis/catalog.example.com/v1/apiregistrations/compute.example.com/sync \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{}'

stamps a fresh hashicorp/go-uuid-generated value onto SyncID — that’s the entire trigger. An explicit value is still settable through the normal PUT .../apiregistrations/:name instead, if you’d rather tag a release id than a random one. Mode is read by Reconciler when it reconciles a subscription against this registration — a free-form key into reconciler.mode in kayra.yaml ("trigger" and "api" in the examples there, but any name works, since it’s just a map), not a fixed set of choices.

Canary rollout#

By default, bumping SyncID reconciles every active subscriber at once. To try a new target against one tenant first:

kayra-cli

kayra-cli update catalog apiregistrations compute.example.com/sync -d '{"canary_namespaces": ["sre"]}'

curl

curl -s -X PUT http://localhost:8080/apis/catalog.example.com/v1/apiregistrations/compute.example.com/sync \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"canary_namespaces": ["sre"]}'

mints a fresh SyncID same as the plain shorthand, but Reconciler only reconciles the named namespaces — every other active subscriber is left exactly as it was, visibly pending (current_sync_id still behind expected_sync_id) until released. Each namespace named must already be subscribed to the registration (404 otherwise, catching a typo before it becomes “nothing reconciled and I don’t know why”).

Once satisfied, release the same build to everyone else:

kayra-cli

kayra-cli update catalog apiregistrations compute.example.com/sync -d '{}'

curl

curl -s -X PUT http://localhost:8080/apis/catalog.example.com/v1/apiregistrations/compute.example.com/sync \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{}'

called again with no canary_namespaces while a rollout is pending — this reuses the exact SyncID the canary already validated (it does not mint another one) and clears the restriction, so every remaining subscriber reconciles to that same build. Calling the plain shorthand with no rollout pending behaves exactly as before canary existed at all: a fresh SyncID, straight to everyone.

CanaryNamespaces is pipeline state for an in-progress rollout, not a spec field: only this endpoint ever sets or clears it, so an unrelated PUT .../apiregistrations/:name mid-rollout (changing owner, say) can’t silently finish or restart one.

OpenAPI spec#

Kayra knows nothing about the app behind a registration, so it learns what the app serves from the app itself. Every infrapi/lib app serves its route table as an OpenAPI document at /-/openapi.json. Whenever a registration’s sync_id changes, kayra reconciler fetches it through the gateway (<reconciler.gateway_url>/apis/<api>/-/openapi.json, see config) and stores it in etcd with the sync_id it was fetched for and the resources derived from its paths. Only one fetch runs per registration and sync_id, however many subscriptions the sync fans out to.

The spec is mandatory. A sync can’t complete without it: if the fetch fails (gateway unset or unreachable, a non-200 response, a document that isn’t OpenAPI, or one over 1 MiB), no subscription moves to the new sync_id. Each one is marked state: error and keeps its previous current_sync_id, so an active subscription keeps working. The fetch is retried on the next change to the registration or the subscription.

Resources come from the /apis/<api>/v1/[namespaces/{namespace}/]<resource>[/{name}[/<action>]] paths. A path under namespaces/{namespace}/ is a namespaced resource, an action is part of the resource name (dashboards/refresh), and verbs follow the HTTP method: GET is list on a collection and get on an item, and POST, PUT, PATCH and DELETE are create, update, patch and delete.

Any authenticated caller can read the stored spec, exactly as the app served it:

curl -s http://localhost:8080/apis/catalog.example.com/v1/apiregistrations/telemetry.giservices.io/openapi.json \
  -H "Authorization: Bearer $TOKEN"

GET /apis lists every api group with its resources: Kayra’s own, derived from its live route table, then each registration’s, from its stored spec. A registration with no spec yet is listed with no resources. This is what kayra-cli apis shows.

Checking sync status#

GET /apis/catalog.example.com/v1/apiregistrations/:name/status reports the registration’s own current sync_id plus every active subscription’s own sync state against it, in one call — rather than listing every namespace’s subscriptions by hand and comparing each one’s expected_sync_id yourself. A subscription still pending its initial activation is omitted: there’s no sync state to report for one yet.

kayra-cli

kayra-cli get catalog apiregistrations compute.example.com/status

curl

curl -s http://localhost:8080/apis/catalog.example.com/v1/apiregistrations/compute.example.com/status \
  -H "Authorization: Bearer $TOKEN"
{
  "name": "compute.example.com",
  "sync_id": "f872573b-8ad8-9dd5-79f0-234fcb41517e",
  "subscriptions": [
    {"namespace": "sre", "state": "success", "current_sync_id": "f872573b-8ad8-9dd5-79f0-234fcb41517e", "synced": true},
    {"namespace": "apps", "state": "success", "current_sync_id": "old-build", "synced": false}
  ]
}