ignition-stack 0.2.0__tar.gz → 0.4.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 (151) hide show
  1. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/PKG-INFO +1 -1
  2. ignition_stack-0.4.0/builtin_modules.yaml +122 -0
  3. ignition_stack-0.4.0/ignition_stack/__init__.py +1 -0
  4. ignition_stack-0.4.0/ignition_stack/catalog/builtins.py +171 -0
  5. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/cli.py +51 -1
  6. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/completion.py +15 -0
  7. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/compose/engine.py +63 -36
  8. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/compose/templates/services/ignition.yaml.j2 +9 -6
  9. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/config/schema.py +76 -30
  10. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/postsetup/generator.py +41 -21
  11. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/profiles/base.py +34 -1
  12. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/profiles/hub_and_spoke.py +3 -0
  13. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/profiles/scaleout.py +10 -6
  14. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/services/resolver.py +1 -0
  15. ignition_stack-0.4.0/ignition_stack/templates/post-setup/gateway-network-link.md.j2 +23 -0
  16. ignition_stack-0.4.0/ignition_stack/update_check.py +129 -0
  17. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/wizard.py +43 -2
  18. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/pyproject.toml +3 -2
  19. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/tests/golden/profiles/hub-and-spoke/docker-compose.yaml +29 -0
  20. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/tests/golden/profiles/scaleout/docker-compose.yaml +13 -0
  21. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/tests/golden/profiles/scaleout-redundant/docker-compose.yaml +8 -0
  22. ignition_stack-0.4.0/tests/test_builtin_catalog_smoke.py +114 -0
  23. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/tests/test_compose_engine.py +3 -4
  24. ignition_stack-0.4.0/tests/test_disable_builtins.py +241 -0
  25. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/tests/test_init_standalone.py +1 -24
  26. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/tests/test_profiles.py +110 -38
  27. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/tests/test_service_catalog.py +16 -8
  28. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/tests/test_service_catalog_smoke.py +3 -3
  29. ignition_stack-0.4.0/tests/test_update_check.py +108 -0
  30. ignition_stack-0.2.0/ignition_stack/__init__.py +0 -1
  31. ignition_stack-0.2.0/ignition_stack/templates/post-setup/gateway-network-link.md.j2 +0 -18
  32. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/.gitignore +0 -0
  33. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/LICENSE +0 -0
  34. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/README.md +0 -0
  35. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/catalog/__init__.py +0 -0
  36. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/catalog/download.py +0 -0
  37. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/catalog/loader.py +0 -0
  38. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/catalog/schema.py +0 -0
  39. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/catalog/verify.py +0 -0
  40. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/commands/__init__.py +0 -0
  41. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/commands/modules.py +0 -0
  42. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/compose/__init__.py +0 -0
  43. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/compose/templates/footer.yaml.j2 +0 -0
  44. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/compose/templates/header.yaml.j2 +0 -0
  45. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/compose/templates/services/bootstrap.yaml.j2 +0 -0
  46. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/compose/writer.py +0 -0
  47. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/config/__init__.py +0 -0
  48. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/config/io.py +0 -0
  49. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/lifecycle/__init__.py +0 -0
  50. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/lifecycle/cleanup.py +0 -0
  51. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/lifecycle/record.py +0 -0
  52. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/lifecycle/regenerate.py +0 -0
  53. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/postsetup/__init__.py +0 -0
  54. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/profiles/__init__.py +0 -0
  55. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/profiles/advisory.py +0 -0
  56. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/profiles/mcp_n8n.py +0 -0
  57. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/profiles/standalone.py +0 -0
  58. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/services/__init__.py +0 -0
  59. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/services/loader.py +0 -0
  60. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/services/manifest.py +0 -0
  61. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/__init__.py +0 -0
  62. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/post-setup/_default.md.j2 +0 -0
  63. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/post-setup/device-connection.md.j2 +0 -0
  64. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/post-setup/identity-provider.md.j2 +0 -0
  65. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/post-setup/kafka-connector.md.j2 +0 -0
  66. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/post-setup/mcp-module.md.j2 +0 -0
  67. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/post-setup/mqtt-engine-connection.md.j2 +0 -0
  68. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/post-setup/opc-ua-connection.md.j2 +0 -0
  69. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/post-setup/redundancy-pairing.md.j2 +0 -0
  70. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/post-setup/reverse-proxy.md.j2 +0 -0
  71. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/redundancy/redundancy.xml.j2 +0 -0
  72. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/chariot/compose.yaml.j2 +0 -0
  73. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/chariot/manifest.yaml +0 -0
  74. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/chariot/seed/service/USAGE.md +0 -0
  75. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/emqx/compose.yaml.j2 +0 -0
  76. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/emqx/manifest.yaml +0 -0
  77. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/emqx/seed/service/USAGE.md +0 -0
  78. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/hivemq/compose.yaml.j2 +0 -0
  79. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/hivemq/manifest.yaml +0 -0
  80. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/hivemq/seed/service/USAGE.md +0 -0
  81. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/kafka/compose.yaml.j2 +0 -0
  82. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/kafka/manifest.yaml +0 -0
  83. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/kafka/seed/service/USAGE.md +0 -0
  84. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/keycloak/compose.yaml.j2 +0 -0
  85. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/keycloak/manifest.yaml +0 -0
  86. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/keycloak/seed/service/import/ignition-realm.json +0 -0
  87. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/mariadb/compose.yaml.j2 +0 -0
  88. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/mariadb/manifest.yaml +0 -0
  89. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/mariadb/seed/service/initdb/00-create-extra-databases.sh +0 -0
  90. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/modbus-sim/compose.yaml.j2 +0 -0
  91. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/modbus-sim/manifest.yaml +0 -0
  92. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/modbus-sim/seed/service/USAGE.md +0 -0
  93. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/mongo/compose.yaml.j2 +0 -0
  94. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/mongo/manifest.yaml +0 -0
  95. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/mongo/seed/service/initdb/01-demo-collection.js +0 -0
  96. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/mysql/compose.yaml.j2 +0 -0
  97. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/mysql/manifest.yaml +0 -0
  98. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/mysql/seed/service/initdb/00-create-extra-databases.sh +0 -0
  99. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/n8n/compose.yaml.j2 +0 -0
  100. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/n8n/manifest.yaml +0 -0
  101. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/n8n/seed/service/USAGE.md +0 -0
  102. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/opcua-sim/compose.yaml.j2 +0 -0
  103. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/opcua-sim/manifest.yaml +0 -0
  104. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/opcua-sim/seed/service/USAGE.md +0 -0
  105. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/postgres/compose.yaml.j2 +0 -0
  106. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/postgres/manifest.yaml +0 -0
  107. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/postgres/seed/gateway-resources/config/resources/core/ignition/database-connection/db/config.json +0 -0
  108. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/postgres/seed/gateway-resources/config/resources/core/ignition/database-connection/db/resource.json +0 -0
  109. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/postgres/seed/gateway-resources/config/resources/core/ignition/secret-provider/internal-secret-provider/config.json +0 -0
  110. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/postgres/seed/gateway-resources/config/resources/core/ignition/secret-provider/internal-secret-provider/resource.json +0 -0
  111. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/postgres/seed/service/initdb/00-create-extra-databases.sh +0 -0
  112. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/rabbitmq/compose.yaml.j2 +0 -0
  113. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/rabbitmq/manifest.yaml +0 -0
  114. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/rabbitmq/seed/service/enabled_plugins +0 -0
  115. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/standalone-postgres/docker-compose.yaml +0 -0
  116. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/standalone-postgres/scripts/docker-bootstrap.sh +0 -0
  117. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/standalone-postgres/services/ignition/config/resources/core/config-mode.json +0 -0
  118. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/standalone-postgres/services/ignition/config/resources/dev/config-mode.json +0 -0
  119. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/ignition_stack/templates/standalone-postgres/services/ignition/projects/.gitkeep +0 -0
  120. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/modules.yaml +0 -0
  121. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/tests/__init__.py +0 -0
  122. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/tests/conftest.py +0 -0
  123. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/tests/golden/combos/network-split/docker-compose.yaml +0 -0
  124. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/tests/golden/combos/smoke-stack/docker-compose.yaml +0 -0
  125. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/tests/golden/profiles/mcp-n8n/docker-compose.yaml +0 -0
  126. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/tests/golden/profiles/standalone/docker-compose.yaml +0 -0
  127. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/tests/golden/scaleout-skeleton/docker-compose.yaml +0 -0
  128. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/tests/golden/services/chariot/docker-compose.yaml +0 -0
  129. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/tests/golden/services/db-mariadb/docker-compose.yaml +0 -0
  130. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/tests/golden/services/db-mongo/docker-compose.yaml +0 -0
  131. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/tests/golden/services/db-mysql/docker-compose.yaml +0 -0
  132. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/tests/golden/services/db-postgres/docker-compose.yaml +0 -0
  133. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/tests/golden/services/emqx/docker-compose.yaml +0 -0
  134. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/tests/golden/services/hivemq/docker-compose.yaml +0 -0
  135. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/tests/golden/services/kafka/docker-compose.yaml +0 -0
  136. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/tests/golden/services/keycloak/docker-compose.yaml +0 -0
  137. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/tests/golden/services/modbus-sim/docker-compose.yaml +0 -0
  138. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/tests/golden/services/n8n/docker-compose.yaml +0 -0
  139. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/tests/golden/services/opcua-sim/docker-compose.yaml +0 -0
  140. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/tests/golden/services/rabbitmq/docker-compose.yaml +0 -0
  141. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/tests/golden/standalone-postgres/docker-compose.yaml +0 -0
  142. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/tests/test_completion.py +0 -0
  143. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/tests/test_declarative_io.py +0 -0
  144. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/tests/test_docs_cli_reference.py +0 -0
  145. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/tests/test_lifecycle.py +0 -0
  146. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/tests/test_modules_catalog.py +0 -0
  147. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/tests/test_modules_cli.py +0 -0
  148. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/tests/test_postsetup.py +0 -0
  149. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/tests/test_redundancy.py +0 -0
  150. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/verification/redundancy-spike/README.md +0 -0
  151. {ignition_stack-0.2.0 → ignition_stack-0.4.0}/verification/smoke/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ignition-stack
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: CLI that generates ready-to-run Docker Compose stacks for Ignition 8.3 SCADA demos and SE engagements
5
5
  Author-email: Eric Knorr <etknorr@gmail.com>
