Skip to content

Commit e4b9de9

Browse files
committed
n8n-iac: infrastructure-as-code for n8n workflows
Author n8n workflows as typed Python, compile them to wire-exact n8n JSON through a deterministic pipeline, validate them offline, and deploy them with a plan/apply lifecycle. Layout (layering is strictly downward): - n8n_iac/core wire and authoring models, the compiler, and the plan validator (structural rules, expression-reference soundness, cycle policy, credential and per-node rules) - n8n_iac/nodes authoring DSL: Node base plus Webhook, HttpRequest, Set, Switch, and the Workflow container - n8n_iac/client REST transport (retries, pagination, error mapping), resource APIs, and the plan/apply deployer with versionId optimistic locking - n8n_iac/cli validate / plan / apply / destroy / import Verified against a live n8n instance (Public API 1.1.1): wire-format fidelity, update semantics, activation, cursor pagination, plan/apply convergence, and both destroy tiers. Gates: 524 offline tests at 100% line and branch coverage, 12 end-to-end tests, mypy --strict clean, ruff clean. Byte-identical output under any PYTHONHASHSEED is a tested property.
0 parents  commit e4b9de9

101 files changed

Lines changed: 15130 additions & 0 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.gitattributes‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Determinism is the point of this project, and the golden fixtures are
2+
# compared byte-for-byte. A Windows checkout rewriting LF -> CRLF would break
3+
# them on that platform only, so line endings are pinned everywhere.
4+
* text=auto eol=lf
5+
6+
*.png binary
7+
*.jpg binary
8+
*.ico binary

‎.github/workflows/ci.yml‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
workflow_dispatch:
8+
9+
permissions:
10+
contents: read
11+
12+
concurrency:
13+
group: ${{ github.workflow }}-${{ github.ref }}
14+
cancel-in-progress: true
15+
16+
jobs:
17+
# OPUS_SPEC §3.7: Python 3.12 / 3.13 x Linux, macOS, Windows.
18+
# 3.12 is the strict floor -- do not add 3.10/3.11 back.
19+
test:
20+
name: pytest (py${{ matrix.python-version }} / ${{ matrix.os }})
21+
runs-on: ${{ matrix.os }}
22+
strategy:
23+
fail-fast: false
24+
matrix:
25+
os: [ubuntu-latest, macos-latest, windows-latest]
26+
python-version: ["3.12", "3.13"]
27+
steps:
28+
- uses: actions/checkout@v4
29+
30+
- name: Install Poetry
31+
run: pipx install poetry
32+
33+
- uses: actions/setup-python@v5
34+
with:
35+
python-version: ${{ matrix.python-version }}
36+
37+
- name: Install dependencies
38+
run: poetry install --no-interaction
39+
40+
# The coverage gate (100% line AND branch) lives in pyproject.toml.
41+
- name: Run tests with coverage gate
42+
run: poetry run pytest --cov --cov-branch
43+
44+
quality:
45+
name: types, lint, codegen drift
46+
runs-on: ubuntu-latest
47+
steps:
48+
- uses: actions/checkout@v4
49+
50+
- name: Install Poetry
51+
run: pipx install poetry
52+
53+
- uses: actions/setup-python@v5
54+
with:
55+
python-version: "3.12"
56+
57+
- name: Install dependencies
58+
run: poetry install --no-interaction
59+
60+
- name: mypy --strict
61+
run: poetry run mypy --strict n8n_iac
62+
63+
- name: ruff
64+
run: poetry run ruff check n8n_iac tests
65+
66+
# The async facade is committed generated code; this fails if it was
67+
# hand-edited or if the sync source moved without regenerating.
68+
- name: Async facade is regeneration-stable
69+
run: poetry run python -m n8n_iac.client._async_codegen --check

‎.gitignore‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Python
2+
__pycache__/
3+
*.py[cod]
4+
*$py.class
5+
*.so
6+
.Python
7+
8+
# Distribution / packaging
9+
build/
10+
dist/
11+
*.egg-info/
12+
.eggs/
13+
14+
# Virtualenvs
15+
.venv/
16+
venv/
17+
env/
18+
19+
# Tooling caches
20+
.mypy_cache/
21+
.pytest_cache/
22+
.ruff_cache/
23+
.hypothesis/
24+
.tox/
25+
.nox/
26+
27+
# Coverage
28+
.coverage
29+
.coverage.*
30+
coverage.xml
31+
htmlcov/
32+
33+
# n8n-iac runtime artifacts: deployment state binds uids to a specific
34+
# instance's workflow ids. It is environment-local and must never be shared.
35+
.n8n-iac-state.json
36+
*.n8n-iac-state.json
37+
38+
# Internal design documents -- kept in the working tree, not published.
39+
CLAUDE.md
40+
FABLE_SPEC.md
41+
OPUS_SPEC.md
42+
43+
# Editors / OS
44+
.idea/
45+
.vscode/
46+
*.swp
47+
.DS_Store
48+
Thumbs.db

‎LICENSE‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 n8n-iac contributors
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

‎README.md‎

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
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+
[![CI](https://github.com/SAKMZ/n8n-python-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/SAKMZ/n8n-python-sdk/actions/workflows/ci.yml)
8+
[![Python 3.12+](https://img.shields.io/badge/python-3.12%2B-blue)](https://www.python.org/downloads/)
9+
[![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
10+
[![Checked with mypy](https://img.shields.io/badge/mypy-strict-2a6db2)](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.

‎n8n_iac/__init__.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""n8n-iac -- infrastructure-as-code SDK for n8n.
2+
3+
Workflows are authored as typed Python, compiled to wire-exact n8n JSON
4+
through a deterministic pipeline, validated offline, and deployed with a
5+
plan/apply lifecycle.
6+
7+
Layers, strictly downward: ``core`` (wire models, compiler, validator) ->
8+
``nodes`` (authoring DSL) -> ``client`` (REST transport, deployer) ->
9+
``cli``. Transport logic never reaches into the core engine, and planning
10+
logic never reaches into transport.
11+
"""
12+
13+
__version__ = "0.1.0"

‎n8n_iac/cli/__init__.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""n8n_iac.cli -- the ``n8n-iac`` command line interface (OPUS §2.5)."""
2+
3+
from n8n_iac.cli.main import main
4+
5+
__all__ = ["main"]

0 commit comments

Comments
 (0)