ignition-stack 0.3.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.3.0 → ignition_stack-0.4.0}/PKG-INFO +1 -1
  2. ignition_stack-0.4.0/ignition_stack/__init__.py +1 -0
  3. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/cli.py +26 -0
  4. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/compose/engine.py +34 -36
  5. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/compose/templates/services/ignition.yaml.j2 +6 -6
  6. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/config/schema.py +47 -28
  7. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/postsetup/generator.py +41 -21
  8. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/profiles/hub_and_spoke.py +3 -0
  9. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/profiles/scaleout.py +10 -6
  10. ignition_stack-0.4.0/ignition_stack/templates/post-setup/gateway-network-link.md.j2 +23 -0
  11. ignition_stack-0.4.0/ignition_stack/update_check.py +129 -0
  12. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/pyproject.toml +1 -1
  13. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/golden/profiles/hub-and-spoke/docker-compose.yaml +29 -0
  14. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/golden/profiles/scaleout/docker-compose.yaml +13 -0
  15. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/golden/profiles/scaleout-redundant/docker-compose.yaml +8 -0
  16. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/test_disable_builtins.py +0 -8
  17. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/test_init_standalone.py +1 -24
  18. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/test_profiles.py +52 -38
  19. ignition_stack-0.4.0/tests/test_update_check.py +108 -0
  20. ignition_stack-0.3.0/ignition_stack/__init__.py +0 -1
  21. ignition_stack-0.3.0/ignition_stack/templates/post-setup/gateway-network-link.md.j2 +0 -18
  22. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/.gitignore +0 -0
  23. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/LICENSE +0 -0
  24. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/README.md +0 -0
  25. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/builtin_modules.yaml +0 -0
  26. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/catalog/__init__.py +0 -0
  27. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/catalog/builtins.py +0 -0
  28. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/catalog/download.py +0 -0
  29. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/catalog/loader.py +0 -0
  30. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/catalog/schema.py +0 -0
  31. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/catalog/verify.py +0 -0
  32. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/commands/__init__.py +0 -0
  33. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/commands/modules.py +0 -0
  34. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/completion.py +0 -0
  35. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/compose/__init__.py +0 -0
  36. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/compose/templates/footer.yaml.j2 +0 -0
  37. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/compose/templates/header.yaml.j2 +0 -0
  38. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/compose/templates/services/bootstrap.yaml.j2 +0 -0
  39. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/compose/writer.py +0 -0
  40. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/config/__init__.py +0 -0
  41. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/config/io.py +0 -0
  42. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/lifecycle/__init__.py +0 -0
  43. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/lifecycle/cleanup.py +0 -0
  44. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/lifecycle/record.py +0 -0
  45. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/lifecycle/regenerate.py +0 -0
  46. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/postsetup/__init__.py +0 -0
  47. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/profiles/__init__.py +0 -0
  48. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/profiles/advisory.py +0 -0
  49. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/profiles/base.py +0 -0
  50. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/profiles/mcp_n8n.py +0 -0
  51. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/profiles/standalone.py +0 -0
  52. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/services/__init__.py +0 -0
  53. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/services/loader.py +0 -0
  54. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/services/manifest.py +0 -0
  55. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/services/resolver.py +0 -0
  56. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/__init__.py +0 -0
  57. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/post-setup/_default.md.j2 +0 -0
  58. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/post-setup/device-connection.md.j2 +0 -0
  59. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/post-setup/identity-provider.md.j2 +0 -0
  60. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/post-setup/kafka-connector.md.j2 +0 -0
  61. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/post-setup/mcp-module.md.j2 +0 -0
  62. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/post-setup/mqtt-engine-connection.md.j2 +0 -0
  63. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/post-setup/opc-ua-connection.md.j2 +0 -0
  64. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/post-setup/redundancy-pairing.md.j2 +0 -0
  65. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/post-setup/reverse-proxy.md.j2 +0 -0
  66. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/redundancy/redundancy.xml.j2 +0 -0
  67. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/chariot/compose.yaml.j2 +0 -0
  68. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/chariot/manifest.yaml +0 -0
  69. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/chariot/seed/service/USAGE.md +0 -0
  70. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/emqx/compose.yaml.j2 +0 -0
  71. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/emqx/manifest.yaml +0 -0
  72. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/emqx/seed/service/USAGE.md +0 -0
  73. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/hivemq/compose.yaml.j2 +0 -0
  74. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/hivemq/manifest.yaml +0 -0
  75. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/hivemq/seed/service/USAGE.md +0 -0
  76. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/kafka/compose.yaml.j2 +0 -0
  77. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/kafka/manifest.yaml +0 -0
  78. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/kafka/seed/service/USAGE.md +0 -0
  79. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/keycloak/compose.yaml.j2 +0 -0
  80. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/keycloak/manifest.yaml +0 -0
  81. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/keycloak/seed/service/import/ignition-realm.json +0 -0
  82. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/mariadb/compose.yaml.j2 +0 -0
  83. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/mariadb/manifest.yaml +0 -0
  84. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/mariadb/seed/service/initdb/00-create-extra-databases.sh +0 -0
  85. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/modbus-sim/compose.yaml.j2 +0 -0
  86. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/modbus-sim/manifest.yaml +0 -0
  87. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/modbus-sim/seed/service/USAGE.md +0 -0
  88. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/mongo/compose.yaml.j2 +0 -0
  89. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/mongo/manifest.yaml +0 -0
  90. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/mongo/seed/service/initdb/01-demo-collection.js +0 -0
  91. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/mysql/compose.yaml.j2 +0 -0
  92. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/mysql/manifest.yaml +0 -0
  93. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/mysql/seed/service/initdb/00-create-extra-databases.sh +0 -0
  94. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/n8n/compose.yaml.j2 +0 -0
  95. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/n8n/manifest.yaml +0 -0
  96. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/n8n/seed/service/USAGE.md +0 -0
  97. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/opcua-sim/compose.yaml.j2 +0 -0
  98. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/opcua-sim/manifest.yaml +0 -0
  99. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/opcua-sim/seed/service/USAGE.md +0 -0
  100. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/postgres/compose.yaml.j2 +0 -0
  101. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/postgres/manifest.yaml +0 -0
  102. {ignition_stack-0.3.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
  103. {ignition_stack-0.3.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
  104. {ignition_stack-0.3.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
  105. {ignition_stack-0.3.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
  106. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/postgres/seed/service/initdb/00-create-extra-databases.sh +0 -0
  107. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/rabbitmq/compose.yaml.j2 +0 -0
  108. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/rabbitmq/manifest.yaml +0 -0
  109. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/rabbitmq/seed/service/enabled_plugins +0 -0
  110. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/standalone-postgres/docker-compose.yaml +0 -0
  111. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/standalone-postgres/scripts/docker-bootstrap.sh +0 -0
  112. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/standalone-postgres/services/ignition/config/resources/core/config-mode.json +0 -0
  113. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/standalone-postgres/services/ignition/config/resources/dev/config-mode.json +0 -0
  114. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/standalone-postgres/services/ignition/projects/.gitkeep +0 -0
  115. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/wizard.py +0 -0
  116. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/modules.yaml +0 -0
  117. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/__init__.py +0 -0
  118. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/conftest.py +0 -0
  119. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/golden/combos/network-split/docker-compose.yaml +0 -0
  120. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/golden/combos/smoke-stack/docker-compose.yaml +0 -0
  121. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/golden/profiles/mcp-n8n/docker-compose.yaml +0 -0
  122. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/golden/profiles/standalone/docker-compose.yaml +0 -0
  123. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/golden/scaleout-skeleton/docker-compose.yaml +0 -0
  124. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/golden/services/chariot/docker-compose.yaml +0 -0
  125. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/golden/services/db-mariadb/docker-compose.yaml +0 -0
  126. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/golden/services/db-mongo/docker-compose.yaml +0 -0
  127. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/golden/services/db-mysql/docker-compose.yaml +0 -0
  128. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/golden/services/db-postgres/docker-compose.yaml +0 -0
  129. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/golden/services/emqx/docker-compose.yaml +0 -0
  130. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/golden/services/hivemq/docker-compose.yaml +0 -0
  131. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/golden/services/kafka/docker-compose.yaml +0 -0
  132. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/golden/services/keycloak/docker-compose.yaml +0 -0
  133. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/golden/services/modbus-sim/docker-compose.yaml +0 -0
  134. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/golden/services/n8n/docker-compose.yaml +0 -0
  135. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/golden/services/opcua-sim/docker-compose.yaml +0 -0
  136. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/golden/services/rabbitmq/docker-compose.yaml +0 -0
  137. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/golden/standalone-postgres/docker-compose.yaml +0 -0
  138. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/test_builtin_catalog_smoke.py +0 -0
  139. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/test_completion.py +0 -0
  140. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/test_compose_engine.py +0 -0
  141. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/test_declarative_io.py +0 -0
  142. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/test_docs_cli_reference.py +0 -0
  143. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/test_lifecycle.py +0 -0
  144. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/test_modules_catalog.py +0 -0
  145. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/test_modules_cli.py +0 -0
  146. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/test_postsetup.py +0 -0
  147. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/test_redundancy.py +0 -0
  148. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/test_service_catalog.py +0 -0
  149. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/test_service_catalog_smoke.py +0 -0
  150. {ignition_stack-0.3.0 → ignition_stack-0.4.0}/verification/redundancy-spike/README.md +0 -0
  151. {ignition_stack-0.3.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.3.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 @@
1
+ __version__ = "0.4.0"
@@ -56,6 +56,11 @@ from ignition_stack.profiles import (
56
56
  list_profiles,
57
57
  )
58
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
+ )
59
64
  from ignition_stack.wizard import run_wizard
60
65
 
61
66
  app = typer.Typer(
@@ -89,6 +94,27 @@ def _root(
89
94
  if ctx.invoked_subcommand is None:
90
95
  console.print(ctx.get_help())
91
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
+ )
92
118
 
93
119
 
94
120
  def _profile_help() -> str:
@@ -132,10 +132,7 @@ def _render_database(config: ProjectConfig) -> str:
132
132
  """
133
133
  db = config.database
134
134
  assert db is not None
135
- if config.is_multi_gateway:
136
- container_name_ref = f"{db.name}-${{COMPOSE_PROJECT_NAME}}"
137
- else:
138
- 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}}"
139
136
  tpl = _service_jinja_env().get_template(f"{db.kind}/compose.yaml.j2")
140
137
  return tpl.render(
141
138
  name=db.name,
@@ -187,9 +184,7 @@ def _service_dependencies(manifest: object, config: ProjectConfig) -> list[str]:
187
184
  return deps
188
185
 
189
186
 
190
- def _gateway_context(
191
- gw: GatewayConfig, config: ProjectConfig, catalog: Catalog | None
192
- ) -> dict[str, object]:
187
+ def _gateway_context(gw: GatewayConfig, config: ProjectConfig, catalog: Catalog | None) -> dict[str, object]:
193
188
  """Build the per-gateway context dict shared by the bootstrap + gateway fragments."""
194
189
  multi = config.is_multi_gateway
195
190
 
@@ -216,10 +211,7 @@ def _gateway_context(
216
211
  # (DB/broker access). Gateways with no role tag default to
217
212
  # frontend membership; explicit role=backend lands a gateway on
218
213
  # only the backend (rare; used for backend-only edge cases).
219
- if gw.role == "backend":
220
- networks = [NETWORK_BACKEND]
221
- else:
222
- networks = [NETWORK_FRONTEND, NETWORK_BACKEND]
214
+ networks = [NETWORK_BACKEND] if gw.role == "backend" else [NETWORK_FRONTEND, NETWORK_BACKEND]
223
215
 
224
216
  module_identifiers = _module_identifiers_for(gw, catalog)
225
217
  cached_modules = bool(gw.modules)
@@ -254,24 +246,40 @@ def _bootstrap_context(ctx: dict[str, object]) -> dict[str, object]:
254
246
  }
255
247
 
256
248
 
257
- def _ignition_context(
258
- ctx: dict[str, object], config: ProjectConfig, multi: bool
259
- ) -> dict[str, object]:
249
+ def _ignition_context(ctx: dict[str, object], config: ProjectConfig, multi: bool) -> dict[str, object]:
260
250
  gw: GatewayConfig = ctx["gw"] # type: ignore[assignment]
261
251
  # IGNITION_EDITION lives in the anchor as "standard", so only emit an
262
252
  # override when this gateway differs - keeps Phase 2's environment
263
253
  # block as the bare anchor reference.
264
254
  edition_override = gw.ignition_edition if gw.ignition_edition != "standard" else None
265
255
 
266
- # Redundancy wiring (Phase 4, per the verified Phase-3 spike):
267
- # - Any node in a pair opens its incoming Gateway Network policy
268
- # (Unrestricted + no-SSL) so the plain redundancy link auto-approves.
269
- # - The backup additionally points a generic outgoing GAN connection at
270
- # the master (HOST/PORT/ENABLESSL - all three, not just HOST, or it
271
- # defaults to SSL:8060 and faults) and must NOT be renamed via -n: it
272
- # 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.
273
268
  is_redundant = gw.redundancy is not None
274
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
+
275
283
  return {
276
284
  "service_name": ctx["service_name"],
277
285
  "bootstrap_service_name": ctx["bootstrap_service_name"],
@@ -288,10 +296,9 @@ def _ignition_context(
288
296
  "modules_enabled": _modules_enabled_for(gw, ctx["module_identifiers"]), # type: ignore[arg-type]
289
297
  "database_service": config.database.name if config.database else None,
290
298
  "networks": ctx["networks"],
291
- "redundant": is_redundant,
292
299
  "rename": not is_backup,
293
- "gan_peer_host": gw.redundancy.peer if is_backup else None,
294
- "gan_port": gw.redundancy.gan_port if is_backup else None,
300
+ "gan_incoming": gan_incoming,
301
+ "gan_outgoing": gan_outgoing,
295
302
  }
296
303
 
297
304
 
@@ -300,19 +307,13 @@ def _module_identifiers_for(gw: GatewayConfig, catalog: Catalog | None) -> str:
300
307
  if not gw.modules:
301
308
  return ""
302
309
  if catalog is None:
303
- raise ValueError(
304
- f"gateway '{gw.name}' lists modules {gw.modules} but no catalog "
305
- "was passed to render_compose; load modules.yaml first"
306
- )
310
+ raise ValueError(f"gateway '{gw.name}' lists modules {gw.modules} but no catalog " "was passed to render_compose; load modules.yaml first")
307
311
  identifiers: list[str] = []
308
312
  for slug in gw.modules:
309
313
  try:
310
314
  entry = catalog.by_name(slug)
311
315
  except KeyError as exc:
312
- raise ValueError(
313
- f"gateway '{gw.name}' references unknown module '{slug}'; "
314
- "check modules.yaml and the gateway config"
315
- ) from exc
316
+ raise ValueError(f"gateway '{gw.name}' references unknown module '{slug}'; " "check modules.yaml and the gateway config") from exc
316
317
  # Modules-only env vars: JDBC drivers shouldn't be enumerated here.
317
318
  if not _is_module(entry):
318
319
  continue
@@ -376,10 +377,7 @@ def _describe(config: ProjectConfig) -> str:
376
377
  """Human-readable header comment summarizing the stack."""
377
378
  n = len(config.gateways)
378
379
  if n == 1 and config.database and config.database.kind == "postgres" and not config.services:
379
- return (
380
- "Walking skeleton: one Ignition 8.3 gateway, one Postgres, "
381
- "env-driven commissioning so first boot needs no UI."
382
- )
380
+ return "Walking skeleton: one Ignition 8.3 gateway, one Postgres, " "env-driven commissioning so first boot needs no UI."
383
381
  parts = [f"{n} Ignition 8.3 gateway{'s' if n != 1 else ''}"]
384
382
  if config.database:
385
383
  parts.append(f"one {config.database.kind}")
@@ -25,18 +25,18 @@
25
25
  {%- if disable_active %}
26
26
  GATEWAY_MODULES_ENABLED: "{{ modules_enabled }}"
27
27
  {%- endif %}
28
- {%- if redundant %}
28
+ {%- if gan_incoming %}
29
29
  GATEWAY_NETWORK_ENABLED: "true"
30
30
  GATEWAY_NETWORK_ALLOWINCOMING: "true"
31
31
  GATEWAY_NETWORK_SECURITYPOLICY: "Unrestricted"
32
32
  GATEWAY_NETWORK_REQUIRESSL: "false"
33
33
  GATEWAY_NETWORK_REQUIRETWOWAYAUTH: "false"
34
34
  {%- endif %}
35
- {%- if gan_peer_host %}
36
- GATEWAY_NETWORK_0_HOST: "{{ gan_peer_host }}"
37
- GATEWAY_NETWORK_0_PORT: "{{ gan_port }}"
38
- GATEWAY_NETWORK_0_ENABLESSL: "false"
39
- {%- 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 %}
40
40
  command: >
41
41
  {%- if rename %}
42
42
  -n {{ gateway_name_ref }}
@@ -61,24 +61,15 @@ class RedundancyConfig(BaseModel):
61
61
 
62
62
  model_config = ConfigDict(extra="forbid")
63
63
 
64
- mode: Literal["master", "backup"] = Field(
65
- description="This node's redundancy role: 'master' or 'backup'."
66
- )
64
+ mode: Literal["master", "backup"] = Field(description="This node's redundancy role: 'master' or 'backup'.")
67
65
  peer: str = Field(
68
- description=(
69
- "Service name of the other node in the pair. The backup points at "
70
- "the master here (and over the Gateway Network); the master points "
71
- "at its backup."
72
- ),
66
+ description=("Service name of the other node in the pair. The backup points at " "the master here (and over the Gateway Network); the master points " "at its backup."),
73
67
  )
74
68
  gan_port: int = Field(
75
69
  default=8088,
76
70
  ge=1,
77
71
  le=65535,
78
- description=(
79
- "Gateway Network port the redundancy link rides. 8088 is plain "
80
- "(non-SSL) and auto-approves; 8060 is SSL and needs a cert approval."
81
- ),
72
+ description=("Gateway Network port the redundancy link rides. 8088 is plain " "(non-SSL) and auto-approves; 8060 is SSL and needs a cert approval."),
82
73
  )
83
74
  seed_redundancy_xml: bool = Field(
84
75
  default=True,
@@ -151,15 +142,26 @@ class GatewayConfig(BaseModel):
151
142
  "compose engine wires the backup's Gateway Network link to the master."
152
143
  ),
153
144
  )
145
+ gan_outgoing: list[str] = Field(
146
+ default_factory=list,
147
+ description=(
148
+ "Service names of peer gateways this one opens an outgoing Gateway "
149
+ "Network connection to. Multi-gateway profiles set this to auto-form "
150
+ "the GAN with no UI approval (scaleout: each frontend -> backend; "
151
+ "hub-and-spoke: each spoke -> hub). The compose engine renders one "
152
+ "GATEWAY_NETWORK_<i>_HOST/PORT/ENABLESSL trio per entry on the plain, "
153
+ "non-SSL port 8088, and opens an Unrestricted incoming policy on every "
154
+ "GAN participant so the plain link auto-approves - the same proven "
155
+ "pattern the redundancy link rides. Plain transport is a demo-only "
156
+ "default; cross-host deployments should switch to SSL + approved certs."
157
+ ),
158
+ )
154
159
 
155
160
  @field_validator("name")
156
161
  @classmethod
157
162
  def _validate_name(cls, v: str) -> str:
158
163
  if not _NAME_RE.match(v):
159
- raise ValueError(
160
- "gateway name must start with a lowercase letter and contain only "
161
- "lowercase letters, digits, hyphens, or underscores"
162
- )
164
+ raise ValueError("gateway name must start with a lowercase letter and contain only " "lowercase letters, digits, hyphens, or underscores")
163
165
  return v
164
166
 
165
167
  @field_validator("ignition_edition")
@@ -277,10 +279,7 @@ class ReverseProxyConfig(BaseModel):
277
279
  if not stripped:
278
280
  raise ValueError("reverse-proxy path must not be empty")
279
281
  if stripped.startswith("/") or "\\" in stripped:
280
- raise ValueError(
281
- "reverse-proxy path must be a relative POSIX path "
282
- "(no leading '/' and no backslashes)"
283
- )
282
+ raise ValueError("reverse-proxy path must be a relative POSIX path " "(no leading '/' and no backslashes)")
284
283
  # Normalize "./foo" -> "foo" so the writer can join cleanly.
285
284
  return stripped.removeprefix("./")
286
285
 
@@ -332,10 +331,7 @@ class ProjectConfig(BaseModel):
332
331
  )
333
332
  mcp_dropin: bool = Field(
334
333
  default=False,
335
- description=(
336
- "True when the project should scaffold modules/dropin/ for the "
337
- "EA-gated MCP module. Set by the mcp-n8n profile."
338
- ),
334
+ description=("True when the project should scaffold modules/dropin/ for the " "EA-gated MCP module. Set by the mcp-n8n profile."),
339
335
  )
340
336
  profile: str | None = Field(
341
337
  default=None,
@@ -375,10 +371,7 @@ class ProjectConfig(BaseModel):
375
371
  @classmethod
376
372
  def _validate_name(cls, v: str) -> str:
377
373
  if not _NAME_RE.match(v):
378
- raise ValueError(
379
- "name must start with a lowercase letter and contain only "
380
- "lowercase letters, digits, hyphens, or underscores"
381
- )
374
+ raise ValueError("name must start with a lowercase letter and contain only " "lowercase letters, digits, hyphens, or underscores")
382
375
  return v
383
376
 
384
377
  @model_validator(mode="after")
@@ -420,3 +413,29 @@ class ProjectConfig(BaseModel):
420
413
  "redundancy is Edge-to-Edge only, so both nodes must share an edition"
421
414
  )
422
415
  return self
416
+
417
+ @model_validator(mode="after")
418
+ def _gan_aggregation_target_is_standard(self) -> ProjectConfig:
419
+ """A Gateway Network aggregation link must terminate on a standard gateway.
420
+
421
+ Edge is a leaf edition: gateways aggregate *into* a full (standard)
422
+ gateway, never into an Edge one. So a ``gan_outgoing`` link may be
423
+ ``edge -> standard`` or ``standard -> standard``, but it may never target
424
+ an Edge node - this rejects both ``edge -> edge`` and ``standard -> edge``
425
+ (the latter is what ``scaleout --edge-role backend`` would produce).
426
+ Redundancy is unaffected: its Edge-to-Edge pair link rides
427
+ ``redundancy.peer``, not ``gan_outgoing``, and stays valid.
428
+ """
429
+ by_name = {gw.name: gw for gw in self.gateways}
430
+ for gw in self.gateways:
431
+ for peer in gw.gan_outgoing:
432
+ target = by_name.get(peer)
433
+ if target is None or target.ignition_edition != "edge":
434
+ continue
435
+ raise ValueError(
436
+ f"gateway '{gw.name}' opens a Gateway Network link to "
437
+ f"'{target.name}', which runs the Edge edition; aggregate into "
438
+ "a standard gateway instead (Edge is a leaf edition). "
439
+ "Edge-to-Edge is supported only for redundancy pairs."
440
+ )
441
+ return self
@@ -55,19 +55,15 @@ from files. Bring it up with `docker compose up -d` and the gateway is ready.
55
55
  # manifest. Kept here (not in a manifest) because they're a property of the
56
56
  # resolved topology, not of any one catalog entry.
57
57
  _GATEWAY_NETWORK_LINK_REASON = (
58
- "The Phase-1 matrix marks the gateway-network-link row partial: each "
59
- "gateway's UUID and the outbound peer-link path are file-seeded, but the "
60
- "per-link approval happens in the gateway UI, so the frontend<->backend "
61
- "link is finished by hand."
62
- )
63
- _MCP_MODULE_REASON = (
64
- "The Ignition MCP module is Early-Access and gated behind a survey, so the "
65
- "CLI cannot bundle it. Request the .modl, drop it in, and re-up the stack."
58
+ "This stack auto-forms its gateway-network links: each connecting gateway "
59
+ "opens a plain (non-SSL, port 8088) outgoing connection and every node runs "
60
+ "an Unrestricted incoming policy, so the links are accepted on sight with no "
61
+ "UI approval. This step is a verification, not a manual procedure - confirm "
62
+ "the links came up, and reach for the runbook only if one did not."
66
63
  )
64
+ _MCP_MODULE_REASON = "The Ignition MCP module is Early-Access and gated behind a survey, so the " "CLI cannot bundle it. Request the .modl, drop it in, and re-up the stack."
67
65
  _REVERSE_PROXY_REASON = (
68
- "The CLI never clones a proxy silently. The wizard scaffolded a README that "
69
- "walks through installing ia-eknorr/traefik-reverse-proxy in front of the "
70
- "stack."
66
+ "The CLI never clones a proxy silently. The wizard scaffolded a README that " "walks through installing ia-eknorr/traefik-reverse-proxy in front of the " "stack."
71
67
  )
72
68
  _REDUNDANCY_PAIRING_REASON = (
73
69
  "This stack seeds redundancy fully: a pre-seeded redundancy.xml sets each "
@@ -126,7 +122,7 @@ def _collect_steps(config: ProjectConfig) -> list[_Step]:
126
122
  for item in manifest.post_setup:
127
123
  steps.append(_Step(item.connection, item.reason, slug))
128
124
 
129
- if config.profile == "scaleout":
125
+ if any(gw.gan_outgoing for gw in config.gateways):
130
126
  steps.append(_Step("gateway-network-link", _GATEWAY_NETWORK_LINK_REASON, ""))
131
127
  if any(gw.redundancy is not None for gw in config.gateways):
132
128
  steps.append(_Step("redundancy-pairing", _REDUNDANCY_PAIRING_REASON, ""))
@@ -142,9 +138,10 @@ def _context(config: ProjectConfig, step: _Step) -> dict[str, object]:
142
138
  """Build the render context one snippet sees.
143
139
 
144
140
  ``env_vars`` is the (key, value) list the reader copies into the gateway
145
- screen: a service step exposes that service's preset ``.env`` keys; the
146
- gateway-network-link step exposes ``COMPOSE_PROJECT_NAME`` (the link target
147
- is named after the compose project); the rest copy nothing.
141
+ screen: a service step exposes that service's preset ``.env`` keys. The
142
+ gateway-network-link step copies nothing - the links auto-form from env, so
143
+ it carries ``gan_links`` (who connects to whom) for a verification readout
144
+ instead.
148
145
  """
149
146
  catalog = load_all_services()
150
147
  gateways = [
@@ -157,12 +154,7 @@ def _context(config: ProjectConfig, step: _Step) -> dict[str, object]:
157
154
  for gw in config.gateways
158
155
  ]
159
156
 
160
- if step.service:
161
- env_vars = sorted(catalog[step.service].env.items())
162
- elif step.connection == "gateway-network-link":
163
- env_vars = [("COMPOSE_PROJECT_NAME", config.name)]
164
- else:
165
- env_vars = []
157
+ env_vars = sorted(catalog[step.service].env.items()) if step.service else []
166
158
 
167
159
  return {
168
160
  "project_name": config.name,
@@ -172,6 +164,7 @@ def _context(config: ProjectConfig, step: _Step) -> dict[str, object]:
172
164
  "gateway_url": gateways[0]["url"],
173
165
  "gateways": gateways,
174
166
  "redundancy_pairs": _redundancy_pairs(config),
167
+ "gan_links": _gan_links(config),
175
168
  "env_vars": env_vars,
176
169
  "env_map": dict(env_vars),
177
170
  "proxy_path": config.reverse_proxy.path if config.reverse_proxy else "",
@@ -207,6 +200,33 @@ def _redundancy_pairs(config: ProjectConfig) -> list[dict[str, object]]:
207
200
  return pairs
208
201
 
209
202
 
203
+ def _gan_links(config: ProjectConfig) -> list[dict[str, object]]:
204
+ """Auto-formed Gateway Network links, for the gateway-network-link step.
205
+
206
+ One entry per outgoing connection a gateway declares in ``gan_outgoing``
207
+ (scaleout frontend -> backend, hub-and-spoke spoke -> hub): it names the
208
+ source and target, their UIs, and the plain port the link rides so the
209
+ verification readout can point the reader at each end.
210
+ """
211
+ by_name = {gw.name: gw for gw in config.gateways}
212
+ links: list[dict[str, object]] = []
213
+ for gw in config.gateways:
214
+ for peer in gw.gan_outgoing:
215
+ target = by_name.get(peer)
216
+ links.append(
217
+ {
218
+ "source": gw.name,
219
+ "source_role": gw.role or gw.name,
220
+ "source_url": f"http://localhost:{gw.http_port}",
221
+ "target": peer,
222
+ "target_role": (target.role or target.name) if target else peer,
223
+ "target_url": f"http://localhost:{target.http_port}" if target else "",
224
+ "port": 8088,
225
+ }
226
+ )
227
+ return links
228
+
229
+
210
230
  def _render_step(env: Environment, ctx: dict[str, object]) -> str:
211
231
  connection = ctx["connection"]
212
232
  try:
@@ -65,6 +65,9 @@ class HubAndSpokeProfile:
65
65
  role="spoke",
66
66
  ignition_edition="edge" if spokes_run_edge else "standard",
67
67
  http_port=9088 + i,
68
+ # Each spoke opens a plain Gateway Network link to the hub so
69
+ # the GAN auto-forms with no UI approval; the hub aggregates.
70
+ gan_outgoing=["hub"],
68
71
  )
69
72
  )
70
73
 
@@ -10,12 +10,13 @@ joins the frontend AND backend networks so a frontend can reach the DB the
10
10
  backend owns. The network split is on by default - that's the whole point
11
11
  of the scaleout demo - but ``options.network_split`` can override it.
12
12
 
13
- The plan's validation calls for "two networked gateways (frontend + backend)
14
- + a DB; the gateway-network link config is present per the Phase-1 matrix";
15
- the gateway-network link itself is a follow-up resource set the seeding
16
- matrix marks ``file-seedable-config: yes``, so it travels with the
17
- ``gateway-resources/`` overlay once that catalog grows. Today the
18
- ``services`` list is empty by default; users add brokers/IDPs on top.
13
+ The gateway-network link auto-forms with no UI approval: each frontend gets
14
+ ``gan_outgoing=["backend"]``, which the compose engine renders as a plain
15
+ (non-SSL, port 8088) outgoing Gateway Network connection, and every participant
16
+ runs an ``Unrestricted`` incoming policy so the link is accepted on sight - the
17
+ same proven pattern the redundancy link rides. POST-SETUP carries a *verify*
18
+ step (not a manual procedure). Today the ``services`` list is empty by default;
19
+ users add brokers/IDPs on top.
19
20
  """
20
21
 
21
22
  from __future__ import annotations
@@ -47,6 +48,9 @@ class ScaleoutProfile:
47
48
  role="frontend",
48
49
  ignition_edition="edge" if edge_role == "frontend" else "standard",
49
50
  http_port=9088 + (i - 1),
51
+ # Each frontend opens a plain Gateway Network link to the
52
+ # backend so the GAN auto-forms with no UI approval.
53
+ gan_outgoing=["backend"],
50
54
  )
51
55
  )
52
56
  gateways.append(
@@ -0,0 +1,23 @@
1
+ ## Verify the gateway-network link
2
+
3
+ {{ reason }}
4
+ {% for link in gan_links %}
5
+ - **{{ link.source_role }}** (`{{ link.source }}`): {{ link.source_url }}
6
+ connects to **{{ link.target_role }}** (`{{ link.target }}`) over the Gateway Network on **port {{ link.port }}** (plain, no SSL).
7
+ {%- endfor %}
8
+
9
+ Within about a minute of the gateways reaching RUNNING, open any gateway above
10
+ and go to Config -> Networking -> Gateway Network. Each outgoing connection shows
11
+ **Running**, and the receiving gateway lists the peer as **Approved**
12
+ automatically - no UI approval is needed, because every node runs an
13
+ `Unrestricted` policy with SSL disabled and the link rides plain port 8088.
14
+
15
+ If a link has not formed, confirm the containers are up and re-check after the
16
+ gateways finish starting. The plain-vs-SSL trade-off and the manual port-8060
17
+ certificate-approval steps are documented in `docs/docs/guides/redundancy.md`
18
+ (same Gateway Network mechanics).
19
+
20
+ > **Security:** this stack uses **plain, non-SSL** gateway-network links with an
21
+ > `Unrestricted` policy. That is safe only on an isolated demo network. For any
22
+ > cross-host or production deployment, switch to SSL on port 8060 with approved
23
+ > certificates.
@@ -0,0 +1,129 @@
1
+ """Passive "a newer release is available" notifier.
2
+
3
+ Mirrors the pattern used by npm/update-notifier, gh, and pip's own notice: on a
4
+ real command invocation, check PyPI at most once a day (cached), fail silently
5
+ on any error, and never delay or block the command the user actually ran. The
6
+ notice is advisory only - this module never installs anything. Presentation
7
+ (TTY gating, printing) lives in the CLI; this module is the pure decision layer
8
+ so it stays testable without a console.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import sys
16
+ import time
17
+ from pathlib import Path
18
+
19
+ import httpx
20
+
21
+ from ignition_stack import __version__
22
+
23
+ _PYPI_URL = "https://pypi.org/pypi/ignition-stack/json"
24
+ _CHECK_INTERVAL = 24 * 60 * 60 # seconds between live PyPI checks
25
+ _HTTP_TIMEOUT = 1.5 # short on purpose: the check must never stall a command
26
+ _OPT_OUT_ENV = "IGNITION_STACK_NO_UPDATE_CHECK"
27
+
28
+
29
+ def _cache_path() -> Path:
30
+ base = os.environ.get("XDG_CACHE_HOME")
31
+ root = Path(base) if base else Path.home() / ".cache"
32
+ return root / "ignition-stack" / "update-check.json"
33
+
34
+
35
+ def _is_newer(latest: str, current: str) -> bool:
36
+ """True when ``latest`` is a strictly higher release than ``current``.
37
+
38
+ Both sides are this project's own clean ``X.Y.Z`` releases, so an int-tuple
39
+ compare on the dotted parts is exact. Anything that does not parse cleanly
40
+ returns False, so an unexpected version string can never raise a bogus
41
+ "update available" notice.
42
+ """
43
+ try:
44
+ latest_parts = tuple(int(p) for p in latest.split("."))
45
+ current_parts = tuple(int(p) for p in current.split("."))
46
+ except ValueError:
47
+ return False
48
+ return latest_parts > current_parts
49
+
50
+
51
+ def _read_cache(path: Path) -> dict | None:
52
+ try:
53
+ return json.loads(path.read_text())
54
+ except (OSError, ValueError):
55
+ return None
56
+
57
+
58
+ def _write_cache(path: Path, latest: str, now: float) -> None:
59
+ try:
60
+ path.parent.mkdir(parents=True, exist_ok=True)
61
+ path.write_text(json.dumps({"checked_at": now, "latest": latest}))
62
+ except OSError:
63
+ pass # caching is best-effort; never fail the CLI over it
64
+
65
+
66
+ def _fetch_latest() -> str | None:
67
+ try:
68
+ resp = httpx.get(_PYPI_URL, timeout=_HTTP_TIMEOUT)
69
+ resp.raise_for_status()
70
+ return resp.json()["info"]["version"]
71
+ except Exception:
72
+ return None # offline, slow, rate-limited, malformed - all non-fatal
73
+
74
+
75
+ def _latest_version(now: float) -> str | None:
76
+ """Latest version from cache when fresh, otherwise refresh from PyPI.
77
+
78
+ The freshness window bounds live network calls to once per ``_CHECK_INTERVAL``,
79
+ so the common path is a local file read with no request at all.
80
+ """
81
+ path = _cache_path()
82
+ cached = _read_cache(path)
83
+ if cached and now - cached.get("checked_at", 0) < _CHECK_INTERVAL:
84
+ return cached.get("latest")
85
+ latest = _fetch_latest()
86
+ if latest is not None:
87
+ _write_cache(path, latest, now)
88
+ return latest
89
+
90
+
91
+ def detect_upgrade_command() -> str:
92
+ """Return the exact command the user should run to upgrade.
93
+
94
+ ``sys.prefix`` is the environment the running CLI lives in; both managed
95
+ installers leave an unambiguous marker in that path. Match only those two
96
+ and fall back to plain pip for everything else (a venv, ``--user`` site, a
97
+ system Python) - suggesting ``pipx upgrade`` to someone on plain pip just
98
+ errors for them, so when there is no clear marker the generic command is
99
+ the safe choice rather than a guess.
100
+ """
101
+ if f"pipx{os.sep}venvs" in sys.prefix:
102
+ return "pipx upgrade ignition-stack"
103
+ if f"uv{os.sep}tools" in sys.prefix:
104
+ return "uv tool upgrade ignition-stack"
105
+ return "pip install --upgrade ignition-stack"
106
+
107
+
108
+ def check_for_update(*, now: float | None = None) -> tuple[str, str] | None:
109
+ """Return ``(current, latest)`` when a newer release exists, else ``None``.
110
+
111
+ Applies the opt-out gate and the once-a-day cache policy, then compares.
112
+ Presentation (and the TTY gate) is the caller's job.
113
+ """
114
+ if os.environ.get(_OPT_OUT_ENV):
115
+ return None
116
+ now = time.time() if now is None else now
117
+ latest = _latest_version(now)
118
+ if latest and _is_newer(latest, __version__):
119
+ return (__version__, latest)
120
+ return None
121
+
122
+
123
+ def should_notify() -> bool:
124
+ """Whether a notice may be shown at all, independent of version state.
125
+
126
+ Suppressed when stdout is not a TTY (CI, pipes, shell completion), so the
127
+ notice never contaminates scripted output or non-interactive runs.
128
+ """
129
+ return sys.stdout.isatty()
@@ -60,7 +60,7 @@ packages = ["ignition_stack"]
60
60
  include = ["ignition_stack", "tests", "README.md", "pyproject.toml", "modules.yaml", "builtin_modules.yaml"]
61
61
 
62
62
  [tool.ruff]
63
- line-length = 100
63
+ line-length = 180
64
64
  target-version = "py311"
65
65
 
66
66
  [tool.ruff.lint]