Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.tensor9.com/llms.txt

Use this file to discover all available pages before exploring further.

An ops command template is a small file you check into a Git repo (or ship straight to your control plane) that describes one runnable operation, the data it touches, and the side effects it has. Customers review the template once and either approve a single execution or pre-approve repeated runs within constraints. The actual work happens inside your customer’s appliance, not on your laptop. Why templates: source from Git so PR review gates every change, your customer pre-approves the body and variable constraints once, then you fill in variable values per run with no per-run approval within the signed scope.

Three template flavors

There are three template kinds, each identified by file extension. Both .tensor9.* and .t9.* are accepted.
FlavorExtensionWire-level typeWhen to use
Terraform.tensor9.tf / .t9.tfTfFromTmplMutating cloud or Kubernetes work, anything that benefits from declarative HCL plus provisioners or data-source queries.
Script.tensor9.sh / .t9.shScriptFromTmplOne-shot bash. Metadata lives in a : <<'TENSOR9' ... TENSOR9 heredoc at the top of the file.
Kubectl.tensor9.kubectl / .t9.kubectlKubectlFromTmplPlain kubectl invocations against the appliance’s K8s cluster.
The wire-level type names appear in the audit log and in tensor9 ops command list output; you’ll mostly see them when correlating events across systems. Most teams reach for Terraform. The provisioner-driven shape works across cloud APIs, Kubernetes, and arbitrary local-exec, and the data-source-driven shape is even more reviewable for read-only queries against a cloud provider’s API. The script flavor exists for cases where bash is genuinely simpler.

Anatomy of a Terraform template

There are two canonical shapes for .tensor9.tf templates, distinguished by where the actual work happens. Both are common in the open-source tensor9ine/cmdlib reference repo.

Shape A: provisioner-driven (shell pipeline wrapped in HCL)

The simplest example is linux/disk-usage. It runs df -h on the appliance host and surfaces the output. Read-only.
terraform {
  required_providers {
    tensor9 = { source = "tf-providers.prod-1.tensor9.com/tensor9/tensor9", version = "~> 2.41" }
    null    = { source = "hashicorp/null", version = "~> 3.2" }
  }
}

provider "tensor9" {
  mode = "ops"
}

variable "MOUNT_PREFIX" {
  type        = string
  default     = "/"
  description = "Filesystem path prefix; only mounts under this prefix are reported"
  validation {
    condition     = startswith(var.MOUNT_PREFIX, "/")
    error_message = "MOUNT_PREFIX must be an absolute path beginning with /"
  }
}

resource "tensor9_command" "this" {
  name        = "disk-usage"
  display     = "Disk usage"
  description = "Show human-readable disk usage (df -h) for mounts under MOUNT_PREFIX. Read-only."
  icon        = "disk"
  data_access = ["Storage"]
}

resource "null_resource" "df" {
  triggers = {
    mount_prefix = var.MOUNT_PREFIX
  }
  provisioner "local-exec" {
    command = "df -h | awk 'NR==1 || $6 ~ \"^${var.MOUNT_PREFIX}\"'"
  }
}
Your customer reviews the metadata block (tensor9_command "this"), the variable, and the literal shell pipeline. On approval, the appliance shells out via local-exec, and Tensor9 captures every line prefixed with <resource> (local-exec): from the apply log as the command’s stdout, uploads the captured bytes to your blob store (S3 in your customer’s account), and stores only a small [blob: ...]\\n<presigned-url> payload on the command record. Templates can write to stdout freely up to the 5 GiB cap; the release script fetches the actual bytes via the URL during preview, sha256-verifies, and shows them to the customer.

Shape B: data-source-driven (cloud API queries)

