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 groupcli, the seven public commands (version,render,diff,terraform,encrypt,decrypt, plus thesurgerygroup), the two hidden commands (preinit,genkey), and six surgery sub-commands (edit,list,import,remove,move,rename).stacks.cmd— command implementations and theContextobject that encapsulates the resolved layer/stack/env directory tree. Includesconfig.py(env-var defaults),context.py(path math),preinit.py(file copy and Jinja render),render.py,diff.py,terraform.py, andsurgery.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_overridesemantics),run.py(subprocess.runwrappers), andtemplate.py(Jinja render withStrictUndefined).
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 commita1fadd8. Every block corresponds to a real.pyfile 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:
- Context construction.
Context.__init__inferspathfrom the current working directory andoutfrompath/<STACKS_OUTPUT_DIR>. It walks four parents up to findroot_dir. It splits the layer name into env, sub-env, instance. - Output directory reset.
helpers.directory_remove(work_dir, keep=[".terraform", ".terraformrc", ".terraform.lock.hcl"]). - File copy.
helpers.copy_filesis invoked four times:stacks_dir → work_dir(*.tf,*.tfvars.jinja) prefixedcommon_.stack_dir → work_dir(*.tfvars.jinja) prefixedstack_.- For each entry under
base_dir: directories areshutil.copytree'd, non-*.tffiles areshutil.copyfile'd,*.tffiles are then copied viahelpers.copy_fileswith prefixbase_. path → work_dir(*.tfvars.jinja) prefixedlayer_.
- First Jinja pass.
*.tfvars.jinjafiles inwork_dirare rendered in place against avarmapping built from on-disk.tfvarsplus the eightstacks_*predefined variables. - Second Jinja pass.
*.tffiles inwork_dirare rendered in place against the samevarmapping (recomputed to include now-rendered*.tfvars.jinja). - Variable universe write. Every variable in the merged set that is
not already declared as
variable "..." {}in the rendered*.tfis written intostacks.tf.jsonwith an empty body. Terraform sees this as a no-op variable declaration but it lets the universe round-trip. - Variables file write. All resolved variables go to
zzz.auto.tfvars.json(thezzz.prefix gives it the highestauto.tfvarsprecedence for Terraform). terraform init(optional). Per--initflag.
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.tfvarsdeclarations. 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_PATHexported (orSTACKS_*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.
stacksis 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 (
pathlibis portable, buthelpers.run.run_scriptshells out tobash, 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.tomlfor these variables. If you shipstacksto 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, anddiffaccept 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
stacksitself does not ship a Dockerfile at commita1fadd8. 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 applyruns against the same layer hit the same lock-tfstate. - Filesystem.
stacks renderrebuildsstacks.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 neverwhen you can guarantee the.terraform/cache is fresh. - Persist
.terraform/— it is preserved acrossstacks renderruns bydirectory_remove'skeeplist. CI caches that pinstacks.out/.terraform/between jobs save the registry round-trip. - Pre-warm modules —
terraform init -backend=falseonce per job-image build is enough to populatemodules/.
5. Operations
5.1 Day-to-day runbook
- [x] Operator runs
stacks renderto confirm the layer renders cleanly. - [x] Operator runs
stacks diffto confirm rendered changes match expectations. - [x] Operator runs
stacks terraform planto validate Terraform-level effects. - [ ] Operator runs
stacks terraform applyto 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 movere-renders the source layer before pulling the state; it does not automatically re-render the destination layer. Runstacks renderin 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 versionprints two lines.stacks encrypt/stacks decryptprint the result.- All other commands either succeed silently or stream the wrapped process's stdout/stderr through.
- On unhandled exceptions, Click prints
ClickException.message; onsubprocess.CalledProcessError,helpers.run.run_commandcallssys.exit(e.returncode)so the parent shell sees the wrapped binary's return code.
Tip
If you need richer audit logging, wrap
stacksinvocations 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:
helpers.copy_files— oneshutil.copyfileper matched file. Linear in the number of*.tfand*.tfvars.jinjafiles instacks_dir,stack_dir,base_dir, and the layer.helpers.jinja2_render— runs Jinja2 inStrictUndefinedmode against every*.tfvars.jinjaand then every*.tf. Linear in template size.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.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
stacksdoes 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 multiplestacksinvocations side-by-side) is the supported scale-out path.
7. Security
7.1 Trust model
- Layer authors are trusted. A
*.tfvars.jinjatemplate can call any registered Jinja filter and any predefined variable. The bundled filters includelookupandfilesystem(read access to the repo) andthrow(which raises). There is no sandbox. - Operators are trusted. Anyone who can run
stacks renderagainst a layer withSTACKS_PRIVATE_KEY_PATHset 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_EDITORandEDITORare passed tosubprocess.runas the first element ofargv— 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_removeto wipework_dir.helpers.copy_filesfour times.shutil.copytree/shutil.copyfilefor non-*.tfitems inbase_dir.helpers.hcl2_readagainst the variable-precedence chain to build the Jinjavarmapping.helpers.jinja2_rendertwice (once for*.tfvars.jinja, once for*.tf).helpers.json_writeforstacks.tf.jsonandzzz.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:
- Stash with
gitpython. - Render the stashed working tree into
<work_dir>.old. git stash pop.- Render the live tree into
<work_dir>.new. diff -ur --colorbetween them.rm -rfboth.
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:
moveparses the source resource id fromterraform state show -no-color <addr>, captures the first attribute withidfrom the parsed HCL2 output, then chains_importagainst a freshContext(path=to_path)followed byremoveagainst the original. The author's comment is preserved verbatim in source: "this is ugly but it works, do NOT touch."editruns render,terraform state pull > old.tfstate, copies that tonew.tfstate, opensnew.tfstatein the editor, runsdiff -ru --color(with|| truebecausediffreturns non-zero on any difference), and promptsProceed with changes? [y/N]beforeterraform 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 withdecoderfunc, and successivelymerges them intodata. Callsdecrypton the result whenshould_decryptisTrue.json_read,yaml_read,hcl2_readare thin wrappers passingjson.load,yaml.safe_load,hcl2.loadrespectively. All defaultshould_decrypt=True, must_decrypt=True.config_writeis the dual;json_writeandyaml_writeare wrappers with sensible indenting (2-space JSON, 2-space YAML withwidth=1000).
stacks.helpers.crypto
Self-contained envelope crypto. The encrypt path:
- Generate 32 random bytes for the AES-256 key.
- Generate 12 random bytes for the GCM IV.
- PKCS#7-pad plaintext to 128-bit boundary.
- AES-GCM encrypt.
- RSA-OAEP-SHA256 encrypt the AES key under the public key.
- Concatenate base64 fields with
;separators, wrap inENC[...].
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="")— walkssrc, shutil-copies any file whose name matches one of theincludefnmatch patterns, intodst/<prefix><name>. Createsdstif missing.directory_remove(path, keep=[])— iteratespathand removes every child whose name is not inkeep. Used to wipestacks.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 (checksumfor hashing,filesystemfor file I/O,lookupfor cross-layer reads,throwfor assertions,encoding/deepformatfor value formatting), but the exact filter signatures and semantics live in the source files themselves. Treat the source undersrc/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
_overridehack with proper HCL2 override semantics (helpers/merge.py). - Replace
env.tfvarsglob with*.tfvarsincmd/preinit.py:variables_predefined.stacks_environmentsonce all consumer stacks have migrated to v2. - Add a sandbox or allowlist for filters —
helpers/template.pyregisters everything infilters.__all__unconditionally. - Add a
--dry-runtostacks surgery editthat skips theterraform state pusheven 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:
- Install in editable mode:
pip install -e .. - Match the existing module layout — new commands belong under
src/stacks/cmd/, new helpers undersrc/stacks/helpers/, new filters undersrc/stacks/filters/. - Wire any new CLI surface through
src/stacks/main.pyso 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>], andenvironments/<env>/exists. - Output detection. The rendered Terraform working tree is at
<layer>/<STACKS_OUTPUT_DIR>afterstacks rendercompletes. - Variable universe.
stacks.tf.jsonandzzz.auto.tfvars.jsonare the canonical artefacts a downstream tool can read to enumerate variables.
Important
Build automation should always invoke
stacks renderrather than reading partially rendered state fromstacks.out/. The directory is rebuilt every run; reading mid-render produces garbage.