Jesse A. Green / notebook

stacks Administrator & Developer Guide

Generated April 2026 from cisco-open/stacks at a1fadd8

AI-generated documentation. The library is checked by sampling (three repositories so far); this guide was not individually reviewed. Verify against the project before relying on it.

cisco-open/stacks is a small (~30 kLOC of Python source across 24 files at commit a1fadd8) Terraform code pre-processor distributed as a Click CLI. This guide is the operator and developer reference: it covers the runtime architecture, every environment-variable and CLI flag, deployment into CI and operator workstations, the security model around inline secrets, and the internals of every shipped module.

End-user task documentation — install commands, daily workflows, recipes — lives in the companion User Guide.


1. Architecture Overview

stacks is a thin orchestration layer over the terraform binary. It is not a daemon, not an agent, and does not maintain its own state — every invocation begins with a directory walk and ends after a terraform subprocess returns. All on-disk state is either Terraform-owned (the .tfstate files) or transient (stacks.out/ is rebuilt every run).

The runtime is composed of three Python sub-packages:

  • stacks.main — the Click entry point. Defines the top-level group cli, the seven public commands (version, render, diff, terraform, encrypt, decrypt, plus the surgery group), the two hidden commands (preinit, genkey), and six surgery sub-commands (edit, list, import, remove, move, rename).
  • stacks.cmd — command implementations and the Context object that encapsulates the resolved layer/stack/env directory tree. Includes config.py (env-var defaults), context.py (path math), preinit.py (file copy and Jinja render), render.py, diff.py, terraform.py, and surgery.py (state operations).
  • stacks.helpers — reusable utilities. config.py (HCL2/JSON/YAML read/write with merging), crypto.py (genkey/encrypt/decrypt with PKCS#7 padding and RSA-OAEP-SHA256 + AES-GCM), directory.py (copy_files, directory_remove), merge.py (deepmerge wrapper with _override semantics), run.py (subprocess.run wrappers), and template.py (Jinja render with StrictUndefined).

A stacks.filters package is referenced from helpers/template.py — custom Jinja filters are registered automatically by name from filters.__all__ and bound to the active Context.

1.1 Component diagram

+---------------------------------------------------------------+
|                        stacks CLI (Click)                     |
|  src/stacks/main.py                                           |
|                                                               |
|   version  preinit*  render  diff  terraform                  |
|   genkey*  encrypt   decrypt  surgery {edit,list,             |
|                                        import,remove,         |
|                                        move,rename}           |
+----+-----------------+----------------+---------------+-------+
     |                 |                |               |
     v                 v                v               v
+----+----+      +-----+------+   +-----+-----+   +-----+-----+
| cmd     |      | cmd        |   | helpers   |   | helpers   |
| Context |----->| preinit /  |-->| template  |   | crypto    |
| .tf*    |      | render /   |   | (Jinja2,  |   | (RSA-OAEP |
| paths   |      | diff /     |   | Strict-   |   |  + AES-   |
+---------+      | terraform /|   | Undefined)|   |  GCM,     |
                 | surgery    |   +-----------+   |  PKCS#7)  |
                 +-----+------+         |         +-----+-----+
                       |                v               |
                       |        +-------+-------+       |
                       |        | helpers       |       |
                       |        | config (HCL2, |<------+ ENC[...]
                       |        |  JSON, YAML)  |        decrypt
                       |        |  merge        |
                       |        +-------+-------+
                       |                |
                       v                v
                 +-----+----+     +-----+-----+
                 | helpers  |     | filesystem |
                 | run      |     | layer dir + |
                 | (subproc)|     | environments|
                 +-----+----+     +------+-----+
                       |                 |
                       v                 v
                 +-----+----+      +-----+-----+
                 | terraform |      | stacks.out|
                 | binary    |<----| (rendered) |
                 | (external)|      +-----------+
                 +-----------+

  * = hidden command

Note

The diagram is rendered from the actual import graph of src/stacks/ at commit a1fadd8. Every block corresponds to a real .py file or external binary the code invokes. There is no hidden network layer, daemon, or RPC service.

1.2 Component inventory

Component Path Role
CLI entry point src/stacks/main.py Click group cli, all command and subgroup wiring, stdin handling for encrypt.
Command package src/stacks/cmd/__init__.py Re-exports Context, diff, preinit, render, surgery, terraform.
Defaults src/stacks/cmd/config.py os.getenv resolution for the eight STACKS_* configuration variables.
Context src/stacks/cmd/context.py Single class Context. Computes path, root_dir, envs_dir, stacks_dir, stack_dir, base_dir, work_dir, terraform_dir, modules_dir, universe_file, variables_file, plus stack, layer, env, subenv, instance.
Render preparation src/stacks/cmd/preinit.py File copy + Jinja render + stacks.tf.json / zzz.auto.tfvars.json generation.
Render driver src/stacks/cmd/render.py Calls preinit, then optionally terraform init.
Git diff driver src/stacks/cmd/diff.py git stash, double render, diff -ur --color.
Terraform wrapper src/stacks/cmd/terraform.py render + terraform -chdir=<work_dir> <args>.
State surgery src/stacks/cmd/surgery.py _list, _import, remove, move, edit.
Helpers — config IO src/stacks/helpers/config.py config_read, json_read, yaml_read, hcl2_read, config_write, json_write, yaml_write.
Helpers — crypto src/stacks/helpers/crypto.py genkey, encrypt, decrypt with recursive list/dict descent.
Helpers — directory src/stacks/helpers/directory.py copy_files, directory_remove.
Helpers — merge src/stacks/helpers/merge.py merge (deepmerge wrapper with top-level _override handling).
Helpers — run src/stacks/helpers/run.py run_command, run_script.
Helpers — template src/stacks/helpers/template.py jinja2_render with bound filters.
Filters src/stacks/filters/ Custom Jinja filters: checksum, deepformat, encoding, filesystem, lookup, throw.

1.3 Technology stack

Layer Technology
Language Python 3
Distribution pyproject.toml (PEP 621) + uv.lock for reproducible dev
CLI Click
Templating Jinja2 with StrictUndefined
Config parsing python-hcl2, pyyaml, stdlib json
Crypto cryptography (RSA-2048, AES-GCM, PKCS#7)
Merge deepmerge
Git GitPython for stacks diff
External binary terraform
External binary bash (only for stacks surgery edit redirection)

1.4 Render-time data flow

The end-to-end pass that stacks render makes through the filesystem:

  1. Context construction. Context.__init__ infers path from the current working directory and out from path/<STACKS_OUTPUT_DIR>. It walks four parents up to find root_dir. It splits the layer name into env, sub-env, instance.
  2. Output directory reset. helpers.directory_remove(work_dir, keep=[".terraform", ".terraformrc", ".terraform.lock.hcl"]).
  3. File copy. helpers.copy_files is invoked four times:
    • stacks_dir → work_dir (*.tf, *.tfvars.jinja) prefixed common_.
    • stack_dir → work_dir (*.tfvars.jinja) prefixed stack_.
    • For each entry under base_dir: directories are shutil.copytree'd, non-*.tf files are shutil.copyfile'd, *.tf files are then copied via helpers.copy_files with prefix base_.
    • path → work_dir (*.tfvars.jinja) prefixed layer_.
  4. First Jinja pass. *.tfvars.jinja files in work_dir are rendered in place against a var mapping built from on-disk .tfvars plus the eight stacks_* predefined variables.
  5. Second Jinja pass. *.tf files in work_dir are rendered in place against the same var mapping (recomputed to include now-rendered *.tfvars.jinja).
  6. Variable universe write. Every variable in the merged set that is not already declared as variable "..." {} in the rendered *.tf is written into stacks.tf.json with an empty body. Terraform sees this as a no-op variable declaration but it lets the universe round-trip.
  7. Variables file write. All resolved variables go to zzz.auto.tfvars.json (the zzz. prefix gives it the highest auto.tfvars precedence for Terraform).
  8. terraform init (optional). Per --init flag.

1.5 Variable precedence chain (authoritative)

The preinit stage merges variables in ascending priority order (later layers overwrite earlier layers):

1. environments/<env>/env.tfvars
2. environments/<env>/<subenv>/*.tfvars     (when subenv is present)
3. <stacks_dir>/*.tfvars                    (rendered work_dir name: common_*.tfvars.jinja)
4. <stack_dir>/*.tfvars                     (rendered work_dir name: stack_*.tfvars.jinja)
5. <layer_dir>/*.tfvars                     (rendered work_dir name: layer_*.tfvars.jinja)
6. The eight predefined `stacks_*` variables (always last)

helpers.merge deepmerges dicts and lists at every level, with one special-case rule: top-level keys ending in _override are replaced rather than merged.

Important

Variables from *.auto.tfvars (Terraform's own auto-loading) are also picked up during the universe-file write so that bare resource-only stacks don't lose pre-existing .auto.tfvars declarations. They are not merged into the Jinja variable set — only Terraform's own variable resolution sees them.


2. System Requirements

2.1 Operator workstation

Component Requirement
OS macOS, Linux, or any platform that runs Python 3 + bash + terraform
Python 3.x (pyproject.toml requires Python 3 metadata; the codebase uses pathlib, f-strings, dict-merge |)
terraform Any HCL2-compatible release. Version is not pinned by stacks.
git Required for stacks diff (uses gitpython).
bash Required for stacks surgery edit (run_script calls bash -c).
Disk Each layer materialises into stacks.out/ — a few hundred KB to a few MB depending on stack size.

2.2 CI runner

CI runs of stacks need everything an operator workstation needs, plus:

  • STACKS_PRIVATE_KEY_PATH exported (or STACKS_* env vars overridden for the runner's filesystem layout).
  • Network access to Terraform's module registry and provider download endpoints (if the layer pulls remote modules).
  • Network access to the configured Terraform state backend.

2.3 Excluded environments

  • No daemon/server. stacks is invoked synchronously and exits. There is no listener and no IPC.
  • No native binaries. All runtime artefacts are Python.
  • Windows is not explicitly tested. The code uses POSIX path conventions (pathlib is portable, but helpers.run.run_script shells out to bash, which is not bundled with Windows). Use WSL.

3. Configuration Reference

3.1 Environment variables (complete)

Every environment variable read by stacks is enumerated in src/stacks/cmd/config.py (with one exception read in src/stacks/helpers/crypto.py). There are no others.

Variable Type Default Read in Purpose
STACKS_ENVIRONMENTS_DIR path environments cmd/config.py:4 Directory below root_dir that holds per-environment .tfvars files.
STACKS_STACKS_DIR path stacks cmd/config.py:5 Directory below root_dir that holds stack definitions.
STACKS_BASE_DIR path base cmd/config.py:6 Per-stack base directory containing modules and .tf files.
STACKS_LAYERS_DIR path layers cmd/config.py:7 Per-stack layers directory holding individual layer dirs.
STACKS_OUTPUT_DIR path stacks.out cmd/config.py:8 Per-layer rendered output directory name.
STACKS_TERRAFORM_PATH path terraform cmd/config.py:9 Terraform binary stacks should invoke.
STACKS_EDITOR command unset cmd/config.py:10 Editor for stacks surgery edit.
EDITOR command vi cmd/config.py:10 Fallback editor when STACKS_EDITOR is unset.
STACKS_PRIVATE_KEY_PATH path list unset helpers/crypto.py:87 Comma-separated list of RSA private key paths used during decrypt.

Important

No defaults exist in pyproject.toml for these variables. If you ship stacks to a CI runner, you are responsible for exporting whatever deviates from the built-in defaults shown above.

3.2 CLI flags (complete)

Every Click flag and argument that is registered in main.py:

Command Flag / Argument Type Default Required Description
render --init option auto no One of auto, always, never. Controls whether terraform init runs after preinit.
terraform --init option auto no Same semantics as render --init.
terraform args unprocessed positional — no Forwarded verbatim to the wrapped Terraform binary.
genkey --public-key-path option — yes Where to write the generated public key.
genkey --private-key-path option — yes Where to write the generated private key.
encrypt --public-key-path option — yes RSA public key for encryption.
encrypt --from-stdin flag False no Read plaintext from stdin instead of a positional argument.
encrypt string positional — conditional Required unless --from-stdin is set.
decrypt --private-key-path option — yes RSA private key for decryption. Comma-separated list accepted.
decrypt string positional — yes The ENC[...] payload, including brackets.
surgery import address positional — yes Terraform address to import into.
surgery import resource positional — yes Real-world resource id.
surgery remove address positional — yes Terraform address to remove from state.
surgery move from_address positional — yes Source resource address.
surgery move to_address positional — yes Destination resource address.
surgery move to_path positional — yes Destination layer path, relative to repo root.
surgery rename from_address positional — yes Source address (in current layer).
surgery rename to_address positional — yes Destination address (same layer).

Note

surgery list, surgery edit, version, preinit, and diff accept no flags or arguments at all.

3.3 Computed paths (Context attributes)

Every *_dir and *_file attribute the rest of the code reads off of Context:

Attribute Computed as
path cwd (or constructor argument)
root_dir path.parent.parent.parent.parent (four levels up)
envs_dir root_dir / STACKS_ENVIRONMENTS_DIR
stacks_dir root_dir / STACKS_STACKS_DIR
stack_dir path.parent.parent
base_dir stack_dir / STACKS_BASE_DIR
work_dir path / STACKS_OUTPUT_DIR (or constructor out)
terraform_dir work_dir / .terraform
modules_dir terraform_dir / modules
universe_file work_dir / stacks.tf.json
variables_file work_dir / zzz.auto.tfvars.json
stack stack_dir.name
layer path.name
env layer.split("_", 1)[0].split("@", 1)[0]
subenv layer.split("_", 1)[0].split("@", 1)[1] if @ present
env_dir envs_dir / env
subenv_dir env_dir / subenv if subenv else None
instance layer.split("_", 1)[1] if _ present else None

The constructor asserts that env_dir.exists() and (when applicable) subenv_dir.exists(). These are the only runtime invariants the code enforces about the directory structure.

3.4 Variable precedence — full table

Already enumerated in Section 1.5; reproduced here for the operator quick-reference:

Priority Source File pattern at read time Rendered as
1 (lowest) env environments/<env>/env.tfvars n/a
2 sub-env environments/<env>/<subenv>/*.tfvars n/a
3 stacks-wide stacks/*.tfvars common_*.tfvars.jinja
4 stack <stack>/*.tfvars stack_*.tfvars.jinja
5 layer <layer>/*.tfvars layer_*.tfvars.jinja
6 (highest) predefined n/a stacks_* keys

4. Deployment & Scaling

4.1 Workstation deployment

git clone https://github.com/cisco-open/stacks.git
cd stacks
pip install .

Or, in a virtual environment that should track uv.lock:

git clone https://github.com/cisco-open/stacks.git
cd stacks
uv sync

Both produce a stacks console-script entry point pointing at stacks.main:cli.

4.2 CI / container deployment

A minimal Dockerfile that produces a runnable image:

FROM python:3-slim

RUN apt-get update \
 && apt-get install -y --no-install-recommends git bash curl unzip \
 && rm -rf /var/lib/apt/lists/*

# Pin a Terraform version that matches your stacks
ARG TF_VERSION=1.x.x
RUN curl -fsSL "https://releases.hashicorp.com/terraform/${TF_VERSION}/terraform_${TF_VERSION}_linux_amd64.zip" \
      -o /tmp/tf.zip \
 && unzip /tmp/tf.zip -d /usr/local/bin \
 && rm /tmp/tf.zip

WORKDIR /opt/stacks
COPY . .
RUN pip install .

ENV STACKS_TERRAFORM_PATH=/usr/local/bin/terraform
ENTRYPOINT ["stacks"]

Note

stacks itself does not ship a Dockerfile at commit a1fadd8. The example above is a documented template — adjust pin values to your environment's standards.

4.3 Scaling characteristics

stacks has no state of its own. There is nothing to scale horizontally; every invocation is independent. Concurrency is constrained by:

  • Terraform state locks. Two simultaneous stacks terraform apply runs against the same layer hit the same lock-tfstate.
  • Filesystem. stacks render rebuilds stacks.out/ in place; running two renders for the same layer at the same time will produce a corrupt output directory.
  • CI parallelism. Different layers are independent. Different environments are independent. Different stacks are independent. Run per-layer Terraform plans in parallel without coordination.

4.4 Caching strategies

  • Skip terraform init — pass --init never when you can guarantee the .terraform/ cache is fresh.
  • Persist .terraform/ — it is preserved across stacks render runs by directory_remove's keep list. CI caches that pin stacks.out/.terraform/ between jobs save the registry round-trip.
  • Pre-warm modules — terraform init -backend=false once per job-image build is enough to populate modules/.

5. Operations

5.1 Day-to-day runbook

  • [x] Operator runs stacks render to confirm the layer renders cleanly.
  • [x] Operator runs stacks diff to confirm rendered changes match expectations.
  • [x] Operator runs stacks terraform plan to validate Terraform-level effects.
  • [ ] Operator runs stacks terraform apply to execute.
  • [ ] Operator commits the layer change with the rendered diff in the review.

5.2 Key management runbook

Step Command Notes
Generate stacks genkey --public-key-path keys/stacks.pub --private-key-path keys/stacks.key One-time per project. Store the private key in a secret manager.
Encrypt stacks encrypt --public-key-path keys/stacks.pub 'value' Anyone with the public key can encrypt.
Decrypt stacks decrypt --private-key-path keys/stacks.key 'ENC[...]' For ad-hoc verification. Avoid in scripts — helpers.decrypt runs automatically during render.
Rotate Set STACKS_PRIVATE_KEY_PATH=new.key,old.key Re-encrypt at leisure; drop old.key once the round-trip is clean.

Tip

Treat the private key with the same care as a Terraform state file. Anyone who can render a layer can decrypt every secret reachable from it.

5.3 State surgery runbook

Goal Command Underlying Terraform
List resources stacks surgery list terraform state list
Adopt existing resource stacks surgery import <addr> <id> terraform import
Drop without destroy stacks surgery remove <addr> terraform state rm
Move within stack stacks surgery move <from> <to> <to-path> state show + import + state rm
Rename in same layer stacks surgery rename <from> <to> Same as move with to_path = path
Inline edit stacks surgery edit state pull + state push

Note

stacks surgery move re-renders the source layer before pulling the state; it does not automatically re-render the destination layer. Run stacks render in the destination yourself if its template depends on the just-moved resource.

5.4 Health checks

There is no health endpoint. Health is verified empirically:

stacks version             # CLI is reachable
stacks terraform version   # wrapped binary is reachable, layer is valid

5.5 Logging

stacks itself emits very little output:

  • stacks version prints two lines.
  • stacks encrypt / stacks decrypt print the result.
  • All other commands either succeed silently or stream the wrapped process's stdout/stderr through.
  • On unhandled exceptions, Click prints ClickException.message; on subprocess.CalledProcessError, helpers.run.run_command calls sys.exit(e.returncode) so the parent shell sees the wrapped binary's return code.

Tip

If you need richer audit logging, wrap stacks invocations in your CI tool's job log. The CLI does not write to disk for logging purposes.


6. Performance

6.1 Hot paths

The render pipeline is dominated by:

  1. helpers.copy_files — one shutil.copyfile per matched file. Linear in the number of *.tf and *.tfvars.jinja files in stacks_dir, stack_dir, base_dir, and the layer.
  2. helpers.jinja2_render — runs Jinja2 in StrictUndefined mode against every *.tfvars.jinja and then every *.tf. Linear in template size.
  3. helpers.hcl2_read — invoked many times during preinit (once per variable layer in the precedence chain plus a final pass to enumerate declared variables). HCL2 parsing is the slowest individual step.
  4. terraform init — when not skipped, dwarfs everything else.

6.2 Tuning levers

Lever How Effect
Skip init --init never Removes the largest single-call cost.
Cache .terraform/ Persist between CI jobs Cuts init to a metadata refresh.
Reduce template count Inline rather than split .tfvars.jinja files Fewer glob calls + fewer Jinja parses.
Avoid _override keys Use proper deepmerge semantics Lets helpers.merge skip the special-case branch.

6.3 Resource ceiling

For small stacks (the only kind documented in the source — there are no benchmarks shipped), stacks render completes in well under a second on modern hardware, dominated by HCL2 parsing. terraform init costs are indistinguishable from running terraform init directly.

Note

stacks does not parallelise its file copies, Jinja renders, or HCL2 reads. The pipeline is sequential by design — variable precedence requires deterministic merge order. Parallelising at the layer level (run multiple stacks invocations side-by-side) is the supported scale-out path.


7. Security

7.1 Trust model

  • Layer authors are trusted. A *.tfvars.jinja template can call any registered Jinja filter and any predefined variable. The bundled filters include lookup and filesystem (read access to the repo) and throw (which raises). There is no sandbox.
  • Operators are trusted. Anyone who can run stacks render against a layer with STACKS_PRIVATE_KEY_PATH set has plaintext access to every encrypted value in scope.
  • Public keys are not sensitive. They can be checked into the repository alongside *.tfvars.

7.2 Secret handling

stacks defines a single secret-handling envelope: the ENC[...] string.

ENC[<sym_key_b64>;<gcm_tag_b64>;<iv_b64>;<ciphertext_b64>]
Field Algorithm Notes
<sym_key_b64> RSA-OAEP-SHA256 The 256-bit AES key, encrypted under the public key. MGF1-SHA256.
<gcm_tag_b64> AES-GCM auth tag 16 bytes, base64.
<iv_b64> random 12-byte IV os.urandom(12).
<ciphertext_b64> AES-256-GCM ciphertext Plaintext is PKCS#7-padded to 128-bit boundary first.

Key generation. helpers.crypto.genkey produces RSA-2048 with public exponent 2**16 + 1, PEM-encoded, PKCS#8 unencrypted private key, SubjectPublicKeyInfo public key. The private key file has no password — filesystem ACLs are the only access control.

Decryption recursion. helpers.crypto.decrypt walks lists and dicts recursively and decrypts every leaf string that begins with ENC[ and ends with ]. Strings without the envelope are returned unchanged.

Multi-key rollover. STACKS_PRIVATE_KEY_PATH accepts a comma-separated list of paths. The decrypt loop tries each key in order, breaking on the first successful RSA-OAEP unwrap. If no key works and must_decrypt=True (the default), the last ValueError is re-raised.

Caution

The private key on disk is unencrypted PKCS#8. There is no passphrase prompt and no integration with OS keychains. Securing the private key is entirely a filesystem-permissions and secret-manager concern.

7.3 Network surface

stacks itself opens no sockets. All network activity is delegated to the wrapped terraform binary:

  • Provider plugin downloads (during init).
  • Module registry fetches (during init).
  • State backend reads/writes (during every state-touching command).

Operators are responsible for the network policies that apply to those endpoints.

7.4 Process surface

stacks shells out to:

Binary Invoked from Args under user control?
terraform (STACKS_TERRAFORM_PATH) helpers.run.run_command via cmd.terraform, cmd.render, cmd.surgery yes (stacks terraform <args>)
bash helpers.run.run_script for stacks surgery edit partially — the script body is interpolated from STACKS_EDITOR and the work_dir path
git via GitPython in cmd.diff no
diff, cp, rm via helpers.run_command in cmd.diff and cmd.surgery.edit no
Editor (STACKS_EDITOR / EDITOR / vi) helpers.run_command in cmd.surgery.edit yes — the env var is the binary name

Warning

STACKS_EDITOR and EDITOR are passed to subprocess.run as the first element of argv — i.e. the binary path. A malicious env var pointing at an arbitrary executable will run that executable with the temporary state file as its only argument. Treat these env vars as part of the trusted operator environment.

7.5 Terraform args pass-through

stacks terraform is decorated with context_settings={"ignore_unknown_options": True} and uses click.UNPROCESSED for args, so the wrapper does no flag validation. Whatever you type is forwarded verbatim (after Click's own argument splitting). This is intentional — it lets every Terraform command and flag work — but it also means there is no defence in depth at the stacks layer.

7.6 Repo governance

  • Issue #23 (open) tracks a Security Policy violation regarding outside collaborators. Operators evaluating the project for adoption should track that issue for resolution.
  • Issue #8 (closed) recorded an inquiry about the project's status — see Developer Internals for current code-stewardship signals.

8. Developer Internals

8.1 Repository structure

.
├── pyproject.toml                       # PEP 621 metadata, declares the `stacks` console script
├── uv.lock                              # uv-pinned dependency lockfile
├── README.md                            # (high-level overview)
├── CONTRIBUTING.md                      # contribution policy
├── LICENSE                              # OSS license
├── docs/
│   ├── 1.1. Considerations before using.md
│   ├── 1.2. Stacks vs its alternatives.md
│   ├── 2.1. Installation instructions.md
│   ├── 2.2. I am starting from scratch.md
│   ├── 2.3. I am collaborating to an existing stack.md
│   ├── 2.4. I am collaborating to Stacks itself.md
│   ├── 3.1.1. Global Terraform code.md
│   ├── 3.1.2. Reusable root modules.md
│   ├── 3.1.3. Jinja templating for Terraform.md
│   ├── 3.1.4. Jinja templating for variables.md
│   ├── 3.1.5. Custom Jinja functions.md
│   ├── 3.1.6. Inline secret encryption.md
│   ├── 3.1.7. Automatic variable initialization.md
│   ├── 3.2.1. Terraform state backend configuration.md
│   ├── 3.2.2. Terraform provider generation.md
│   ├── 3.2.3. Input validation.md
│   ├── 3.3.1. stacks render.md
│   ├── 3.3.2. stacks terraform.md
│   ├── 3.3.3. stacks diff.md
│   ├── 3.3.4. stacks encrypt.md
│   ├── 3.3.5. stacks decrypt.md
│   ├── 3.3.6. stacks surgery list.md
│   ├── 3.3.7. stacks surgery import.md
│   ├── 3.3.8. stacks surgery remove.md
│   ├── 3.3.9. stacks surgery rename.md
│   ├── 3.3.10. stacks surgery move.md
│   ├── 3.3.11. stacks surgery edit.md
│   ├── 3.3.12. stacks version.md
│   ├── 3.4. Directory structure.md
│   └── 3.5. Special variables.md
└── src/stacks/
    ├── __init__.py
    ├── main.py
    ├── cmd/
    │   ├── __init__.py
    │   ├── config.py
    │   ├── context.py
    │   ├── diff.py
    │   ├── preinit.py
    │   ├── render.py
    │   ├── surgery.py
    │   └── terraform.py
    ├── helpers/
    │   ├── __init__.py
    │   ├── config.py
    │   ├── crypto.py
    │   ├── directory.py
    │   ├── merge.py
    │   ├── run.py
    │   └── template.py
    └── filters/
        ├── __init__.py
        ├── checksum.py
        ├── deepformat.py
        ├── encoding.py
        ├── filesystem.py
        ├── lookup.py
        └── throw.py

8.2 Module reference

stacks.main

The CLI entry point. Defines cli, version, preinit (hidden), render, diff, terraform, genkey (hidden), encrypt, decrypt, the surgery group, and the surgery sub-commands. The only logic the module itself contains is the stdin/positional dispatch in encrypt:

if from_stdin:
    string = sys.stdin.read()
    if not string:
        raise click.ClickException("No input received from stdin.")
elif string is None:
    raise click.ClickException("You must provide either a string argument or use --from-stdin.")

Everything else is delegated to cmd.* and helpers.*.

stacks.cmd.config

A single file of os.getenv calls. Read once at import, the eight constants (ENVIRONMENTS_DIR, STACKS_DIR, BASE_DIR, LAYERS_DIR, OUTPUT_DIR, TERRAFORM_PATH, EDITOR, plus the implicit EDITOR fallback) are imported by cmd.context, cmd.render, cmd.surgery, and cmd.terraform. To override a value, export the corresponding STACKS_* env var before the Python process starts — the constants are not re-read at command time.

stacks.cmd.context

Defines Context. The constructor's first conditional handles the re-entrant case where the out directory passed in has the same name as STACKS_OUTPUT_DIR and shares a parent with path — used by cmd.diff to render against alternative output directories (stacks.out.old, stacks.out.new).

Context does not cache anything across calls. Re-entrancy is achieved by simply constructing a new Context per command.

stacks.cmd.preinit

The 100-line workhorse. Reads:

  • helpers.directory_remove to wipe work_dir.
  • helpers.copy_files four times.
  • shutil.copytree / shutil.copyfile for non-*.tf items in base_dir.
  • helpers.hcl2_read against the variable-precedence chain to build the Jinja var mapping.
  • helpers.jinja2_render twice (once for *.tfvars.jinja, once for *.tf).
  • helpers.json_write for stacks.tf.json and zzz.auto.tfvars.json.

The variables_predefined dict is the source of the eight stacks_* Jinja variables. Note the # TODO: replace 'env.tfvars' with '*.tfvars' after all stacks have been upgraded to v2 comment in the stacks_environments map — a v2 migration is in flight.

stacks.cmd.render

Three lines of substance. Calls preinit.preinit, then conditionally runs terraform -chdir=<work_dir> init based on the --init flag.

stacks.cmd.diff

Drives the rendered-vs-rendered comparison:

  1. Stash with gitpython.
  2. Render the stashed working tree into <work_dir>.old.
  3. git stash pop.
  4. Render the live tree into <work_dir>.new.
  5. diff -ur --color between them.
  6. rm -rf both.

Uses helpers.run_command for diff and rm so the user sees coloured output streamed to the terminal.

stacks.cmd.terraform

Seven lines. Calls render.render(ctx, init) then helpers.run_command(config.TERRAFORM_PATH, f"-chdir={ctx.work_dir}", *args). Returns the completed subprocess.CompletedProcess.

stacks.cmd.surgery

Implements _list, _import, remove, move, edit. The non-trivial logic is in move and edit:

  • move parses the source resource id from terraform state show -no-color <addr>, captures the first attribute with id from the parsed HCL2 output, then chains _import against a fresh Context(path=to_path) followed by remove against the original. The author's comment is preserved verbatim in source: "this is ugly but it works, do NOT touch."
  • edit runs render, terraform state pull > old.tfstate, copies that to new.tfstate, opens new.tfstate in the editor, runs diff -ru --color (with || true because diff returns non-zero on any difference), and prompts Proceed with changes? [y/N] before terraform state push new.tfstate.

Surgery sub-commands like list are exposed in main.py under stacks.cmd.surgery._list because Python reserves list as a builtin — the underscore-prefixed names are intentional.

stacks.helpers.config

Generic decoder dispatch:

  • config_read(patterns, should_decrypt, must_decrypt, decoderfunc, **decoderargs) globs every pattern, reads each matching file with decoderfunc, and successively merges them into data. Calls decrypt on the result when should_decrypt is True.
  • json_read, yaml_read, hcl2_read are thin wrappers passing json.load, yaml.safe_load, hcl2.load respectively. All default should_decrypt=True, must_decrypt=True.
  • config_write is the dual; json_write and yaml_write are wrappers with sensible indenting (2-space JSON, 2-space YAML with width=1000).

stacks.helpers.crypto

Self-contained envelope crypto. The encrypt path:

  1. Generate 32 random bytes for the AES-256 key.
  2. Generate 12 random bytes for the GCM IV.
  3. PKCS#7-pad plaintext to 128-bit boundary.
  4. AES-GCM encrypt.
  5. RSA-OAEP-SHA256 encrypt the AES key under the public key.
  6. Concatenate base64 fields with ; separators, wrap in ENC[...].

The decrypt path is the reverse, plus a list/dict recursion guard:

elif isinstance(data, list):
    return [decrypt(...) for item in data]
elif isinstance(data, dict):
    return {key: decrypt(...) for key, value in data.items()}

Strings without the ENC[ envelope are passed through verbatim.

stacks.helpers.directory

Two functions.

  • copy_files(src, dst, include=None, prefix="") — walks src, shutil-copies any file whose name matches one of the include fnmatch patterns, into dst/<prefix><name>. Creates dst if missing.
  • directory_remove(path, keep=[]) — iterates path and removes every child whose name is not in keep. Used to wipe stacks.out/ while preserving .terraform/, .terraformrc, .terraform.lock.hcl.

stacks.helpers.merge

A four-line function around deepmerge.always_merger.merge with one custom rule: top-level keys in b ending in _override replace the corresponding key in a rather than merging. The TODO in source notes this is a layering hack pending HCL-language support for override semantics.

stacks.helpers.run

run_command(*argv, interactive=True) is the only subprocess wrapper. When interactive=True (the default), stdout/stderr stream to the terminal and a non-zero exit calls sys.exit(e.returncode). When interactive=False, output is captured and CalledProcessError is re-raised — used by cmd.surgery.move to parse terraform state show output.

run_script(script) shells out to bash -c <script> for cases that need redirection (stacks surgery edit).

stacks.helpers.template

jinja2_render(ctx, patterns, data={}) renders every file in patterns in place. Each file is parsed with jinja2.Template(... , undefined=jinja2.StrictUndefined), rendered against data union-merged with the registered filters. The filter binding is the notable trick:

for filter in filters.__all__:
    filter_name = filter.__name__

    def filter_with_context(*args, filter_name=filter_name, **kwargs):
        return getattr(filters, filter_name)(ctx, *args, **kwargs)

    filters_dict[filter_name] = filter_with_context

The closure captures filter_name as a default argument so each filter gets the correct ctx injected. Two exception classes are translated into Exception with prefixed messages: UndefinedError becomes Failure to render <PATH>: ... and TemplateSyntaxError becomes Failure to render <PATH> at line <N>, in statement <STMT>: ....

stacks.filters (LOW priority — referenced but not deeply read)

The filters/ package is referenced from helpers.template and exposes six modules — checksum, deepformat, encoding, filesystem, lookup, throw. Each is registered automatically via filters.__all__. Custom filter signatures are filter(ctx, *args, **kwargs) — ctx is bound by the closure shown above before Jinja invokes them.

Note

The filter modules sit in a LOW-priority folder (src/stacks/filters/) and were not included in the in-depth module read. Names hint at functionality (checksum for hashing, filesystem for file I/O, lookup for cross-layer reads, throw for assertions, encoding/deepformat for value formatting), but the exact filter signatures and semantics live in the source files themselves. Treat the source under src/stacks/filters/ as authoritative for any filter you need to use.

8.3 Code patterns and conventions

Pattern Where Why
Context constructed per command every cmd/*.py Stateless re-entrancy; allows cmd/diff.py to render against alternative output dirs trivially.
Underscored sub-command names cmd/surgery.py (_list, _import) Avoids Python builtin shadowing while keeping Click command names clean (list, import).
Two-pass Jinja rendering cmd/preinit.py First pass renders *.tfvars.jinja so the second pass over *.tf can read the resulting .tfvars.
assert for invariants cmd/context.py Hard-fails on misuse — the CLI is single-shot and never hides assertion errors behind a recovery path.
subprocess.run with check=True helpers/run.py Any non-zero exit propagates immediately. Interactive mode shortcircuits to sys.exit.
Recursive decrypt over containers helpers/crypto.py Lets HCL2/JSON/YAML structures contain encrypted leaves at any depth without callers writing recursion.
_override deepmerge override helpers/merge.py Pragmatic top-level escape hatch until HCL2 itself supports override semantics.

8.4 Testing strategy

No tests/ directory ships with the source under src/. The project treats stacks render against real layers as the integration test. Contributors validating changes should point a known-good stack repository at the modified stacks checkout via pip install -e . and run the full render-diff-plan cycle.

8.5 Potential improvements

Direct from # TODO markers and pragmatic observations:

  • Replace the _override hack with proper HCL2 override semantics (helpers/merge.py).
  • Replace env.tfvars glob with *.tfvars in cmd/preinit.py:variables_predefined.stacks_environments once all consumer stacks have migrated to v2.
  • Add a sandbox or allowlist for filters — helpers/template.py registers everything in filters.__all__ unconditionally.
  • Add a --dry-run to stacks surgery edit that skips the terraform state push even on confirmation.
  • Address issue #23 (open) — Security Policy violation re: outside collaborators.

8.6 Contributing guide

The repository ships a CONTRIBUTING.md (top-level, see file tree). Contributors interested in deeper changes should:

  1. Install in editable mode: pip install -e ..
  2. Match the existing module layout — new commands belong under src/stacks/cmd/, new helpers under src/stacks/helpers/, new filters under src/stacks/filters/.
  3. Wire any new CLI surface through src/stacks/main.py so Click metadata is the single source of truth for the CLI surface.

9. Integration Points

9.1 With Terraform

stacks integrates with Terraform exclusively through the CLI. The integration surface is:

Surface Usage
Binary path STACKS_TERRAFORM_PATH
Working dir terraform -chdir=<stacks.out> flag
State commands state list, state show, state rm, state pull, state push, import
Init commands init
Pass-through stacks terraform <anything>
Variable hand-off stacks.tf.json (declarations) + zzz.auto.tfvars.json (values)

There is no Go-API integration, no Terraform provider, and no plugin.

9.2 With Git

stacks diff uses git.Repo(path=ctx.path, search_parent_directories=True) to find the repository, then repository.git.stash() / repository.git.stash("pop") to flip between HEAD and working tree. The integration relies on git stash semantics — staged changes that conflict with the stash will surface as a GitPython error and abort the diff.

9.3 With CI systems

The CLI is a single binary, so any CI runner that can install Python, terraform, and git can run it. Common patterns:

CI Integration
GitHub Actions setup-python + setup-terraform + pip install <stacks-clone>
GitLab CI Pre-built image with python:3 + Terraform + stacks installed; cache .terraform/ between jobs.
Generic pip install . from a pinned commit; export STACKS_PRIVATE_KEY_PATH from the runner's secret store.

9.4 Editor integration

stacks surgery edit looks up the editor via STACKS_EDITOR → EDITOR → vi. Any editor that accepts a single positional file path works (nano, nvim, code --wait, subl --wait).

9.5 Filesystem invariants for callers

External tools (build scripts, mono-repo tooling, IDE plugins) that discover stacks-managed layers should rely on the directory layout documented in Section 1.4:

  • Layer detection. A directory is a layer iff it sits four levels below a directory named <STACKS_ENVIRONMENTS_DIR>, and its name parses as <env>[@<subenv>][_<instance>], and environments/<env>/ exists.
  • Output detection. The rendered Terraform working tree is at <layer>/<STACKS_OUTPUT_DIR> after stacks render completes.
  • Variable universe. stacks.tf.json and zzz.auto.tfvars.json are the canonical artefacts a downstream tool can read to enumerate variables.

Important

Build automation should always invoke stacks render rather than reading partially rendered state from stacks.out/. The directory is rebuilt every run; reading mid-render produces garbage.