For read-only questions that map cleanly onto a cloud provider’s API (list buckets, find idle instances, describe a workflow), you can write a template with no null_resource at all. Use data blocks to query, locals to filter / transform, and output blocks to emit JSON.
terraform {
  required_providers {
    tensor9 = { source = "tf-providers.prod-1.tensor9.com/tensor9/tensor9", version = "~> 2.41" }
    aws     = { source = "hashicorp/aws", version = "~> 5.0" }
  }
}

provider "tensor9" {
  mode = "ops"
}

variable "REGION" {
  type        = string
  default     = "us-east-1"
  description = "AWS region for the provider"
}

provider "aws" {
  region = var.REGION
}

resource "tensor9_command" "this" {
  name        = "list-public-s3-buckets"
  display     = "List public S3 buckets"
  description = "Diagnostic: list S3 buckets whose ACL grants READ or WRITE to AllUsers / AuthenticatedUsers. Read-only."
  icon        = "search"
  data_access = ["Infrastructure"]
}

data "aws_s3_buckets" "all" {}
data "aws_s3_bucket_acl" "by_bucket" {
  for_each = toset(data.aws_s3_buckets.all.buckets[*].name)
  bucket   = each.value
}

locals {
  public_buckets = [/* ... filtering logic ... */]
}

output "public_buckets" {
  value = local.public_buckets
}
When the template has output { ... } blocks and no local-exec, the appliance captures tofu output -json as the command’s stdout. The surface a customer sees at review time names the exact AWS APIs that will be called, which is the information they need to approve. The trade-off is more verbose HCL and per-API network round trips at execution time.

Three things doing real work in either shape

provider "tensor9" { mode = "ops" }. mode = "ops" tells the Tensor9 provider that this template is metadata-only from its perspective. The provider does not need to reach the control plane, and the endpoint argument is not required. Use this mode for every ops template; the only time you’d set mode to something else is for non-template stacks the same provider also serves. The full provider reference (other modes, every tensor9_* resource and data source) is published alongside the provider releases at tf-providers.prod-1.tensor9.com/tensor9/tensor9. resource "tensor9_command" "this". Exactly one of these per template. It is the metadata block customers review at approval time:
AttributeRequiredPurpose
nameyesMachine-readable identifier (lowercase, hyphenated, 1-64 chars). Defaults to the filename stem if omitted.
displayyesHuman-readable label shown on your customer’s review screen.
descriptionyesOne or two sentences describing what the command does. Your customer reads this before approving.
iconnoHint for the review UI (e.g. disk, server, activity).
data_accessyesList of data categories the command can see. See “Data access tags” below.
side_effectsnoList of side-effect tags. See “Side-effect tags” below.
The execution shape itself, which is either null_resource + provisioner "local-exec" (Shape A), or data blocks + output blocks (Shape B), or a mix. See “Execution model” below for the operational details.

Source of truth: HCL bytes are canonical

The HCL bytes the appliance executes and the metadata your customer sees in the review pane come from the same source. At template-import time, the bytes are parsed once, the parsed tensor9_command "this" metadata is rendered into the review pane, and a content hash binds the parsed view to the executed bytes. If the parser ever produced a different result than the executor (a Tensor9 bug), the content hash check would catch the divergence. When you author a template, the literal HCL description = "...", data_access = [...], etc. are exactly what your customer reads at approval time. There is no separate “review-display” layer to keep in sync.

Outputs are surfaced verbatim; sensitive = true does not redact

Terraform’s output { value = ...; sensitive = true } controls how tofu displays the value in its own CLI; it does not affect the Tensor9 release flow. Any output block’s value flows through the release pipeline as-is: your customer sees the raw decrypted value in the support portal before signing release, and you see whatever your customer releases. If your template legitimately emits a secret (e.g. aws/rotate-iam-access-key.tensor9.tf returns a freshly-minted secret access key), call this out in the template’s description so your customer’s reviewer knows what they’re approving: “Emits a fresh AWS access key in the released output.” Your customer still sees the value, and the audit chain still records that it was released, but the surface-level expectation is now clear.

Variables

