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.
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/PKG-INFO +1 -1
- ignition_stack-0.4.0/ignition_stack/__init__.py +1 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/cli.py +26 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/compose/engine.py +34 -36
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/compose/templates/services/ignition.yaml.j2 +6 -6
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/config/schema.py +47 -28
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/postsetup/generator.py +41 -21
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/profiles/hub_and_spoke.py +3 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/profiles/scaleout.py +10 -6
- ignition_stack-0.4.0/ignition_stack/templates/post-setup/gateway-network-link.md.j2 +23 -0
- ignition_stack-0.4.0/ignition_stack/update_check.py +129 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/pyproject.toml +1 -1
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/golden/profiles/hub-and-spoke/docker-compose.yaml +29 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/golden/profiles/scaleout/docker-compose.yaml +13 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/golden/profiles/scaleout-redundant/docker-compose.yaml +8 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/test_disable_builtins.py +0 -8
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/test_init_standalone.py +1 -24
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/test_profiles.py +52 -38
- ignition_stack-0.4.0/tests/test_update_check.py +108 -0
- ignition_stack-0.3.0/ignition_stack/__init__.py +0 -1
- ignition_stack-0.3.0/ignition_stack/templates/post-setup/gateway-network-link.md.j2 +0 -18
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/.gitignore +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/LICENSE +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/README.md +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/builtin_modules.yaml +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/catalog/__init__.py +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/catalog/builtins.py +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/catalog/download.py +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/catalog/loader.py +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/catalog/schema.py +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/catalog/verify.py +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/commands/__init__.py +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/commands/modules.py +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/completion.py +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/compose/__init__.py +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/compose/templates/footer.yaml.j2 +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/compose/templates/header.yaml.j2 +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/compose/templates/services/bootstrap.yaml.j2 +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/compose/writer.py +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/config/__init__.py +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/config/io.py +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/lifecycle/__init__.py +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/lifecycle/cleanup.py +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/lifecycle/record.py +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/lifecycle/regenerate.py +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/postsetup/__init__.py +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/profiles/__init__.py +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/profiles/advisory.py +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/profiles/base.py +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/profiles/mcp_n8n.py +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/profiles/standalone.py +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/services/__init__.py +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/services/loader.py +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/services/manifest.py +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/services/resolver.py +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/__init__.py +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/post-setup/_default.md.j2 +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/post-setup/device-connection.md.j2 +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/post-setup/identity-provider.md.j2 +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/post-setup/kafka-connector.md.j2 +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/post-setup/mcp-module.md.j2 +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/post-setup/mqtt-engine-connection.md.j2 +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/post-setup/opc-ua-connection.md.j2 +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/post-setup/redundancy-pairing.md.j2 +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/post-setup/reverse-proxy.md.j2 +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/redundancy/redundancy.xml.j2 +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/chariot/compose.yaml.j2 +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/chariot/manifest.yaml +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/chariot/seed/service/USAGE.md +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/emqx/compose.yaml.j2 +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/emqx/manifest.yaml +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/emqx/seed/service/USAGE.md +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/hivemq/compose.yaml.j2 +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/hivemq/manifest.yaml +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/hivemq/seed/service/USAGE.md +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/kafka/compose.yaml.j2 +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/kafka/manifest.yaml +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/kafka/seed/service/USAGE.md +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/keycloak/compose.yaml.j2 +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/keycloak/manifest.yaml +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/keycloak/seed/service/import/ignition-realm.json +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/mariadb/compose.yaml.j2 +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/mariadb/manifest.yaml +0 -0
- {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
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/modbus-sim/compose.yaml.j2 +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/modbus-sim/manifest.yaml +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/modbus-sim/seed/service/USAGE.md +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/mongo/compose.yaml.j2 +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/mongo/manifest.yaml +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/mongo/seed/service/initdb/01-demo-collection.js +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/mysql/compose.yaml.j2 +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/mysql/manifest.yaml +0 -0
- {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
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/n8n/compose.yaml.j2 +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/n8n/manifest.yaml +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/n8n/seed/service/USAGE.md +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/opcua-sim/compose.yaml.j2 +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/opcua-sim/manifest.yaml +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/opcua-sim/seed/service/USAGE.md +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/postgres/compose.yaml.j2 +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/postgres/manifest.yaml +0 -0
- {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
- {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
- {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
- {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
- {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
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/rabbitmq/compose.yaml.j2 +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/rabbitmq/manifest.yaml +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/services/rabbitmq/seed/service/enabled_plugins +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/standalone-postgres/docker-compose.yaml +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/standalone-postgres/scripts/docker-bootstrap.sh +0 -0
- {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
- {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
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/templates/standalone-postgres/services/ignition/projects/.gitkeep +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/ignition_stack/wizard.py +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/modules.yaml +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/__init__.py +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/conftest.py +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/golden/combos/network-split/docker-compose.yaml +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/golden/combos/smoke-stack/docker-compose.yaml +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/golden/profiles/mcp-n8n/docker-compose.yaml +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/golden/profiles/standalone/docker-compose.yaml +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/golden/scaleout-skeleton/docker-compose.yaml +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/golden/services/chariot/docker-compose.yaml +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/golden/services/db-mariadb/docker-compose.yaml +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/golden/services/db-mongo/docker-compose.yaml +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/golden/services/db-mysql/docker-compose.yaml +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/golden/services/db-postgres/docker-compose.yaml +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/golden/services/emqx/docker-compose.yaml +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/golden/services/hivemq/docker-compose.yaml +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/golden/services/kafka/docker-compose.yaml +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/golden/services/keycloak/docker-compose.yaml +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/golden/services/modbus-sim/docker-compose.yaml +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/golden/services/n8n/docker-compose.yaml +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/golden/services/opcua-sim/docker-compose.yaml +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/golden/services/rabbitmq/docker-compose.yaml +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/golden/standalone-postgres/docker-compose.yaml +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/test_builtin_catalog_smoke.py +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/test_completion.py +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/test_compose_engine.py +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/test_declarative_io.py +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/test_docs_cli_reference.py +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/test_lifecycle.py +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/test_modules_catalog.py +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/test_modules_cli.py +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/test_postsetup.py +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/test_redundancy.py +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/test_service_catalog.py +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/tests/test_service_catalog_smoke.py +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/verification/redundancy-spike/README.md +0 -0
- {ignition_stack-0.3.0 → ignition_stack-0.4.0}/verification/smoke/README.md +0 -0
|
@@ -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
|
-
#
|
|
267
|
-
#
|
|
268
|
-
#
|
|
269
|
-
#
|
|
270
|
-
#
|
|
271
|
-
#
|
|
272
|
-
#
|
|
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
|
-
"
|
|
294
|
-
"
|
|
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
|
|
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
|
-
{%-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
{%-
|
|
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
|
-
"
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
"
|
|
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
|
|
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
|
|
146
|
-
gateway-network-link step
|
|
147
|
-
|
|
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
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
``services`` list is empty by default;
|
|
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()
|