6
6
  License: MIT
@@ -0,0 +1,122 @@
1
+ # Catalog of the built-in IA modules that ship inside the Ignition gateway
2
+ # image. Used to translate a gateway's `disable_builtins` slugs into the
3
+ # GATEWAY_MODULES_ENABLED whitelist the engine emits (enabled = all built-ins
4
+ # minus the disabled ones, plus any third-party module identifiers).
5
+ #
6
+ # Why this file exists:
7
+ # GATEWAY_MODULES_ENABLED is a strict WHITELIST, not a blocklist - anything
8
+ # not listed is quarantined at boot (verified live on 8.3.6). So "disable
9
+ # Vision" can only be expressed as "enable every built-in except Vision".
10
+ # That inversion needs the COMPLETE built-in set; an incomplete list would
11
+ # silently quarantine the modules we forgot to enumerate. This file is that
12
+ # complete set, and tests/test_builtin_catalog.py (marked `smoke`) re-derives
13
+ # it from the live image so a stale list fails CI loudly instead of dropping
14
+ # modules silently.
15
+ #
16
+ # Provenance (how to regenerate when bumping the Ignition image):
17
+ # 1. Boot the pinned image with no whitelist so every built-in loads:
18
+ # docker run --rm -e ACCEPT_IGNITION_EULA=Y -e GATEWAY_ADMIN_PASSWORD=x \
19
+ # inductiveautomation/ignition:<tag>
20
+ # 2. Read the loaded set from the gateway log lines:
21
+ # Starting up module '<identifier>' ... module-name=<name>
22
+ # 3. Update `ignition_version` and the `modules` list below to match.
23
+ # The smoke guard test automates this comparison.
24
+ #
25
+ # `identifier` is the fully-qualified module id used verbatim in
26
+ # GATEWAY_MODULES_ENABLED. `slug` is the friendly kebab name a user puts in
27
+ # `disable_builtins`. `name` is the gateway's display name (for wizard labels).
28
+
29
+ version: 1
30
+
31
+ # The exact Ignition image tag this built-in set was captured from. The guard
32
+ # test only asserts a match when the running image reports this version.
33
+ ignition_version: "8.3.6"
34
+
35
+ modules:
36
+ - slug: alarm-notification
37
+ identifier: com.inductiveautomation.alarm-notification
38
+ name: Alarm Notification
39
+ - slug: allen-bradley-driver
40
+ identifier: com.inductiveautomation.opcua.drivers.ablegacy
41
+ name: Allen-Bradley Driver
42
+ - slug: bacnet-driver
43
+ identifier: com.inductiveautomation.opcua.drivers.bacnet
44
+ name: BACnet Driver
45
+ - slug: enterprise-administration
46
+ identifier: com.inductiveautomation.eam
47
+ name: Enterprise Administration
48
+ - slug: event-streams
49
+ identifier: com.inductiveautomation.eventstream
50
+ name: Event Streams
51
+ - slug: historian-core
52
+ identifier: com.inductiveautomation.historian
53
+ name: Historian Core
54
+ - slug: kafka-connector
55
+ identifier: com.inductiveautomation.connectors.kafka
56
+ name: Kafka Connector
57
+ - slug: legacy-dnp3-driver
58
+ identifier: com.inductiveautomation.opcua.drivers.dnp3
59
+ name: Legacy DNP3 Driver
60
+ - slug: logix-driver
61
+ identifier: com.inductiveautomation.opcua.drivers.logix
62
+ name: Logix Driver
63
+ - slug: mariadb-jdbc-driver
64
+ identifier: com.inductiveautomation.jdbc.mariadb
65
+ name: MariaDB JDBC Driver
66
+ - slug: micro800-driver
67
+ identifier: com.inductiveautomation.opcua.drivers.micro800
68
+ name: Micro800 Driver
69
+ - slug: mitsubishi-driver
70
+ identifier: com.inductiveautomation.opcua.drivers.mitsubishi
71
+ name: Mitsubishi Driver
72
+ - slug: modbus-driver
73
+ identifier: com.inductiveautomation.opcua.drivers.modbus
74
+ name: Modbus Driver
75
+ - slug: mssql-jdbc-driver
76
+ identifier: com.inductiveautomation.jdbc.mssql
77
+ name: MSSQL JDBC Driver
78
+ - slug: omron-driver
79
+ identifier: com.inductiveautomation.opcua.drivers.omron
80
+ name: Omron Driver
81
+ - slug: opc-ua
82
+ identifier: com.inductiveautomation.opcua
83
+ name: OPC-UA
84
+ - slug: perspective
85
+ identifier: com.inductiveautomation.perspective
86
+ name: Perspective
87
+ - slug: postgresql-jdbc-driver
88
+ identifier: com.inductiveautomation.jdbc.postgresql
89
+ name: PostgreSQL JDBC Driver
90
+ - slug: reporting
91
+ identifier: com.inductiveautomation.reporting
92
+ name: Reporting
93
+ - slug: sfc
94
+ identifier: com.inductiveautomation.sfc
95
+ name: SFC
96
+ - slug: siemens-drivers
97
+ identifier: com.inductiveautomation.opcua.drivers.siemens
98
+ name: Siemens Drivers
99
+ - slug: siemens-enhanced-driver
100
+ identifier: com.inductiveautomation.opcua.drivers.siemens-symbolic
101
+ name: Siemens Enhanced Driver
102
+ - slug: sms-notification
103
+ identifier: com.inductiveautomation.sms-notification
104
+ name: SMS Notification
105
+ - slug: sql-bridge
106
+ identifier: com.inductiveautomation.sqlbridge
107
+ name: SQL Bridge
108
+ - slug: sql-historian
109
+ identifier: com.inductiveautomation.historian.sql
110
+ name: SQL Historian
111
+ - slug: symbol-factory
112
+ identifier: com.inductiveautomation.symbol-factory
113
+ name: Symbol Factory
114
+ - slug: udp-and-tcp-drivers
115
+ identifier: com.inductiveautomation.opcua.drivers.tcpudp
116
+ name: UDP and TCP Drivers
117
+ - slug: vision
118
+ identifier: com.inductiveautomation.vision
119
+ name: Vision
120
+ - slug: webdev
121
+ identifier: com.inductiveautomation.webdev
122
+ name: WebDev
@@ -0,0 +1 @@
1
+ __version__ = "0.4.0"
@@ -0,0 +1,171 @@
1
+ """Load and query the built-in IA module catalog (``builtin_modules.yaml``).
2
+
3
+ The third-party catalog (``modules.yaml`` + ``catalog/schema.py``) covers
4
+ modules the CLI *adds*. This module covers the modules that *already ship*
5
+ inside the gateway image, which the engine needs in order to translate a
6
+ gateway's ``disable_builtins`` slugs into a ``GATEWAY_MODULES_ENABLED``
7
+ whitelist.
8
+
9
+ The whitelist is strict: anything not listed is quarantined at boot. So
10
+ "disable Vision" is expressed as "enable every built-in except Vision",
11
+ which requires the *complete* built-in set - hence a pinned data file plus a
12
+ smoke guard test that re-derives it from the live image.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from functools import lru_cache
18
+ from importlib import resources
19
+ from pathlib import Path
20
+ from typing import Annotated
21
+
22
+ import yaml
23
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
24
+
25
+
26
+ class BuiltinCatalogLoadError(Exception):
27
+ """Raised when builtin_modules.yaml cannot be read or fails validation."""
28
+
29
+
30
+ DEFAULT_BUILTIN_CATALOG_NAME = "builtin_modules.yaml"
31
+
32
+
33
+ class BuiltinModule(BaseModel):
34
+ """One built-in IA module that ships inside the gateway image."""
35
+
36
+ model_config = ConfigDict(extra="forbid", frozen=True)
37
+
38
+ slug: Annotated[
39
+ str,
40
+ Field(
41
+ min_length=1,
42
+ pattern=r"^[a-z0-9][a-z0-9-]*$",
43
+ description="Friendly kebab name a user puts in `disable_builtins`.",
44
+ ),
45
+ ]
46
+ identifier: Annotated[
47
+ str,
48
+ Field(
49
+ min_length=1,
50
+ pattern=r"^[a-z0-9.-]+$",
51
+ description="Fully-qualified module id, used verbatim in GATEWAY_MODULES_ENABLED.",
52
+ ),
53
+ ]
54
+ name: Annotated[str, Field(min_length=1, description="Gateway display name (wizard label).")]
55
+
56
+
57
+ class BuiltinCatalog(BaseModel):
58
+ """Top-level shape of builtin_modules.yaml."""
59
+
60
+ model_config = ConfigDict(extra="forbid", frozen=True)
61
+
62
+ version: Annotated[int, Field(ge=1)]
63
+ ignition_version: Annotated[
64
+ str,
65
+ Field(min_length=1, description="Image tag this built-in set was captured from."),
66
+ ]
67
+ modules: Annotated[list[BuiltinModule], Field(min_length=1)]
68
+
69
+ @field_validator("modules")
70
+ @classmethod
71
+ def _slugs_unique(cls, modules: list[BuiltinModule]) -> list[BuiltinModule]:
72
+ slugs = [m.slug for m in modules]
73
+ dupes = sorted({s for s in slugs if slugs.count(s) > 1})
74
+ if dupes:
75
+ raise ValueError(f"duplicate built-in slugs: {', '.join(dupes)}")
76
+ return modules
77
+
78
+ @property
79
+ def slugs(self) -> set[str]:
80
+ """Every known built-in slug."""
81
+ return {m.slug for m in self.modules}
82
+
83
+ def identifiers_excluding(self, disabled_slugs: list[str]) -> list[str]:
84
+ """FQ identifiers of every built-in whose slug is not in ``disabled_slugs``.
85
+
86
+ Order follows the catalog (already alphabetical by slug) so generated
87
+ whitelists are deterministic and golden-stable.
88
+ """
89
+ disabled = set(disabled_slugs)
90
+ return [m.identifier for m in self.modules if m.slug not in disabled]
91
+
92
+
93
+ def load_builtin_catalog(path: Path | None = None) -> BuiltinCatalog:
94
+ """Load and validate the built-in catalog.
95
+
96
+ With ``path=None`` the catalog shipped with the installed package is used;
97
+ a path overrides it (test fixtures). Mirrors ``catalog.loader.load_catalog``.
98
+ """
99
+ yaml_text = _read_yaml_text(path)
100
+ name = DEFAULT_BUILTIN_CATALOG_NAME
101
+ try:
102
+ raw = yaml.safe_load(yaml_text)
103
+ except yaml.YAMLError as exc:
104
+ raise BuiltinCatalogLoadError(f"{name} is not valid YAML: {exc}") from exc
105
+
106
+ if not isinstance(raw, dict):
107
+ raise BuiltinCatalogLoadError(f"{name} top-level must be a mapping.")
108
+
109
+ try:
110
+ return BuiltinCatalog.model_validate(raw)
111
+ except ValidationError as exc:
112
+ raise BuiltinCatalogLoadError(
113
+ f"{DEFAULT_BUILTIN_CATALOG_NAME} failed schema validation:\n{exc}"
114
+ ) from exc
115
+
116
+
117
+ @lru_cache(maxsize=1)
118
+ def default_builtin_catalog() -> BuiltinCatalog:
119
+ """The built-in catalog shipped with the package, loaded once and cached.
120
+
121
+ The data file is immutable package data, so both config validation and the
122
+ compose engine can share a single memoized read rather than re-parsing YAML
123
+ on every gateway.
124
+ """
125
+ return load_builtin_catalog()
126
+
127
+
128
+ def builtin_slugs() -> frozenset[str]:
129
+ """Slugs of the shipped built-in catalog, for cheap ``disable_builtins`` validation."""
130
+ return frozenset(default_builtin_catalog().slugs)
131
+
132
+
133
+ def validate_disable_slugs(slugs: list[str]) -> None:
134
+ """Raise ``ValueError`` if any slug is not a known built-in.
135
+
136
+ Shared by ``GatewayConfig`` field validation (construction-time) and
137
+ ``profiles.apply_disable_builtins`` (post-construction mutation, which
138
+ pydantic does not re-validate), so the wizard/CLI path is guarded too. A
139
+ typo would otherwise be a silent no-op - the slug just isn't disabled.
140
+ """
141
+ known = builtin_slugs()
142
+ unknown = [s for s in slugs if s not in known]
143
+ if unknown:
144
+ raise ValueError(
145
+ f"unknown built-in module slug(s): {', '.join(unknown)}. "
146
+ f"Valid slugs are: {', '.join(sorted(known))}"
147
+ )
148
+
149
+
150
+ def _read_yaml_text(path: Path | None) -> str:
151
+ if path is not None:
152
+ if not path.is_file():
153
+ raise BuiltinCatalogLoadError(f"Built-in catalog not found at {path}.")
154
+ return path.read_text(encoding="utf-8")
155
+
156
+ # Installed wheels: force-included as ignition_stack/builtin_modules.yaml.
157
+ # Editable dev installs: it lives at the repo root next to pyproject.toml.
158
+ try:
159
+ bundled = resources.files("ignition_stack").joinpath(DEFAULT_BUILTIN_CATALOG_NAME)
160
+ if bundled.is_file():
161
+ return bundled.read_text(encoding="utf-8")
162
+ except (FileNotFoundError, OSError, ModuleNotFoundError):
163
+ pass
164
+
165
+ repo_root = Path(__file__).resolve().parents[2]
166
+ dev_path = repo_root / DEFAULT_BUILTIN_CATALOG_NAME
167
+ if not dev_path.is_file():
168
+ raise BuiltinCatalogLoadError(
169
+ f"{DEFAULT_BUILTIN_CATALOG_NAME} not found in package data or at {dev_path}."
170
+ )
171
+ return dev_path.read_text(encoding="utf-8")
@@ -22,6 +22,7 @@ from rich.console import Console
22
22
  from ignition_stack import __version__
23
23
  from ignition_stack.commands.modules import modules_app
24
24
  from ignition_stack.completion import (
25
+ complete_disable_builtin,
25
26
  complete_edge_role,
26
27
  complete_output_format,
27
28
  complete_profile,
@@ -55,6 +56,11 @@ from ignition_stack.profiles import (
55
56
  list_profiles,
56
57
  )
57
58
  from ignition_stack.services.resolver import resolve
59
+ from ignition_stack.update_check import (
60
+ check_for_update,
61
+ detect_upgrade_command,
62
+ should_notify,
63
+ )
58
64
  from ignition_stack.wizard import run_wizard
59
65
 
60
66
  app = typer.Typer(
@@ -88,6 +94,27 @@ def _root(
88
94
  if ctx.invoked_subcommand is None:
89
95
  console.print(ctx.get_help())
90
96
  raise typer.Exit()
97
+ _notify_update_available()
98
+
99
+
100
+ def _notify_update_available() -> None:
101
+ """Print a one-line advisory when a newer release is on PyPI.
102
+
103
+ Runs only for real subcommands (not --version or bare help) and only on an
104
+ interactive terminal. Best-effort: any failure inside the check is swallowed
105
+ rather than allowed to disrupt the command the user actually ran.
106
+ """
107
+ if not should_notify():
108
+ return
109
+ result = check_for_update()
110
+ if result is None:
111
+ return
112
+ current, latest = result
113
+ console.print(
114
+ f"[dim]update available[/dim] [yellow]{current}[/yellow] -> "
115
+ f"[green]{latest}[/green] · run: [cyan]{detect_upgrade_command()}[/cyan]",
116
+ highlight=False,
117
+ )
91
118
 
92
119
 
93
120
  def _profile_help() -> str:
@@ -172,6 +199,17 @@ def init(
172
199
  ),
173
200
  autocompletion=complete_redundant_role,
174
201
  ),
202
+ disable_builtin: list[str] = typer.Option( # noqa: B008 - Typer pattern
203
+ [],
204
+ "--disable-builtin",
205
+ help=(
206
+ "Built-in IA module to turn off on every gateway (repeatable), e.g. "
207
+ "--disable-builtin vision --disable-builtin sfc. Emits a "
208
+ "GATEWAY_MODULES_ENABLED whitelist of everything else. Slugs "
209
+ "tab-complete; an unknown slug is rejected with the full valid list."
210
+ ),
211
+ autocompletion=complete_disable_builtin,
212
+ ),
175
213
  from_file: Path | None = typer.Option( # noqa: B008 - Typer pattern
176
214
  None,
177
215
  "--from-file",
@@ -240,6 +278,7 @@ def init(
240
278
  reverse_proxy=reverse_proxy,
241
279
  proxy_path=proxy_path,
242
280
  redundant=redundant,
281
+ disable_builtin=disable_builtin,
243
282
  )
244
283
 
245
284
  if dry_run:
@@ -328,6 +367,7 @@ def _build_from_profile(
328
367
  reverse_proxy: str | None,
329
368
  proxy_path: str,
330
369
  redundant: str | None,
370
+ disable_builtin: list[str],
331
371
  ) -> ProjectConfig:
332
372
  """Materialize a config from the named profile + CLI flags, or exit cleanly."""
333
373
  try:
@@ -345,6 +385,7 @@ def _build_from_profile(
345
385
  network_split=network_split,
346
386
  reverse_proxy=proxy,
347
387
  redundant_role=redundant,
388
+ disable_builtins=tuple(disable_builtin),
348
389
  )
349
390
  try:
350
391
  config = build_profile(profile, name, options)
@@ -463,7 +504,9 @@ def _options_from_config(config: ProjectConfig) -> ProfileOptions:
463
504
  'none' to keep the new profile from re-introducing its edge default); the
464
505
  spoke count from the number of spoke-role gateways, the frontend count from
465
506
  the number of frontend-role gateways, and the network split is carried over
466
- verbatim so a reshape preserves the user's topology choice.
507
+ verbatim so a reshape preserves the user's topology choice. Disabled
508
+ built-in modules are carried over too (see below) so a reshape doesn't
509
+ silently re-enable Vision/SFC/etc.
467
510
  """
