ignition-stack 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ignition_stack/__init__.py +1 -0
- ignition_stack/catalog/__init__.py +10 -0
- ignition_stack/catalog/download.py +145 -0
- ignition_stack/catalog/loader.py +65 -0
- ignition_stack/catalog/schema.py +158 -0
- ignition_stack/catalog/verify.py +72 -0
- ignition_stack/cli.py +354 -0
- ignition_stack/commands/__init__.py +0 -0
- ignition_stack/commands/modules.py +178 -0
- ignition_stack/completion.py +46 -0
- ignition_stack/compose/__init__.py +4 -0
- ignition_stack/compose/engine.py +397 -0
- ignition_stack/compose/templates/footer.yaml.j2 +12 -0
- ignition_stack/compose/templates/header.yaml.j2 +14 -0
- ignition_stack/compose/templates/services/bootstrap.yaml.j2 +19 -0
- ignition_stack/compose/templates/services/ignition.yaml.j2 +35 -0
- ignition_stack/compose/writer.py +428 -0
- ignition_stack/config/__init__.py +8 -0
- ignition_stack/config/schema.py +311 -0
- ignition_stack/lifecycle/__init__.py +31 -0
- ignition_stack/lifecycle/cleanup.py +71 -0
- ignition_stack/lifecycle/record.py +67 -0
- ignition_stack/lifecycle/regenerate.py +62 -0
- ignition_stack/modules.yaml +83 -0
- ignition_stack/postsetup/__init__.py +3 -0
- ignition_stack/postsetup/generator.py +187 -0
- ignition_stack/profiles/__init__.py +27 -0
- ignition_stack/profiles/advisory.py +132 -0
- ignition_stack/profiles/base.py +108 -0
- ignition_stack/profiles/hub_and_spoke.py +87 -0
- ignition_stack/profiles/mcp_n8n.py +55 -0
- ignition_stack/profiles/scaleout.py +65 -0
- ignition_stack/profiles/standalone.py +44 -0
- ignition_stack/services/__init__.py +25 -0
- ignition_stack/services/loader.py +69 -0
- ignition_stack/services/manifest.py +106 -0
- ignition_stack/services/resolver.py +133 -0
- ignition_stack/templates/__init__.py +0 -0
- ignition_stack/templates/post-setup/_default.md.j2 +12 -0
- ignition_stack/templates/post-setup/device-connection.md.j2 +11 -0
- ignition_stack/templates/post-setup/gateway-network-link.md.j2 +18 -0
- ignition_stack/templates/post-setup/identity-provider.md.j2 +13 -0
- ignition_stack/templates/post-setup/kafka-connector.md.j2 +11 -0
- ignition_stack/templates/post-setup/mcp-module.md.j2 +11 -0
- ignition_stack/templates/post-setup/mqtt-engine-connection.md.j2 +11 -0
- ignition_stack/templates/post-setup/opc-ua-connection.md.j2 +11 -0
- ignition_stack/templates/post-setup/reverse-proxy.md.j2 +8 -0
- ignition_stack/templates/services/chariot/compose.yaml.j2 +17 -0
- ignition_stack/templates/services/chariot/manifest.yaml +22 -0
- ignition_stack/templates/services/chariot/seed/service/USAGE.md +14 -0
- ignition_stack/templates/services/emqx/compose.yaml.j2 +16 -0
- ignition_stack/templates/services/emqx/manifest.yaml +21 -0
- ignition_stack/templates/services/emqx/seed/service/USAGE.md +11 -0
- ignition_stack/templates/services/hivemq/compose.yaml.j2 +12 -0
- ignition_stack/templates/services/hivemq/manifest.yaml +19 -0
- ignition_stack/templates/services/hivemq/seed/service/USAGE.md +16 -0
- ignition_stack/templates/services/kafka/compose.yaml.j2 +27 -0
- ignition_stack/templates/services/kafka/manifest.yaml +20 -0
- ignition_stack/templates/services/kafka/seed/service/USAGE.md +17 -0
- ignition_stack/templates/services/keycloak/compose.yaml.j2 +31 -0
- ignition_stack/templates/services/keycloak/manifest.yaml +25 -0
- ignition_stack/templates/services/keycloak/seed/service/import/ignition-realm.json +31 -0
- ignition_stack/templates/services/mariadb/compose.yaml.j2 +26 -0
- ignition_stack/templates/services/mariadb/manifest.yaml +15 -0
- ignition_stack/templates/services/mariadb/seed/service/initdb/00-create-extra-databases.sh +19 -0
- ignition_stack/templates/services/modbus-sim/compose.yaml.j2 +12 -0
- ignition_stack/templates/services/modbus-sim/manifest.yaml +19 -0
- ignition_stack/templates/services/modbus-sim/seed/service/USAGE.md +10 -0
- ignition_stack/templates/services/mongo/compose.yaml.j2 +21 -0
- ignition_stack/templates/services/mongo/manifest.yaml +14 -0
- ignition_stack/templates/services/mongo/seed/service/initdb/01-demo-collection.js +10 -0
- ignition_stack/templates/services/mysql/compose.yaml.j2 +26 -0
- ignition_stack/templates/services/mysql/manifest.yaml +15 -0
- ignition_stack/templates/services/mysql/seed/service/initdb/00-create-extra-databases.sh +19 -0
- ignition_stack/templates/services/n8n/compose.yaml.j2 +16 -0
- ignition_stack/templates/services/n8n/manifest.yaml +16 -0
- ignition_stack/templates/services/n8n/seed/service/USAGE.md +11 -0
- ignition_stack/templates/services/opcua-sim/compose.yaml.j2 +13 -0
- ignition_stack/templates/services/opcua-sim/manifest.yaml +21 -0
- ignition_stack/templates/services/opcua-sim/seed/service/USAGE.md +11 -0
- ignition_stack/templates/services/postgres/compose.yaml.j2 +26 -0
- ignition_stack/templates/services/postgres/manifest.yaml +21 -0
- ignition_stack/templates/services/postgres/seed/gateway-resources/config/resources/core/ignition/database-connection/db/config.json +32 -0
- ignition_stack/templates/services/postgres/seed/gateway-resources/config/resources/core/ignition/database-connection/db/resource.json +19 -0
- ignition_stack/templates/services/postgres/seed/gateway-resources/config/resources/core/ignition/secret-provider/internal-secret-provider/config.json +19 -0
- ignition_stack/templates/services/postgres/seed/gateway-resources/config/resources/core/ignition/secret-provider/internal-secret-provider/resource.json +19 -0
- ignition_stack/templates/services/postgres/seed/service/initdb/00-create-extra-databases.sh +17 -0
- ignition_stack/templates/services/rabbitmq/compose.yaml.j2 +19 -0
- ignition_stack/templates/services/rabbitmq/manifest.yaml +23 -0
- ignition_stack/templates/services/rabbitmq/seed/service/enabled_plugins +1 -0
- ignition_stack/templates/standalone-postgres/docker-compose.yaml +62 -0
- ignition_stack/templates/standalone-postgres/scripts/docker-bootstrap.sh +78 -0
- ignition_stack/templates/standalone-postgres/services/ignition/config/resources/core/config-mode.json +7 -0
- ignition_stack/templates/standalone-postgres/services/ignition/config/resources/dev/config-mode.json +7 -0
- ignition_stack/templates/standalone-postgres/services/ignition/projects/.gitkeep +0 -0
- ignition_stack/wizard.py +362 -0
- ignition_stack-0.1.0.dist-info/METADATA +97 -0
- ignition_stack-0.1.0.dist-info/RECORD +100 -0
- ignition_stack-0.1.0.dist-info/WHEEL +4 -0
- ignition_stack-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
"""Project tree writer.
|
|
2
|
+
|
|
3
|
+
The writer composes a generated project at ``<output>/<name>/`` by:
|
|
4
|
+
|
|
5
|
+
1. Copying the static asset tree (``scripts/``, ``services/<dir>/``)
|
|
6
|
+
from the package's template payload, parametrised per-gateway when
|
|
7
|
+
the project has more than one.
|
|
8
|
+
2. Rendering ``docker-compose.yaml`` through
|
|
9
|
+
:func:`ignition_stack.compose.engine.render_compose`, which preserves
|
|
10
|
+
YAML anchors via ruamel.yaml.
|
|
11
|
+
3. Rendering ``.env`` from the resolved :class:`ProjectConfig`,
|
|
12
|
+
including per-gateway HTTP port entries when the project is
|
|
13
|
+
multi-gateway.
|
|
14
|
+
|
|
15
|
+
Every file is written in binary with explicit ``\\n`` newlines so the
|
|
16
|
+
output is byte-identical on Linux, macOS, and Windows.
|
|
17
|
+
``docker-bootstrap.sh`` in particular must be LF-only or bash inside
|
|
18
|
+
the container chokes on CR bytes.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from importlib import resources
|
|
24
|
+
from importlib.resources.abc import Traversable
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from ignition_stack.catalog.loader import CatalogLoadError, load_catalog
|
|
28
|
+
from ignition_stack.catalog.schema import Catalog
|
|
29
|
+
from ignition_stack.compose.engine import render_compose
|
|
30
|
+
from ignition_stack.config.schema import ProjectConfig
|
|
31
|
+
from ignition_stack.lifecycle.record import write_record
|
|
32
|
+
from ignition_stack.postsetup import generate_post_setup
|
|
33
|
+
from ignition_stack.services.loader import load_all_services, service_dir
|
|
34
|
+
from ignition_stack.services.resolver import resolve
|
|
35
|
+
|
|
36
|
+
_STATIC_PACKAGE = "ignition_stack.templates"
|
|
37
|
+
_STATIC_PROFILE = "standalone-postgres"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def write_project(
|
|
41
|
+
config: ProjectConfig,
|
|
42
|
+
target_dir: Path,
|
|
43
|
+
*,
|
|
44
|
+
keep_cli: bool = False,
|
|
45
|
+
overwrite: bool = False,
|
|
46
|
+
) -> list[Path]:
|
|
47
|
+
"""Generate the project tree at ``target_dir``.
|
|
48
|
+
|
|
49
|
+
The config is resolved first (implicit dependencies expanded - see
|
|
50
|
+
:func:`ignition_stack.services.resolver.resolve`) so the compose output and
|
|
51
|
+
the on-disk seeds agree on the same fully-expanded stack.
|
|
52
|
+
|
|
53
|
+
``keep_cli`` turns on SE-demo mode: the resolved config is recorded under
|
|
54
|
+
``.ignition-stack/`` so ``reset`` / ``switch-profile`` can regenerate the
|
|
55
|
+
project in place. The default (one-shot) leaves no such primitive behind.
|
|
56
|
+
|
|
57
|
+
``overwrite`` lets ``reset`` / ``switch-profile`` write into a directory
|
|
58
|
+
that still holds the preserved ``.ignition-stack/`` record; normal ``init``
|
|
59
|
+
leaves it ``False`` so a stray non-empty directory still refuses to clobber.
|
|
60
|
+
|
|
61
|
+
Returns the list of files written (absolute paths), in the order they
|
|
62
|
+
were written. Raises :class:`FileExistsError` if ``target_dir`` already
|
|
63
|
+
has files and ``overwrite`` is ``False``.
|
|
64
|
+
"""
|
|
65
|
+
target_dir = Path(target_dir).resolve()
|
|
66
|
+
if not overwrite and target_dir.exists() and any(target_dir.iterdir()):
|
|
67
|
+
raise FileExistsError(
|
|
68
|
+
f"target directory '{target_dir}' is not empty; refusing to overwrite"
|
|
69
|
+
)
|
|
70
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
|
|
72
|
+
config = resolve(config)
|
|
73
|
+
|
|
74
|
+
written: list[Path] = []
|
|
75
|
+
|
|
76
|
+
written.extend(_copy_static_tree(config, target_dir))
|
|
77
|
+
written.extend(_copy_service_seeds(config, target_dir))
|
|
78
|
+
written.extend(_overlay_gateway_resources(config, target_dir))
|
|
79
|
+
_ensure_modules_cache_dir(config, target_dir)
|
|
80
|
+
written.append(_write_compose(config, target_dir))
|
|
81
|
+
written.append(_write_env(config, target_dir))
|
|
82
|
+
written.append(_write_makefile(config, target_dir))
|
|
83
|
+
|
|
84
|
+
proxy_file = _write_reverse_proxy_readme(config, target_dir)
|
|
85
|
+
if proxy_file is not None:
|
|
86
|
+
written.append(proxy_file)
|
|
87
|
+
dropin_file = _write_mcp_dropin_readme(config, target_dir)
|
|
88
|
+
if dropin_file is not None:
|
|
89
|
+
written.append(dropin_file)
|
|
90
|
+
written.append(_write_post_setup(config, target_dir))
|
|
91
|
+
|
|
92
|
+
if keep_cli:
|
|
93
|
+
written.append(write_record(config, target_dir))
|
|
94
|
+
|
|
95
|
+
return written
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _copy_service_seeds(config: ProjectConfig, target_dir: Path) -> list[Path]:
|
|
99
|
+
"""Copy each service's ``seed/service/`` tree into ``services/<name>/``.
|
|
100
|
+
|
|
101
|
+
These are files mounted into the service's own container (a Postgres
|
|
102
|
+
initdb script, a broker config, a realm export). The destination dir is
|
|
103
|
+
the database name for the DB and the service slug for everyone else - the
|
|
104
|
+
same names the compose fragments mount from.
|
|
105
|
+
"""
|
|
106
|
+
written: list[Path] = []
|
|
107
|
+
for src_dir, dest_name in _seed_sources(config):
|
|
108
|
+
seed_service = src_dir / "seed" / "service"
|
|
109
|
+
if not seed_service.is_dir():
|
|
110
|
+
continue
|
|
111
|
+
for rel, content, executable in _walk_template(seed_service):
|
|
112
|
+
written.append(
|
|
113
|
+
_write_static(target_dir, f"services/{dest_name}/{rel}", content, executable)
|
|
114
|
+
)
|
|
115
|
+
return written
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _overlay_gateway_resources(config: ProjectConfig, target_dir: Path) -> list[Path]:
|
|
119
|
+
"""Overlay every seeding service's ``seed/gateway-resources/`` onto gateways.
|
|
120
|
+
|
|
121
|
+
Per the Phase-1 matrix, file-seedable connections (db-connection, the
|
|
122
|
+
internal-secret-provider that holds its password, ...) are dropped into
|
|
123
|
+
each gateway's ``config/resources/`` tree, which the bootstrap copies into
|
|
124
|
+
the gateway data volume on first boot. The same resource set lands on every
|
|
125
|
+
gateway so each can reach the shared service.
|
|
126
|
+
"""
|
|
127
|
+
written: list[Path] = []
|
|
128
|
+
gw_dirs = [gw.name for gw in config.gateways] if config.is_multi_gateway else ["ignition"]
|
|
129
|
+
for src_dir, _ in _seed_sources(config):
|
|
130
|
+
resources_tree = src_dir / "seed" / "gateway-resources"
|
|
131
|
+
if not resources_tree.is_dir():
|
|
132
|
+
continue
|
|
133
|
+
for rel, content, executable in _walk_template(resources_tree):
|
|
134
|
+
for gw_dir in gw_dirs:
|
|
135
|
+
written.append(
|
|
136
|
+
_write_static(target_dir, f"services/{gw_dir}/{rel}", content, executable)
|
|
137
|
+
)
|
|
138
|
+
return written
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _seed_sources(config: ProjectConfig) -> list[tuple[object, str]]:
|
|
142
|
+
"""(service-catalog dir, on-disk destination name) for the DB + each service.
|
|
143
|
+
|
|
144
|
+
Only services whose manifest sets ``seeds_gateway_resources`` contribute
|
|
145
|
+
gateway resources, but ``seed/service/`` is copied for any service that
|
|
146
|
+
ships one regardless.
|
|
147
|
+
"""
|
|
148
|
+
sources: list[tuple[object, str]] = []
|
|
149
|
+
if config.database is not None:
|
|
150
|
+
sources.append((service_dir(config.database.kind), config.database.name))
|
|
151
|
+
for svc in config.services:
|
|
152
|
+
sources.append((service_dir(svc), svc))
|
|
153
|
+
return sources
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _ensure_modules_cache_dir(config: ProjectConfig, target_dir: Path) -> None:
|
|
157
|
+
"""Create an empty modules/cache/ when any gateway lists modules.
|
|
158
|
+
|
|
159
|
+
The compose engine emits a bind mount of this directory into every
|
|
160
|
+
gateway's bootstrap container; absent the directory, `docker compose
|
|
161
|
+
up` fails before the bootstrap runs. The directory stays empty here
|
|
162
|
+
- `ignition-stack modules download` (Phase 3) populates it.
|
|
163
|
+
"""
|
|
164
|
+
if not any(gw.modules for gw in config.gateways):
|
|
165
|
+
return
|
|
166
|
+
(target_dir / "modules" / "cache").mkdir(parents=True, exist_ok=True)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _copy_static_tree(config: ProjectConfig, target_dir: Path) -> list[Path]:
|
|
170
|
+
"""Copy the static portion of the project tree.
|
|
171
|
+
|
|
172
|
+
For a single-gateway project the scripts + services/ignition layout
|
|
173
|
+
is copied verbatim (Phase 2 walking skeleton). For multi-gateway
|
|
174
|
+
projects the services subtree is duplicated once per gateway under
|
|
175
|
+
``services/<gateway-name>/`` so each bootstrap container can mount
|
|
176
|
+
its own template-source.
|
|
177
|
+
"""
|
|
178
|
+
written: list[Path] = []
|
|
179
|
+
static_root = _static_root()
|
|
180
|
+
|
|
181
|
+
for src_rel, content, executable in _walk_template(static_root):
|
|
182
|
+
if src_rel == "docker-compose.yaml":
|
|
183
|
+
# The compose file is rendered separately by the engine; the
|
|
184
|
+
# bundled copy is only there to seed the byte-identical Phase
|
|
185
|
+
# 2 golden during development.
|
|
186
|
+
continue
|
|
187
|
+
if src_rel.startswith("services/"):
|
|
188
|
+
if config.is_multi_gateway:
|
|
189
|
+
for gw in config.gateways:
|
|
190
|
+
fan_rel = src_rel.replace("services/ignition/", f"services/{gw.name}/", 1)
|
|
191
|
+
written.append(_write_static(target_dir, fan_rel, content, executable))
|
|
192
|
+
else:
|
|
193
|
+
written.append(_write_static(target_dir, src_rel, content, executable))
|
|
194
|
+
else:
|
|
195
|
+
written.append(_write_static(target_dir, src_rel, content, executable))
|
|
196
|
+
|
|
197
|
+
return written
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _write_static(target_dir: Path, rel: str, content: bytes, executable: bool) -> Path:
|
|
201
|
+
dst = target_dir / rel
|
|
202
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
203
|
+
dst.write_bytes(content)
|
|
204
|
+
if executable:
|
|
205
|
+
dst.chmod(0o755)
|
|
206
|
+
return dst
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _write_compose(config: ProjectConfig, target_dir: Path) -> Path:
|
|
210
|
+
catalog = _maybe_load_catalog(config)
|
|
211
|
+
rendered = render_compose(config, catalog=catalog)
|
|
212
|
+
dst = target_dir / "docker-compose.yaml"
|
|
213
|
+
dst.write_bytes(rendered.encode("utf-8"))
|
|
214
|
+
return dst
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _maybe_load_catalog(config: ProjectConfig) -> Catalog | None:
|
|
218
|
+
"""Load the bundled catalog only when a gateway actually needs it.
|
|
219
|
+
|
|
220
|
+
Saves a parse + I/O for the common module-free single-gateway case
|
|
221
|
+
and keeps test fixtures from needing modules.yaml to be reachable.
|
|
222
|
+
"""
|
|
223
|
+
if not any(gw.modules for gw in config.gateways):
|
|
224
|
+
return None
|
|
225
|
+
try:
|
|
226
|
+
return load_catalog()
|
|
227
|
+
except CatalogLoadError as exc:
|
|
228
|
+
raise RuntimeError(
|
|
229
|
+
"modules referenced in project config but modules.yaml could not be loaded"
|
|
230
|
+
) from exc
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _write_env(config: ProjectConfig, target_dir: Path) -> Path:
|
|
234
|
+
env_path = target_dir / ".env"
|
|
235
|
+
env_path.write_bytes(_render_env(config).encode("utf-8"))
|
|
236
|
+
return env_path
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _render_env(config: ProjectConfig) -> str:
|
|
240
|
+
"""Render the project .env. Always LF-terminated, no Windows surprises.
|
|
241
|
+
|
|
242
|
+
Data-driven: the fixed gateway keys come first, then the database block,
|
|
243
|
+
then each catalog service contributes its image key and preset credentials
|
|
244
|
+
from its manifest ``env`` map, so adding a service needs no writer change.
|
|
245
|
+
"""
|
|
246
|
+
lines = [
|
|
247
|
+
"# Generated by ignition-stack. Edit values, do not delete keys.",
|
|
248
|
+
f"COMPOSE_PROJECT_NAME={config.name}",
|
|
249
|
+
]
|
|
250
|
+
if not config.is_multi_gateway:
|
|
251
|
+
# Phase 2 walking-skeleton compatibility: a single shared
|
|
252
|
+
# GATEWAY_NAME / GATEWAY_HTTP_PORT pair the bootstrap + gateway
|
|
253
|
+
# services both reference.
|
|
254
|
+
gw = config.gateways[0]
|
|
255
|
+
lines += [
|
|
256
|
+
f"GATEWAY_NAME={config.name}",
|
|
257
|
+
f"GATEWAY_HTTP_PORT={gw.http_port}",
|
|
258
|
+
]
|
|
259
|
+
else:
|
|
260
|
+
for gw in config.gateways:
|
|
261
|
+
lines.append(f"{gw.env_prefix}_HTTP_PORT={gw.http_port}")
|
|
262
|
+
|
|
263
|
+
lines.append(f"IGNITION_IMAGE={config.ignition_image}")
|
|
264
|
+
if config.database is not None:
|
|
265
|
+
lines.append(f"{config.database.image_env}={config.database.image}")
|
|
266
|
+
lines += [
|
|
267
|
+
f"ADMIN_USERNAME={config.admin_username}",
|
|
268
|
+
f"ADMIN_PASSWORD={config.admin_password}",
|
|
269
|
+
]
|
|
270
|
+
if config.database is not None:
|
|
271
|
+
db = config.database
|
|
272
|
+
lines += [
|
|
273
|
+
f"DB_USER={db.user}",
|
|
274
|
+
f"DB_PASSWORD={db.password}",
|
|
275
|
+
f"DB_HOST={db.name}",
|
|
276
|
+
]
|
|
277
|
+
if db.extra_databases and db.kind in {"postgres", "mysql", "mariadb"}:
|
|
278
|
+
lines.append(f"EXTRA_DATABASES={','.join(db.extra_databases)}")
|
|
279
|
+
|
|
280
|
+
catalog = load_all_services()
|
|
281
|
+
for svc in sorted(config.services):
|
|
282
|
+
manifest = catalog[svc]
|
|
283
|
+
lines.append(f"{manifest.image_env}={manifest.image}")
|
|
284
|
+
for key, value in manifest.env.items():
|
|
285
|
+
lines.append(f"{key}={value}")
|
|
286
|
+
|
|
287
|
+
lines.append(f"TZ={config.timezone}")
|
|
288
|
+
return "\n".join(lines) + "\n"
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _static_root() -> Traversable:
|
|
292
|
+
return resources.files(_STATIC_PACKAGE) / _STATIC_PROFILE
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
_TRAEFIK_README = """\
|
|
296
|
+
# Reverse proxy: ia-eknorr/traefik-reverse-proxy
|
|
297
|
+
|
|
298
|
+
This project's wizard offered to install the preferred Traefik reverse
|
|
299
|
+
proxy here. The repo lives at https://github.com/ia-eknorr/traefik-reverse-proxy
|
|
300
|
+
and is **not** cloned automatically (the CLI never bundles a proxy
|
|
301
|
+
silently). Install it manually:
|
|
302
|
+
|
|
303
|
+
```sh
|
|
304
|
+
cd {path}
|
|
305
|
+
git clone https://github.com/ia-eknorr/traefik-reverse-proxy.git .
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
Then read that repo's README for routing and TLS setup. The gateway is
|
|
309
|
+
already exposed on a host port via `docker-compose.yaml`; the proxy can
|
|
310
|
+
either replace that mapping or sit in front of it.
|
|
311
|
+
"""
|
|
312
|
+
|
|
313
|
+
_MCP_DROPIN_README = """\
|
|
314
|
+
# MCP module drop-in
|
|
315
|
+
|
|
316
|
+
The Ignition MCP module is Early-Access and gated behind a survey, so
|
|
317
|
+
this CLI cannot bundle it. To enable the MCP service in this stack:
|
|
318
|
+
|
|
319
|
+
1. Request the module from
|
|
320
|
+
https://inductiveautomation.com/early-access (Ignition MCP).
|
|
321
|
+
2. Drop the resulting `.modl` file into this directory:
|
|
322
|
+
`modules/dropin/<filename>.modl`.
|
|
323
|
+
3. Re-run `docker compose up -d`. The bootstrap will copy any `.modl`
|
|
324
|
+
present here into the gateway's `user-lib/modules/` on startup.
|
|
325
|
+
|
|
326
|
+
n8n is already configured in this stack; the MCP module is what
|
|
327
|
+
exposes the Ignition side of the conversation to n8n's workflows.
|
|
328
|
+
"""
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _write_reverse_proxy_readme(config: ProjectConfig, target_dir: Path) -> Path | None:
|
|
332
|
+
if config.reverse_proxy is None:
|
|
333
|
+
return None
|
|
334
|
+
proxy_dir = target_dir / config.reverse_proxy.path
|
|
335
|
+
proxy_dir.mkdir(parents=True, exist_ok=True)
|
|
336
|
+
dst = proxy_dir / "README.md"
|
|
337
|
+
dst.write_bytes(_TRAEFIK_README.format(path=config.reverse_proxy.path).encode("utf-8"))
|
|
338
|
+
return dst
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _write_mcp_dropin_readme(config: ProjectConfig, target_dir: Path) -> Path | None:
|
|
342
|
+
if not config.mcp_dropin:
|
|
343
|
+
return None
|
|
344
|
+
dropin_dir = target_dir / "modules" / "dropin"
|
|
345
|
+
dropin_dir.mkdir(parents=True, exist_ok=True)
|
|
346
|
+
dst = dropin_dir / "README.md"
|
|
347
|
+
dst.write_bytes(_MCP_DROPIN_README.encode("utf-8"))
|
|
348
|
+
return dst
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# `@@PROJECT@@` is substituted with the compose project name. A sentinel is
|
|
352
|
+
# used instead of str.format / f-strings because the help recipe contains
|
|
353
|
+
# literal awk braces that those would try to interpret. `down -v` is pinned to
|
|
354
|
+
# `-p $(PROJECT)` so `wipe` only ever removes this project's labelled resources.
|
|
355
|
+
_MAKEFILE = """\
|
|
356
|
+
# Generated by ignition-stack for the "@@PROJECT@@" stack.
|
|
357
|
+
# `make help` lists targets. These wrap docker compose so the everyday loop
|
|
358
|
+
# (up / down / logs) and the scoped teardown (wipe) are one word each.
|
|
359
|
+
COMPOSE := docker compose
|
|
360
|
+
PROJECT := @@PROJECT@@
|
|
361
|
+
|
|
362
|
+
.DEFAULT_GOAL := help
|
|
363
|
+
.PHONY: help up down logs wipe reset
|
|
364
|
+
|
|
365
|
+
help: ## List available targets.
|
|
366
|
+
\t@grep -hE '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) \\
|
|
367
|
+
\t\t| awk 'BEGIN {FS = ":.*?## "} {printf " %-8s %s\\n", $$1, $$2}'
|
|
368
|
+
|
|
369
|
+
up: ## Start the stack in the background.
|
|
370
|
+
\t$(COMPOSE) up -d
|
|
371
|
+
|
|
372
|
+
down: ## Stop the stack, keeping volumes and data.
|
|
373
|
+
\t$(COMPOSE) down
|
|
374
|
+
|
|
375
|
+
logs: ## Follow logs for every service.
|
|
376
|
+
\t$(COMPOSE) logs -f
|
|
377
|
+
|
|
378
|
+
wipe: ## Remove ONLY this project's containers, networks, and volumes.
|
|
379
|
+
\t$(COMPOSE) -p $(PROJECT) down -v --remove-orphans
|
|
380
|
+
|
|
381
|
+
reset: ## Regenerate this project from its recorded SE-demo config.
|
|
382
|
+
\tignition-stack reset
|
|
383
|
+
"""
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _write_makefile(config: ProjectConfig, target_dir: Path) -> Path:
|
|
387
|
+
"""Write the project ``Makefile`` (up/down/logs/wipe/reset).
|
|
388
|
+
|
|
389
|
+
``wipe`` is scoped to the compose project name so it cannot reach unrelated
|
|
390
|
+
Docker resources; that scoping is the Phase-7 cleanup contract.
|
|
391
|
+
"""
|
|
392
|
+
body = _MAKEFILE.replace("@@PROJECT@@", config.name)
|
|
393
|
+
dst = target_dir / "Makefile"
|
|
394
|
+
dst.write_bytes(body.encode("utf-8"))
|
|
395
|
+
return dst
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _write_post_setup(config: ProjectConfig, target_dir: Path) -> Path:
|
|
399
|
+
"""Write ``POST-SETUP.md`` from the matrix-driven generator.
|
|
400
|
+
|
|
401
|
+
Always written: a fully-seedable stack gets a "no manual steps required"
|
|
402
|
+
note, anything with a deferred connection gets one section per step. The
|
|
403
|
+
generator owns the content (see :mod:`ignition_stack.postsetup`).
|
|
404
|
+
"""
|
|
405
|
+
dst = target_dir / "POST-SETUP.md"
|
|
406
|
+
dst.write_bytes(generate_post_setup(config).encode("utf-8"))
|
|
407
|
+
return dst
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _walk_template(root: Traversable, prefix: str = "") -> list[tuple[str, bytes, bool]]:
|
|
411
|
+
"""Yield (relative-path, bytes, executable) for every file under ``root``.
|
|
412
|
+
|
|
413
|
+
Skips Python package markers (``__init__.py``) and any pyc artifacts so
|
|
414
|
+
the generated project doesn't contain Python plumbing.
|
|
415
|
+
"""
|
|
416
|
+
out: list[tuple[str, bytes, bool]] = []
|
|
417
|
+
for entry in sorted(root.iterdir(), key=lambda e: e.name):
|
|
418
|
+
name = entry.name
|
|
419
|
+
rel = f"{prefix}{name}"
|
|
420
|
+
if entry.is_dir():
|
|
421
|
+
out.extend(_walk_template(entry, prefix=f"{rel}/"))
|
|
422
|
+
continue
|
|
423
|
+
if name == "__init__.py" or name.endswith(".pyc"):
|
|
424
|
+
continue
|
|
425
|
+
# Shell scripts (the bootstrap, Postgres initdb hooks, ...) must be
|
|
426
|
+
# executable so the container can run them directly.
|
|
427
|
+
out.append((rel, entry.read_bytes(), rel.endswith(".sh")))
|
|
428
|
+
return out
|