|
| 1 | +# n8n-iac |
| 2 | + |
| 3 | +**Infrastructure-as-code for [n8n](https://n8n.io).** Author workflows as typed Python, compile |
| 4 | +them to wire-exact n8n JSON, catch broken wiring *before* deploying, and ship with a |
| 5 | +Terraform-style `plan` / `apply` lifecycle. |
| 6 | + |
| 7 | +[](https://github.com/SAKMZ/n8n-python-sdk/actions/workflows/ci.yml) |
| 8 | +[](https://www.python.org/downloads/) |
| 9 | +[](LICENSE) |
| 10 | +[](https://mypy-lang.org/) |
| 11 | + |
| 12 | +```python |
| 13 | +with Workflow(uid="order-sync", name="Order Sync", active=True) as wf: |
| 14 | + Webhook(path="orders") >> Set(name="Shape", fields=[...]) >> HttpRequest(url=...) |
| 15 | +``` |
| 16 | + |
| 17 | +```console |
| 18 | +$ n8n-iac plan --env prod |
| 19 | +~ UPDATE Order Sync |
| 20 | + ~ /nodes/Shape/parameters/assignments … |
| 21 | +WARNING R5 output 1 ('silver') of 'Route' is not connected while other branches are |
| 22 | +``` |
| 23 | + |
| 24 | +--- |
| 25 | + |
| 26 | +## Why this exists |
| 27 | + |
| 28 | +n8n workflows live as JSON blobs inside a UI. That means no meaningful code review — a one-node |
| 29 | +change reads as a 400-line diff — no environments, no CI, and no way to catch a silently-unwired |
| 30 | +branch until it quietly drops data in production. |
| 31 | + |
| 32 | +`n8n-iac` moves the workflow into your repository: |
| 33 | + |
| 34 | +| | n8n UI | n8n-iac | |
| 35 | +| --------------- | ----------------------- | ---------------------------------------- | |
| 36 | +| Source of truth | The instance's database | Your git repo | |
| 37 | +| Review | 400-line JSON diff | A readable Python diff | |
| 38 | +| Environments | Manual re-wiring | One definition, per-env credential binding | |
| 39 | +| Broken wiring | Found in production | Found offline, before deploy | |
| 40 | +| Deploy | Click "import" | `plan` → review → `apply` | |
| 41 | + |
| 42 | +## Project status |
| 43 | + |
| 44 | +**Alpha (v0.1.0).** Usable and tested, with sharp edges worth knowing about up front. |
| 45 | + |
| 46 | +What's proven: 524 offline tests at 100% line *and* branch coverage, `mypy --strict` clean, CI |
| 47 | +green on Python 3.12/3.13 across Linux, macOS, and Windows. A 12-test end-to-end suite runs |
| 48 | +against a real n8n instance (Public API 1.1.1) and covers create/read fidelity, update semantics, |
| 49 | +activation, pagination, plan/apply convergence, and both destroy tiers. |
| 50 | + |
| 51 | +What's not: only **16 node types** have full parameter schemas (others work through a permissive |
| 52 | +escape hatch, without validation), `import` emits canonical JSON rather than reconstructed Python |
| 53 | +source, the async client covers transport and resources but not the deployer, and there's no PyPI |
| 54 | +release yet. The e2e suite has been run against one n8n version, so older or newer instances may |
| 55 | +differ. |
| 56 | + |
| 57 | +Breaking changes are possible before 1.0. If you deploy to something you care about, pin a commit. |
| 58 | + |
| 59 | +## Install |
| 60 | + |
| 61 | +```bash |
| 62 | +pip install git+https://github.com/SAKMZ/n8n-python-sdk.git |
| 63 | +``` |
| 64 | + |
| 65 | +Requires **Python 3.12+**. |
| 66 | + |
| 67 | +## Quickstart |
| 68 | + |
| 69 | +```python |
| 70 | +from n8n_iac.nodes import HttpRequest, Set, SetField, Webhook, Workflow |
| 71 | + |
| 72 | +with Workflow(uid="order-sync", name="Order Sync", tags=("iac",), active=True) as wf: |
| 73 | + hook = Webhook(path="orders", http_method="POST") |
| 74 | + shape = Set( |
| 75 | + name="Shape Order", |
| 76 | + fields=[ |
| 77 | + SetField("order_id", "={{ $json.body.id }}"), |
| 78 | + SetField("source", "webhook"), |
| 79 | + ], |
| 80 | + ) |
| 81 | + forward = HttpRequest( |
| 82 | + name="Forward To ERP", |
| 83 | + url="https://erp.example.com/orders", |
| 84 | + method="POST", |
| 85 | + ) |
| 86 | + hook >> shape >> forward |
| 87 | + |
| 88 | +print(wf.to_json()) # wire-exact n8n JSON, importable as-is |
| 89 | +print(wf.diagnostics()) # () -- this workflow is clean |
| 90 | +``` |
| 91 | + |
| 92 | +`>>` wires nodes together. Node positions, node IDs, and `webhookId` are derived automatically — |
| 93 | +you never lay out a canvas by hand. |
| 94 | + |
| 95 | +> **Expressions need the `=` prefix**, exactly as in the n8n UI. `"={{ $json.body.id }}"` is an |
| 96 | +> expression; `"{{ $json.body.id }}"` is a literal string that happens to contain braces. This |
| 97 | +> catches everyone at least once. (`Cond` is the exception — it wraps a leading `$` for you.) |
| 98 | +
|
| 99 | +## What the validator catches |
| 100 | + |
| 101 | +This is the part that earns its keep, and it runs **fully offline** — no instance, no network. |
| 102 | + |
| 103 | +A `Switch` with a branch nobody wired up: |
| 104 | + |
| 105 | +```python |
| 106 | +route = Switch(name="Route", rules=[ |
| 107 | + Rule(when=Cond("$json.tier", "equals", "gold"), output="gold"), |
| 108 | + Rule(when=Cond("$json.tier", "equals", "silver"), output="silver"), |
| 109 | +]) |
| 110 | +Webhook(path="demo") >> route |
| 111 | +route.out("gold") >> HttpRequest(name="Gold Path", url="https://example.com/gold") |
| 112 | +``` |
| 113 | + |
| 114 | +``` |
| 115 | +WARNING R5 output 1 ('silver') of 'Route' is not connected while other branches are |
| 116 | +``` |
| 117 | + |
| 118 | +An expression referencing a node that isn't actually upstream — which n8n resolves to |
| 119 | +`undefined` at runtime, silently, raising nothing of its own: |
| 120 | + |
| 121 | +```python |
| 122 | +a = Set(name="First", fields=[SetField("x", "={{ $('Second').item.json.y }}")]) |
| 123 | +b = HttpRequest(name="Second", url="https://example.com/x") |
| 124 | +hook >> a >> b # 'First' runs BEFORE 'Second' |
| 125 | +``` |
| 126 | + |
| 127 | +``` |
| 128 | +ERROR R6 expression in 'First' references 'Second', which is not a transitive |
| 129 | + ancestor -- this yields undefined at runtime |
| 130 | +``` |
| 131 | + |
| 132 | +The rule set also covers unreachable nodes, dead-end transforms, illegal cycles (loop nodes |
| 133 | +exempted), port-index bounds, missing AI sub-node attachments, hardcoded secrets, and per-node |
| 134 | +rules for Webhook and HTTP Request. |
| 135 | + |
| 136 | +## Deploying |
| 137 | + |
| 138 | +Describe your environments in `n8n_iac.toml`: |
| 139 | + |
| 140 | +```toml |
| 141 | +[project] |
| 142 | +workflows = "workflows.py:build_all" # a callable returning your Workflow list |
| 143 | +state_file = ".n8n-iac-state.json" |
| 144 | + |
| 145 | +[environments.prod] |
| 146 | +base_url = "https://n8n.example.com" |
| 147 | +api_key_env = "N8N_API_KEY" # the env var NAME -- never the key itself |
| 148 | +production = true |
| 149 | + |
| 150 | +[environments.prod.credentials] |
| 151 | +erp-api = "17" # logical name -> credential id on that instance |
| 152 | +``` |
| 153 | + |
| 154 | +```bash |
| 155 | +n8n-iac validate # offline: compile + validate, no instance needed |
| 156 | +n8n-iac plan --env prod # what would change, as a colorized diff |
| 157 | +n8n-iac apply --env prod # after a confirmation prompt |
| 158 | +n8n-iac destroy --env prod <uid> # deactivate and unbind (--hard to delete) |
| 159 | +n8n-iac import --workflow-id 42 --uid billing # adopt an existing workflow |
| 160 | +``` |
| 161 | + |
| 162 | +`plan --detailed-exitcode` exits `2` when changes are pending, so it drops straight into a CI |
| 163 | +gate; `plan --json` emits the machine-readable plan. |
| 164 | + |
| 165 | +Credentials bind **by reference, never by value**: your Python names a logical credential |
| 166 | +(`HeaderAuth("erp-api")`), the TOML maps that name to an id per instance, and the API key itself |
| 167 | +only ever lives in an environment variable. No secret is written to a config file, a state file, |
| 168 | +or compiled output — and rule `C1` fails the build if one gets hardcoded. |
| 169 | + |
| 170 | +### Two things to know about `destroy` |
| 171 | + |
| 172 | +n8n's public API has **no archive endpoint** — `isArchived` is readable but nothing can set it. |
| 173 | +So `destroy` has two tiers: the default *retires* a workflow (deactivates it and drops it from |
| 174 | +your state file, leaving it inert on the instance), and `--hard` deletes it, which is |
| 175 | +irreversible and cascades to execution history. |
| 176 | + |
| 177 | +## Determinism |
| 178 | + |
| 179 | +Compiling the same source twice produces **byte-identical** JSON, on any machine, under any |
| 180 | +`PYTHONHASHSEED`. This is a tested property, not an aspiration — it's what makes `plan` diffs |
| 181 | +trustworthy and keeps a no-op deploy showing zero changes. |
| 182 | + |
| 183 | +It falls out of a few deliberate constraints: node IDs and `webhookId` are `uuid5`-derived from |
| 184 | +your `Workflow.uid` (never random), every collection reaching output has a defined sort, and |
| 185 | +diagnostics have a total order. The suite includes a subprocess matrix that recompiles under |
| 186 | +different hash seeds and asserts the bytes match. |
| 187 | + |
| 188 | +## Contributing |
| 189 | + |
| 190 | +Contributions are welcome — issues, bug reports, and pull requests alike. |
| 191 | + |
| 192 | +The most valuable contributions right now: |
| 193 | + |
| 194 | +- **Node schemas.** 16 types are modeled out of hundreds. Adding one is self-contained: a params |
| 195 | + model, a registry entry, and tests. |
| 196 | +- **Testing against more n8n versions.** The e2e suite has only met one. If it fails against |
| 197 | + yours, that's a genuinely useful bug report — include your n8n version. |
| 198 | +- **Bug reports with a minimal reproducing workflow.** A 10-line `Workflow(...)` that misbehaves |
| 199 | + beats a long description. |
| 200 | + |
| 201 | +Development setup, and the four gates that must pass: |
| 202 | + |
| 203 | +```bash |
| 204 | +poetry install |
| 205 | +poetry run pytest --cov --cov-branch # 100% line and branch coverage |
| 206 | +poetry run mypy --strict n8n_iac # zero errors |
| 207 | +poetry run ruff check n8n_iac tests |
| 208 | +poetry run python -m n8n_iac.client._async_codegen --check # generated code is current |
| 209 | +``` |
| 210 | + |
| 211 | +No Poetry? A plain venv works — `pip install -e .` plus the dev dependencies. |
| 212 | + |
| 213 | +The end-to-end tests need a real n8n and are excluded by default. They only ever touch workflows |
| 214 | +they create (all named with an `n8n-iac-e2e` prefix) and delete them afterwards, so running them |
| 215 | +against an instance with real workflows on it is safe: |
| 216 | + |
| 217 | +```bash |
| 218 | +N8N_BASE_URL=http://localhost:5678 N8N_API_KEY=... poetry run pytest -m e2e |
| 219 | +``` |
| 220 | + |
| 221 | +A few house rules worth knowing before you open a PR: prefer an explicit `raise` over `assert` |
| 222 | +for narrowing (`assert` creates a branch coverage can't reach), never iterate a `dict` or `set` |
| 223 | +for anything that reaches output, and if you change a validator rule, update the matching golden |
| 224 | +fixture — reviewing that diff by hand, since `--update-golden` will happily bless a regression. |
| 225 | + |
| 226 | +## License |
| 227 | + |
| 228 | +MIT — see [LICENSE](LICENSE). Not affiliated with or endorsed by n8n GmbH. |
0 commit comments