468
511
  edge_roles = [gw.role or gw.name for gw in config.gateways if gw.ignition_edition == "edge"]
469
512
  spoke_count = sum(1 for gw in config.gateways if (gw.role or "") == "spoke")
@@ -479,6 +522,12 @@ def _options_from_config(config: ProjectConfig) -> ProfileOptions:
479
522
  ),
480
523
  None,
481
524
  )
525
+ # Disabled built-ins are applied stack-wide, so carry over the slugs disabled
526
+ # on EVERY gateway (the intersection) - that is the stack-wide intent, and it
527
+ # won't over-disable a module that a hand-authored config turned off on only
528
+ # one node. The target profile re-applies it uniformly.
529
+ disabled_sets = [set(gw.disable_builtins) for gw in config.gateways]
530
+ disable_builtins = tuple(sorted(set.intersection(*disabled_sets))) if disabled_sets else ()
482
531
  return ProfileOptions(
483
532
  spokes=spoke_count or 3,
484
533
  frontends=frontend_count or 1,
@@ -488,6 +537,7 @@ def _options_from_config(config: ProjectConfig) -> ProfileOptions:
488
537
  database_kind=config.database.kind if config.database else None,
489
538
  services=tuple(config.services),
490
539
  redundant_role=redundant_role,
540
+ disable_builtins=disable_builtins,
491
541
  )
