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,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,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 %}
|