Step 4 — Wire an Action

You have the static model: entity types, a cell that decides placement, and a relationship that connects tenants to clusters. The last piece is the verb — a way to grow the fleet. That's an Action, and this is where Terrantula stops being a read-only catalog and starts doing things, always within the rules you set.

In this step you'll wire OnboardTenant: a single declaration that takes operator input, picks the least-loaded cluster, creates the tenant and its runs_on link, and — crucially — opens a pull request against your repository. Terrantula never runs terraform apply. Your CI does, on merge.

What an Action ties together

An Action bundles five decisions into one document:

  • parameters — the inputs the operator provides.
  • recommendations — a placement query against a cell. This is where least-loaded gets invoked.
  • operation — what changes in the graph, with explicit states for each phase of the run.
  • triggerhow the change reaches your real infrastructure. This is the part that opens a PR.
  • (optionally) conditions and dependsOn — guards and ordering.

Add this fifth document to your blueprint.yaml. It's the biggest one, because it ties everything together — read the comments as you go:

---
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, description: GitHub repo in owner/name form }
  # 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   # tenant 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.
    createRelationship:
      relationshipType: runs_on
      to: "{{ recommendations.target-cluster.id }}"
      onSuccess: active
      onFailure: removing
      properties:
        namespace:    "tenant-{{ parameters.customer_id }}"
        allocated_at: "{{ run.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 }}",
            "plan_tier": "{{ parameters.plan_tier }}",
            "region": "{{ recommendations.target-cluster.properties.region }}",
            "namespace": "tenant-{{ parameters.customer_id }}"
          }

Walking the structure

  • associatedWith + scope: collectioncollection scope means this Action appears at the entity-type level ("onboard a new tenant"). instance scope would attach it to each existing tenant ("suspend this tenant").
  • recommendationscell: prod-clusters, sortBy: tenant-count, order: asc resolves to "the least-loaded cluster in the fleet." The chosen cluster is then available as {{ recommendations.target-cluster.* }} throughout the document. This is the placement decision Terraform never made.
  • operation — declares the graph change and the entity's state at each phase. onTrigger: provisioning is where the tenant sits while the PR is open; onSuccess: active is where it lands once the change applies. The createRelationship block creates the runs_on link in the same operation, putting the per-tenant namespace on the relationship.
  • {{ ... }} interpolation — values flow from parameters (operator input), recommendations (the placement result), and secrets (the credential) into both the operation and the trigger.
  • trigger: pull-request — commits tenants/<customer_id>.tfvars.json to a branch and opens a GitHub PR. That's the whole change it makes to your infrastructure: a file and a PR.

The trigger opens a PR — it does not apply

This is the most important property of the whole system, so it's worth stating plainly:

Terrantula never runs

terraform apply There is no terraform apply anywhere in this Action. The pull-request trigger commits files and opens a PR. When a reviewer merges it, your existing GitHub Actions workflow runs the apply. Terrantula validates parameters, evaluates conditions, enforces the per-cluster and cell constraints, and runs placement — all before the PR opens — then tracks the outcome and transitions the tenant to activewhen the change lands. Your runner and state never leave your control.

This is why the operation declares onTrigger, onSuccess, and onFailure states: the tenant is provisioning while the PR is open, becomes active when the merge applies cleanly, and goes to failed if the apply fails — an honest, recoverable lifecycle that mirrors what your CI actually did.

You need a credential and a place to put files

The Action references {{ secrets.github-token }} for the PR, so declare the secret (add it to blueprint.yaml) and set its value out-of-band:

---
apiVersion: terrantula.io/v1
kind: Secret
metadata:
  name: github-token
spec:
  description: GitHub PAT or App token for committing files + opening PRs
terrantula apply --file blueprint.yaml
terrantula secrets set-value github-token --value "$GITHUB_TOKEN"

The value is encrypted at rest and never appears in your catalog, version control, or logs — you only ever reference it by name.

The trigger is the only swappable part

Notice the operation, recommendations, and placement logic say nothing about how the change ships. Swap the trigger block for type: terraform-cloud and the same Action fires a TFC run instead; type: atlantis opens a PR your Atlantis instance picks up; type: atmos-workflow invokes an Atmos workflow on your runner. The trigger is the only thing that knows about your runner — Terrantula sits on top of whatever you already run. See the Triggers Referencefor each one.

Fire it

OnboardTenant is a collection-scope Action, so it's available at the Tenant type level — operators run it from the dashboard's Action surface (or via the API / SDK). Each firing creates an ActionRun: a record of who fired what, with which parameters, and how it ended. That's your audit trail. The run opens the PR; when it merges and your CI applies, Terrantula completes the run and transitions the tenant to active.

What you just did

  • Wired OnboardTenant, the Action that grows your fleet: parameters → placement → graph change → PR.
  • Confirmed the trigger opens a pull request and your CI applies — Terrantula never runs Terraform.
  • Declared and set the github-token secret the trigger needs, by reference only.

That completes the fleet: you can now see it, place into it, connect it, and grow it through reviewable PRs. The last page points you at where to go next.


Prev: ← Step 3 — Add relationships · Next: Next Steps →