Three template flavors
There are three template kinds, each identified by file extension. Both.tensor9.* and .t9.* are accepted.
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 islinux/disk-usage. It runs df -h on the
appliance host and surfaces the output. Read-only.
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 nonull_resource at all. Use data blocks
to query, locals to filter / transform, and output blocks to
emit JSON.
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:
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 parsedtensor9_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
Eachvariable block becomes a parameter you set at submission time:
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:
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.
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 ontensor9 ops command create:
The tier selects one of three pre-provisioned ServiceAccounts on the
appliance (one per tier, set up at install time). Each tier SA has a
fixed cluster-wide role granting that tier’s verbs; the actuator
does not yet narrow the role to the specific namespace or resource
list your template declares. Your customer’s pre-approval review is
where scope is discussed; per-command role minting bounded by the
template’s declarations is on the roadmap. 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 everyrequired_providers constraint with ~> (pessimistic
lower bound), not >=. The execution model below explains why
constraint discipline matters more here than in normal Terraform:
~> 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.tfin that tempdir. - Runs
tofu init -input=false(downloads providers; consults public registry andtf-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.
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 fetchhashicorp/aws,hashicorp/null,hashicorp/kubernetes, etc.tf-providers.prod-1.tensor9.com, to fetch thetensor9provider.
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
resourceblock looks like a freshCreateto 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_disabledthat depends on something imported), the apply will attempt to create from scratch and fail withEntityAlreadyExists. The right strategy depends on the resource: AWS APIs vary, and there is no single workaround. Examples that work in practice:RunInstancesandCreateSnapshotaccept idempotency tokens.kubernetes_annotationsis an upsert against an existing object.aws s3 cphonors conditional headers (--if-none-match).CreateUserrequires a check-then-create vianull_resource + local-execshelling out toaws iam get-userfirst.cmdlib’saws/snapshot-ebs-volume.tensor9.tfandaws/rotate-iam-access-key.tensor9.tfare good reference patterns. The general rule: assume yourapplyruns against an empty state, and design the body to either upsert correctly or to no-op cleanly when the resource already exists.
-
-refresh=falseis set deliberately. The appliance does not call providerreadAPIs 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_resourceruns a multi-steplocal-execthat 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-nothinglocal-exec. datablocks 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.
- No drift detection. A
-
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 declareddata_accessandside_effects, not a fully-resolved diff. For afor_eachover a data source, the cardinality is not visible at review time; bound the blast radius viadata_accessandside_effectsdeclarations rather than relying on review to catch fan-out. -
timestamp()intriggersis not necessary. Because state is empty every run,null_resourcewill Create on every execution regardless of triggers. The cargo-cultrun_at = timestamp()pattern adds nothing here. Usetimestamp()only when you need a timestamp value at execution time (e.g.kubectl.kubernetes.io/restartedAt = timestamp()for K8s rolling restart annotations, ortimeadd(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: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: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 HCLtensor9_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:
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
--output json for scripting.
Related
- Git template sources: how a folder of templates becomes a re-syncable source.
- Submitting and tracking ops commands: how customers see a command and release output.
- Pre-approvals: how to let customers approve a template once and let you run it many times.