Each variable block becomes a parameter you set at submission time:
tensor9 ops command create \
  --appName my-app \
  --customerName acme-corp \
  --template linux-disk-usage \
  --vars MOUNT_PREFIX=/var/lib/myapp \
  --commandName check-myapp-disk \
  --reason "investigating disk pressure"
Validation runs on the appliance before the template executes. If the value you pass fails the condition, the command fails with the error_message you wrote. Validation is your customer’s only guarantee that the values you supply are constrained, so spend a moment writing real conditions on every variable that can vary.

Data access tags

data_access declares the categories of appliance data the command can touch. Your customer sees this list at approval time and can reject if a template asks for more than they expect. Values must come from the canonical enum:
TagWhat it covers
SecretsAPI keys, credentials, signing keys.
PiiPersonally-identifiable information.
RbacIdentity and access bindings.
LogsApplication logs, system logs (log show, journalctl).
ConfigsApplication configuration files.
InfrastructurePod / node / cluster metadata, AWS resource state, system processes.
NetworkNetwork connections, routing, security-group state.
StorageDisk usage, file listings, snapshot metadata, EBS volumes.
CustomResourcesApplication-defined resources (Temporal workflows, custom CRDs, app-owned objects).
MetricsApplication metrics, telemetry endpoints, performance counters.
Pick the smallest set that honestly describes what the command can see. A kubectl get pods is Infrastructure. A tail on an app log is Logs. A df is Storage. Adding more tags than necessary makes templates harder to approve.

Side-effect tags

side_effects declares what the command does to the system, beyond just reading. The shape is a free-form list of strings you write; your customer’s review screen surfaces each tag verbatim. The convention in cmdlib is kebab-case verb-object phrases: pod-restarts, node-drain, pod-evictions, iam-key-rotation, ebs-snapshot, rds-snapshot.
resource "tensor9_command" "this" {
  name         = "drain-node"
  display      = "Drain node"
  description  = "Cordon the node and evict its pods so it can be safely terminated."
  icon         = "server"
  data_access  = ["Infrastructure"]
  side_effects = ["node-drain", "pod-evictions"]
}
Read-only templates omit side_effects entirely. Mutating templates list every distinct visible-to-customer effect. If you add templates against cmdlib, match the existing tag vocabulary in that repo to keep your customer-side review experience consistent.

Permission tiers

Three tiers govern what a Kubectl-tier command can do, set at submission time on tensor9 ops command create:
TierIntent
ReadOnlyInspect-only. The template’s RBAC role grants get / list / describe verbs in Kubernetes.
ReadWriteMutate state your customer can re-create (restart deployments, scale replicas, rotate credentials).
AdminDestructive or platform-touching (drop database, delete namespace, rotate root keys).
The tier flows into the role minted on the appliance at execution time; the actuator scopes the role’s verbs accordingly. Choose the lowest tier that still lets the template do its job. The tier is only consulted for Kubectl-tier commands today; Tf and Script templates run with the appliance host’s process identity and rely on data_access plus side_effects for customer-side review.

Versioning

Pin every required_providers constraint with ~> (pessimistic lower bound), not >=. The execution model below explains why constraint discipline matters more here than in normal Terraform:
terraform {
  required_providers {
    tensor9    = { source = "tf-providers.prod-1.tensor9.com/tensor9/tensor9", version = "~> 2.41" }
    null       = { source = "hashicorp/null", version = "~> 3.2" }
    aws        = { source = "hashicorp/aws", version = "~> 5.0" }
    kubernetes = { source = "hashicorp/kubernetes", version = "~> 2.20" }
  }
}
~> 2.41 accepts 2.41.x patch updates but rejects a 3.0.0 major. ~> 5.0 accepts 5.x minor updates but rejects a 6.0. Pin every external provider, not just tensor9. The provider source URL for the tensor9 provider is published as part of each release; use the exact URL above. There is no committed lock file (.terraform.lock.hcl); the execution model uses a fresh working directory per run, so lock files have nowhere to persist. Constraint discipline is the only thing standing between a clean re-execution and a surprise breaking change in a transitive provider.

