ignition-stack 0.1.0__tar.gz → 0.2.0__tar.gz

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 (150) hide show
  1. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/.gitignore +0 -5
  2. ignition_stack-0.2.0/LICENSE +21 -0
  3. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/PKG-INFO +5 -6
  4. ignition_stack-0.2.0/ignition_stack/__init__.py +1 -0
  5. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/cli.py +217 -32
  6. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/completion.py +33 -0
  7. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/compose/engine.py +15 -8
  8. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/compose/templates/services/ignition.yaml.j2 +14 -0
  9. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/compose/writer.py +51 -8
  10. ignition_stack-0.2.0/ignition_stack/config/__init__.py +20 -0
  11. ignition_stack-0.2.0/ignition_stack/config/io.py +125 -0
  12. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/config/schema.py +84 -0
  13. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/lifecycle/__init__.py +1 -1
  14. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/lifecycle/cleanup.py +3 -2
  15. ignition_stack-0.2.0/ignition_stack/lifecycle/record.py +73 -0
  16. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/lifecycle/regenerate.py +2 -2
  17. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/postsetup/generator.py +38 -0
  18. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/profiles/__init__.py +4 -0
  19. ignition_stack-0.2.0/ignition_stack/profiles/base.py +203 -0
  20. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/profiles/hub_and_spoke.py +5 -0
  21. ignition_stack-0.2.0/ignition_stack/profiles/scaleout.py +80 -0
  22. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/services/resolver.py +53 -1
  23. ignition_stack-0.2.0/ignition_stack/templates/post-setup/redundancy-pairing.md.j2 +24 -0
  24. ignition_stack-0.2.0/ignition_stack/templates/redundancy/redundancy.xml.j2 +25 -0
  25. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/standalone-postgres/scripts/docker-bootstrap.sh +9 -1
  26. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/wizard.py +76 -7
  27. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/modules.yaml +1 -1
  28. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/pyproject.toml +7 -8
  29. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/tests/golden/profiles/scaleout/docker-compose.yaml +0 -1
  30. ignition_stack-0.2.0/tests/golden/profiles/scaleout-redundant/docker-compose.yaml +155 -0
  31. ignition_stack-0.2.0/tests/test_declarative_io.py +193 -0
  32. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/tests/test_docs_cli_reference.py +3 -1
  33. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/tests/test_lifecycle.py +51 -43
  34. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/tests/test_profiles.py +131 -3
  35. ignition_stack-0.2.0/tests/test_redundancy.py +267 -0
  36. ignition_stack-0.2.0/verification/redundancy-spike/README.md +239 -0
  37. ignition_stack-0.2.0/verification/smoke/README.md +58 -0
  38. ignition_stack-0.1.0/ignition_stack/__init__.py +0 -1
  39. ignition_stack-0.1.0/ignition_stack/config/__init__.py +0 -8
  40. ignition_stack-0.1.0/ignition_stack/lifecycle/record.py +0 -67
  41. ignition_stack-0.1.0/ignition_stack/profiles/base.py +0 -108
  42. ignition_stack-0.1.0/ignition_stack/profiles/scaleout.py +0 -65
  43. ignition_stack-0.1.0/scripts/seeding-poc/README.md +0 -37
  44. ignition_stack-0.1.0/verification/phase-1-3/README.md +0 -58
  45. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/README.md +0 -0
  46. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/catalog/__init__.py +0 -0
  47. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/catalog/download.py +0 -0
  48. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/catalog/loader.py +0 -0
  49. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/catalog/schema.py +0 -0
  50. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/catalog/verify.py +0 -0
  51. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/commands/__init__.py +0 -0
  52. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/commands/modules.py +0 -0
  53. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/compose/__init__.py +0 -0
  54. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/compose/templates/footer.yaml.j2 +0 -0
  55. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/compose/templates/header.yaml.j2 +0 -0
  56. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/compose/templates/services/bootstrap.yaml.j2 +0 -0
  57. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/postsetup/__init__.py +0 -0
  58. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/profiles/advisory.py +0 -0
  59. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/profiles/mcp_n8n.py +0 -0
  60. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/profiles/standalone.py +0 -0
  61. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/services/__init__.py +0 -0
  62. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/services/loader.py +0 -0
  63. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/services/manifest.py +0 -0
  64. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/__init__.py +0 -0
  65. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/post-setup/_default.md.j2 +0 -0
  66. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/post-setup/device-connection.md.j2 +0 -0
  67. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/post-setup/gateway-network-link.md.j2 +0 -0
  68. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/post-setup/identity-provider.md.j2 +0 -0
  69. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/post-setup/kafka-connector.md.j2 +0 -0
  70. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/post-setup/mcp-module.md.j2 +0 -0
  71. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/post-setup/mqtt-engine-connection.md.j2 +0 -0
  72. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/post-setup/opc-ua-connection.md.j2 +0 -0
  73. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/post-setup/reverse-proxy.md.j2 +0 -0
  74. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/services/chariot/compose.yaml.j2 +0 -0
  75. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/services/chariot/manifest.yaml +0 -0
  76. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/services/chariot/seed/service/USAGE.md +0 -0
  77. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/services/emqx/compose.yaml.j2 +0 -0
  78. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/services/emqx/manifest.yaml +0 -0
  79. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/services/emqx/seed/service/USAGE.md +0 -0
  80. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/services/hivemq/compose.yaml.j2 +0 -0
  81. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/services/hivemq/manifest.yaml +0 -0
  82. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/services/hivemq/seed/service/USAGE.md +0 -0
  83. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/services/kafka/compose.yaml.j2 +0 -0
  84. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/services/kafka/manifest.yaml +0 -0
  85. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/services/kafka/seed/service/USAGE.md +0 -0
  86. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/services/keycloak/compose.yaml.j2 +0 -0
  87. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/services/keycloak/manifest.yaml +0 -0
  88. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/services/keycloak/seed/service/import/ignition-realm.json +0 -0
  89. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/services/mariadb/compose.yaml.j2 +0 -0
  90. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/services/mariadb/manifest.yaml +0 -0
  91. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/services/mariadb/seed/service/initdb/00-create-extra-databases.sh +0 -0
  92. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/services/modbus-sim/compose.yaml.j2 +0 -0
  93. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/services/modbus-sim/manifest.yaml +0 -0
  94. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/services/modbus-sim/seed/service/USAGE.md +0 -0
  95. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/services/mongo/compose.yaml.j2 +0 -0
  96. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/services/mongo/manifest.yaml +0 -0
  97. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/services/mongo/seed/service/initdb/01-demo-collection.js +0 -0
  98. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/services/mysql/compose.yaml.j2 +0 -0
  99. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/services/mysql/manifest.yaml +0 -0
  100. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/services/mysql/seed/service/initdb/00-create-extra-databases.sh +0 -0
  101. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/services/n8n/compose.yaml.j2 +0 -0
  102. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/services/n8n/manifest.yaml +0 -0
  103. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/services/n8n/seed/service/USAGE.md +0 -0
  104. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/services/opcua-sim/compose.yaml.j2 +0 -0
  105. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/services/opcua-sim/manifest.yaml +0 -0
  106. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/services/opcua-sim/seed/service/USAGE.md +0 -0
  107. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/services/postgres/compose.yaml.j2 +0 -0
  108. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/services/postgres/manifest.yaml +0 -0
  109. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/services/postgres/seed/gateway-resources/config/resources/core/ignition/database-connection/db/config.json +0 -0
  110. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/services/postgres/seed/gateway-resources/config/resources/core/ignition/database-connection/db/resource.json +0 -0
  111. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/services/postgres/seed/gateway-resources/config/resources/core/ignition/secret-provider/internal-secret-provider/config.json +0 -0
  112. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/services/postgres/seed/gateway-resources/config/resources/core/ignition/secret-provider/internal-secret-provider/resource.json +0 -0
  113. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/services/postgres/seed/service/initdb/00-create-extra-databases.sh +0 -0
  114. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/services/rabbitmq/compose.yaml.j2 +0 -0
  115. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/services/rabbitmq/manifest.yaml +0 -0
  116. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/services/rabbitmq/seed/service/enabled_plugins +0 -0
  117. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/standalone-postgres/docker-compose.yaml +0 -0
  118. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/standalone-postgres/services/ignition/config/resources/core/config-mode.json +0 -0
  119. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/standalone-postgres/services/ignition/config/resources/dev/config-mode.json +0 -0
  120. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/ignition_stack/templates/standalone-postgres/services/ignition/projects/.gitkeep +0 -0
  121. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/tests/__init__.py +0 -0
  122. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/tests/conftest.py +0 -0
  123. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/tests/golden/combos/network-split/docker-compose.yaml +0 -0
  124. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/tests/golden/combos/smoke-stack/docker-compose.yaml +0 -0
  125. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/tests/golden/profiles/hub-and-spoke/docker-compose.yaml +0 -0
  126. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/tests/golden/profiles/mcp-n8n/docker-compose.yaml +0 -0
  127. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/tests/golden/profiles/standalone/docker-compose.yaml +0 -0
  128. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/tests/golden/scaleout-skeleton/docker-compose.yaml +0 -0
  129. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/tests/golden/services/chariot/docker-compose.yaml +0 -0
  130. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/tests/golden/services/db-mariadb/docker-compose.yaml +0 -0
  131. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/tests/golden/services/db-mongo/docker-compose.yaml +0 -0
  132. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/tests/golden/services/db-mysql/docker-compose.yaml +0 -0
  133. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/tests/golden/services/db-postgres/docker-compose.yaml +0 -0
  134. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/tests/golden/services/emqx/docker-compose.yaml +0 -0
  135. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/tests/golden/services/hivemq/docker-compose.yaml +0 -0
  136. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/tests/golden/services/kafka/docker-compose.yaml +0 -0
  137. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/tests/golden/services/keycloak/docker-compose.yaml +0 -0
  138. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/tests/golden/services/modbus-sim/docker-compose.yaml +0 -0
  139. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/tests/golden/services/n8n/docker-compose.yaml +0 -0
  140. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/tests/golden/services/opcua-sim/docker-compose.yaml +0 -0
  141. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/tests/golden/services/rabbitmq/docker-compose.yaml +0 -0
  142. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/tests/golden/standalone-postgres/docker-compose.yaml +0 -0
  143. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/tests/test_completion.py +0 -0
  144. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/tests/test_compose_engine.py +0 -0
  145. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/tests/test_init_standalone.py +0 -0
  146. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/tests/test_modules_catalog.py +0 -0
  147. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/tests/test_modules_cli.py +0 -0
  148. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/tests/test_postsetup.py +0 -0
  149. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/tests/test_service_catalog.py +0 -0
  150. {ignition_stack-0.1.0 → ignition_stack-0.2.0}/tests/test_service_catalog_smoke.py +0 -0
