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.
Files changed (100) hide show
  1. ignition_stack/__init__.py +1 -0
  2. ignition_stack/catalog/__init__.py +10 -0
  3. ignition_stack/catalog/download.py +145 -0
  4. ignition_stack/catalog/loader.py +65 -0
  5. ignition_stack/catalog/schema.py +158 -0
  6. ignition_stack/catalog/verify.py +72 -0
  7. ignition_stack/cli.py +354 -0
  8. ignition_stack/commands/__init__.py +0 -0
  9. ignition_stack/commands/modules.py +178 -0
  10. ignition_stack/completion.py +46 -0
  11. ignition_stack/compose/__init__.py +4 -0
  12. ignition_stack/compose/engine.py +397 -0
  13. ignition_stack/compose/templates/footer.yaml.j2 +12 -0
  14. ignition_stack/compose/templates/header.yaml.j2 +14 -0
  15. ignition_stack/compose/templates/services/bootstrap.yaml.j2 +19 -0
  16. ignition_stack/compose/templates/services/ignition.yaml.j2 +35 -0
  17. ignition_stack/compose/writer.py +428 -0
  18. ignition_stack/config/__init__.py +8 -0
  19. ignition_stack/config/schema.py +311 -0
  20. ignition_stack/lifecycle/__init__.py +31 -0
  21. ignition_stack/lifecycle/cleanup.py +71 -0
  22. ignition_stack/lifecycle/record.py +67 -0
  23. ignition_stack/lifecycle/regenerate.py +62 -0
  24. ignition_stack/modules.yaml +83 -0
  25. ignition_stack/postsetup/__init__.py +3 -0
  26. ignition_stack/postsetup/generator.py +187 -0
  27. ignition_stack/profiles/__init__.py +27 -0
  28. ignition_stack/profiles/advisory.py +132 -0
  29. ignition_stack/profiles/base.py +108 -0
  30. ignition_stack/profiles/hub_and_spoke.py +87 -0
  31. ignition_stack/profiles/mcp_n8n.py +55 -0
  32. ignition_stack/profiles/scaleout.py +65 -0
  33. ignition_stack/profiles/standalone.py +44 -0
  34. ignition_stack/services/__init__.py +25 -0
  35. ignition_stack/services/loader.py +69 -0
  36. ignition_stack/services/manifest.py +106 -0
  37. ignition_stack/services/resolver.py +133 -0
  38. ignition_stack/templates/__init__.py +0 -0
  39. ignition_stack/templates/post-setup/_default.md.j2 +12 -0
  40. ignition_stack/templates/post-setup/device-connection.md.j2 +11 -0
  41. ignition_stack/templates/post-setup/gateway-network-link.md.j2 +18 -0
  42. ignition_stack/templates/post-setup/identity-provider.md.j2 +13 -0
  43. ignition_stack/templates/post-setup/kafka-connector.md.j2 +11 -0
  44. ignition_stack/templates/post-setup/mcp-module.md.j2 +11 -0
  45. ignition_stack/templates/post-setup/mqtt-engine-connection.md.j2 +11 -0
  46. ignition_stack/templates/post-setup/opc-ua-connection.md.j2 +11 -0
  47. ignition_stack/templates/post-setup/reverse-proxy.md.j2 +8 -0
  48. ignition_stack/templates/services/chariot/compose.yaml.j2 +17 -0
  49. ignition_stack/templates/services/chariot/manifest.yaml +22 -0
  50. ignition_stack/templates/services/chariot/seed/service/USAGE.md +14 -0
  51. ignition_stack/templates/services/emqx/compose.yaml.j2 +16 -0
  52. ignition_stack/templates/services/emqx/manifest.yaml +21 -0
  53. ignition_stack/templates/services/emqx/seed/service/USAGE.md +11 -0
  54. ignition_stack/templates/services/hivemq/compose.yaml.j2 +12 -0
  55. ignition_stack/templates/services/hivemq/manifest.yaml +19 -0
  56. ignition_stack/templates/services/hivemq/seed/service/USAGE.md +16 -0
  57. ignition_stack/templates/services/kafka/compose.yaml.j2 +27 -0
  58. ignition_stack/templates/services/kafka/manifest.yaml +20 -0
  59. ignition_stack/templates/services/kafka/seed/service/USAGE.md +17 -0
  60. ignition_stack/templates/services/keycloak/compose.yaml.j2 +31 -0
  61. ignition_stack/templates/services/keycloak/manifest.yaml +25 -0
  62. ignition_stack/templates/services/keycloak/seed/service/import/ignition-realm.json +31 -0
  63. ignition_stack/templates/services/mariadb/compose.yaml.j2 +26 -0
  64. ignition_stack/templates/services/mariadb/manifest.yaml +15 -0
  65. ignition_stack/templates/services/mariadb/seed/service/initdb/00-create-extra-databases.sh +19 -0
  66. ignition_stack/templates/services/modbus-sim/compose.yaml.j2 +12 -0
  67. ignition_stack/templates/services/modbus-sim/manifest.yaml +19 -0
  68. ignition_stack/templates/services/modbus-sim/seed/service/USAGE.md +10 -0
  69. ignition_stack/templates/services/mongo/compose.yaml.j2 +21 -0
  70. ignition_stack/templates/services/mongo/manifest.yaml +14 -0
  71. ignition_stack/templates/services/mongo/seed/service/initdb/01-demo-collection.js +10 -0
  72. ignition_stack/templates/services/mysql/compose.yaml.j2 +26 -0
  73. ignition_stack/templates/services/mysql/manifest.yaml +15 -0
  74. ignition_stack/templates/services/mysql/seed/service/initdb/00-create-extra-databases.sh +19 -0
  75. ignition_stack/templates/services/n8n/compose.yaml.j2 +16 -0
  76. ignition_stack/templates/services/n8n/manifest.yaml +16 -0
  77. ignition_stack/templates/services/n8n/seed/service/USAGE.md +11 -0
  78. ignition_stack/templates/services/opcua-sim/compose.yaml.j2 +13 -0
  79. ignition_stack/templates/services/opcua-sim/manifest.yaml +21 -0
  80. ignition_stack/templates/services/opcua-sim/seed/service/USAGE.md +11 -0
  81. ignition_stack/templates/services/postgres/compose.yaml.j2 +26 -0
  82. ignition_stack/templates/services/postgres/manifest.yaml +21 -0
  83. ignition_stack/templates/services/postgres/seed/gateway-resources/config/resources/core/ignition/database-connection/db/config.json +32 -0
  84. ignition_stack/templates/services/postgres/seed/gateway-resources/config/resources/core/ignition/database-connection/db/resource.json +19 -0
  85. ignition_stack/templates/services/postgres/seed/gateway-resources/config/resources/core/ignition/secret-provider/internal-secret-provider/config.json +19 -0
  86. ignition_stack/templates/services/postgres/seed/gateway-resources/config/resources/core/ignition/secret-provider/internal-secret-provider/resource.json +19 -0
  87. ignition_stack/templates/services/postgres/seed/service/initdb/00-create-extra-databases.sh +17 -0
  88. ignition_stack/templates/services/rabbitmq/compose.yaml.j2 +19 -0
  89. ignition_stack/templates/services/rabbitmq/manifest.yaml +23 -0
  90. ignition_stack/templates/services/rabbitmq/seed/service/enabled_plugins +1 -0
  91. ignition_stack/templates/standalone-postgres/docker-compose.yaml +62 -0
  92. ignition_stack/templates/standalone-postgres/scripts/docker-bootstrap.sh +78 -0
  93. ignition_stack/templates/standalone-postgres/services/ignition/config/resources/core/config-mode.json +7 -0
  94. ignition_stack/templates/standalone-postgres/services/ignition/config/resources/dev/config-mode.json +7 -0
  95. ignition_stack/templates/standalone-postgres/services/ignition/projects/.gitkeep +0 -0
  96. ignition_stack/wizard.py +362 -0
  97. ignition_stack-0.1.0.dist-info/METADATA +97 -0
  98. ignition_stack-0.1.0.dist-info/RECORD +100 -0
  99. ignition_stack-0.1.0.dist-info/WHEEL +4 -0
  100. 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())