Catalog YAML Guide

You know the concepts. This page shows you how they look as YAML — the declarative catalog you actually write and apply.

The catalog is a set of documents that describe your fleet model: entity types, cells, relationship types, actions, and secrets. You apply it with one command, and Terrantula reconciles the model. The snippets below are drawn from the canonical demos under examples/ in the repository — they're real, runnable config, not invented for the docs.

"Catalog" is the kit; the "kinds" are the parts

The catalog is the whole declarative model. Each document in it has a kindEntityType, Cell, RelationshipType, Action, Secret. If you've used Kubernetes manifests, the shape will feel familiar: apiVersion + kind + metadata + spec, one resource per document.

The document shape

Every catalog document follows the same envelope:

apiVersion: terrantula.io/v1
kind: EntityType        # which kind of thing this document defines
metadata:
  name: TenantCluster   # the name you'll reference everywhere else
spec:
  # ... kind-specific fields

You can put many documents in one file, separated by --- (standard multi-document YAML). A whole fleet blueprint is typically a few hundred lines in a single blueprint.yaml. You apply it like this:

terrantula apply --file blueprint.yaml

Terrantula validates the entire document set, then reconciles it: new kinds are created, changed ones updated. References between documents (a cell pointing at an entity type, a relationship type pointing at two entity types) are checked at apply time, so a typo in a name fails loudly instead of silently.

We'll walk the kinds in the order you'd naturally define them.

EntityType — the shape of a thing

Here's the TenantCluster entity type from the canonical demo. Read the comments — they explain each block.

apiVersion: terrantula.io/v1
kind: EntityType
metadata:
  name: TenantCluster
spec:
  displayName: Tenant Cluster
  # The lifecycle states an instance can occupy, and where it starts.
  states: [provisioning, active, draining, decommissioned]
  initialState: provisioning
  # Typed fields every cluster instance carries.
  properties:
    - name: region
      type: string
      required: true
    - name: tier
      type: string
      enum: [basic, premium]   # only these values are valid
      required: true
    - name: kubernetes_version
      type: string
      required: true
    - name: arn
      type: string
      description: AWS EKS cluster ARN
  # A measured value — here, derived automatically.
  metrics:
    - name: tenant-count
      unit: integer
      source:
        type: derived
        derivedFrom: relationship-count
        filter:
          relationshipType: runs_on
          states: [active]
  # A hard limit on a metric.
  constraints:
    - metric: tenant-count
      max: 50          # no cluster may host more than 50 tenants

A few things to notice:

  • states plus initialState is the entity's lifecycle. active and failed are always available implicitly; you list the rest.
  • The tenant-count metric is derived: Terrantula computes it by counting active runs_on relationships pointing at the cluster. You never set it by hand — it tracks reality as relationships are created and removed.
  • The constraint turns that metric into a rule. It's enforced when Terrantula tries to place a tenant, before anything provisions.

The Tenant entity type is simpler — properties and a lifecycle, no derived metrics:

apiVersion: terrantula.io/v1
kind: EntityType
metadata:
  name: Tenant
spec:
  displayName: Tenant
  states: [pending, provisioning, active, suspended, deprovisioning, failed]
  initialState: pending
  properties:
    - { name: customer_id,       type: string, required: true }
    - { name: plan_tier,         type: string, enum: [basic, premium], required: true }
    - { name: region_preference, type: string, required: true }
    - { name: contact_email,     type: string, required: true }

Properties can be written in the long form (one field per line) or the compact inline-map form shown here. Both are valid YAML; use whichever reads better.

Cell — placement and aggregate limits

A cell groups entities of one type, ranks them for placement, and caps the group:

apiVersion: terrantula.io/v1
kind: Cell
metadata:
  name: prod-clusters
spec:
  displayName: Production cluster fleet
  entityType: TenantCluster      # cell members are clusters
  placementPolicy: least-loaded  # rank by lowest metric when placing
  constraints:
    - metric: tenant-count
      aggregate: sum
      max: 500                   # ≤ 500 tenants summed across the whole cell

Note the two layers of limit working together: the entity type capped each cluster at 50 tenants; this cell caps the fleet at 500 summed across all clusters. placementPolicy: least-loaded means when Terrantula needs to place a new tenant, it sorts the clusters by tenant-count ascending and picks the emptiest one that still has room.

RelationshipType — the shape of a connection

apiVersion: terrantula.io/v1
kind: RelationshipType
metadata:
  name: runs_on
spec:
  displayName: Runs On
  from: Tenant            # the from-end entity type
  to: TenantCluster       # the to-end entity type
  cardinality: many-to-one  # many tenants per cluster, one cluster per tenant
  states: [assigning, active, migrating, removing]
  properties:
    - { name: namespace,    type: string, required: true }
    - { name: allocated_at, type: string }

cardinality is enforced, not advisory. With many-to-one, Terrantula will reject an attempt to give one tenant two active runs_on relationships. The relationship also has its own states and properties — here, the per-tenant Kubernetes namespace lives on the relationship, because it's a fact about the link between a specific tenant and a specific cluster.

Secret — a referenced credential

Secrets declare a credential by name; you set the value separately so it never lands in your YAML or version control:

apiVersion: terrantula.io/v1
kind: Secret
metadata:
  name: tfc-api-token
spec:
  description: Terraform Cloud API token for run dispatch

You set the value out-of-band, after applying:

terrantula secrets set-value tfc-api-token --value "$TFC_TOKEN"

In Actions you reference it as {{ secrets.tfc-api-token }}. The value is encrypted at rest and never printed.

Action — the workflow that opens PRs

The Action is the largest document because it ties everything together: inputs, placement, what changes in the graph, and how the change reaches your infrastructure. Here's OnboardTenant using the pull-request trigger (bare Terraform + GitHub Actions, one file per tenant):

apiVersion: terrantula.io/v1
kind: Action
metadata:
  name: OnboardTenant
spec:
  displayName: Onboard a new tenant
  description: Commits a new tenant config file and opens a PR; existing CI applies on merge.
  associatedWith:
    entityType: Tenant
    scope: collection        # "create a new one" lives at the type level
  # Inputs the operator provides.
  parameters:
    - { name: customer_id,       type: string, required: true }
    - { name: plan_tier,         type: string, enum: [basic, premium], required: true }
    - { name: region_preference, type: string, required: true }
    - { name: contact_email,     type: string, required: true }
    - { name: target_repo,       type: string, required: true }
  # Placement query against the cell — pick the least-loaded cluster.
  recommendations:
    - name: target-cluster
      title: Target cluster
      cell: prod-clusters
      sortBy: tenant-count
      order: asc
      required: true
  # What changes in the graph.
  operation:
    type: create-entity
    entityType: Tenant
    onTrigger: provisioning   # state while the PR is open
    onSuccess: active         # state once the change applies
    onFailure: failed
    properties:
      customer_id:       "{{ parameters.customer_id }}"
      plan_tier:         "{{ parameters.plan_tier }}"
      region_preference: "{{ parameters.region_preference }}"
      contact_email:     "{{ parameters.contact_email }}"
    # Also create the tenant→cluster relationship on success.
    createRelationship:
      relationshipType: runs_on
      to: "{{ recommendations.target-cluster.id }}"
      onSuccess: active
      onFailure: removing
      properties:
        namespace: "tenant-{{ parameters.customer_id }}"
  # How the change reaches the customer's infrastructure: open a PR.
  trigger:
    type: pull-request
    repo: "{{ parameters.target_repo }}"
    auth:
      type: token
      token: "{{ secrets.github-token }}"
    title: "Onboard {{ parameters.customer_id }} → {{ recommendations.target-cluster.name }}"
    head: "terrantula/onboard-{{ parameters.customer_id }}"
    base: main
    files:
      - path: "tenants/{{ parameters.customer_id }}.tfvars.json"
        content: |
          {
            "customer_id": "{{ parameters.customer_id }}",
            "region": "{{ recommendations.target-cluster.properties.region }}",
            "namespace": "tenant-{{ parameters.customer_id }}"
          }

Walking the structure:

  • associatedWith + scopecollection scope means this Action appears at the entity-type level ("onboard a new tenant"). instance scope would attach it to each existing entity ("suspend this tenant").
  • recommendations — this is where cell placement is invoked. cell: prod-clusters, sortBy: tenant-count, order: asc resolves to "the least-loaded cluster." The chosen cluster is then available as {{ recommendations.target-cluster.* }} throughout the document.
  • operation — declares the graph change and the entity's state at each phase. The onTrigger/onSuccess/onFailure states make the lifecycle explicit and recoverable.
  • {{ ... }} interpolation — values flow from parameters, recommendations, and secrets into the operation and the trigger. {{ parameters.customer_id }} is operator input; {{ recommendations.target-cluster.properties.region }} is the placement result; {{ secrets.github-token }} is the credential.
  • trigger — the pull-request trigger commits a tenants/<id>.tfvars.json file to a branch and opens a PR. When a reviewer merges it, the customer's existing GitHub Actions workflow runs terraform apply, and Terrantula transitions the tenant to active.
The trigger opens a PR; it does not apply

Notice there is no terraform apply anywhere in this Action. The pull-request trigger commits files and opens a PR. The customer's CI does the apply on merge. Swap the trigger block for type: terraform-cloudand the same Action fires a TFC run instead — the operation and placement logic are unchanged. The trigger is the only thing that knows about your runner.

Putting it together

A full blueprint is just these documents, separated by ---, in dependency order: entity types, then the cell, then the relationship type, then the secrets, then the Action that uses all of them. Apply the whole thing at once:

terrantula apply --file blueprint.yaml

Want to see complete, runnable versions? The canonical demos in examples/ ship full blueprints for several substrates:

  • cattle-saas-tenants — Terraform Cloud (terraform-cloud trigger).
  • bare-tf-gh-actions-onefile-per-tenant — bare Terraform + GitHub Actions (pull-request trigger, one file per tenant).
  • cattle-saas-tenants-atmos — Atmos (atmos-workflow trigger).
  • cattle-saas-tenants-atlantis — Atlantis (atlantis trigger).

Pick the one closest to how you run Terraform and mirror its shape.

You've now seen the whole catalog. Time to write a piece of it yourself — starting with the smallest meaningful unit of fleet modeling: a cell.


Next: Define your first cell → — your first hands-on config.