@@ -30,8 +30,3 @@ Thumbs.db
30
30
  .env
31
31
  .env.local
32
32
  *.env
33
-
34
- # Phase-1 POC artifacts (transient container outputs)
35
- scripts/seeding-poc/runs/
36
- scripts/seeding-poc/.modl-cache/
37
- # Screenshots ARE committed - they're the matrix's evidence.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Eric Knorr
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,12 +1,14 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ignition-stack
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: CLI that generates ready-to-run Docker Compose stacks for Ignition 8.3 SCADA demos and SE engagements
5
- Author-email: Ethan Knorr <eknorr@inductiveautomation.com>
6
- License: Apache-2.0
5
+ Author-email: Eric Knorr <etknorr@gmail.com>
6
+ License: MIT
7
+ License-File: LICENSE
7
8
  Keywords: compose,docker,ignition,sales-engineering,scada
8
9
  Classifier: Development Status :: 2 - Pre-Alpha
9
10
  Classifier: Environment :: Console
11
+ Classifier: License :: OSI Approved :: MIT License
10
12
  Classifier: Programming Language :: Python :: 3.11
11
13
  Classifier: Programming Language :: Python :: 3.12
12
14
  Classifier: Topic :: System :: Installation/Setup
@@ -23,9 +25,6 @@ Requires-Dist: typer>=0.12
23
25
  Provides-Extra: dev
