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.
Files changed (100) hide show
  1. ignition_stack/__init__.py +1 -0
  2. ignition_stack/catalog/__init__.py +10 -0
  3. ignition_stack/catalog/download.py +145 -0
  4. ignition_stack/catalog/loader.py +65 -0
  5. ignition_stack/catalog/schema.py +158 -0
  6. ignition_stack/catalog/verify.py +72 -0
  7. ignition_stack/cli.py +354 -0
  8. ignition_stack/commands/__init__.py +0 -0
  9. ignition_stack/commands/modules.py +178 -0
  10. ignition_stack/completion.py +46 -0
  11. ignition_stack/compose/__init__.py +4 -0
  12. ignition_stack/compose/engine.py +397 -0
  13. ignition_stack/compose/templates/footer.yaml.j2 +12 -0
  14. ignition_stack/compose/templates/header.yaml.j2 +14 -0
  15. ignition_stack/compose/templates/services/bootstrap.yaml.j2 +19 -0
  16. ignition_stack/compose/templates/services/ignition.yaml.j2 +35 -0
  17. ignition_stack/compose/writer.py +428 -0
  18. ignition_stack/config/__init__.py +8 -0
  19. ignition_stack/config/schema.py +311 -0
  20. ignition_stack/lifecycle/__init__.py +31 -0
  21. ignition_stack/lifecycle/cleanup.py +71 -0
  22. ignition_stack/lifecycle/record.py +67 -0
  23. ignition_stack/lifecycle/regenerate.py +62 -0
  24. ignition_stack/modules.yaml +83 -0
  25. ignition_stack/postsetup/__init__.py +3 -0
  26. ignition_stack/postsetup/generator.py +187 -0
  27. ignition_stack/profiles/__init__.py +27 -0
  28. ignition_stack/profiles/advisory.py +132 -0
  29. ignition_stack/profiles/base.py +108 -0
  30. ignition_stack/profiles/hub_and_spoke.py +87 -0
  31. ignition_stack/profiles/mcp_n8n.py +55 -0
  32. ignition_stack/profiles/scaleout.py +65 -0
  33. ignition_stack/profiles/standalone.py +44 -0
  34. ignition_stack/services/__init__.py +25 -0
  35. ignition_stack/services/loader.py +69 -0
  36. ignition_stack/services/manifest.py +106 -0
  37. ignition_stack/services/resolver.py +133 -0
  38. ignition_stack/templates/__init__.py +0 -0
  39. ignition_stack/templates/post-setup/_default.md.j2 +12 -0
  40. ignition_stack/templates/post-setup/device-connection.md.j2 +11 -0
  41. ignition_stack/templates/post-setup/gateway-network-link.md.j2 +18 -0
  42. ignition_stack/templates/post-setup/identity-provider.md.j2 +13 -0
  43. ignition_stack/templates/post-setup/kafka-connector.md.j2 +11 -0
  44. ignition_stack/templates/post-setup/mcp-module.md.j2 +11 -0
  45. ignition_stack/templates/post-setup/mqtt-engine-connection.md.j2 +11 -0
  46. ignition_stack/templates/post-setup/opc-ua-connection.md.j2 +11 -0
  47. ignition_stack/templates/post-setup/reverse-proxy.md.j2 +8 -0
  48. ignition_stack/templates/services/chariot/compose.yaml.j2 +17 -0
  49. ignition_stack/templates/services/chariot/manifest.yaml +22 -0
  50. ignition_stack/templates/services/chariot/seed/service/USAGE.md +14 -0
  51. ignition_stack/templates/services/emqx/compose.yaml.j2 +16 -0
  52. ignition_stack/templates/services/emqx/manifest.yaml +21 -0
  53. ignition_stack/templates/services/emqx/seed/service/USAGE.md +11 -0
  54. ignition_stack/templates/services/hivemq/compose.yaml.j2 +12 -0
  55. ignition_stack/templates/services/hivemq/manifest.yaml +19 -0
  56. ignition_stack/templates/services/hivemq/seed/service/USAGE.md +16 -0
  57. ignition_stack/templates/services/kafka/compose.yaml.j2 +27 -0
  58. ignition_stack/templates/services/kafka/manifest.yaml +20 -0
  59. ignition_stack/templates/services/kafka/seed/service/USAGE.md +17 -0
  60. ignition_stack/templates/services/keycloak/compose.yaml.j2 +31 -0
  61. ignition_stack/templates/services/keycloak/manifest.yaml +25 -0
  62. ignition_stack/templates/services/keycloak/seed/service/import/ignition-realm.json +31 -0
  63. ignition_stack/templates/services/mariadb/compose.yaml.j2 +26 -0
  64. ignition_stack/templates/services/mariadb/manifest.yaml +15 -0
  65. ignition_stack/templates/services/mariadb/seed/service/initdb/00-create-extra-databases.sh +19 -0
  66. ignition_stack/templates/services/modbus-sim/compose.yaml.j2 +12 -0
  67. ignition_stack/templates/services/modbus-sim/manifest.yaml +19 -0
  68. ignition_stack/templates/services/modbus-sim/seed/service/USAGE.md +10 -0
  69. ignition_stack/templates/services/mongo/compose.yaml.j2 +21 -0
  70. ignition_stack/templates/services/mongo/manifest.yaml +14 -0
  71. ignition_stack/templates/services/mongo/seed/service/initdb/01-demo-collection.js +10 -0
  72. ignition_stack/templates/services/mysql/compose.yaml.j2 +26 -0
  73. ignition_stack/templates/services/mysql/manifest.yaml +15 -0
  74. ignition_stack/templates/services/mysql/seed/service/initdb/00-create-extra-databases.sh +19 -0
  75. ignition_stack/templates/services/n8n/compose.yaml.j2 +16 -0
  76. ignition_stack/templates/services/n8n/manifest.yaml +16 -0
  77. ignition_stack/templates/services/n8n/seed/service/USAGE.md +11 -0
  78. ignition_stack/templates/services/opcua-sim/compose.yaml.j2 +13 -0
  79. ignition_stack/templates/services/opcua-sim/manifest.yaml +21 -0
  80. ignition_stack/templates/services/opcua-sim/seed/service/USAGE.md +11 -0
  81. ignition_stack/templates/services/postgres/compose.yaml.j2 +26 -0
  82. ignition_stack/templates/services/postgres/manifest.yaml +21 -0
  83. ignition_stack/templates/services/postgres/seed/gateway-resources/config/resources/core/ignition/database-connection/db/config.json +32 -0
  84. ignition_stack/templates/services/postgres/seed/gateway-resources/config/resources/core/ignition/database-connection/db/resource.json +19 -0
  85. ignition_stack/templates/services/postgres/seed/gateway-resources/config/resources/core/ignition/secret-provider/internal-secret-provider/config.json +19 -0
  86. ignition_stack/templates/services/postgres/seed/gateway-resources/config/resources/core/ignition/secret-provider/internal-secret-provider/resource.json +19 -0
  87. ignition_stack/templates/services/postgres/seed/service/initdb/00-create-extra-databases.sh +17 -0
  88. ignition_stack/templates/services/rabbitmq/compose.yaml.j2 +19 -0
  89. ignition_stack/templates/services/rabbitmq/manifest.yaml +23 -0
  90. ignition_stack/templates/services/rabbitmq/seed/service/enabled_plugins +1 -0
  91. ignition_stack/templates/standalone-postgres/docker-compose.yaml +62 -0
  92. ignition_stack/templates/standalone-postgres/scripts/docker-bootstrap.sh +78 -0
  93. ignition_stack/templates/standalone-postgres/services/ignition/config/resources/core/config-mode.json +7 -0
  94. ignition_stack/templates/standalone-postgres/services/ignition/config/resources/dev/config-mode.json +7 -0
  95. ignition_stack/templates/standalone-postgres/services/ignition/projects/.gitkeep +0 -0
  96. ignition_stack/wizard.py +362 -0
  97. ignition_stack-0.1.0.dist-info/METADATA +97 -0
  98. ignition_stack-0.1.0.dist-info/RECORD +100 -0
  99. ignition_stack-0.1.0.dist-info/WHEEL +4 -0
  100. 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
@@ -0,0 +1,8 @@
1
+ from ignition_stack.config.schema import (
2
+ DatabaseConfig,
3
+ GatewayConfig,
4
+ ProjectConfig,
5
+ ReverseProxyConfig,
6
+ )
7
+
8
+ __all__ = ["DatabaseConfig", "GatewayConfig", "ProjectConfig", "ReverseProxyConfig"]