Execution model

The operational details of how the appliance turns a template into output are worth understanding, because the model is intentionally narrower than normal Terraform.

Per-execution working directory

Every command execution gets a fresh temporary directory:
  1. Appliance creates a unique tempdir.
  2. Writes the template’s HCL to main.tf in that tempdir.
  3. Runs tofu init -input=false (downloads providers; consults public registry and tf-providers.prod-1.tensor9.com).
  4. Runs tofu apply -auto-approve -input=false -no-color -refresh=false.
  5. Runs tofu output -json.
  6. Captures local-exec stdout (if any) and the JSON output (if any).
  7. Deletes the tempdir.
State is born and dies with each invocation. There is no remote backend, no state lock, no terraform.tfstate carried between runs, no terraform import step, no workspaces.

Network requirements for tofu init

Because step 3 runs every time and there’s no shared plugin cache, the appliance must be able to reach two registry endpoints over HTTPS on every command:
  • registry.opentofu.org (or the equivalent Terraform registry your external provider sources resolve through), to fetch hashicorp/aws, hashicorp/null, hashicorp/kubernetes, etc.
  • tf-providers.prod-1.tensor9.com, to fetch the tensor9 provider.
Customers running their appliance in a restricted-egress VPC must whitelist both endpoints. Without that, every ops command fails at the tofu init step with a generic “provider download failed” error, which the appliance surfaces as ExecutionFailed with the init stderr in the output. Local plugin caching (to amortize download cost across runs) is a roadmap item; for now, every execution pays the full init cost.

What this implies for what you can write

  • Every resource block looks like a fresh Create to Terraform on every execution. There is no prior state to compute drift against. For naturally-stateful resources (aws_iam_user, aws_iam_access_key.old_disabled that depends on something imported), the apply will attempt to create from scratch and fail with EntityAlreadyExists. The right strategy depends on the resource: AWS APIs vary, and there is no single workaround. Examples that work in practice:
    • RunInstances and CreateSnapshot accept idempotency tokens.
    • kubernetes_annotations is an upsert against an existing object.
    • aws s3 cp honors conditional headers (--if-none-match).
    • CreateUser requires a check-then-create via null_resource + local-exec shelling out to aws iam get-user first.
    • cmdlib’s aws/snapshot-ebs-volume.tensor9.tf and aws/rotate-iam-access-key.tensor9.tf are good reference patterns. The general rule: assume your apply runs against an empty state, and design the body to either upsert correctly or to no-op cleanly when the resource already exists.
  • -refresh=false is set deliberately. The appliance does not call provider read APIs to verify state before computing the diff (there’s no prior state to refresh). Three consequences worth designing for:
    1. No drift detection. A kubernetes_annotations (or similar) resource just calls the upsert; it does not first check whether the upstream object already has the value. This is the right model for at-least-once upserts; it does not give you compare-and-set semantics.
    2. No transactional rollback across resources. If a null_resource runs a multi-step local-exec that succeeds halfway and then fails, the next execution starts from an empty state again. Partial-mutation rollback is the template author’s responsibility: design idempotent steps or use a single all-or-nothing local-exec.
    3. data blocks still run every invocation. They’re the only refresh-equivalent and they hit the cloud API on every command, not just when something changes. Bill accordingly.
    The general rule: every mutating template is at-least-once. Assume you’ll re-execute on partial failure and design the body to either upsert correctly or to no-op cleanly when the resource already exists.
  • Your customer at review time sees the template’s HCL, not a tofu plan. Plans depend on data-source results which can only run after the appliance is authorized to do so. Customers are reviewing “what this template will do” plus the declared data_access and side_effects, not a fully-resolved diff. For a for_each over a data source, the cardinality is not visible at review time; bound the blast radius via data_access and side_effects declarations rather than relying on review to catch fan-out.
  • timestamp() in triggers is not necessary. Because state is empty every run, null_resource will Create on every execution regardless of triggers. The cargo-cult run_at = timestamp() pattern adds nothing here. Use timestamp() only when you need a timestamp value at execution time (e.g. kubectl.kubernetes.io/restartedAt = timestamp() for K8s rolling restart annotations, or timeadd(timestamp(), "-${N}h") for date-window filtering).