492
542
 
493
543
 
@@ -77,3 +77,18 @@ def complete_module_name(incomplete: str) -> list[str]:
77
77
  # catalog must degrade to "no suggestions", never break the shell line.
78
78
  return []
79
79
  return [entry.name for entry in entries if entry.name.startswith(incomplete)]
80
+
81
+
82
+ def complete_disable_builtin(incomplete: str) -> list[tuple[str, str]]:
83
+ """Built-in module slugs (with display name) matching the typed prefix.
84
+
85
+ Reads the bundled built-in catalog; degrades to no suggestions on any error
86
+ so a TAB never breaks the shell line.
87
+ """
88
+ try:
89
+ from ignition_stack.catalog.builtins import default_builtin_catalog
90
+
91
+ modules = default_builtin_catalog().modules
92
+ except Exception:
93
+ return []
94
+ return [(m.slug, m.name) for m in modules if m.slug.startswith(incomplete)]
@@ -39,6 +39,7 @@ from typing import TYPE_CHECKING
39
39
  from jinja2 import Environment, PackageLoader, StrictUndefined
40
40
  from ruamel.yaml import YAML
41
41
 
42
+ from ignition_stack.catalog.builtins import default_builtin_catalog
42
43
  from ignition_stack.services.loader import load_all_services, load_service
