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.
Three template flavors
There are three template kinds, each identified by file extension.
Both .tensor9.* and .t9.* are accepted.
| Flavor | Extension | Wire-level type | When to use |
|---|
| Terraform | .tensor9.tf / .t9.tf | TfFromTmpl | Mutating cloud or Kubernetes work, anything that benefits from declarative HCL plus provisioners or data-source queries. |
| Script | .tensor9.sh / .t9.sh | ScriptFromTmpl | One-shot bash. Metadata lives in a : <<'TENSOR9' ... TENSOR9 heredoc at the top of the file. |
| Kubectl | .tensor9.kubectl / .t9.kubectl | KubectlFromTmpl | Plain 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.
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:
| Attribute | Required | Purpose |
|---|
name | yes | Machine-readable identifier (lowercase, hyphenated, 1-64 chars). Defaults to the filename stem if omitted. |
display | yes | Human-readable label shown on your customer’s review screen. |
description | yes | One or two sentences describing what the command does. Your customer reads this before approving. |
icon | no | Hint for the review UI (e.g. disk, server, activity). |
data_access | yes | List of data categories the command can see. See “Data access tags” below. |
side_effects | no | List 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 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:
| Tag | What it covers |
|---|
Secrets | API keys, credentials, signing keys. |
Pii | Personally-identifiable information. |
Rbac | Identity and access bindings. |
Logs | Application logs, system logs (log show, journalctl). |
Configs | Application configuration files. |
Infrastructure | Pod / node / cluster metadata, AWS resource state, system processes. |
Network | Network connections, routing, security-group state. |
Storage | Disk usage, file listings, snapshot metadata, EBS volumes. |
CustomResources | Application-defined resources (Temporal workflows, custom CRDs, app-owned objects). |
Metrics | Application 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_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:
| Tier | Intent |
|---|
ReadOnly | Inspect-only. The template’s RBAC role grants get / list / describe verbs in Kubernetes. |
ReadWrite | Mutate state your customer can re-create (restart deployments, scale replicas, rotate credentials). |
Admin | Destructive 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:
- Appliance creates a unique tempdir.
- Writes the template’s HCL to
main.tf in that tempdir.
- Runs
tofu init -input=false (downloads providers; consults
public registry and tf-providers.prod-1.tensor9.com).
- Runs
tofu apply -auto-approve -input=false -no-color -refresh=false.
- Runs
tofu output -json.
- Captures local-exec stdout (if any) and the JSON output (if any).
- 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:
- 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.
- 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.
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:
| Binary | Notes |
|---|
tofu | OpenTofu (the apply runner). Always present. |
bash, awk, sed, grep, df, tail, head, find, xargs | Standard POSIX tools. Always present. |
kubectl | Pre-configured against the appliance’s cluster (no update-kubeconfig needed for in-cluster operations). |
aws | Available; AWS credentials are injected as env vars (see below) when the template targets an AWS-backed appliance. |
jq | Available 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 envelope | Notes |
|---|
name | name | identical |
display | displayName | rename + suffix |
description | description | identical |
icon | icon | identical |
data_access | dataAccess | snake → camel |
side_effects | sideEffects | snake → 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.