ignition-stack 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ignition_stack/__init__.py +1 -0
- ignition_stack/catalog/__init__.py +10 -0
- ignition_stack/catalog/download.py +145 -0
- ignition_stack/catalog/loader.py +65 -0
- ignition_stack/catalog/schema.py +158 -0
- ignition_stack/catalog/verify.py +72 -0
- ignition_stack/cli.py +354 -0
- ignition_stack/commands/__init__.py +0 -0
- ignition_stack/commands/modules.py +178 -0
- ignition_stack/completion.py +46 -0
- ignition_stack/compose/__init__.py +4 -0
- ignition_stack/compose/engine.py +397 -0
- ignition_stack/compose/templates/footer.yaml.j2 +12 -0
- ignition_stack/compose/templates/header.yaml.j2 +14 -0
- ignition_stack/compose/templates/services/bootstrap.yaml.j2 +19 -0
- ignition_stack/compose/templates/services/ignition.yaml.j2 +35 -0
- ignition_stack/compose/writer.py +428 -0
- ignition_stack/config/__init__.py +8 -0
- ignition_stack/config/schema.py +311 -0
- ignition_stack/lifecycle/__init__.py +31 -0
- ignition_stack/lifecycle/cleanup.py +71 -0
- ignition_stack/lifecycle/record.py +67 -0
- ignition_stack/lifecycle/regenerate.py +62 -0
- ignition_stack/modules.yaml +83 -0
- ignition_stack/postsetup/__init__.py +3 -0
- ignition_stack/postsetup/generator.py +187 -0
- ignition_stack/profiles/__init__.py +27 -0
- ignition_stack/profiles/advisory.py +132 -0
- ignition_stack/profiles/base.py +108 -0
- ignition_stack/profiles/hub_and_spoke.py +87 -0
- ignition_stack/profiles/mcp_n8n.py +55 -0
- ignition_stack/profiles/scaleout.py +65 -0
- ignition_stack/profiles/standalone.py +44 -0
- ignition_stack/services/__init__.py +25 -0
- ignition_stack/services/loader.py +69 -0
- ignition_stack/services/manifest.py +106 -0
- ignition_stack/services/resolver.py +133 -0
- ignition_stack/templates/__init__.py +0 -0
- ignition_stack/templates/post-setup/_default.md.j2 +12 -0
- ignition_stack/templates/post-setup/device-connection.md.j2 +11 -0
- ignition_stack/templates/post-setup/gateway-network-link.md.j2 +18 -0
- ignition_stack/templates/post-setup/identity-provider.md.j2 +13 -0
- ignition_stack/templates/post-setup/kafka-connector.md.j2 +11 -0
- ignition_stack/templates/post-setup/mcp-module.md.j2 +11 -0
- ignition_stack/templates/post-setup/mqtt-engine-connection.md.j2 +11 -0
- ignition_stack/templates/post-setup/opc-ua-connection.md.j2 +11 -0
- ignition_stack/templates/post-setup/reverse-proxy.md.j2 +8 -0
- ignition_stack/templates/services/chariot/compose.yaml.j2 +17 -0
- ignition_stack/templates/services/chariot/manifest.yaml +22 -0
- ignition_stack/templates/services/chariot/seed/service/USAGE.md +14 -0
- ignition_stack/templates/services/emqx/compose.yaml.j2 +16 -0
- ignition_stack/templates/services/emqx/manifest.yaml +21 -0
- ignition_stack/templates/services/emqx/seed/service/USAGE.md +11 -0
- ignition_stack/templates/services/hivemq/compose.yaml.j2 +12 -0
- ignition_stack/templates/services/hivemq/manifest.yaml +19 -0
- ignition_stack/templates/services/hivemq/seed/service/USAGE.md +16 -0
- ignition_stack/templates/services/kafka/compose.yaml.j2 +27 -0
- ignition_stack/templates/services/kafka/manifest.yaml +20 -0
- ignition_stack/templates/services/kafka/seed/service/USAGE.md +17 -0
- ignition_stack/templates/services/keycloak/compose.yaml.j2 +31 -0
- ignition_stack/templates/services/keycloak/manifest.yaml +25 -0
- ignition_stack/templates/services/keycloak/seed/service/import/ignition-realm.json +31 -0
- ignition_stack/templates/services/mariadb/compose.yaml.j2 +26 -0
- ignition_stack/templates/services/mariadb/manifest.yaml +15 -0
- ignition_stack/templates/services/mariadb/seed/service/initdb/00-create-extra-databases.sh +19 -0
- ignition_stack/templates/services/modbus-sim/compose.yaml.j2 +12 -0
- ignition_stack/templates/services/modbus-sim/manifest.yaml +19 -0
- ignition_stack/templates/services/modbus-sim/seed/service/USAGE.md +10 -0
- ignition_stack/templates/services/mongo/compose.yaml.j2 +21 -0
- ignition_stack/templates/services/mongo/manifest.yaml +14 -0
- ignition_stack/templates/services/mongo/seed/service/initdb/01-demo-collection.js +10 -0
- ignition_stack/templates/services/mysql/compose.yaml.j2 +26 -0
- ignition_stack/templates/services/mysql/manifest.yaml +15 -0
- ignition_stack/templates/services/mysql/seed/service/initdb/00-create-extra-databases.sh +19 -0
- ignition_stack/templates/services/n8n/compose.yaml.j2 +16 -0
- ignition_stack/templates/services/n8n/manifest.yaml +16 -0
- ignition_stack/templates/services/n8n/seed/service/USAGE.md +11 -0
- ignition_stack/templates/services/opcua-sim/compose.yaml.j2 +13 -0
- ignition_stack/templates/services/opcua-sim/manifest.yaml +21 -0
- ignition_stack/templates/services/opcua-sim/seed/service/USAGE.md +11 -0
- ignition_stack/templates/services/postgres/compose.yaml.j2 +26 -0
- ignition_stack/templates/services/postgres/manifest.yaml +21 -0
- ignition_stack/templates/services/postgres/seed/gateway-resources/config/resources/core/ignition/database-connection/db/config.json +32 -0
- ignition_stack/templates/services/postgres/seed/gateway-resources/config/resources/core/ignition/database-connection/db/resource.json +19 -0
- ignition_stack/templates/services/postgres/seed/gateway-resources/config/resources/core/ignition/secret-provider/internal-secret-provider/config.json +19 -0
- ignition_stack/templates/services/postgres/seed/gateway-resources/config/resources/core/ignition/secret-provider/internal-secret-provider/resource.json +19 -0
- ignition_stack/templates/services/postgres/seed/service/initdb/00-create-extra-databases.sh +17 -0
- ignition_stack/templates/services/rabbitmq/compose.yaml.j2 +19 -0
- ignition_stack/templates/services/rabbitmq/manifest.yaml +23 -0
- ignition_stack/templates/services/rabbitmq/seed/service/enabled_plugins +1 -0
- ignition_stack/templates/standalone-postgres/docker-compose.yaml +62 -0
- ignition_stack/templates/standalone-postgres/scripts/docker-bootstrap.sh +78 -0
- ignition_stack/templates/standalone-postgres/services/ignition/config/resources/core/config-mode.json +7 -0
- ignition_stack/templates/standalone-postgres/services/ignition/config/resources/dev/config-mode.json +7 -0
- ignition_stack/templates/standalone-postgres/services/ignition/projects/.gitkeep +0 -0
- ignition_stack/wizard.py +362 -0
- ignition_stack-0.1.0.dist-info/METADATA +97 -0
- ignition_stack-0.1.0.dist-info/RECORD +100 -0
- ignition_stack-0.1.0.dist-info/WHEEL +4 -0
- ignition_stack-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Matrix-driven ``POST-SETUP.md`` generator.
|
|
2
|
+
|
|
3
|
+
``ignition-stack`` pre-seeds every connection the Phase-1 seedability matrix
|
|
4
|
+
marks file-seedable (db-connection, the internal-secret-provider that holds
|
|
5
|
+
its password, OPC-UA endpoints, ...). The connections it *cannot* fully seed -
|
|
6
|
+
a secret generated at runtime, a module that isn't publicly downloadable, a
|
|
7
|
+
gateway-network handshake approved in the UI - are deferred to ``POST-SETUP.md``
|
|
8
|
+
and finished by hand once the stack is up.
|
|
9
|
+
|
|
10
|
+
This module turns the deferred set into that document. It is purely a function
|
|
11
|
+
of the resolved :class:`~ignition_stack.config.schema.ProjectConfig`:
|
|
12
|
+
|
|
13
|
+
1. Each catalog service's manifest declares its deferred connections in
|
|
14
|
+
``post_setup`` (a list of ``connection``/``reason`` pairs). The database and
|
|
15
|
+
every selected service contribute theirs.
|
|
16
|
+
2. Three profile-level flags add steps the matrix flags as not-fully-seedable:
|
|
17
|
+
``profile == "scaleout"`` (the gateway-network link is UI-approved),
|
|
18
|
+
``mcp_dropin`` (the EA-gated MCP module), and a set ``reverse_proxy`` (the
|
|
19
|
+
Traefik scaffold).
|
|
20
|
+
|
|
21
|
+
Each step renders through a per-connection Jinja2 snippet at
|
|
22
|
+
``templates/post-setup/<connection>.md.j2`` (falling back to ``_default.md.j2``)
|
|
23
|
+
so adding a new fallback connection is a manifest entry + a snippet, with no
|
|
24
|
+
change here. Every snippet gives the reader the three things the validation
|
|
25
|
+
contract requires: the **deep-link URL** to open, the **in-UI screen path** to
|
|
26
|
+
navigate to, and the exact **``.env`` variable name** to copy.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
|
|
33
|
+
from jinja2 import Environment, PackageLoader, StrictUndefined, TemplateNotFound
|
|
34
|
+
|
|
35
|
+
from ignition_stack.config.schema import ProjectConfig
|
|
36
|
+
from ignition_stack.services.loader import load_all_services
|
|
37
|
+
|
|
38
|
+
_HEADER = """\
|
|
39
|
+
# Post-setup steps
|
|
40
|
+
|
|
41
|
+
`ignition-stack` pre-seeds everything the Phase-1 seedability matrix marks
|
|
42
|
+
file-seedable. The connections below carry a secret or a handshake that
|
|
43
|
+
cannot travel in a file, so finish them by hand after `docker compose up -d`.
|
|
44
|
+
Each step names the screen to open and the `.env` value to copy into it.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
_NO_MANUAL_STEPS = """\
|
|
48
|
+
# Post-setup steps
|
|
49
|
+
|
|
50
|
+
**No manual steps required.** Every connection in this stack is pre-seeded
|
|
51
|
+
from files. Bring it up with `docker compose up -d` and the gateway is ready.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
# Reasons for the profile-level steps that aren't tied to a single service
|
|
55
|
+
# manifest. Kept here (not in a manifest) because they're a property of the
|
|
56
|
+
# resolved topology, not of any one catalog entry.
|
|
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."
|
|
66
|
+
)
|
|
67
|
+
_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."
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class _Step:
|
|
76
|
+
"""One manual follow-up: a deferred connection plus why it's deferred.
|
|
77
|
+
|
|
78
|
+
``service`` is the catalog slug the step came from (so the renderer can pull
|
|
79
|
+
that service's ``.env`` keys), or ``""`` for the profile-level steps that
|
|
80
|
+
aren't owned by a single service.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
connection: str
|
|
84
|
+
reason: str
|
|
85
|
+
service: str
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def generate_post_setup(config: ProjectConfig) -> str:
|
|
89
|
+
"""Render the body of ``POST-SETUP.md`` for a resolved project config.
|
|
90
|
+
|
|
91
|
+
Always returns a document: a "no manual steps required" note when the stack
|
|
92
|
+
is fully seedable, or a header plus one section per deferred connection.
|
|
93
|
+
"""
|
|
94
|
+
steps = _collect_steps(config)
|
|
95
|
+
if not steps:
|
|
96
|
+
return _NO_MANUAL_STEPS
|
|
97
|
+
|
|
98
|
+
env = _jinja_env()
|
|
99
|
+
sections = [_render_step(env, _context(config, step)) for step in steps]
|
|
100
|
+
return _HEADER + "\n" + "\n\n".join(sections) + "\n"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _collect_steps(config: ProjectConfig) -> list[_Step]:
|
|
104
|
+
"""Gather every deferred connection, service steps first then profile steps."""
|
|
105
|
+
catalog = load_all_services()
|
|
106
|
+
steps: list[_Step] = []
|
|
107
|
+
|
|
108
|
+
# Database slug == its kind (postgres/mysql/mariadb/mongo); the catalog is
|
|
109
|
+
# keyed the same way, so look both up by slug.
|
|
110
|
+
slugs: list[str] = []
|
|
111
|
+
if config.database is not None:
|
|
112
|
+
slugs.append(config.database.kind)
|
|
113
|
+
slugs.extend(config.services)
|
|
114
|
+
|
|
115
|
+
for slug in slugs:
|
|
116
|
+
manifest = catalog.get(slug)
|
|
117
|
+
if manifest is None:
|
|
118
|
+
continue
|
|
119
|
+
for item in manifest.post_setup:
|
|
120
|
+
steps.append(_Step(item.connection, item.reason, slug))
|
|
121
|
+
|
|
122
|
+
if config.profile == "scaleout":
|
|
123
|
+
steps.append(_Step("gateway-network-link", _GATEWAY_NETWORK_LINK_REASON, ""))
|
|
124
|
+
if config.mcp_dropin:
|
|
125
|
+
steps.append(_Step("mcp-module", _MCP_MODULE_REASON, ""))
|
|
126
|
+
if config.reverse_proxy is not None:
|
|
127
|
+
steps.append(_Step("reverse-proxy", _REVERSE_PROXY_REASON, ""))
|
|
128
|
+
|
|
129
|
+
return steps
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _context(config: ProjectConfig, step: _Step) -> dict[str, object]:
|
|
133
|
+
"""Build the render context one snippet sees.
|
|
134
|
+
|
|
135
|
+
``env_vars`` is the (key, value) list the reader copies into the gateway
|
|
136
|
+
screen: a service step exposes that service's preset ``.env`` keys; the
|
|
137
|
+
gateway-network-link step exposes ``COMPOSE_PROJECT_NAME`` (the link target
|
|
138
|
+
is named after the compose project); the rest copy nothing.
|
|
139
|
+
"""
|
|
140
|
+
catalog = load_all_services()
|
|
141
|
+
gateways = [
|
|
142
|
+
{
|
|
143
|
+
"name": gw.name,
|
|
144
|
+
"role": gw.role or gw.name,
|
|
145
|
+
"edition": gw.ignition_edition,
|
|
146
|
+
"url": f"http://localhost:{gw.http_port}",
|
|
147
|
+
}
|
|
148
|
+
for gw in config.gateways
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
if step.service:
|
|
152
|
+
env_vars = sorted(catalog[step.service].env.items())
|
|
153
|
+
elif step.connection == "gateway-network-link":
|
|
154
|
+
env_vars = [("COMPOSE_PROJECT_NAME", config.name)]
|
|
155
|
+
else:
|
|
156
|
+
env_vars = []
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
"project_name": config.name,
|
|
160
|
+
"connection": step.connection,
|
|
161
|
+
"reason": step.reason,
|
|
162
|
+
"service": step.service,
|
|
163
|
+
"gateway_url": gateways[0]["url"],
|
|
164
|
+
"gateways": gateways,
|
|
165
|
+
"env_vars": env_vars,
|
|
166
|
+
"env_map": dict(env_vars),
|
|
167
|
+
"proxy_path": config.reverse_proxy.path if config.reverse_proxy else "",
|
|
168
|
+
"dropin_dir": "modules/dropin",
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _render_step(env: Environment, ctx: dict[str, object]) -> str:
|
|
173
|
+
connection = ctx["connection"]
|
|
174
|
+
try:
|
|
175
|
+
template = env.get_template(f"{connection}.md.j2")
|
|
176
|
+
except TemplateNotFound:
|
|
177
|
+
template = env.get_template("_default.md.j2")
|
|
178
|
+
return template.render(**ctx).rstrip()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _jinja_env() -> Environment:
|
|
182
|
+
return Environment(
|
|
183
|
+
loader=PackageLoader("ignition_stack.templates", "post-setup"),
|
|
184
|
+
undefined=StrictUndefined,
|
|
185
|
+
keep_trailing_newline=True,
|
|
186
|
+
autoescape=False,
|
|
187
|
+
)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Architecture profiles: pre-canned shapes that turn intent into config.
|
|
2
|
+
|
|
3
|
+
Importing this package registers every built-in profile by side-effect so
|
|
4
|
+
``get_profile("scaleout")`` works without explicit module imports.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from ignition_stack.profiles import hub_and_spoke, mcp_n8n, scaleout, standalone # noqa: F401
|
|
8
|
+
from ignition_stack.profiles.advisory import Advisory, spoke_advisory
|
|
9
|
+
from ignition_stack.profiles.base import (
|
|
10
|
+
Profile,
|
|
11
|
+
ProfileOptions,
|
|
12
|
+
build_profile,
|
|
13
|
+
get_profile,
|
|
14
|
+
list_profiles,
|
|
15
|
+
)
|
|
16
|
+
from ignition_stack.profiles.hub_and_spoke import ProfileError
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"Advisory",
|
|
20
|
+
"Profile",
|
|
21
|
+
"ProfileError",
|
|
22
|
+
"ProfileOptions",
|
|
23
|
+
"build_profile",
|
|
24
|
+
"get_profile",
|
|
25
|
+
"list_profiles",
|
|
26
|
+
"spoke_advisory",
|
|
27
|
+
]
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Hub-and-spoke RAM advisory.
|
|
2
|
+
|
|
3
|
+
The hub-and-spoke profile spins up one hub gateway plus *N* spokes; each
|
|
4
|
+
Ignition gateway needs ~1.5 GB to run comfortably. The advisory turns that
|
|
5
|
+
math into a proportional friction signal so SEs can see the cost of a
|
|
6
|
+
large demo without being silently blocked when they really do want it:
|
|
7
|
+
|
|
8
|
+
- **green** (≤4 spokes): the common case; proceed without prompting.
|
|
9
|
+
- **yellow** (5-8 spokes): show the advisory, ask the user to confirm.
|
|
10
|
+
- **red** (≥9 spokes): refuse unless ``--force`` is set, with a message
|
|
11
|
+
explaining the RAM estimate.
|
|
12
|
+
|
|
13
|
+
The actual *threshold* is the spoke count (per the design's tiered-advisory
|
|
14
|
+
decision); the RAM math is folded into the message so the user understands
|
|
15
|
+
*why* the tier landed where it did. ``available_bytes`` is injectable so
|
|
16
|
+
tests don't depend on the host's free memory.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from typing import Literal
|
|
23
|
+
|
|
24
|
+
import psutil
|
|
25
|
+
|
|
26
|
+
Tier = Literal["green", "yellow", "red"]
|
|
27
|
+
|
|
28
|
+
# A single Ignition gateway needs ~1.5 GB at idle (anchor heap = 2 GB default
|
|
29
|
+
# memory_mb, plus JVM overhead). The advisory estimate stays conservative
|
|
30
|
+
# enough that "fits in available RAM" is a real signal, not a worst-case wall.
|
|
31
|
+
_BYTES_PER_GATEWAY = 1_500 * 1024 * 1024
|
|
32
|
+
|
|
33
|
+
# Tier thresholds by spoke count (1 hub + N spokes). Closed intervals; ties go
|
|
34
|
+
# to the lower tier (5 spokes is yellow, 9 spokes is red).
|
|
35
|
+
_YELLOW_MIN_SPOKES = 5
|
|
36
|
+
_RED_MIN_SPOKES = 9
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class Advisory:
|
|
41
|
+
"""Outcome of evaluating a hub-and-spoke spoke count.
|
|
42
|
+
|
|
43
|
+
``tier`` drives the CLI flow (green = proceed silently; yellow = confirm;
|
|
44
|
+
red = exit non-zero unless --force). ``message`` is the human-readable
|
|
45
|
+
explanation suitable for stderr or a confirm prompt; it always references
|
|
46
|
+
the totals so the user sees the math.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
tier: Tier
|
|
50
|
+
spoke_count: int
|
|
51
|
+
total_gateways: int
|
|
52
|
+
estimated_gb: float
|
|
53
|
+
available_gb: float
|
|
54
|
+
message: str
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def spoke_advisory(spoke_count: int, available_bytes: int | None = None) -> Advisory:
|
|
58
|
+
"""Compute the advisory for ``spoke_count`` spoke gateways.
|
|
59
|
+
|
|
60
|
+
``available_bytes`` defaults to ``psutil.virtual_memory().available`` so
|
|
61
|
+
callers don't have to reach for psutil themselves. Tests pass a concrete
|
|
62
|
+
value to make tier+message assertions deterministic.
|
|
63
|
+
|
|
64
|
+
Raises ``ValueError`` for negative spoke counts; zero is allowed (a
|
|
65
|
+
hub-only stack, which is just standalone in disguise but the user might
|
|
66
|
+
pick it explicitly during exploration).
|
|
67
|
+
"""
|
|
68
|
+
if spoke_count < 0:
|
|
69
|
+
raise ValueError(f"spoke_count must be >= 0, got {spoke_count}")
|
|
70
|
+
|
|
71
|
+
if available_bytes is None:
|
|
72
|
+
available_bytes = psutil.virtual_memory().available
|
|
73
|
+
|
|
74
|
+
total_gateways = 1 + spoke_count
|
|
75
|
+
estimated_bytes = total_gateways * _BYTES_PER_GATEWAY
|
|
76
|
+
estimated_gb = estimated_bytes / (1024**3)
|
|
77
|
+
available_gb = available_bytes / (1024**3)
|
|
78
|
+
|
|
79
|
+
tier = _tier_for(spoke_count)
|
|
80
|
+
message = _message_for(tier, spoke_count, total_gateways, estimated_gb, available_gb)
|
|
81
|
+
return Advisory(
|
|
82
|
+
tier=tier,
|
|
83
|
+
spoke_count=spoke_count,
|
|
84
|
+
total_gateways=total_gateways,
|
|
85
|
+
estimated_gb=estimated_gb,
|
|
86
|
+
available_gb=available_gb,
|
|
87
|
+
message=message,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _tier_for(spoke_count: int) -> Tier:
|
|
92
|
+
if spoke_count >= _RED_MIN_SPOKES:
|
|
93
|
+
return "red"
|
|
94
|
+
if spoke_count >= _YELLOW_MIN_SPOKES:
|
|
95
|
+
return "yellow"
|
|
96
|
+
return "green"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _message_for(
|
|
100
|
+
tier: Tier,
|
|
101
|
+
spoke_count: int,
|
|
102
|
+
total_gateways: int,
|
|
103
|
+
estimated_gb: float,
|
|
104
|
+
available_gb: float,
|
|
105
|
+
) -> str:
|
|
106
|
+
"""Render the advisory message for the given tier.
|
|
107
|
+
|
|
108
|
+
Keep these short and concrete: the user sees ``message`` on stderr (red)
|
|
109
|
+
or inside a confirm prompt (yellow); green callers usually skip it
|
|
110
|
+
entirely. Mention spoke count, total gateways, the RAM estimate, and what
|
|
111
|
+
happens next (proceed / confirm / --force).
|
|
112
|
+
"""
|
|
113
|
+
estimate = f"{total_gateways} gateways x ~1.5 GB = ~{estimated_gb:.1f} GB needed"
|
|
114
|
+
available = f"{available_gb:.1f} GB available on this host"
|
|
115
|
+
|
|
116
|
+
if tier == "green":
|
|
117
|
+
return f"Hub-and-spoke with {spoke_count} spoke(s). {estimate}; {available}. Proceeding."
|
|
118
|
+
if tier == "yellow":
|
|
119
|
+
return (
|
|
120
|
+
f"Hub-and-spoke with {spoke_count} spokes is a heavy demo: "
|
|
121
|
+
f"{estimate}, {available}. "
|
|
122
|
+
f"Each gateway has its own JVM, so memory pressure stacks up fast. "
|
|
123
|
+
f"Confirm to proceed."
|
|
124
|
+
)
|
|
125
|
+
# red
|
|
126
|
+
return (
|
|
127
|
+
f"Hub-and-spoke with {spoke_count} spokes ({total_gateways} gateways total) "
|
|
128
|
+
f"needs ~{estimated_gb:.1f} GB; this host has ~{available_gb:.1f} GB available. "
|
|
129
|
+
f"Stacks this large routinely OOM-kill gateways at startup and are slow to "
|
|
130
|
+
f"demo from. Re-run with --force if you really need this many spokes, or "
|
|
131
|
+
f"drop to <= 8."
|
|
132
|
+
)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Profile contract + options + registry.
|
|
2
|
+
|
|
3
|
+
A *profile* is the small piece of code that turns the user's high-level
|
|
4
|
+
intent ("scaleout", "hub-and-spoke with 3 spokes", "mcp-n8n demo") into a
|
|
5
|
+
fully-formed :class:`ProjectConfig`. The compose engine and the dependency
|
|
6
|
+
resolver are profile-agnostic; profiles only shape the inputs they take.
|
|
7
|
+
|
|
8
|
+
Two-stage pipeline:
|
|
9
|
+
|
|
10
|
+
1. Either the CLI flags or the wizard answers populate a
|
|
11
|
+
:class:`ProfileOptions` and pick a profile slug.
|
|
12
|
+
2. ``build_profile(slug, name, options)`` looks up the profile and calls
|
|
13
|
+
its ``build()`` method, returning a ``ProjectConfig`` that
|
|
14
|
+
``services.resolver.resolve()`` then expands the usual implicit deps on.
|
|
15
|
+
|
|
16
|
+
Each profile is a small dataclass with three pieces:
|
|
17
|
+
|
|
18
|
+
- ``slug`` - the wizard/flag value users type.
|
|
19
|
+
- ``summary`` - one-line description for the wizard menu + docs.
|
|
20
|
+
- ``build`` - pure function ``(name, options) -> ProjectConfig``.
|
|
21
|
+
|
|
22
|
+
Keeping ``build`` pure (no I/O, no prompts) is what lets the wizard layer
|
|
23
|
+
and the CLI flag layer share the same code path and stay testable.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from typing import Protocol
|
|
30
|
+
|
|
31
|
+
from ignition_stack.config import ProjectConfig, ReverseProxyConfig
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class ProfileOptions:
|
|
36
|
+
"""Inputs each profile reads to shape the resolved config.
|
|
37
|
+
|
|
38
|
+
Every field has a sensible default so callers only set what they
|
|
39
|
+
actually care about. The wizard fills in many of these from prompts;
|
|
40
|
+
the non-interactive CLI path fills in a subset from flags and leaves
|
|
41
|
+
the rest at their defaults.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
spokes: int = 3
|
|
45
|
+
"""Hub-and-spoke spoke count. Ignored by other profiles."""
|
|
46
|
+
|
|
47
|
+
force: bool = False
|
|
48
|
+
"""Bypass the hub-and-spoke red-tier advisory. Ignored elsewhere."""
|
|
49
|
+
|
|
50
|
+
edge_role: str | None = None
|
|
51
|
+
"""Which gateway role (if any) runs the Edge edition.
|
|
52
|
+
|
|
53
|
+
For scaleout this is typically 'frontend'; for hub-and-spoke it can
|
|
54
|
+
be 'spoke' (every spoke runs Edge) or None. The profile is free to
|
|
55
|
+
apply its own default when this is None.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
reverse_proxy: ReverseProxyConfig | None = None
|
|
59
|
+
"""Reverse-proxy scaffolding. None = plain host-port mapping."""
|
|
60
|
+
|
|
61
|
+
database_kind: str | None = "postgres"
|
|
62
|
+
"""SQL database for the stack. None = no database (gateway-only)."""
|
|
63
|
+
|
|
64
|
+
services: tuple[str, ...] = ()
|
|
65
|
+
"""Additional service catalog slugs the user picked beyond profile defaults."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Profile(Protocol):
|
|
69
|
+
"""A factory that turns ``ProfileOptions`` into a ``ProjectConfig``."""
|
|
70
|
+
|
|
71
|
+
slug: str
|
|
72
|
+
summary: str
|
|
73
|
+
|
|
74
|
+
def build(self, name: str, options: ProfileOptions) -> ProjectConfig: ...
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# Registry populated by the profile modules at import time. Keep alphabetical
|
|
78
|
+
# insertion-order for stable wizard menus + --help listings.
|
|
79
|
+
_REGISTRY: dict[str, Profile] = {}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def register(profile: Profile) -> Profile:
|
|
83
|
+
"""Register a profile by slug. Returns the profile so module-level uses
|
|
84
|
+
can write ``standalone = register(StandaloneProfile())``.
|
|
85
|
+
"""
|
|
86
|
+
if profile.slug in _REGISTRY:
|
|
87
|
+
raise ValueError(f"profile '{profile.slug}' is already registered")
|
|
88
|
+
_REGISTRY[profile.slug] = profile
|
|
89
|
+
return profile
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def get_profile(slug: str) -> Profile:
|
|
93
|
+
"""Look up a registered profile by slug. Raises ``KeyError`` if unknown."""
|
|
94
|
+
try:
|
|
95
|
+
return _REGISTRY[slug]
|
|
96
|
+
except KeyError as exc:
|
|
97
|
+
known = ", ".join(sorted(_REGISTRY))
|
|
98
|
+
raise KeyError(f"unknown profile '{slug}'; known profiles: {known}") from exc
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def list_profiles() -> list[Profile]:
|
|
102
|
+
"""All registered profiles in stable insertion order."""
|
|
103
|
+
return list(_REGISTRY.values())
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def build_profile(slug: str, name: str, options: ProfileOptions) -> ProjectConfig:
|
|
107
|
+
"""Materialize a ``ProjectConfig`` for the named profile."""
|
|
108
|
+
return get_profile(slug).build(name, options)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Hub-and-spoke profile: 1 central hub + N spoke gateways.
|
|
2
|
+
|
|
3
|
+
Edge gateways typically run as the spokes (lightweight, deployed close to
|
|
4
|
+
the data); the hub aggregates them. Per the design's tiered-advisory
|
|
5
|
+
decision, the spoke count drives an advisory: green ≤4, yellow 5-8, red
|
|
6
|
+
≥9 (refuses without ``--force``). The advisory itself lives in
|
|
7
|
+
:mod:`ignition_stack.profiles.advisory`; this profile wires it into the
|
|
8
|
+
build path so a bare ``build()`` raises ``ProfileError`` on the red tier
|
|
9
|
+
unless ``options.force`` is set.
|
|
10
|
+
|
|
11
|
+
Reverse-proxy / database / services flow through the standard
|
|
12
|
+
``ProfileOptions`` contract; a hub-and-spoke stack is just a multi-gateway
|
|
13
|
+
arrangement with a specific role layout and the advisory gate.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
|
|
20
|
+
from ignition_stack.config import DatabaseConfig, GatewayConfig, ProjectConfig
|
|
21
|
+
from ignition_stack.profiles.advisory import Advisory, spoke_advisory
|
|
22
|
+
from ignition_stack.profiles.base import Profile, ProfileOptions, register
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ProfileError(Exception):
|
|
26
|
+
"""Raised when a profile's preconditions are not met.
|
|
27
|
+
|
|
28
|
+
The CLI catches this and exits non-zero with the message. Carries the
|
|
29
|
+
advisory (for hub-and-spoke red-tier) so callers can surface tier +
|
|
30
|
+
counts alongside the message.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, message: str, advisory: Advisory | None = None) -> None:
|
|
34
|
+
super().__init__(message)
|
|
35
|
+
self.advisory = advisory
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class HubAndSpokeProfile:
|
|
40
|
+
slug: str = "hub-and-spoke"
|
|
41
|
+
summary: str = "Central hub gateway + N Edge spoke gateways. Spoke count > 8 needs --force."
|
|
42
|
+
|
|
43
|
+
def build(self, name: str, options: ProfileOptions) -> ProjectConfig:
|
|
44
|
+
advisory = spoke_advisory(options.spokes)
|
|
45
|
+
if advisory.tier == "red" and not options.force:
|
|
46
|
+
raise ProfileError(advisory.message, advisory=advisory)
|
|
47
|
+
|
|
48
|
+
# Hub is a standard gateway; spokes default to Edge unless the user
|
|
49
|
+
# explicitly opted them out via edge_role.
|
|
50
|
+
spokes_run_edge = options.edge_role != "hub" and options.edge_role != "none"
|
|
51
|
+
gateways: list[GatewayConfig] = [
|
|
52
|
+
GatewayConfig(
|
|
53
|
+
name="hub",
|
|
54
|
+
role="hub",
|
|
55
|
+
ignition_edition="standard",
|
|
56
|
+
http_port=9088,
|
|
57
|
+
)
|
|
58
|
+
]
|
|
59
|
+
# Spoke ports start at 9089 and step up - one host port per spoke so
|
|
60
|
+
# the SE can hit any of them directly from the laptop.
|
|
61
|
+
for i in range(1, options.spokes + 1):
|
|
62
|
+
gateways.append(
|
|
63
|
+
GatewayConfig(
|
|
64
|
+
name=f"spoke-{i}",
|
|
65
|
+
role="spoke",
|
|
66
|
+
ignition_edition="edge" if spokes_run_edge else "standard",
|
|
67
|
+
http_port=9088 + i,
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
return ProjectConfig(
|
|
72
|
+
name=name,
|
|
73
|
+
profile=self.slug,
|
|
74
|
+
gateways=gateways,
|
|
75
|
+
database=_database(options),
|
|
76
|
+
services=list(options.services),
|
|
77
|
+
reverse_proxy=options.reverse_proxy,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _database(options: ProfileOptions) -> DatabaseConfig | None:
|
|
82
|
+
if options.database_kind is None:
|
|
83
|
+
return None
|
|
84
|
+
return DatabaseConfig(kind=options.database_kind)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
profile: Profile = register(HubAndSpokeProfile())
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""MCP + n8n profile: one Ignition gateway, n8n, and a manual MCP drop-in.
|
|
2
|
+
|
|
3
|
+
The Ignition MCP module is EA-gated (no public download URL while in Early
|
|
4
|
+
Access), so this profile scaffolds a ``modules/dropin/`` directory and adds
|
|
5
|
+
a POST-SETUP entry pointing at the survey, per the resolved
|
|
6
|
+
[q-mcp-delivery](02-design.md#q-mcp-delivery) decision. The user drops the
|
|
7
|
+
``.modl`` file into that directory before ``docker compose up``; the
|
|
8
|
+
bootstrap copies anything it finds in ``modules/cache`` AND in the drop-in
|
|
9
|
+
dir into the gateway volume.
|
|
10
|
+
|
|
11
|
+
Structurally this is a single-gateway stack plus the n8n service catalog
|
|
12
|
+
entry; the ``mcp_dropin`` flag on ``ProjectConfig`` is what triggers the
|
|
13
|
+
writer to lay down the drop-in README + a POST-SETUP stub.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
|
|
20
|
+
from ignition_stack.config import DatabaseConfig, GatewayConfig, ProjectConfig
|
|
21
|
+
from ignition_stack.profiles.base import Profile, ProfileOptions, register
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class McpN8nProfile:
|
|
26
|
+
slug: str = "mcp-n8n"
|
|
27
|
+
summary: str = "One Ignition gateway + n8n + manual MCP (EA) module drop-in."
|
|
28
|
+
|
|
29
|
+
def build(self, name: str, options: ProfileOptions) -> ProjectConfig:
|
|
30
|
+
gateway = GatewayConfig()
|
|
31
|
+
if options.edge_role in {"gateway", "standalone"}:
|
|
32
|
+
gateway = gateway.model_copy(update={"ignition_edition": "edge"})
|
|
33
|
+
|
|
34
|
+
services = list(options.services)
|
|
35
|
+
if "n8n" not in services:
|
|
36
|
+
services.insert(0, "n8n")
|
|
37
|
+
|
|
38
|
+
return ProjectConfig(
|
|
39
|
+
name=name,
|
|
40
|
+
profile=self.slug,
|
|
41
|
+
gateways=[gateway],
|
|
42
|
+
database=_database(options),
|
|
43
|
+
services=services,
|
|
44
|
+
mcp_dropin=True,
|
|
45
|
+
reverse_proxy=options.reverse_proxy,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _database(options: ProfileOptions) -> DatabaseConfig | None:
|
|
50
|
+
if options.database_kind is None:
|
|
51
|
+
return None
|
|
52
|
+
return DatabaseConfig(kind=options.database_kind)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
profile: Profile = register(McpN8nProfile())
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Scaleout profile: frontend + backend Ignition gateways networked together.
|
|
2
|
+
|
|
3
|
+
Two roles: ``frontend`` (user-facing UI / OPC-UA aggregation) defaults to
|
|
4
|
+
the Edge edition because that's the canonical scaleout shape in the field;
|
|
5
|
+
``backend`` runs the standard edition with the database connection. Both
|
|
6
|
+
gateways join the frontend AND backend networks so the frontend can reach
|
|
7
|
+
the DB the backend owns. The network split is on by default - that's the
|
|
8
|
+
whole point of the scaleout demo.
|
|
9
|
+
|
|
10
|
+
The plan's validation calls for "two networked gateways (frontend + backend)
|
|
11
|
+
+ a DB; the gateway-network link config is present per the Phase-1 matrix";
|
|
12
|
+
the gateway-network link itself is a follow-up resource set the seeding
|
|
13
|
+
matrix marks ``file-seedable-config: yes``, so it travels with the
|
|
14
|
+
``gateway-resources/`` overlay once that catalog grows. Today the
|
|
15
|
+
``services`` list is empty by default; users add brokers/IDPs on top.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
|
|
22
|
+
from ignition_stack.config import DatabaseConfig, GatewayConfig, ProjectConfig
|
|
23
|
+
from ignition_stack.profiles.base import Profile, ProfileOptions, register
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class ScaleoutProfile:
|
|
28
|
+
slug: str = "scaleout"
|
|
29
|
+
summary: str = "Frontend + backend Ignition gateways via gateway network + Postgres."
|
|
30
|
+
|
|
31
|
+
def build(self, name: str, options: ProfileOptions) -> ProjectConfig:
|
|
32
|
+
edge_role = options.edge_role if options.edge_role is not None else "frontend"
|
|
33
|
+
gateways = [
|
|
34
|
+
GatewayConfig(
|
|
35
|
+
name="frontend",
|
|
36
|
+
role="frontend",
|
|
37
|
+
ignition_edition="edge" if edge_role == "frontend" else "standard",
|
|
38
|
+
http_port=9088,
|
|
39
|
+
),
|
|
40
|
+
GatewayConfig(
|
|
41
|
+
name="backend",
|
|
42
|
+
role="backend",
|
|
43
|
+
ignition_edition="edge" if edge_role == "backend" else "standard",
|
|
44
|
+
http_port=9089,
|
|
45
|
+
),
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
return ProjectConfig(
|
|
49
|
+
name=name,
|
|
50
|
+
profile=self.slug,
|
|
51
|
+
network_split=True,
|
|
52
|
+
gateways=gateways,
|
|
53
|
+
database=_database(options),
|
|
54
|
+
services=list(options.services),
|
|
55
|
+
reverse_proxy=options.reverse_proxy,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _database(options: ProfileOptions) -> DatabaseConfig | None:
|
|
60
|
+
if options.database_kind is None:
|
|
61
|
+
return None
|
|
62
|
+
return DatabaseConfig(kind=options.database_kind)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
profile: Profile = register(ScaleoutProfile())
|