43
44
 
44
45
  if TYPE_CHECKING:
@@ -131,10 +132,7 @@ def _render_database(config: ProjectConfig) -> str:
131
132
  """
132
133
  db = config.database
133
134
  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}}"
135
+ container_name_ref = f"{db.name}-${{COMPOSE_PROJECT_NAME}}" if config.is_multi_gateway else f"{db.name}-${{GATEWAY_NAME}}"
138
136
  tpl = _service_jinja_env().get_template(f"{db.kind}/compose.yaml.j2")
139
137
  return tpl.render(
140
138
  name=db.name,
@@ -186,9 +184,7 @@ def _service_dependencies(manifest: object, config: ProjectConfig) -> list[str]:
186
184
  return deps
187
185
 
188
186
 
189
- def _gateway_context(
190
- gw: GatewayConfig, config: ProjectConfig, catalog: Catalog | None
191
- ) -> dict[str, object]:
187
+ def _gateway_context(gw: GatewayConfig, config: ProjectConfig, catalog: Catalog | None) -> dict[str, object]:
192
188
  """Build the per-gateway context dict shared by the bootstrap + gateway fragments."""
193
189
  multi = config.is_multi_gateway
194
190
 
@@ -215,10 +211,7 @@ def _gateway_context(
215
211
  # (DB/broker access). Gateways with no role tag default to
216
212
  # frontend membership; explicit role=backend lands a gateway on
217
213
  # 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]
214
+ networks = [NETWORK_BACKEND] if gw.role == "backend" else [NETWORK_FRONTEND, NETWORK_BACKEND]
222
215
 
223
216
  module_identifiers = _module_identifiers_for(gw, catalog)
224
217
  cached_modules = bool(gw.modules)
@@ -253,24 +246,40 @@ def _bootstrap_context(ctx: dict[str, object]) -> dict[str, object]:
253
246
  }
254
247
 
255
248
 
256
- def _ignition_context(
257
- ctx: dict[str, object], config: ProjectConfig, multi: bool
258
- ) -> dict[str, object]:
249
+ def _ignition_context(ctx: dict[str, object], config: ProjectConfig, multi: bool) -> dict[str, object]:
259
250
  gw: GatewayConfig = ctx["gw"] # type: ignore[assignment]
260
251
  # IGNITION_EDITION lives in the anchor as "standard", so only emit an
261
252
  # override when this gateway differs - keeps Phase 2's environment
262
253
  # block as the bare anchor reference.
263
254
  edition_override = gw.ignition_edition if gw.ignition_edition != "standard" else None
264
255
 
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.
256
+ # Gateway Network wiring (Phase 4, per the verified Phase-3 spike, which the
257
+ # spike itself notes mirrors the publicdemo-all dev stack: 8088 / no-SSL /
258
+ # Unrestricted). Two kinds of GAN link ride this same plain, auto-approving
259
+ # path:
260
+ # - Redundancy: the backup points an outgoing connection at its master and
261
+ # must NOT be renamed via -n (it adopts the master's system name on sync).
262
+ # - Multi-gateway profiles: each gateway names its peers in gan_outgoing
263
+ # (scaleout frontend -> backend, hub-and-spoke spoke -> hub).
264
+ # Every GAN participant carries the full open incoming block. Mirroring the
265
+ # spike's BOTH-ends shape keeps requireSSL=false on the *initiator* too -
266
+ # the spike flagged that as the load-bearing setting for a plain link, so we
267
+ # don't shrink it to receiver-only.
272
268
  is_redundant = gw.redundancy is not None
273
269
  is_backup = is_redundant and gw.redundancy.mode == "backup"
270
+
271
+ # HOST/PORT/ENABLESSL trio per outgoing connection (all plain, SSL off).
272
+ gan_outgoing: list[dict[str, object]] = []
273
+ if is_backup:
274
+ gan_outgoing.append({"host": gw.redundancy.peer, "port": gw.redundancy.gan_port})
275
+ gan_outgoing.extend({"host": peer, "port": 8088} for peer in gw.gan_outgoing)
276
+
277
+ # A gateway opens the Unrestricted incoming policy when it takes part in the
278
+ # GAN at all: it is a redundancy node, it initiates a connection, or some
279
+ # other gateway opens one to it (hub/backend as a link target).
280
+ gan_targets = {peer for other in config.gateways for peer in other.gan_outgoing}
281
+ gan_incoming = is_redundant or bool(gan_outgoing) or gw.name in gan_targets
282
+
274
283
  return {
275
284
  "service_name": ctx["service_name"],
276
285
  "bootstrap_service_name": ctx["bootstrap_service_name"],
@@ -280,12 +289,16 @@ def _ignition_context(
280
289
  "memory_mb": gw.memory_mb,
281
290
  "edition_override": edition_override,
282
291
  "module_identifiers": ctx["module_identifiers"],
292
+ # disable_active drives template emission (not the value's truthiness) so
293
+ # that disabling EVERY built-in emits an empty whitelist - which quarantines
294
+ # all, matching intent - instead of omitting the var and re-enabling all.
295
+ "disable_active": bool(gw.disable_builtins),
296
+ "modules_enabled": _modules_enabled_for(gw, ctx["module_identifiers"]), # type: ignore[arg-type]
283
297
  "database_service": config.database.name if config.database else None,
284
298
  "networks": ctx["networks"],
285
- "redundant": is_redundant,
286
299
  "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,
300
+ "gan_incoming": gan_incoming,
301
+ "gan_outgoing": gan_outgoing,
289
302
  }
290
303
 
291
304
 
@@ -294,19 +307,13 @@ def _module_identifiers_for(gw: GatewayConfig, catalog: Catalog | None) -> str:
294
307
  if not gw.modules:
295
308
  return ""
296
309
  if catalog is None:
297
- raise ValueError(
298
- f"gateway '{gw.name}' lists modules {gw.modules} but no catalog "
299
- "was passed to render_compose; load modules.yaml first"
300
- )
310
+ raise ValueError(f"gateway '{gw.name}' lists modules {gw.modules} but no catalog " "was passed to render_compose; load modules.yaml first")
301
311
  identifiers: list[str] = []
302
312
  for slug in gw.modules:
303
313
  try:
304
314
  entry = catalog.by_name(slug)
305
315
  except KeyError as exc:
306
- raise ValueError(
307
- f"gateway '{gw.name}' references unknown module '{slug}'; "
308
- "check modules.yaml and the gateway config"
309
- ) from exc
316
+ raise ValueError(f"gateway '{gw.name}' references unknown module '{slug}'; " "check modules.yaml and the gateway config") from exc
310
317
  # Modules-only env vars: JDBC drivers shouldn't be enumerated here.
311
318
  if not _is_module(entry):
312
319
  continue
@@ -314,6 +321,29 @@ def _module_identifiers_for(gw: GatewayConfig, catalog: Catalog | None) -> str:
314
321
  return ",".join(identifiers)
315
322
 
316
323
 
324
+ def _modules_enabled_for(gw: GatewayConfig, module_identifiers: str) -> str:
325
+ """The GATEWAY_MODULES_ENABLED whitelist VALUE for this gateway.
326
+
327
+ GATEWAY_MODULES_ENABLED is a strict whitelist: if set, every built-in not
328
+ listed is quarantined at boot. The template emits the var based on
329
+ ``disable_active`` (whether any built-in was disabled), not on this value's
330
+ truthiness - so disabling every built-in yields an empty string here and an
331
+ empty whitelist downstream (quarantines all), rather than silently omitting
332
+ the var and re-enabling everything.
333
+
334
+ When something is disabled the whitelist must be complete: every built-in the
335
+ user did not disable, PLUS any third-party modules we added
336
+ (``module_identifiers``) - or those added modules would be quarantined too.
337
+ Returns '' when nothing is disabled (the var is omitted in that case).
338
+ """
339
+ if not gw.disable_builtins:
340
+ return ""
341
+ enabled = default_builtin_catalog().identifiers_excluding(gw.disable_builtins)
342
+ added = [ident for ident in module_identifiers.split(",") if ident]
343
+ enabled.extend(ident for ident in added if ident not in enabled)
344
+ return ",".join(enabled)
345
+
346
+
317
347
  def _is_module(entry: ModuleEntry | object) -> bool:
318
348
  return getattr(entry, "kind", None) == "module"
319
349
 
@@ -347,10 +377,7 @@ def _describe(config: ProjectConfig) -> str:
347
377
  """Human-readable header comment summarizing the stack."""
348
378
  n = len(config.gateways)
349
379
  if n == 1 and config.database and config.database.kind == "postgres" and not config.services:
350
- return (
351
- "Walking skeleton: one Ignition 8.3 gateway, one Postgres, "
352
- "env-driven commissioning so first boot needs no UI."
353
- )
380
+ return "Walking skeleton: one Ignition 8.3 gateway, one Postgres, " "env-driven commissioning so first boot needs no UI."
354
381
  parts = [f"{n} Ignition 8.3 gateway{'s' if n != 1 else ''}"]
355
382
  if config.database:
356
383
  parts.append(f"one {config.database.kind}")
@@ -22,18 +22,21 @@
22
22
  ACCEPT_MODULE_LICENSES: "{{ module_identifiers }}"
23
23
  ACCEPT_MODULE_CERTS: "{{ module_identifiers }}"
24
24
  {%- endif %}
25
- {%- if redundant %}
25
+ {%- if disable_active %}
26
+ GATEWAY_MODULES_ENABLED: "{{ modules_enabled }}"
27
+ {%- endif %}
28
+ {%- if gan_incoming %}
26
29
  GATEWAY_NETWORK_ENABLED: "true"
27
30
  GATEWAY_NETWORK_ALLOWINCOMING: "true"
28
31
  GATEWAY_NETWORK_SECURITYPOLICY: "Unrestricted"
29
32
  GATEWAY_NETWORK_REQUIRESSL: "false"
30
33
  GATEWAY_NETWORK_REQUIRETWOWAYAUTH: "false"
31
34
  {%- 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"
36
- {%- endif %}
35
+ {%- for conn in gan_outgoing %}
36
+ GATEWAY_NETWORK_{{ loop.index0 }}_HOST: "{{ conn.host }}"
37
+ GATEWAY_NETWORK_{{ loop.index0 }}_PORT: "{{ conn.port }}"
38
+ GATEWAY_NETWORK_{{ loop.index0 }}_ENABLESSL: "false"
39
+ {%- endfor %}
37
40
  command: >
38
41
  {%- if rename %}
39
42
  -n {{ gateway_name_ref }}