Appendix A · The standard library

Terraform

The provider-neutral lifecycle node — rendering, applying, destroying, the events other plugins hang work on, and the binary it manages.

One node, and the only one with no Jsonnet API at all. It knows nothing about any particular cloud: it owns the Terraform binary, the apply, and the lifecycle events that let provider plugins do their work at the right moment. plugins.aws.terraform and plugins.gcp.terraform build on it.

At a glance

{
  "main.tf.json": {
    terraform: { required_version: ">= 1.2" },
    resource: {
      aws_s3_bucket: {
        artifacts: { bucket_prefix: "artifacts-" },
      },
    },
  },
}
npx spellcraft terraform-apply manifest.jsonnet

terraform

Provider-neutral Terraform lifecycle: it renders a manifest, then runs Terraform on the result. It has no Jsonnet API — nothing to import, nothing to call — so what it contributes is two CLI commands, a file handler and four events.

Rendering and applying

Terraform reads JSON, so a manifest emits .tf.json files and needs no special handling. The command renders the manifest into render/, then runs terraform init and terraform apply there. render/ is the Terraform root module — there is no separate working directory to keep in sync.

flageffect
-y, --auto-approvepass -auto-approve to terraform apply
-s, --skip-initskip terraform init, for when you manage it yourself

Tearing down

npx spellcraft terraform-destroy manifest.jsonnet --auto-approve

This re-renders the manifest first. Since render/ is cleaned and rewritten on every run, the module Terraform destroys is always the one matching the manifest, never a stale one left over from the last apply. Same two flags.

The .tf file handler

This node registers a serialiser for .tf, which writes a string through verbatim. That is what makes hand-written HCL carryable: importstr reads the file at evaluation time and the handler writes it out unchanged, beside the generated JSON.

{
  "networking.tf": importstr "./hcl/networking.tf",

  "main.tf.json": {
    output: {
      summary: { value: "${local.network_name} ${var.cidr}" },
    },
  },
}

Terraform reads every .tf and .tf.json in the directory as one module, so the generated JSON above can reference a local or variable declared in the carried-forward HCL. Adoption can start with a directory of existing HCL and move declarations into Jsonnet one at a time. The file is written as-is; interpolating it is not supported.

Lifecycle events

Each command announces its phases, so other plugins can act at the right moment without this node knowing they exist.

eventwhen
@c6fc/spellcraft-plugins:terraform.pre-applyafter the manifest is rendered and written, before terraform init
@c6fc/spellcraft-plugins:terraform.post-applyafter terraform apply returns
@c6fc/spellcraft-plugins:terraform.pre-destroyafter the manifest is rendered and written, before terraform init
@c6fc/spellcraft-plugins:terraform.post-destroyafter terraform destroy returns

The name is the full "<package>:<node>.<phase>" string, not the plugins. prefix a Jsonnet call site uses. A listener registered against plugins.terraform:pre-apply is simply never called.

pre-apply is the one that matters. It is what solves step zero — the work that has to happen before Terraform can plan, which Terraform cannot do for itself. plugins.gcp.terraform listens on it to enable the GCP services the rendered configuration is about to need:

exports._spellcraft_metadata = {
  init: async (spellframe) => {
    spellframe.on('@c6fc/spellcraft-plugins:terraform.pre-apply', async () => {
      await enablePendingServices();
    });
  },
};

Listeners are awaited in registration order, so one that throws stops the apply before it starts. See Lifecycle events for the general mechanism.

The binary

Downloaded on first use, checksum-verified against HashiCorp’s published SHA256SUMS, and cached under node_modules/.cache. Nothing is fetched until a command actually reaches the point of running Terraform, so a manifest that fails to render, or a pre-apply listener that throws, downloads nothing.

It defaults to 1.2.5. Pin another in your project’s package.json:

{
  "config": {
    "tf_version": "1.9.8"
  }
}

All of this node’s own output goes to stderr and Terraform’s own stdout is handed through untouched, so terraform output -raw <name> can be captured from a wrapping script without filtering.