GitOps Entity Kinds
This guide explains how plugin developers register entity kinds and kind extensions for the GitOps reconciliation system.
Overview
Section titled “Overview”The GitOps system lets teams define Checkstack resources (systems, health checks, etc.) as YAML files in Git repositories. Each resource is described by an entity kind that defines:
- A spec schema (Zod) for validating the YAML descriptor
- A reconcile function that creates or updates the resource
- An optional delete function for cleanup
Plugins can also extend existing kinds by adding namespaced spec fields (e.g., the healthcheck plugin extends System with health check assignments).
┌─────────────────────────────────────────────────────────────┐│ YAML Descriptor ││ ││ apiVersion: checkstack.io/v1alpha1 ││ kind: System ││ metadata: ││ name: payment-api ││ spec: ││ description: Payment processing API ││ healthcheck: ← extension namespace ││ - ref: ││ kind: Healthcheck ││ name: payment-db-check │└────────────────────────┬────────────────────────────────────┘ │ ┌──────────────┼──────────────┐ ▼ ▼ ▼ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ Validate │ │ Sort │ │ Reconcile │ │ envelope │ │ by deps │ │ in order │ │ + spec │ │ (topo) │ │ │ └────────────┘ └────────────┘ └────────────┘Descriptor Format
Section titled “Descriptor Format”All YAML descriptors follow the Kubernetes-inspired envelope format:
apiVersion: checkstack.io/v1alpha1 # Required. Must match a registered kind.kind: System # Required. The entity kind name.metadata: # Required. Common fields. name: my-system # Required. URL-safe identifier (lowercase, hyphens). title: My System # Optional. Human-readable display name. description: A brief description # Optional. labels: # Optional. Key-value pairs for filtering. team: platform tags: # Optional. String tags. - productionspec: # Kind-specific configuration. # ... fields defined by the kind's specSchemaRegistering a Kind
Section titled “Registering a Kind”Register kinds during your plugin’s register() phase via the entityKindExtensionPoint:
import { createBackendPlugin } from "@checkstack/backend-api";import { entityKindExtensionPoint } from "@checkstack/gitops-backend";import { CHECKSTACK_API_VERSION } from "@checkstack/gitops-common";import { z } from "zod";
export default createBackendPlugin({ metadata: pluginMetadata,
register(env) { const kindRegistry = env.getExtensionPoint(entityKindExtensionPoint);
kindRegistry.registerKind({ apiVersion: CHECKSTACK_API_VERSION, kind: "System", specSchema: z.object({ // Define the fields that appear under `spec:` in the YAML }),
reconcile: async ({ entity, existingEntityId, context }) => { // Create or update the resource in your plugin's database. // `entity.spec` is fully validated and typed. // `existingEntityId` is set when updating an existing resource. // // Must return { entityId: string } — the plugin-specific ID. const id = existingEntityId ?? await createResource(entity); return { entityId: id }; },
delete: async ({ entityName, entityId, context }) => { // Optional. Called when the entity is removed from Git. if (entityId) await deleteResource(entityId); }, }); },});EntityKindDefinition<TSpec> Interface
Section titled “EntityKindDefinition<TSpec> Interface”| Property | Type | Description |
|---|---|---|
apiVersion | string | API version (e.g., "checkstack.io/v1alpha1") |
kind | string | Unique kind name (e.g., "System", "Healthcheck") |
specSchema | z.ZodType<TSpec> | Zod schema for the spec section |
reconcile | (params) => Promise<{ entityId }> | Create/update handler |
delete | (params) => Promise<void> | Optional cleanup handler |
Reconcile Parameters
Section titled “Reconcile Parameters”reconcile: async ({ entity, // Full envelope (metadata + validated spec) existingEntityId, // Set on updates (from previous reconcile) context, // Logger + resolveEntityRef helper}) => { ... }Delete Parameters
Section titled “Delete Parameters”delete: async ({ entityName, // The metadata.name from the removed descriptor entityId, // Plugin-specific ID from provenance (if available) context, // Logger + resolveEntityRef helper}) => { ... }Registering a Kind Extension
Section titled “Registering a Kind Extension”Extensions let a plugin add namespaced fields to another plugin’s kind. For example, the healthcheck plugin extends System to allow health check assignments:
kindRegistry.registerKindExtension({ apiVersion: CHECKSTACK_API_VERSION, kind: "System", namespace: "healthcheck", // Fields appear under spec.healthcheck
specSchema: z.array( z.object({ ref: entityRefSchema, // { kind, name } degradedThreshold: z.number().int().min(1).optional(), unhealthyThreshold: z.number().int().min(1).optional(), }), ).optional(),
reconcile: async ({ entity, extensionSpec, entityId, context }) => { // `entityId` is the System's plugin-specific ID (from the base reconciler). // `extensionSpec` is the validated data from spec.healthcheck. for (const entry of extensionSpec) { const configId = await context.resolveEntityRef({ kind: entry.ref.kind, entityName: entry.ref.name, }); if (configId) { await assignHealthcheck({ systemId: entityId, configId }); } } },});EntityKindExtensionDefinition<TExtensionSpec> Interface
Section titled “EntityKindExtensionDefinition<TExtensionSpec> Interface”| Property | Type | Description |
|---|---|---|
apiVersion | string | API version of the kind being extended |
kind | string | The kind being extended (e.g., "System") |
namespace | string | Unique namespace under spec (convention: your plugin ID) |
specSchema | z.ZodType<TExtensionSpec> | Zod schema for the extension fields (should be .optional()) |
reconcile | (params) => Promise<void> | Called with the base kind’s entityId |
Extension Registration Order
Section titled “Extension Registration Order”Extensions can be registered before the base kind is registered. The registry stores them and merges them once the base kind is registered. This removes cross-plugin load-order dependencies.
Entity References
Section titled “Entity References”Use Kubernetes-style structured references to create dependencies between entities:
# A System that references a HealthcheckapiVersion: checkstack.io/v1alpha1kind: Systemmetadata: name: payment-apispec: healthcheck: - ref: kind: Healthcheck # The kind of the referenced entity name: payment-db-check # The metadata.name of the referenced entityThe entityRefSchema from @checkstack/gitops-common validates these references:
import { entityRefSchema } from "@checkstack/gitops-common";
// Use in your spec schemaconst mySchema = z.object({ ref: entityRefSchema, // { kind: string, name: string }});Automatic Dependency Resolution
Section titled “Automatic Dependency Resolution”The reconciliation engine automatically detects entity refs in specs using extractEntityRefs(). This walks the entire spec tree and collects all objects matching { kind, name }. These are used to build a dependency graph and reconcile entities in topological order (dependencies first).
You don’t need to do anything special — just use the entityRefSchema in your spec and the engine handles the rest.
Reconciliation Lifecycle
Section titled “Reconciliation Lifecycle”The GitOps sync engine follows a strict Collect → Sort → Reconcile lifecycle:
Git Repository │ ▼┌──────────────┐ ┌──────────────┐ ┌──────────────┐│ Collect │ ──▶ │ Sort │ ──▶ │ Reconcile ││ │ │ │ │ ││ Discover all │ │ Topological │ │ Process each ││ YAML files, │ │ sort using │ │ entity in ││ parse and │ │ entity refs │ │ dependency ││ validate │ │ (Kahn's alg) │ │ order │└──────────────┘ └──────────────┘ └──────────────┘-
Collect: All YAML files matching the provider’s path pattern are discovered, parsed, and validated against registered spec schemas.
-
Sort: Entities are topologically sorted based on their entity refs. If entity A references entity B, B is reconciled first. Cycles are detected and rejected with descriptive error messages.
-
Reconcile: Each entity is reconciled in dependency order:
- The base kind’s
reconcile()is called first, returning anentityId. - Then each extension’s
reconcile()is called with thatentityId. - Provenance records are created/updated to track the mapping.
- The base kind’s
Provenance
Section titled “Provenance”Every reconciled entity gets a provenance record linking:
- The Git source (provider, file path, commit)
- The entity kind and name
- The plugin-specific entity ID
This enables:
- Locking: Resources managed by GitOps are locked from manual editing in the UI.
- Orphan detection: When a YAML file is removed, the orphaned provenance record enables cleanup (automatic or manual, based on the provider’s deletion policy).
Secret References
Section titled “Secret References”Use ${{ secrets.NAME }} template syntax for sensitive values that should be resolved from the GitOps secret store:
spec: config: password: "${{ secrets.my-database-password }}" # Also supports inline interpolation: connectionString: "postgres://user:${{ secrets.DB_PASS }}@host/db"Secret resolution is schema-driven — only fields marked with configString({ "x-secret": true }) are resolved. This is the same annotation pattern used by DynamicForm for the admin UI:
import { configString } from "@checkstack/backend-api";
const postgresConfigSchema = z.object({ host: configString({}).describe("Hostname"), password: configString({ "x-secret": true }).describe("Database password"),});Security Model
Section titled “Security Model”Secrets are not pre-resolved in the spec. Instead:
- Validation: All referenced secrets are validated to exist at sync time. Missing secrets cause the entity to error immediately.
- Metadata guard: Templates in
metadatafields (name,title,description) are rejected to prevent secrets from leaking into display fields. - Schema-driven resolution: The plugin reconciler calls
context.resolveSecretsBySchema()with the typed schema (e.g., the strategy config schema). Only fields annotated withx-secretare resolved — everything else is returned as-is.
This prevents a malicious actor from exfiltrating secrets via display fields or non-secret config fields.
Secret rotation: When a secret is rotated via the admin UI, all entities referencing that secret are automatically flagged for re-reconciliation on the next sync cycle.
ReconcileContext
Section titled “ReconcileContext”Both kind and extension reconcilers receive a ReconcileContext:
interface ReconcileContext { /** Scoped logger */ logger: { debug: (msg: string) => void; info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void; };
/** * Resolve a GitOps entity name to its plugin-specific ID. * Used by extensions to look up cross-kind references. */ resolveEntityRef: (params: { kind: string; entityName: string; }) => Promise<string | undefined>;
/** * Schema-driven secret resolution. Walks the provided Zod schema, * finds fields annotated with configString({ "x-secret": true }), * and resolves ${{ secrets.NAME }} templates only in those fields. */ resolveSecretsBySchema: <T>(params: { value: T; schema: z.ZodTypeAny; }) => Promise<T>;}Usage in a reconciler:
reconcile: async ({ entity, context }) => { // Look up the strategy to get its typed schema (with x-secret annotations) const strategy = registry.getStrategy(entity.spec.strategy);
// Resolve secrets using the strategy's schema — only x-secret fields are resolved const resolvedConfig = await context.resolveSecretsBySchema({ value: entity.spec.config, schema: strategy.config.schema, });
await createConfiguration({ config: resolvedConfig });}Kind Registry Browser
Section titled “Kind Registry Browser”The Kind Registry page (accessible from the user menu) provides a live view of all registered kinds, their base spec schemas, extensions, and auto-generated YAML examples. This is useful for:
- Discovering what entity kinds are available
- Understanding the full spec schema (base + extensions)
- Copying YAML examples as a starting point
Access is controlled by the gitops.kinds.read access rule (granted to all authenticated users by default).
Example: Full Kind + Extension Registration
Section titled “Example: Full Kind + Extension Registration”Here is a complete real-world example showing how the catalog and healthcheck plugins cooperate:
Catalog plugin registers kind: System:
kindRegistry.registerKind({ apiVersion: CHECKSTACK_API_VERSION, kind: "System", specSchema: z.object({}),
reconcile: async ({ entity, existingEntityId, context }) => { const displayName = entity.metadata.title ?? entity.metadata.name; if (existingEntityId) { await entityService.updateSystem(existingEntityId, { name: displayName }); return { entityId: existingEntityId }; } const system = await entityService.createSystem({ name: displayName }); return { entityId: system.id }; },
delete: async ({ entityId }) => { if (entityId) await entityService.deleteSystem(entityId); },});Healthcheck plugin extends System and registers kind: Healthcheck:
kindRegistry.registerKind({ apiVersion: CHECKSTACK_API_VERSION, kind: "Healthcheck", specSchema: z.object({ strategy: z.string().min(1), intervalSeconds: z.number().int().min(1), config: z.record(z.string(), z.unknown()), }), reconcile: async ({ entity, existingEntityId, context }) => { // Look up strategy — its typed schema carries x-secret annotations const strategy = registry.getStrategy(entity.spec.strategy);
// Resolve secrets using the strategy's schema const resolvedConfig = await context.resolveSecretsBySchema({ value: entity.spec.config, schema: strategy.config.schema, }); return { entityId: configId }; },});
kindRegistry.registerKindExtension({ apiVersion: CHECKSTACK_API_VERSION, kind: "System", namespace: "healthcheck", specSchema: z.array( z.object({ ref: entityRefSchema, degradedThreshold: z.number().int().min(1).optional(), unhealthyThreshold: z.number().int().min(1).optional(), }), ).optional(), reconcile: async ({ extensionSpec, entityId, context }) => { // Assign health checks to the system for (const entry of extensionSpec) { const configId = await context.resolveEntityRef({ kind: entry.ref.kind, entityName: entry.ref.name, }); if (configId) { await assignToSystem({ systemId: entityId, configId }); } } },});Resulting YAML:
apiVersion: checkstack.io/v1alpha1kind: Systemmetadata: name: payment-api title: Payment APIspec: healthcheck: - ref: kind: Healthcheck name: payment-db-check degradedThreshold: 2 unhealthyThreshold: 5---apiVersion: checkstack.io/v1alpha1kind: Healthcheckmetadata: name: payment-db-checkspec: strategy: postgres intervalSeconds: 60 config: host: db.internal port: 5432 database: payments user: monitor password: "${{ secrets.payment-db-password }}"Other built-in kinds and extensions
Section titled “Other built-in kinds and extensions”For the full operator-facing list of kinds Checkstack ships out of the
box (SLO, Satellite, the System.dependencies extension, the
Healthcheck.anomaly and System.anomaly extensions, etc.) including
ready-to-copy YAML examples, see
GitOps kind reference.
Troubleshooting
Section titled “Troubleshooting”Kind Not Appearing in Registry
Section titled “Kind Not Appearing in Registry”- Verify your plugin is loaded (check Admin → Plugins)
- Ensure
entityKindExtensionPointis imported from@checkstack/gitops-backend - Check that
registerKind()is called in theregister()phase (notinit())
Extension Not Merging
Section titled “Extension Not Merging”- Verify the
apiVersionandkindmatch the base kind exactly - Check that the
namespaceis unique (no other extension uses the same namespace for the same kind) - Extension schemas should be
.optional()so descriptors without the extension still validate
Reconcile Not Called
Section titled “Reconcile Not Called”- Verify the provider’s path pattern matches your YAML file location
- Check the Sync Status tab for error messages
- Ensure the
apiVersionandkindin your YAML match a registered kind
Dependency Cycle Detected
Section titled “Dependency Cycle Detected”The reconciler rejects circular entity references. Check the error message for the cycle path and restructure your descriptors to break the cycle.