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,397 @@
1
+ """Anchor-preserving compose generation engine.
2
+
3
+ The engine composes a ``docker-compose.yaml`` by concatenating a header
4
+ (holding the ``x-ignition-common`` / ``x-ignition-environment`` anchors)
5
+ with per-service Jinja2 fragments and a footer (volumes + networks),
6
+ then round-tripping the whole text through ruamel.yaml so anchors,
7
+ quotes, block scalars, and the list-form merge key survive intact.
8
+
9
+ The two design constraints that drive the implementation:
10
+
11
+ 1. **Anchors must survive.** Jinja2 doesn't know about anchors, so we
12
+ never build per-fragment Python dicts and try to wire references
13
+ between them. We render fragments to *text*, glue them together
14
+ into a single document, then parse + emit once - anchors resolve
15
+ inside that single parse context.
16
+
17
+ 2. **The Phase 2 golden is byte-stable through the engine.** Empirical
18
+ verification: ruamel 0.19 with ``preserve_quotes`` + ``explicit_start``
19
+ + ``indent(mapping=2, sequence=4, offset=2)`` + ``width=200``
20
+ round-trips Phase 2's compose file unchanged. Future-you: if you
21
+ change the YAML emitter settings here, expect the standalone-postgres
22
+ golden to need a regeneration.
23
+
24
+ The render pipeline is intentionally small so per-service templates carry
25
+ all the parametric complexity. The engine renders the gateway plumbing
26
+ (``bootstrap`` + ``ignition``) from its own ``compose/templates``, then
27
+ renders the database and every selected service from the Phase-5 service
28
+ catalog at ``templates/services/<name>/compose.yaml.j2``. The config is
29
+ expected to be already resolved (see ``services.resolver``); the engine
30
+ never adds or re-resolves services at render time.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import io
36
+ import textwrap
37
+ from typing import TYPE_CHECKING
38
+
39
+ from jinja2 import Environment, PackageLoader, StrictUndefined
40
+ from ruamel.yaml import YAML
41
+
42
+ from ignition_stack.services.loader import load_all_services, load_service
43
+
44
+ if TYPE_CHECKING:
45
+ from ignition_stack.catalog.schema import Catalog, ModuleEntry
46
+ from ignition_stack.config.schema import GatewayConfig, ProjectConfig
47
+
48
+
49
+ # Network names used when network_split is on. The wizard (Phase 6) and
50
+ # CLI flag (Phase 6 too) keep the same names so users can reason about
51
+ # generated stacks in one consistent vocabulary.
52
+ NETWORK_FRONTEND = "frontend"
53
+ NETWORK_BACKEND = "backend"
54
+
55
+ # Canonical render order for catalog services so goldens are deterministic.
56
+ # Databases render in their historical position (right after the gateways),
57
+ # handled separately; the rest follow this kind ordering, alphabetical within
58
+ # a kind.
59
+ _SERVICE_KIND_ORDER = ["mqtt-broker", "idp", "simulator", "streaming", "automation"]
60
+
61
+
62
+ def render_compose(
63
+ config: ProjectConfig,
64
+ catalog: Catalog | None = None,
65
+ ) -> str:
66
+ """Render a ``docker-compose.yaml`` for the given project config.
67
+
68
+ ``catalog`` is required when any gateway lists modules; the engine
69
+ looks up each module's fully-qualified identifier and emits the
70
+ ``ACCEPT_MODULE_LICENSES`` / ``ACCEPT_MODULE_CERTS`` env vars per the
71
+ resolved q-module-install finding. ``GATEWAY_MODULES_ENABLED`` is
72
+ deliberately omitted: the Phase-1 matrix found it acts as a strict
73
+ whitelist that quarantines the built-in IA modules (OPC-UA, SQL
74
+ Historian, ...). Pass ``None`` for module-free stacks.
75
+
76
+ Returns LF-terminated text suitable for writing to disk.
77
+ """
78
+ env = _jinja_env()
79
+ description = _describe(config)
80
+
81
+ header_text = env.get_template("header.yaml.j2").render(
82
+ description_lines=_wrap_description(description),
83
+ )
84
+ service_blocks = _render_services(env, config, catalog)
85
+ footer_text = env.get_template("footer.yaml.j2").render(
86
+ volumes=_volume_names(config),
87
+ networks=_network_names(config),
88
+ )
89
+
90
+ # Blank line between each service block keeps the emitted YAML
91
+ # readable AND matches Phase 2's golden spacing (ruamel preserves
92
+ # blank-line whitespace it sees in the source text on round-trip).
93
+ indented = "\n\n".join(textwrap.indent(b, " ").rstrip() for b in service_blocks)
94
+ raw = f"{header_text}{indented}\n\n{footer_text}"
95
+
96
+ return _round_trip(raw)
97
+
98
+
99
+ def _render_services(
100
+ env: Environment,
101
+ config: ProjectConfig,
102
+ catalog: Catalog | None,
103
+ ) -> list[str]:
104
+ """Render every service fragment to text, ready to indent and concat."""
105
+ blocks: list[str] = []
106
+ multi = config.is_multi_gateway
107
+
108
+ bootstrap_tpl = env.get_template("services/bootstrap.yaml.j2")
109
+ ignition_tpl = env.get_template("services/ignition.yaml.j2")
110
+
111
+ for gw in config.gateways:
112
+ ctx = _gateway_context(gw, config, catalog)
113
+ blocks.append(bootstrap_tpl.render(**_bootstrap_context(ctx)))
114
+ blocks.append(ignition_tpl.render(**_ignition_context(ctx, config, multi)))
115
+
116
+ if config.database is not None:
117
+ blocks.append(_render_database(config))
118
+
119
+ for svc_name in _ordered_services(config):
120
+ blocks.append(_render_catalog_service(svc_name, config))
121
+
122
+ return blocks
123
+
124
+
125
+ def _render_database(config: ProjectConfig) -> str:
126
+ """Render the database fragment from the service catalog (keyed by kind).
127
+
128
+ The container name keeps the Phase-2 conventions for byte-stability:
129
+ single-gateway uses ``db-${GATEWAY_NAME}`` (GATEWAY_NAME equals the project
130
+ name there); multi-gateway uses ``db-${COMPOSE_PROJECT_NAME}``.
131
+ """
132
+ db = config.database
133
+ assert db is not None
134
+ if config.is_multi_gateway:
135
+ container_name_ref = f"{db.name}-${{COMPOSE_PROJECT_NAME}}"
136
+ else:
137
+ container_name_ref = f"{db.name}-${{GATEWAY_NAME}}"
138
+ tpl = _service_jinja_env().get_template(f"{db.kind}/compose.yaml.j2")
139
+ return tpl.render(
140
+ name=db.name,
141
+ container_name_ref=container_name_ref,
142
+ networks=[NETWORK_BACKEND] if config.network_split else [],
143
+ extra_databases=db.extra_databases,
144
+ )
145
+
146
+
147
+ def _render_catalog_service(svc_name: str, config: ProjectConfig) -> str:
148
+ """Render one non-database catalog service from its compose fragment."""
149
+ manifest = load_service(svc_name)
150
+ tpl = _service_jinja_env().get_template(f"{svc_name}/compose.yaml.j2")
151
+ networks = [manifest.network] if config.network_split else []
152
+ return tpl.render(
153
+ name=svc_name,
154
+ image_ref=f"${{{manifest.image_env}}}",
155
+ container_name_ref=f"{svc_name}-${{COMPOSE_PROJECT_NAME}}",
156
+ networks=networks,
157
+ depends_on=_service_dependencies(manifest, config),
158
+ db_host=config.database.name if config.database else None,
159
+ db_kind=config.database.kind if config.database else None,
160
+ )
161
+
162
+
163
+ def _ordered_services(config: ProjectConfig) -> list[str]:
164
+ """Selected non-database services in canonical (kind, name) order."""
165
+ catalog = load_all_services()
166
+ order = {kind: i for i, kind in enumerate(_SERVICE_KIND_ORDER)}
167
+
168
+ def sort_key(name: str) -> tuple[int, str]:
169
+ return (order.get(catalog[name].kind, len(order)), name)
170
+
171
+ return sorted(config.services, key=sort_key)
172
+
173
+
174
+ def _service_dependencies(manifest: object, config: ProjectConfig) -> list[str]:
175
+ """Compose service names this service depends on (each rendered healthy).
176
+
177
+ A service's ``requires:`` capabilities map to the provider already present
178
+ in the resolved config. Today the only requirable capability is a SQL
179
+ database, so the dependency is the database service name when present.
180
+ """
181
+ requires = getattr(manifest, "requires", [])
182
+ deps: list[str] = []
183
+ db_caps = {"sql-database", "postgres-compatible", "mysql-compatible", "document-store"}
184
+ if config.database is not None and any(cap in db_caps for cap in requires):
185
+ deps.append(config.database.name)
186
+ return deps
187
+
188
+
189
+ def _gateway_context(
190
+ gw: GatewayConfig, config: ProjectConfig, catalog: Catalog | None
191
+ ) -> dict[str, object]:
192
+ """Build the per-gateway context dict shared by the bootstrap + gateway fragments."""
193
+ multi = config.is_multi_gateway
194
+
195
+ # Single-gateway keeps Phase 2 conventions (bootstrap, ignition-data,
196
+ # services/ignition); multi-gateway switches to per-gateway names.
197
+ if multi:
198
+ service_name = gw.name
199
+ bootstrap_service_name = f"bootstrap-{gw.name}"
200
+ volume_name = f"{gw.name}-data"
201
+ template_source_dir = gw.name
202
+ gateway_name_ref = f"${{COMPOSE_PROJECT_NAME}}-{gw.name}"
203
+ port_ref = f"${{{gw.env_prefix}_HTTP_PORT}}"
204
+ else:
205
+ service_name = "gateway"
206
+ bootstrap_service_name = "bootstrap"
207
+ volume_name = "ignition-data"
208
+ template_source_dir = "ignition"
209
+ gateway_name_ref = "${GATEWAY_NAME}"
210
+ port_ref = "${GATEWAY_HTTP_PORT}"
211
+
212
+ networks: list[str] = []
213
+ if config.network_split:
214
+ # A gateway always touches the frontend (UI/HTTP) plus the backend
215
+ # (DB/broker access). Gateways with no role tag default to
216
+ # frontend membership; explicit role=backend lands a gateway on
217
+ # only the backend (rare; used for backend-only edge cases).
218
+ if gw.role == "backend":
219
+ networks = [NETWORK_BACKEND]
220
+ else:
221
+ networks = [NETWORK_FRONTEND, NETWORK_BACKEND]
222
+
223
+ module_identifiers = _module_identifiers_for(gw, catalog)
224
+ cached_modules = bool(gw.modules)
225
+
226
+ return {
227
+ "gw": gw,
228
+ "service_name": service_name,
229
+ "bootstrap_service_name": bootstrap_service_name,
230
+ "volume_name": volume_name,
231
+ "template_source_dir": template_source_dir,
232
+ "gateway_name_ref": gateway_name_ref,
233
+ "port_ref": port_ref,
234
+ "networks": networks,
235
+ "module_identifiers": module_identifiers,
236
+ "cached_modules": cached_modules,
237
+ }
238
+
239
+
240
+ def _bootstrap_context(ctx: dict[str, object]) -> dict[str, object]:
241
+ # Bootstrap is a short-lived init container that only writes into the
242
+ # gateway data volume. It does not need network access, so we omit
243
+ # the networks block entirely; with network_split on, compose
244
+ # attaches it to the auto-default network which is fine for a
245
+ # service-completed_successfully exit gate.
246
+ return {
247
+ "bootstrap_service_name": ctx["bootstrap_service_name"],
248
+ "gateway_name_ref": ctx["gateway_name_ref"],
249
+ "volume_name": ctx["volume_name"],
250
+ "template_source_dir": ctx["template_source_dir"],
251
+ "networks": [],
252
+ "cached_modules": ctx["cached_modules"],
253
+ }
254
+
255
+
256
+ def _ignition_context(
257
+ ctx: dict[str, object], config: ProjectConfig, multi: bool
258
+ ) -> dict[str, object]:
259
+ gw: GatewayConfig = ctx["gw"] # type: ignore[assignment]
260
+ # IGNITION_EDITION lives in the anchor as "standard", so only emit an
261
+ # override when this gateway differs - keeps Phase 2's environment
262
+ # block as the bare anchor reference.
263
+ edition_override = gw.ignition_edition if gw.ignition_edition != "standard" else None
264
+ return {
265
+ "service_name": ctx["service_name"],
266
+ "bootstrap_service_name": ctx["bootstrap_service_name"],
267
+ "gateway_name_ref": ctx["gateway_name_ref"],
268
+ "port_ref": ctx["port_ref"],
269
+ "volume_name": ctx["volume_name"],
270
+ "memory_mb": gw.memory_mb,
271
+ "edition_override": edition_override,
272
+ "module_identifiers": ctx["module_identifiers"],
273
+ "database_service": config.database.name if config.database else None,
274
+ "networks": ctx["networks"],
275
+ }
276
+
277
+
278
+ def _module_identifiers_for(gw: GatewayConfig, catalog: Catalog | None) -> str:
279
+ """Comma-separated FQ module identifiers attached to this gateway, or ''."""
280
+ if not gw.modules:
281
+ return ""
282
+ if catalog is None:
283
+ raise ValueError(
284
+ f"gateway '{gw.name}' lists modules {gw.modules} but no catalog "
285
+ "was passed to render_compose; load modules.yaml first"
286
+ )
287
+ identifiers: list[str] = []
288
+ for slug in gw.modules:
289
+ try:
290
+ entry = catalog.by_name(slug)
291
+ except KeyError as exc:
292
+ raise ValueError(
293
+ f"gateway '{gw.name}' references unknown module '{slug}'; "
294
+ "check modules.yaml and the gateway config"
295
+ ) from exc
296
+ # Modules-only env vars: JDBC drivers shouldn't be enumerated here.
297
+ if not _is_module(entry):
298
+ continue
299
+ identifiers.append(entry.module_identifier) # type: ignore[union-attr]
300
+ return ",".join(identifiers)
301
+
302
+
303
+ def _is_module(entry: ModuleEntry | object) -> bool:
304
+ return getattr(entry, "kind", None) == "module"
305
+
306
+
307
+ def _volume_names(config: ProjectConfig) -> list[str]:
308
+ if not config.is_multi_gateway:
309
+ return ["ignition-data"]
310
+ return [f"{g.name}-data" for g in config.gateways]
311
+
312
+
313
+ def _network_names(config: ProjectConfig) -> list[str]:
314
+ if not config.network_split:
315
+ return []
316
+ return [NETWORK_FRONTEND, NETWORK_BACKEND]
317
+
318
+
319
+ def _wrap_description(description: str) -> list[str]:
320
+ """Wrap the header comment at a width that matches Phase 2's golden.
321
+
322
+ The first line carries the ``Generated by ignition-stack.`` prefix
323
+ so we wrap with a 73-char target width that leaves room for the
324
+ ``# `` comment marker.
325
+ """
326
+ prefix = "Generated by ignition-stack. "
327
+ body = prefix + description
328
+ wrapped = textwrap.wrap(body, width=73)
329
+ return wrapped or [body]
330
+
331
+
332
+ def _describe(config: ProjectConfig) -> str:
333
+ """Human-readable header comment summarizing the stack."""
334
+ n = len(config.gateways)
335
+ if (
336
+ n == 1
337
+ and config.database
338
+ and config.database.kind == "postgres"
339
+ and not config.services
340
+ ):
341
+ return (
342
+ "Walking skeleton: one Ignition 8.3 gateway, one Postgres, "
343
+ "env-driven commissioning so first boot needs no UI."
344
+ )
345
+ parts = [f"{n} Ignition 8.3 gateway{'s' if n != 1 else ''}"]
346
+ if config.database:
347
+ parts.append(f"one {config.database.kind}")
348
+ parts.extend(config.services)
349
+ if config.network_split:
350
+ parts.append("frontend/backend network split")
351
+ return ", ".join(parts) + "."
352
+
353
+
354
+ def _jinja_env() -> Environment:
355
+ return Environment(
356
+ loader=PackageLoader("ignition_stack.compose", "templates"),
357
+ undefined=StrictUndefined,
358
+ keep_trailing_newline=True,
359
+ # Compose YAML uses {{ }}-style env interpolation throughout, but
360
+ # those are literal ${...} expressions - Jinja2's default
361
+ # delimiters don't collide. We still set autoescape off because
362
+ # this is YAML, not HTML.
363
+ autoescape=False,
364
+ )
365
+
366
+
367
+ def _service_jinja_env() -> Environment:
368
+ """Jinja env rooted at the service catalog (``templates/services/``).
369
+
370
+ Template names are ``<service>/compose.yaml.j2``; the service catalog dir
371
+ is named by slug for non-databases and by database kind for databases.
372
+ """
373
+ return Environment(
374
+ loader=PackageLoader("ignition_stack.templates", "services"),
375
+ undefined=StrictUndefined,
376
+ keep_trailing_newline=True,
377
+ autoescape=False,
378
+ )
379
+
380
+
381
+ def _round_trip(raw: str) -> str:
382
+ """Parse + emit through ruamel.yaml so anchors and quotes are normalized.
383
+
384
+ The emitter settings are tuned so Phase 2's golden round-trips
385
+ byte-identical; see the docstring at the top of this module.
386
+ """
387
+ yaml = YAML()
388
+ yaml.preserve_quotes = True
389
+ yaml.explicit_start = True
390
+ yaml.indent(mapping=2, sequence=4, offset=2)
391
+ yaml.width = 200
392
+ parsed = yaml.load(raw)
393
+ out = io.StringIO()
394
+ yaml.dump(parsed, out)
395
+ return out.getvalue()
396
+
397
+
@@ -0,0 +1,12 @@
1
+ volumes:
2
+ {%- for vol in volumes %}
3
+ {{ vol }}:
4
+ {%- endfor %}
5
+ {%- if networks %}
6
+
7
+ networks:
8
+ {%- for net in networks %}
9
+ {{ net }}:
10
+ driver: bridge
11
+ {%- endfor %}
12
+ {%- endif %}
@@ -0,0 +1,14 @@
1
+ {%- for line in description_lines %}
2
+ # {{ line }}
3
+ {%- endfor %}
4
+ x-ignition-common: &ignition-common
5
+ image: &ignition-image ${IGNITION_IMAGE}
6
+
7
+ x-ignition-environment: &ignition-environment
8
+ ACCEPT_IGNITION_EULA: "Y"
9
+ GATEWAY_ADMIN_USERNAME: ${ADMIN_USERNAME}
10
+ GATEWAY_ADMIN_PASSWORD: ${ADMIN_PASSWORD}
11
+ IGNITION_EDITION: standard
12
+ TZ: ${TZ}
13
+
14
+ services:
@@ -0,0 +1,19 @@
1
+ {{ bootstrap_service_name }}:
2
+ image: *ignition-image
3
+ user: root
4
+ entrypoint: ["/bin/bash", "/docker-bootstrap.sh"]
5
+ environment:
6
+ GATEWAY_NAME: {{ gateway_name_ref }}
7
+ volumes:
8
+ - {{ volume_name }}:/data
9
+ - ./scripts/docker-bootstrap.sh:/docker-bootstrap.sh:ro
10
+ - ./services/{{ template_source_dir }}:/template-source:ro
11
+ {%- if cached_modules %}
12
+ - ./modules/cache:/modules-cache:ro
13
+ {%- endif %}
14
+ {%- if networks %}
15
+ networks:
16
+ {%- for net in networks %}
17
+ - {{ net }}
18
+ {%- endfor %}
19
+ {%- endif %}
@@ -0,0 +1,35 @@
1
+ {{ service_name }}:
2
+ <<: [*ignition-common]
3
+ container_name: {{ gateway_name_ref }}
4
+ hostname: {{ gateway_name_ref }}
5
+ depends_on:
6
+ {%- if database_service %}
7
+ {{ database_service }}:
8
+ condition: service_healthy
9
+ {%- endif %}
10
+ {{ bootstrap_service_name }}:
11
+ condition: service_completed_successfully
12
+ ports:
13
+ - "{{ port_ref }}:8088"
14
+ volumes:
15
+ - {{ volume_name }}:/usr/local/bin/ignition/data
16
+ environment:
17
+ <<: *ignition-environment
18
+ {%- if edition_override %}
19
+ IGNITION_EDITION: {{ edition_override }}
20
+ {%- endif %}
21
+ {%- if module_identifiers %}
22
+ ACCEPT_MODULE_LICENSES: "{{ module_identifiers }}"
23
+ ACCEPT_MODULE_CERTS: "{{ module_identifiers }}"
24
+ {%- endif %}
25
+ command: >
26
+ -n {{ gateway_name_ref }}
27
+ -m {{ memory_mb }}
28
+ --
29
+ -Dignition.config.mode=dev
30
+ {%- if networks %}
31
+ networks:
32
+ {%- for net in networks %}
33
+ - {{ net }}
34
+ {%- endfor %}
35
+ {%- endif %}