Appliance runtime environment

The local-exec environment ships with a known set of binaries and environment variables. Templates can rely on:
BinaryNotes
tofuOpenTofu (the apply runner). Always present.
bash, awk, sed, grep, df, tail, head, find, xargsStandard POSIX tools. Always present.
kubectlPre-configured against the appliance’s cluster (no update-kubeconfig needed for in-cluster operations).
awsAvailable; AWS credentials are injected as env vars (see below) when the template targets an AWS-backed appliance.
jqAvailable for JSON pipelines in local-exec.
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and AWS_SESSION_TOKEN are injected into the local-exec environment for AWS appliances. The Terraform AWS provider picks them up automatically from the ambient environment. If your template needs a binary not in the list above (e.g. helm, psql, a custom CLI), it will fail at execution time. We are adding more binaries as patterns surface; flag what you need and we’ll discuss.

Direct-create alternative

If you don’t want a Git source for a one-off template, you can ship a single template directly from a JSON spec file:
tensor9 ops template create \
  --appName my-app \
  --file ./my-template.json
The JSON shape mirrors the on-disk template lifted into a structured form. For a Tf template, the file contains:
{
  "name": "disk-usage",
  "displayName": "Disk usage",
  "description": "Show human-readable disk usage (df -h). Read-only.",
  "spec": {
    "kind": "Tf",
    "hcl": "terraform {\n  required_providers { ... }\n}\n\n..."
  },
  "dataAccess": ["Storage"],
  "sideEffects": [],
  "variables": [
    {
      "name": "MOUNT_PREFIX",
      "default": "/",
      "description": "Filesystem path prefix",
      "pattern": "^/.*"
    }
  ]
}
For Script templates, replace the spec block with {"kind":"Script","script":"#!/bin/bash\n..."}; for Kubectl, with {"kind":"Kubectl","invocation":"kubectl get pods -n ${NAMESPACE}"}.

HCL ↔ JSON field name mapping

The on-disk HCL tensor9_command block uses snake_case field names because that’s HCL convention; the JSON envelope uses camelCase because that’s the wire-protocol convention. The two shapes carry the same information; only the spelling differs:
HCL (tensor9_command "this")JSON envelopeNotes
namenameidentical
displaydisplayNamerename + suffix
descriptiondescriptionidentical
iconiconidentical
data_accessdataAccesssnake → camel
side_effectssideEffectssnake → camel
Templates imported from a Git source go through HCL → JSON lifting automatically, so authors only deal with one shape at a time. You only see the JSON shape if you use the direct-create path explicitly. Most teams instead register a Git source (see Git template libraries) so the templates live next to the rest of their infra code and benefit from PR review. The JSON path is best reserved for prototypes and test scaffolding.
The persisted template name is the dir-prefixed filename stem when the template comes in via a Git source. linux/disk-usage.tensor9.tf becomes a template called linux-disk-usage. This keeps siblings across directories from colliding (e.g. linux/disk-usage and darwin/disk-usage coexist as linux-disk-usage and darwin-disk-usage). The HCL’s tensor9_command "this" { name = ... } is overridden by the dir-prefixed stem when imported from a source.

Listing and retrieving templates

# List all templates this app exposes
tensor9 ops template list --appName my-app

# Inspect a single template in full
tensor9 ops template retrieve --appName my-app --templateId <id>
Both accept --output json for scripting.