24
26
  Requires-Dist: pytest>=8.3; extra == 'dev'
25
27
  Requires-Dist: ruff>=0.7; extra == 'dev'
26
- Provides-Extra: poc
27
- Requires-Dist: httpx>=0.27; extra == 'poc'
28
- Requires-Dist: playwright>=1.49; extra == 'poc'
29
28
  Description-Content-Type: text/markdown
30
29
 
31
30
  # ignition-stack
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
@@ -13,6 +13,7 @@ interactive wizard when no profile is named.
13
13
  from __future__ import annotations
14
14
 
15
15
  import subprocess
16
+ from dataclasses import replace
16
17
  from pathlib import Path
17
18
 
18
19
  import typer
@@ -20,9 +21,21 @@ from rich.console import Console
20
21
 
21
22
  from ignition_stack import __version__
22
23
  from ignition_stack.commands.modules import modules_app
23
- from ignition_stack.completion import complete_edge_role, complete_profile
24
+ from ignition_stack.completion import (
25
+ complete_edge_role,
26
+ complete_output_format,
27
+ complete_profile,
28
+ complete_redundant_role,
29
+ complete_reverse_proxy,
30
+ )
24
31
  from ignition_stack.compose import write_project
25
- from ignition_stack.config import ProjectConfig
32
+ from ignition_stack.config import (
33
+ ConfigIOError,
34
+ ProjectConfig,
35
+ ReverseProxyConfig,
36
+ dump_config,
37
+ load_config,
38
+ )
26
39
  from ignition_stack.lifecycle import (
27
40
  LIFECYCLE_DIR,
28
41
  RECORD_NAME,
@@ -37,9 +50,11 @@ from ignition_stack.profiles import (
37
50
  ProfileError,
38
51
  ProfileOptions,
39
52
  build_profile,
53
+ can_host_redundant_role,
40
54
  get_profile,
41
55
  list_profiles,
42
56
  )
57
+ from ignition_stack.services.resolver import resolve
43
58
  from ignition_stack.wizard import run_wizard
44
59
 
45
60
  app = typer.Typer(
@@ -102,6 +117,34 @@ def init(
102
117
  help="Spoke gateway count for the hub-and-spoke profile (ignored otherwise).",
103
118
  min=0,
104
119
  ),
120
+ frontends: int = typer.Option(
121
+ 1,
122
+ "--frontends",
123
+ help="Frontend gateway count for the scaleout profile (ignored otherwise).",
124
+ min=1,
125
+ ),
126
+ network_split: bool | None = typer.Option(
127
+ None,
128
+ "--network-split/--no-network-split",
129
+ help=(
130
+ "Force the frontend/backend network split on or off. Default follows "
131
+ "the profile (scaleout splits, hub-and-spoke does not)."
132
+ ),
133
+ ),
134
+ reverse_proxy: str | None = typer.Option(
135
+ None,
136
+ "--reverse-proxy",
137
+ help=(
138
+ "Scaffold a reverse proxy of the given kind ('traefik'). Lays down a "
139
+ "README + POST-SETUP entry at --proxy-path. Omit for plain host-port mapping."
140
+ ),
141
+ autocompletion=complete_reverse_proxy,
142
+ ),
143
+ proxy_path: str = typer.Option(
144
+ "reverse-proxy",
145
+ "--proxy-path",
146
+ help="Relative directory the reverse-proxy scaffold lives in (with --reverse-proxy).",
147
+ ),
105
148
  force: bool = typer.Option(
106
149
  False,
107
150
  "--force",
@@ -111,23 +154,49 @@ def init(
111
154
  None,
112
155
  "--edge-role",
113
156
  help=(
114
- "Gateway role that runs the Ignition Edge edition. Scaleout defaults "
115
- "to 'frontend'; hub-and-spoke defaults its spokes to Edge. Pass 'none' "
116
- "to disable the profile's edge default; pass a role name ('hub', "
117
- "'gateway', ...) to opt that specific role in."
157
+ "Gateway role that runs the Ignition Edge edition. Scaleout runs all "
158
+ "gateways standard by default; hub-and-spoke defaults its spokes to "
159
+ "Edge. Pass 'none' to disable the profile's edge default; pass a role "
160
+ "name ('frontend', 'hub', 'gateway', ...) to opt that specific role in."
118
161
  ),
119
162
  autocompletion=complete_edge_role,
120
163
  ),
121
- keep_cli: bool = typer.Option(
164
+ redundant: str | None = typer.Option(
165
+ None,
166
+ "--redundant",
167
+ help=(
168
+ "Make a single gateway role redundant, expanding it into a master + "
169
+ "backup pair (e.g. 'backend' for scaleout, 'hub' for hub-and-spoke, "
170
+ "'gateway' for standalone). Frontends and spokes are replicated, not "
171
+ "paired, and are rejected."
172
+ ),
173
+ autocompletion=complete_redundant_role,
174
+ ),
175
+ from_file: Path | None = typer.Option( # noqa: B008 - Typer pattern
176
+ None,
177
+ "--from-file",
178
+ "-f",
179
+ help=(
180
+ "Build from a saved config file (YAML or JSON, as dumped by "
181
+ "--dry-run) instead of a profile or the wizard. Mutually exclusive "
182
+ "with --profile. The project name argument overrides the file's name."
183
+ ),
184
+ ),
185
+ dry_run: bool = typer.Option(
122
186
  False,
123
- "--keep-cli",
187
+ "--dry-run",
124
188
  help=(
125
- "SE-demo mode: keep the lifecycle primitives in .ignition-stack/ so "
126
- "`ignition-stack reset` / `switch-profile` can regenerate the project. "
127
- "The default (one-shot) leaves a self-contained project with no CLI "
128
- "primitives behind."
189
+ "Resolve the config and print it (see --output-format) without "
190
+ "writing any files. The dump is the full build input; redirect it to "
191
+ "a file, edit it, and rebuild with --from-file."
129
192
  ),
130
193
  ),
194
+ output_format: str | None = typer.Option(
195
+ None,
196
+ "--output-format",
197
+ help="Format for the --dry-run dump: 'yaml' (default) or 'json'.",
198
+ autocompletion=complete_output_format,
199
+ ),
131
200
  output_dir: Path | None = typer.Option( # noqa: B008 - Typer pattern
132
201
  None,
133
202
  "--output-dir",
@@ -137,11 +206,15 @@ def init(
137
206
  ) -> None:
138
207
  """Generate a new Ignition stack at ``<output-dir>/<name>``.
139
208
 
140
- With ``--profile``, runs non-interactively from the named profile and its
141
- flags. Without ``--profile``, walks the interactive wizard.
209
+ With ``--from-file``, builds from a saved config file. With ``--profile``,
210
+ runs non-interactively from the named profile and its flags. With neither,
211
+ walks the interactive wizard. ``--dry-run`` resolves the config and prints
212
+ it instead of writing anything.
142
213
  """
143
214
  target = ((output_dir or Path.cwd()) / name).resolve()
144
215
 
216
+ _validate_init_flags(profile=profile, from_file=from_file, dry_run=dry_run, fmt=output_format)
217
+
145
218
  # Name validation runs before either the wizard or the profile build so
146
219
  # invalid names fail fast with a clear exit code (2), instead of bubbling
147
220
  # through the wizard's first prompt or the profile's deep model_validate.
@@ -151,19 +224,38 @@ def init(
151
224
  console.print(f"[red]error[/red]: invalid project name: {exc}")
152
225
  raise typer.Exit(code=2) from exc
153
226
 
154
- if profile is None:
227
+ if from_file is not None:
228
+ config = _load_from_file(from_file, name)
229
+ elif profile is None:
155
230
  config = _run_wizard_or_exit(name)
156
231
  else:
157
- config = _build_from_profile(name, profile, spokes, force, edge_role)
232
+ config = _build_from_profile(
233
+ name,
234
+ profile,
235
+ spokes=spokes,
236
+ frontends=frontends,
237
+ force=force,
238
+ edge_role=edge_role,
239
+ network_split=network_split,
240
+ reverse_proxy=reverse_proxy,
241
+ proxy_path=proxy_path,
242
+ redundant=redundant,
243
+ )
244
+
245
+ if dry_run:
246
+ # Dump the resolved config (the writer resolves too, so this is exactly
247
+ # what would be built) and write nothing. `end=""`/`markup=False` keep
248
+ # the output verbatim and parseable - no rich markup, no extra newline.
249
+ console.print(dump_config(resolve(config), output_format or "yaml"), end="", markup=False)
250
+ raise typer.Exit()
158
251
 
159
252
  try:
160
- files = write_project(config, target, keep_cli=keep_cli)
253
+ files = write_project(config, target)
161
254
  except FileExistsError as exc:
162
255
  console.print(f"[red]error[/red]: {exc}")
163
256
  raise typer.Exit(code=1) from exc
164
257
 
165
- mode = "SE-demo" if keep_cli else "one-shot"
166
- console.print(f"[green]created[/green] {target} ([cyan]{mode}[/cyan])")
258
+ console.print(f"[green]created[/green] {target}")
167
259
  console.print(f" {len(files)} file(s) written")
168
260
  console.print()
169
261
  console.print("Next steps:")
@@ -172,16 +264,70 @@ def init(
172
264
  console.print(
173
265
  f" open http://localhost:{config.gateways[0].http_port} (admin / {config.admin_password})"
174
266
  )
175
- if keep_cli:
176
- console.print()
267
+ console.print()
268
+ console.print(
269
+ f" config recorded in {LIFECYCLE_DIR}/ - run `ignition-stack reset` to "
270
+ "regenerate or `switch-profile <name>` to reshape this stack."
271
+ )
272
+
273
+
274
+ def _validate_init_flags(
275
+ *, profile: str | None, from_file: Path | None, dry_run: bool, fmt: str | None
276
+ ) -> None:
277
+ """Enforce the mutual-exclusion + flag-applicability rules, or exit code 2.
278
+
279
+ ``--from-file`` already fully specifies the topology, so combining it with
280
+ ``--profile`` is ambiguous and rejected. ``--output-format`` only shapes the
281
+ ``--dry-run`` dump, so passing it without ``--dry-run`` is a usage error
282
+ rather than a silent no-op. The value itself is validated against the two
283
+ supported formats here so a bad ``--output-format`` fails before any build.
284
+ """
285
+ if from_file is not None and profile is not None:
177
286
  console.print(
178
- f" primitives kept in {LIFECYCLE_DIR}/ - run `ignition-stack reset` to "
179
- "regenerate or `switch-profile <name>` to reshape this stack."
287
+ "[red]error[/red]: --from-file cannot be combined with --profile; a "
288
+ "config file already specifies the full topology."
180
289
  )
290
+ raise typer.Exit(code=2)
291
+ if fmt is not None and not dry_run:
292
+ console.print("[red]error[/red]: --output-format only applies with --dry-run.")
293
+ raise typer.Exit(code=2)
294
+ if fmt is not None and fmt not in {"yaml", "json"}:
295
+ console.print(
296
+ f"[red]error[/red]: unsupported --output-format '{fmt}'; use 'yaml' or 'json'."
297
+ )
298
+ raise typer.Exit(code=2)
299
+
300
+
301
+ def _load_from_file(from_file: Path, name: str) -> ProjectConfig:
302
+ """Load a config file, override its name with the CLI argument, or exit cleanly.
303
+
304
+ The project-name argument wins over the file's ``name`` so the same dumped
305
+ config can be rebuilt under a new name; everything else comes from the file.
306
+ A parse or validation failure surfaces as a readable error (exit code 2),
307
+ never a traceback.
308
+ """
309
+ try:
310
+ config = load_config(from_file)
311
+ except ConfigIOError as exc:
312
+ console.print(f"[red]error[/red]: {exc}")
313
+ raise typer.Exit(code=2) from exc
314
+ if config.name != name:
315
+ config = config.model_copy(update={"name": name})
316
+ return config
181
317
 
182
318
 
183
319
  def _build_from_profile(
184
- name: str, profile: str, spokes: int, force: bool, edge_role: str | None
320
+ name: str,
321
+ profile: str,
322
+ *,
323
+ spokes: int,
324
+ frontends: int,
325
+ force: bool,
326
+ edge_role: str | None,
327
+ network_split: bool | None,
328
+ reverse_proxy: str | None,
329
+ proxy_path: str,
330
+ redundant: str | None,
185
331
  ) -> ProjectConfig:
186
332
  """Materialize a config from the named profile + CLI flags, or exit cleanly."""
187
333
  try:
@@ -190,7 +336,16 @@ def _build_from_profile(
190
336
  console.print(f"[red]error[/red]: {exc}")
191
337
  raise typer.Exit(code=2) from exc
192
338
 
193
- options = ProfileOptions(spokes=spokes, force=force, edge_role=edge_role)
339
+ proxy = ReverseProxyConfig(kind=reverse_proxy, path=proxy_path) if reverse_proxy else None
340
+ options = ProfileOptions(
341
+ spokes=spokes,
342
+ frontends=frontends,
343
+ force=force,
344
+ edge_role=edge_role,
345
+ network_split=network_split,
346
+ reverse_proxy=proxy,
347
+ redundant_role=redundant,
348
+ )
194
349
  try:
195
350
  config = build_profile(profile, name, options)
196
351
  except ProfileError as exc:
@@ -220,14 +375,14 @@ def reset(
220
375
  Path("."),
221
376
  "--project-dir",
222
377
  "-C",
223
- help="The generated SE-demo project to reset. Defaults to the current directory.",
378
+ help="The generated project to reset. Defaults to the current directory.",
224
379
  ),
225
380
  ) -> None:
226
- """Regenerate an SE-demo project from its recorded config.
381
+ """Regenerate a project from its recorded config.
227
382
 
228
383
  Reads ``.ignition-stack/config.json``, clears the generated tree (keeping the
229
- record and the modules cache), and re-runs generation. Only works on SE-demo
230
- projects (``init --keep-cli``); a one-shot project has no record to reset from.
384
+ record and the modules cache), and re-runs generation. Works on any project
385
+ generated by this CLI; a directory without a record can't be reset.
231
386
  """
232
387
  project_dir = project_dir.resolve()
233
388
  try:
@@ -252,10 +407,10 @@ def switch_profile(
252
407
  Path("."),
253
408
  "--project-dir",
254
409
  "-C",
255
- help="The generated SE-demo project to reshape. Defaults to the current directory.",
410
+ help="The generated project to reshape. Defaults to the current directory.",
256
411
  ),
257
412
  ) -> None:
258
- """Reshape an SE-demo project under a different architecture profile.
413
+ """Reshape a project under a different architecture profile.
259
414
 
260
415
  Carries the recorded database, services, reverse-proxy, and edge intent over
261
416
  to the new profile, then regenerates in place and re-records the result.
@@ -274,6 +429,19 @@ def switch_profile(
274
429
  raise typer.Exit(code=2) from exc
275
430
 
276
431
  options = _options_from_config(current)
432
+ # Redundancy is pinned to a profile-specific role (e.g. standalone's
433
+ # 'gateway'), which the target profile may not have. Building its base
434
+ # topology lets us check before build_profile's mark_redundant would reject
435
+ # it - drop the intent with an advisory rather than failing the reshape.
436
+ if options.redundant_role is not None and not can_host_redundant_role(
437
+ get_profile(profile).build(current.name, options), options.redundant_role
438
+ ):
439
+ console.print(
440
+ f"[yellow]note[/yellow]: redundancy on '{options.redundant_role}' was not "
441
+ f"carried to {profile} (no matching gateway); re-apply with --redundant "
442
+ "if the new topology has a role to pair"
443
+ )
444
+ options = replace(options, redundant_role=None)
277
445
  try:
278
446
  new_config = build_profile(profile, current.name, options)
279
447
  except ProfileError as exc:
@@ -293,16 +461,33 @@ def _options_from_config(config: ProjectConfig) -> ProfileOptions:
293
461
 
294
462
  Edge intent is recovered from whichever gateway runs the Edge edition (or
295
463
  'none' to keep the new profile from re-introducing its edge default); the
296
- spoke count from the number of spoke-role gateways.
464
+ spoke count from the number of spoke-role gateways, the frontend count from
465
+ the number of frontend-role gateways, and the network split is carried over
466
+ verbatim so a reshape preserves the user's topology choice.
297
467
  """
298
468
  edge_roles = [gw.role or gw.name for gw in config.gateways if gw.ignition_edition == "edge"]
299
469
  spoke_count = sum(1 for gw in config.gateways if (gw.role or "") == "spoke")
470
+ frontend_count = sum(1 for gw in config.gateways if (gw.role or "") == "frontend")
471
+ # Redundancy intent is carried by the master node (the backup is re-derived
472
+ # by the resolver), so recover the role/name of whichever gateway is the
473
+ # master and let the new profile re-expand the pair.
474
+ redundant_role = next(
475
+ (
476
+ gw.role or gw.name
477
+ for gw in config.gateways
478
+ if gw.redundancy is not None and gw.redundancy.mode == "master"
479
+ ),
480
+ None,
481
+ )
300
482
  return ProfileOptions(
301
483
  spokes=spoke_count or 3,
484
+ frontends=frontend_count or 1,
302
485
  edge_role=edge_roles[0] if edge_roles else "none",
486
+ network_split=config.network_split,
303
487
  reverse_proxy=config.reverse_proxy,
304
488
  database_kind=config.database.kind if config.database else None,
305
489
  services=tuple(config.services),
490
+ redundant_role=redundant_role,
306
491
  )
307
492
 
308
493
 
@@ -33,6 +33,39 @@ def complete_edge_role(incomplete: str) -> list[str]:
33
33
  return [role for role in EDGE_ROLE_VALUES if role.startswith(incomplete)]
34
34
 
35
35
 
36
+ # Reverse-proxy kinds the CLI can scaffold. Mirrors ReverseProxyConfig.kind
37
+ # (only Traefik exists today); kept here as the completion vocabulary since
38
+ # the Literal lives in the pydantic model, not a runtime registry.
39
+ REVERSE_PROXY_VALUES = ("traefik",)
40
+
41
+
42
+ def complete_reverse_proxy(incomplete: str) -> list[str]:
43
+ """Reverse-proxy kind names matching the typed prefix."""
44
+ return [kind for kind in REVERSE_PROXY_VALUES if kind.startswith(incomplete)]
45
+
46
+
47
+ # Roles `init --redundant` can pair. Only the singleton workhorse roles are
48
+ # eligible (a scaleout 'backend', a hub-and-spoke 'hub', a standalone
49
+ # 'gateway'); replicated 'frontend'/'spoke' tiers are rejected by the profile
50
+ # builder, so they are intentionally absent here.
51
+ REDUNDANT_ROLE_VALUES = ("backend", "hub", "gateway")
52
+
53
+
54
+ def complete_redundant_role(incomplete: str) -> list[str]:
55
+ """Redundancy-eligible role names matching the typed prefix."""
56
+ return [role for role in REDUNDANT_ROLE_VALUES if role.startswith(incomplete)]
57
+
58
+
59
+ # Serialization formats `init --dry-run --output-format` accepts. Mirrors the
60
+ # `Format` literal in config/io.py; kept here as the completion vocabulary.
61
+ OUTPUT_FORMAT_VALUES = ("yaml", "json")
62
+
63
+
64
+ def complete_output_format(incomplete: str) -> list[str]:
65
+ """Config dump format names matching the typed prefix."""
66
+ return [fmt for fmt in OUTPUT_FORMAT_VALUES if fmt.startswith(incomplete)]
67
+
68
+
36
69
  def complete_module_name(incomplete: str) -> list[str]:
37
70
  """Catalog entry slugs from the bundled catalog matching the typed prefix."""
38
71
  try:
@@ -261,6 +261,16 @@ def _ignition_context(
261
261
  # override when this gateway differs - keeps Phase 2's environment
262
262
  # block as the bare anchor reference.
263
263
  edition_override = gw.ignition_edition if gw.ignition_edition != "standard" else None
264
+
265
+ # Redundancy wiring (Phase 4, per the verified Phase-3 spike):
266
+ # - Any node in a pair opens its incoming Gateway Network policy
267
+ # (Unrestricted + no-SSL) so the plain redundancy link auto-approves.
268
+ # - The backup additionally points a generic outgoing GAN connection at
269
+ # the master (HOST/PORT/ENABLESSL - all three, not just HOST, or it
270
+ # defaults to SSL:8060 and faults) and must NOT be renamed via -n: it
271
+ # adopts the master's system name on first sync.
272
+ is_redundant = gw.redundancy is not None
273
+ is_backup = is_redundant and gw.redundancy.mode == "backup"
264
274
  return {
265
275
  "service_name": ctx["service_name"],
266
276
  "bootstrap_service_name": ctx["bootstrap_service_name"],
@@ -272,6 +282,10 @@ def _ignition_context(
272
282
  "module_identifiers": ctx["module_identifiers"],
273
283
  "database_service": config.database.name if config.database else None,
274
284
  "networks": ctx["networks"],
285
+ "redundant": is_redundant,
286
+ "rename": not is_backup,
287
+ "gan_peer_host": gw.redundancy.peer if is_backup else None,
288
+ "gan_port": gw.redundancy.gan_port if is_backup else None,
275
289
  }
276
290
 
277
291
 
@@ -332,12 +346,7 @@ def _wrap_description(description: str) -> list[str]:
332
346
  def _describe(config: ProjectConfig) -> str:
333
347
  """Human-readable header comment summarizing the stack."""
334
348
  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
- ):
349
+ if n == 1 and config.database and config.database.kind == "postgres" and not config.services:
341
350
  return (
342
351
  "Walking skeleton: one Ignition 8.3 gateway, one Postgres, "
343
352
  "env-driven commissioning so first boot needs no UI."
@@ -393,5 +402,3 @@ def _round_trip(raw: str) -> str:
393
402
  out = io.StringIO()
394
403
  yaml.dump(parsed, out)
395
404
  return out.getvalue()
396
-
397
-
@@ -21,9 +21,23 @@
21
21
  {%- if module_identifiers %}
22
22
  ACCEPT_MODULE_LICENSES: "{{ module_identifiers }}"
23
23
  ACCEPT_MODULE_CERTS: "{{ module_identifiers }}"
24
+ {%- endif %}
25
+ {%- if redundant %}
26
+ GATEWAY_NETWORK_ENABLED: "true"
27
+ GATEWAY_NETWORK_ALLOWINCOMING: "true"
28
+ GATEWAY_NETWORK_SECURITYPOLICY: "Unrestricted"
29
+ GATEWAY_NETWORK_REQUIRESSL: "false"
30
+ GATEWAY_NETWORK_REQUIRETWOWAYAUTH: "false"
31
+ {%- endif %}
32
+ {%- if gan_peer_host %}
33
+ GATEWAY_NETWORK_0_HOST: "{{ gan_peer_host }}"
34
+ GATEWAY_NETWORK_0_PORT: "{{ gan_port }}"
35
+ GATEWAY_NETWORK_0_ENABLESSL: "false"
24
36
  {%- endif %}
25
37
  command: >
38
+ {%- if rename %}
26
39
  -n {{ gateway_name_ref }}
40
+ {%- endif %}
27
41
  -m {{ memory_mb }}
28
42
  --
29
43
  -Dignition.config.mode=dev
@@ -24,6 +24,8 @@ from importlib import resources
24
24
  from importlib.resources.abc import Traversable
25
25
  from pathlib import Path
26
26
 
27
+ from jinja2 import Environment, PackageLoader, StrictUndefined
28
+
27
29
  from ignition_stack.catalog.loader import CatalogLoadError, load_catalog
28
30
  from ignition_stack.catalog.schema import Catalog
29
31
  from ignition_stack.compose.engine import render_compose
@@ -41,7 +43,6 @@ def write_project(
41
43
  config: ProjectConfig,
42
44
  target_dir: Path,
43
45
  *,
44
- keep_cli: bool = False,
45
46
  overwrite: bool = False,
46
47
  ) -> list[Path]:
47
48
  """Generate the project tree at ``target_dir``.
@@ -50,9 +51,10 @@ def write_project(
50
51
  :func:`ignition_stack.services.resolver.resolve`) so the compose output and
51
52
  the on-disk seeds agree on the same fully-expanded stack.
52
53
 
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.
54
+ Every project records its resolved config under ``.ignition-stack/`` so
55
+ ``reset`` / ``switch-profile`` can regenerate or reshape it in place; the
56
+ same artifact can be dumped with ``init --dry-run`` and rebuilt with
57
+ ``init -f``.
56
58
 
57
59
  ``overwrite`` lets ``reset`` / ``switch-profile`` write into a directory
58
60
  that still holds the preserved ``.ignition-stack/`` record; normal ``init``
@@ -76,6 +78,7 @@ def write_project(
76
78
  written.extend(_copy_static_tree(config, target_dir))
77
79
  written.extend(_copy_service_seeds(config, target_dir))
78
80
  written.extend(_overlay_gateway_resources(config, target_dir))
81
+ written.extend(_write_redundancy_seeds(config, target_dir))
79
82
  _ensure_modules_cache_dir(config, target_dir)
80
83
  written.append(_write_compose(config, target_dir))
81
84
  written.append(_write_env(config, target_dir))
@@ -88,9 +91,7 @@ def write_project(
88
91
  if dropin_file is not None:
89
92
  written.append(dropin_file)
90
93
  written.append(_write_post_setup(config, target_dir))
91
-
92
- if keep_cli:
93
- written.append(write_record(config, target_dir))
94
+ written.append(write_record(config, target_dir))
94
95
 
95
96
  return written
96
97
 
@@ -138,6 +139,48 @@ def _overlay_gateway_resources(config: ProjectConfig, target_dir: Path) -> list[
138
139
  return written
139
140
 
140
141
 
142
+ def _write_redundancy_seeds(config: ProjectConfig, target_dir: Path) -> list[Path]:
143
+ """Drop a per-node ``redundancy.xml`` into each redundant gateway's tree.
144
+
145
+ Per the Phase-3 spike, nothing sets the redundancy *role* via env var, so a
146
+ pre-seeded ``data/redundancy.xml`` is what makes a node a master or backup.
147
+ The file lands at ``services/<gateway>/redundancy.xml`` (the gateway's
148
+ template-source root); the bootstrap copies it to the data-volume root on
149
+ first boot. Master and backup differ only in ``noderole`` and ``gan.host``;
150
+ the backup points its host at the master's service name. Generated stacks
151
+ use the plain (non-SSL) link on port 8088, which auto-approves without the
152
+ certificate handshake the SSL path (8060) would force.
153
+ """
154
+ env = _redundancy_jinja_env()
155
+ template = env.get_template("redundancy.xml.j2")
156
+ written: list[Path] = []
157
+ for gw in config.gateways:
158
+ red = gw.redundancy
159
+ if red is None or not red.seed_redundancy_xml:
160
+ continue
161
+ is_master = red.mode == "master"
162
+ rendered = template.render(
163
+ noderole="Master" if is_master else "Backup",
164
+ # The master listens; the backup connects to the master's service
165
+ # name. An empty host on the master matches the verified seed file.
166
+ gan_host="" if is_master else red.peer,
167
+ gan_port=red.gan_port,
168
+ enable_ssl="true" if red.gan_port == 8060 else "false",
169
+ )
170
+ rel = f"services/{gw.name}/redundancy.xml"
171
+ written.append(_write_static(target_dir, rel, rendered.encode(), False))
172
+ return written
173
+
174
+
175
+ def _redundancy_jinja_env() -> Environment:
176
+ return Environment(
177
+ loader=PackageLoader("ignition_stack.templates", "redundancy"),
178
+ undefined=StrictUndefined,
179
+ keep_trailing_newline=True,
180
+ autoescape=False,
181
+ )
182
+
183
+
141
184
  def _seed_sources(config: ProjectConfig) -> list[tuple[object, str]]:
142
185
  """(service-catalog dir, on-disk destination name) for the DB + each service.
143
186
 
@@ -378,7 +421,7 @@ logs: ## Follow logs for every service.
378
421
  wipe: ## Remove ONLY this project's containers, networks, and volumes.
379
422
  \t$(COMPOSE) -p $(PROJECT) down -v --remove-orphans
380
423
 
381
- reset: ## Regenerate this project from its recorded SE-demo config.
424
+ reset: ## Regenerate this project from its recorded config.
382
425
  \tignition-stack reset
383
426
  """
384
427