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,23 @@
1
+ # RabbitMQ - message broker; the bundled enabled_plugins turns on the MQTT
2
+ # plugin so it can act as an MQTT broker alongside AMQP.
3
+ name: rabbitmq
4
+ kind: mqtt-broker
5
+ summary: RabbitMQ broker with the MQTT plugin and management UI enabled.
6
+ image: rabbitmq:4.3.1-management
7
+ image_env: RABBITMQ_IMAGE
8
+ network: backend
9
+ provides:
10
+ - mqtt-broker
11
+ requires: []
12
+ env:
13
+ RABBITMQ_USER: ignition
14
+ RABBITMQ_PASSWORD: ignition
15
+ RABBITMQ_MQTT_PORT: "1885"
16
+ RABBITMQ_MGMT_PORT: "15672"
17
+ seeds_gateway_resources: false
18
+ post_setup:
19
+ - connection: mqtt-engine-connection
20
+ reason: >-
21
+ Linking an Ignition gateway to the broker needs the Cirrus Link MQTT
22
+ Engine/Transmission module plus an MQTT server endpoint in the gateway,
23
+ configured once the stack is up.
@@ -0,0 +1 @@
1
+ [rabbitmq_management,rabbitmq_mqtt].
@@ -0,0 +1,62 @@
1
+ ---
2
+ # Generated by ignition-stack. Walking skeleton: one Ignition 8.3 gateway,
3
+ # one Postgres, env-driven commissioning so first boot needs no UI.
4
+ x-ignition-common: &ignition-common
5
+ image: &ignition-image ${IGNITION_IMAGE}
6
+
7
+ x-ignition-environment: &ignition-environment
8
+ ACCEPT_IGNITION_EULA: "Y"
9
+ GATEWAY_ADMIN_USERNAME: ${ADMIN_USERNAME}
10
+ GATEWAY_ADMIN_PASSWORD: ${ADMIN_PASSWORD}
11
+ IGNITION_EDITION: standard
12
+ TZ: ${TZ}
13
+
14
+ services:
15
+ bootstrap:
16
+ image: *ignition-image
17
+ user: root
18
+ entrypoint: ["/bin/bash", "/docker-bootstrap.sh"]
19
+ environment:
20
+ GATEWAY_NAME: ${GATEWAY_NAME}
21
+ volumes:
22
+ - ignition-data:/data
23
+ - ./scripts/docker-bootstrap.sh:/docker-bootstrap.sh:ro
24
+ - ./services/ignition:/template-source:ro
25
+
26
+ gateway:
27
+ <<: [*ignition-common]
28
+ container_name: ${GATEWAY_NAME}
29
+ hostname: ${GATEWAY_NAME}
30
+ depends_on:
31
+ db:
32
+ condition: service_healthy
33
+ bootstrap:
34
+ condition: service_completed_successfully
35
+ ports:
36
+ - "${GATEWAY_HTTP_PORT}:8088"
37
+ volumes:
38
+ - ignition-data:/usr/local/bin/ignition/data
39
+ environment:
40
+ <<: *ignition-environment
41
+ command: >
42
+ -n ${GATEWAY_NAME}
43
+ -m 2048
44
+ --
45
+ -Dignition.config.mode=dev
46
+
47
+ db:
48
+ image: ${POSTGRES_IMAGE}
49
+ hostname: db
50
+ container_name: db-${GATEWAY_NAME}
51
+ environment:
52
+ TZ: ${TZ}
53
+ POSTGRES_USER: ${DB_USER}
54
+ POSTGRES_PASSWORD: ${DB_PASSWORD}
55
+ healthcheck:
56
+ test: ["CMD", "pg_isready", "-h", "localhost", "-U", "${DB_USER}"]
57
+ interval: 5s
58
+ timeout: 10s
59
+ retries: 10
60
+
61
+ volumes:
62
+ ignition-data:
@@ -0,0 +1,78 @@
1
+ #!/bin/bash
2
+ # Vendored from ignition-stack Phase 1 (scripts/seeding-poc/baseline/bootstrap.sh).
3
+ #
4
+ # This script runs once per gateway data volume. It:
5
+ # 1. Copies the base /usr/local/bin/ignition/data into the persistent
6
+ # named volume so the gateway can write its .resources/ caches.
7
+ # 2. Layers the project's resources and projects on top from a read-only
8
+ # /template-source mount. We copy (not :ro bind-mount) because the
9
+ # gateway needs to write .resources/ cache dirs inside those trees
10
+ # and an :ro bind blocks that.
11
+ # 3. chown -R 2003:2003 /data so the gateway user owns everything. Without
12
+ # this, cp from a host bind-mount lands files owned by root or the
13
+ # host UID and the gateway faults with AccessDeniedException at start.
14
+ # 4. Writes a deterministic gateway-network UUID derived from GATEWAY_NAME
15
+ # so the same project name always produces the same gateway identity.
16
+ #
17
+ # NOTE on commissioning: we deliberately do NOT write commissioning.json={}.
18
+ # Doing so bypasses env-driven commissioning, which means
19
+ # ACCEPT_IGNITION_EULA / GATEWAY_ADMIN_PASSWORD / IGNITION_EDITION are
20
+ # ignored and the gateway boots with no admin user. The sentinel file alone
21
+ # prevents this script from re-seeding on container restart.
22
+ set -e
23
+
24
+ DATA_DIR="/data"
25
+ TEMPLATE_SRC="/template-source"
26
+
27
+ if [ ! -f "${DATA_DIR}/.ignition-seed-complete" ]; then
28
+ echo "Seeding data for gateway..."
29
+
30
+ # Copy base ignition data into the persistent volume.
31
+ cp -dpR /usr/local/bin/ignition/data/* "${DATA_DIR}/"
32
+
33
+ # Layer the project's resources and projects on top.
34
+ if [ -d "${TEMPLATE_SRC}/config/resources" ]; then
35
+ echo "Copying resources from ${TEMPLATE_SRC}/config/resources -> ${DATA_DIR}/config/resources"
36
+ mkdir -p "${DATA_DIR}/config/resources"
37
+ cp -R "${TEMPLATE_SRC}/config/resources/." "${DATA_DIR}/config/resources/"
38
+ fi
39
+ if [ -d "${TEMPLATE_SRC}/projects" ]; then
40
+ echo "Copying projects from ${TEMPLATE_SRC}/projects -> ${DATA_DIR}/projects"
41
+ mkdir -p "${DATA_DIR}/projects"
42
+ cp -R "${TEMPLATE_SRC}/projects/." "${DATA_DIR}/projects/"
43
+ fi
44
+
45
+ # Drop any cached artifacts from the modules cache into the gateway.
46
+ # The compose engine mounts <project>/modules/cache to /modules-cache:ro
47
+ # for any gateway that lists modules or JDBC drivers; gateways without
48
+ # any skip this block harmlessly. Third-party .modl files go to
49
+ # user-lib/modules (the companion ACCEPT_MODULE_* env vars on the gateway
50
+ # service tell Ignition to trust + load them on boot); JDBC .jar drivers
51
+ # go to user-lib/jdbc where the gateway's driver configs expect them.
52
+ if [ -d "/modules-cache" ]; then
53
+ echo "Dropping cached .modl modules from /modules-cache -> ${DATA_DIR}/user-lib/modules"
54
+ mkdir -p "${DATA_DIR}/user-lib/modules"
55
+ cp /modules-cache/*.modl "${DATA_DIR}/user-lib/modules/" 2>/dev/null || true
56
+ echo "Dropping cached .jar drivers from /modules-cache -> ${DATA_DIR}/user-lib/jdbc"
57
+ mkdir -p "${DATA_DIR}/user-lib/jdbc"
58
+ cp /modules-cache/*.jar "${DATA_DIR}/user-lib/jdbc/" 2>/dev/null || true
59
+ fi
60
+
61
+ # Hand ownership of everything in /data to the ignition user (uid 2003)
62
+ # so the gateway can write its .resources/ caches and any UI-driven changes.
63
+ chown -R 2003:2003 "${DATA_DIR}"
64
+
65
+ # Deterministic gateway-network UUID derived from GATEWAY_NAME.
66
+ UUID=$(echo -n "${GATEWAY_NAME}" | md5sum | awk '{print $1}' | sed 's/\(........\)\(....\)\(....\)\(....\)\(............\)/\1-\2-\3-\4-\5/' | tr -d '[:space:]')
67
+ mkdir -p "${DATA_DIR}/config/local/ignition/gateway-network"
68
+ echo -n "${UUID}" > "${DATA_DIR}/config/local/ignition/gateway-network/uuid.txt"
69
+ echo "Generated UUID for gateway: ${UUID}"
70
+
71
+ touch "${DATA_DIR}/.ignition-seed-complete"
72
+
73
+ echo "Seeding complete for gateway."
74
+ else
75
+ echo "Gateway already seeded, skipping..."
76
+ fi
77
+
78
+ echo "Bootstrap completed successfully."
@@ -0,0 +1,7 @@
1
+ {
2
+ "title": "Core",
3
+ "description": "Core collection of locally managed Gateway configuration resources",
4
+ "enabled": true,
5
+ "inheritable": true,
6
+ "parent": "external"
7
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "title": "Development",
3
+ "description": "",
4
+ "enabled": true,
5
+ "inheritable": true,
6
+ "parent": "core"
7
+ }
@@ -0,0 +1,362 @@
1
+ """Interactive wizard that walks the architecture decision tree.
2
+
3
+ The wizard's *core* (``walk()``) is a pure function over a small
4
+ :class:`Prompter` protocol. Real CLI invocations pass a
5
+ :class:`QuestionaryPrompter` that delegates to ``questionary``; tests pass
6
+ a :class:`ScriptedPrompter` with a pre-recorded sequence of answers and
7
+ assert on the resulting :class:`ProjectConfig`. Wizard logic stays
8
+ testable without a TTY this way.
9
+
10
+ The UX shape borrows from Create T3 App: select -> defaults -> summary ->
11
+ generate. Each step is one Questionary prompt; the summary screen
12
+ recaps the resolved choices and a single confirm gates the write.
13
+
14
+ Step order:
15
+
16
+ 1. **Profile** - which of the four canned shapes the user wants.
17
+ 2. **Profile-specific** - spoke count for hub-and-spoke (skipped elsewhere).
18
+ 3. **Database** - SQL flavor for the stack, or "none".
19
+ 4. **Edition per role** - which role (if any) runs Edge. The default is
20
+ profile-driven: scaleout proposes "frontend", hub-and-spoke proposes
21
+ "spoke (all spokes)", standalone and mcp-n8n propose "none".
22
+ 5. **Reverse proxy** - existing/install-Traefik/skip.
23
+ 6. **Summary + confirm**.
24
+
25
+ Per-gateway env-var overrides (``memory_mb`` etc.) are deferred to Phase 7
26
+ when the lifecycle/reset commands need them; the gateway model already
27
+ accepts them, so adding a wizard step on top is a non-breaking follow-up.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from collections.abc import Sequence
33
+ from dataclasses import dataclass, field
34
+ from typing import Any, Protocol
35
+
36
+ from ignition_stack.config import ProjectConfig, ReverseProxyConfig
37
+ from ignition_stack.profiles import (
38
+ ProfileError,
39
+ ProfileOptions,
40
+ build_profile,
41
+ list_profiles,
42
+ )
43
+
44
+ # Database options shown in the wizard, in the order they appear on screen.
45
+ _DB_CHOICES: list[tuple[str, str]] = [
46
+ ("postgres", "Postgres (recommended)"),
47
+ ("mysql", "MySQL"),
48
+ ("mariadb", "MariaDB"),
49
+ ("mongo", "MongoDB"),
50
+ ("none", "No database (gateway-only stack)"),
51
+ ]
52
+
53
+ # Per-profile default edge-role proposal. The wizard offers this as the
54
+ # default selection in the edition prompt; the user can override.
55
+ _DEFAULT_EDGE_ROLE: dict[str, str] = {
56
+ "standalone": "none",
57
+ "scaleout": "frontend",
58
+ "hub-and-spoke": "spoke",
59
+ "mcp-n8n": "none",
60
+ }
61
+
62
+
63
+ class Prompter(Protocol):
64
+ """Minimal prompt surface the wizard uses.
65
+
66
+ Implementations: :class:`QuestionaryPrompter` (real CLI) and the test
67
+ harness's ``ScriptedPrompter``. Keeping the surface this small keeps
68
+ the wizard easy to mock and easy to reason about.
69
+ """
70
+
71
+ def select(
72
+ self,
73
+ message: str,
74
+ choices: Sequence[tuple[str, str]],
75
+ default: str | None = None,
76
+ ) -> str:
77
+ """Single-choice prompt. ``choices`` is a list of ``(value, label)``
78
+ pairs; the chosen ``value`` is returned. ``default`` is one of the
79
+ values (or None for first-choice default)."""
80
+
81
+ def text(
82
+ self,
83
+ message: str,
84
+ default: str = "",
85
+ ) -> str:
86
+ """Free-text prompt; returns the user's string (or ``default``)."""
87
+
88
+ def confirm(self, message: str, default: bool = False) -> bool:
89
+ """Yes/no prompt; returns the user's choice (or ``default``)."""
90
+
91
+ def integer(self, message: str, default: int, minimum: int = 0) -> int:
92
+ """Integer prompt; validates ``>= minimum`` and returns the parsed value."""
93
+
94
+
95
+ @dataclass
96
+ class WizardOutcome:
97
+ """What the wizard produces.
98
+
99
+ The :class:`ProjectConfig` is the headline output; ``confirmed`` lets
100
+ the caller distinguish "user reviewed the summary and said yes" from
101
+ "user bailed at the summary" so the CLI can exit non-zero cleanly.
102
+ """
103
+
104
+ config: ProjectConfig
105
+ confirmed: bool
106
+ profile: str
107
+ options: ProfileOptions
108
+ summary_lines: list[str] = field(default_factory=list)
109
+
110
+
111
+ def run_wizard(name: str, prompter: Prompter | None = None) -> ProjectConfig:
112
+ """Run the wizard. Used by the CLI; raises if the user cancels at the summary.
113
+
114
+ ``prompter`` defaults to a :class:`QuestionaryPrompter`. Tests pass a
115
+ scripted prompter to drive the wizard without a TTY.
116
+ """
117
+ if prompter is None:
118
+ prompter = QuestionaryPrompter()
119
+ outcome = walk(name, prompter)
120
+ if not outcome.confirmed:
121
+ raise KeyboardInterrupt("wizard cancelled at summary")
122
+ return outcome.config
123
+
124
+
125
+ def walk(name: str, prompter: Prompter) -> WizardOutcome:
126
+ """Walk the decision tree and return the resolved config + summary.
127
+
128
+ Pure modulo the prompter; no I/O, no global state. Profile validation
129
+ happens once, at the end - red-tier hub-and-spoke advisories surface as
130
+ :class:`ProfileError`, which the CLI catches.
131
+ """
132
+ profile_slug = _ask_profile(prompter)
133
+ spokes = _ask_spokes(prompter) if profile_slug == "hub-and-spoke" else 3
134
+ db_kind = _ask_database(prompter)
135
+ edge_role = _ask_edge_role(prompter, profile_slug)
136
+ reverse_proxy = _ask_reverse_proxy(prompter)
137
+
138
+ options = ProfileOptions(
139
+ spokes=spokes,
140
+ force=False, # the wizard prompts on yellow/red instead of using --force.
141
+ edge_role=edge_role,
142
+ reverse_proxy=reverse_proxy,
143
+ database_kind=db_kind,
144
+ )
145
+
146
+ # Hub-and-spoke advisory: ask the user inside the wizard rather than
147
+ # demanding --force. Yellow asks for confirmation, red asks for the
148
+ # acknowledgement first and then proceeds via the ``force=True`` path so
149
+ # the profile's red-tier guard doesn't block them.
150
+ if profile_slug == "hub-and-spoke":
151
+ options = _confirm_advisory_if_needed(prompter, options)
152
+
153
+ try:
154
+ config = build_profile(profile_slug, name, options)
155
+ except ProfileError as exc:
156
+ # Only happens if the user declined the red-tier confirmation;
157
+ # treat as an explicit cancel.
158
+ return WizardOutcome(
159
+ config=ProjectConfig(name=name),
160
+ confirmed=False,
161
+ profile=profile_slug,
162
+ options=options,
163
+ summary_lines=[f"advisory: {exc}"],
164
+ )
165
+
166
+ summary = _summarize(config, profile_slug, options)
167
+ confirmed = _ask_summary_confirm(prompter, summary)
168
+ return WizardOutcome(
169
+ config=config,
170
+ confirmed=confirmed,
171
+ profile=profile_slug,
172
+ options=options,
173
+ summary_lines=summary,
174
+ )
175
+
176
+
177
+ # --------------------------------------------------------------------------- #
178
+ # Step implementations
179
+ # --------------------------------------------------------------------------- #
180
+
181
+
182
+ def _ask_profile(prompter: Prompter) -> str:
183
+ choices = [(p.slug, f"{p.slug:<14} - {p.summary}") for p in list_profiles()]
184
+ return prompter.select("Architecture profile?", choices, default="standalone")
185
+
186
+
187
+ def _ask_spokes(prompter: Prompter) -> int:
188
+ return prompter.integer("Spoke gateway count?", default=3, minimum=0)
189
+
190
+
191
+ def _ask_database(prompter: Prompter) -> str | None:
192
+ raw = prompter.select("Database?", _DB_CHOICES, default="postgres")
193
+ return None if raw == "none" else raw
194
+
195
+
196
+ def _ask_edge_role(prompter: Prompter, profile_slug: str) -> str | None:
197
+ choices = _edition_choices_for(profile_slug)
198
+ if not choices:
199
+ return None
200
+ default = _DEFAULT_EDGE_ROLE.get(profile_slug, "none")
201
+ raw = prompter.select("Run the Edge edition on which role?", choices, default=default)
202
+ return None if raw == "none" else raw
203
+
204
+
205
+ def _edition_choices_for(profile_slug: str) -> list[tuple[str, str]]:
206
+ """The set of roles that can be Edge-ified per profile, plus 'none'."""
207
+ if profile_slug == "scaleout":
208
+ return [
209
+ ("none", "All gateways run standard"),
210
+ ("frontend", "Frontend runs Edge (recommended for scaleout)"),
211
+ ("backend", "Backend runs Edge"),
212
+ ]
213
+ if profile_slug == "hub-and-spoke":
214
+ return [
215
+ ("none", "All gateways run standard"),
216
+ ("spoke", "All spokes run Edge (recommended for hub-and-spoke)"),
217
+ ("hub", "Hub runs Edge (unusual; spokes stay standard)"),
218
+ ]
219
+ # standalone / mcp-n8n: single gateway
220
+ return [
221
+ ("none", "Standard edition"),
222
+ ("gateway", "Edge edition"),
223
+ ]
224
+
225
+
226
+ def _ask_reverse_proxy(prompter: Prompter) -> ReverseProxyConfig | None:
227
+ choice = prompter.select(
228
+ "Reverse proxy?",
229
+ [
230
+ ("external", "I already run one (Traefik, nginx, ...): plain host-port mapping"),
231
+ ("install", "Install ia-eknorr/traefik-reverse-proxy"),
232
+ ("skip", "Skip - the gateway is exposed directly on a host port"),
233
+ ],
234
+ default="external",
235
+ )
236
+ if choice != "install":
237
+ return None
238
+ path = prompter.text(
239
+ "Where should the proxy live? (relative path under the project)",
240
+ default="reverse-proxy",
241
+ )
242
+ return ReverseProxyConfig(kind="traefik", path=path)
243
+
244
+
245
+ def _confirm_advisory_if_needed(prompter: Prompter, options: ProfileOptions) -> ProfileOptions:
246
+ """Surface yellow/red advisories during the wizard run.
247
+
248
+ Green tier proceeds silently. Yellow asks for confirmation; declining
249
+ rolls back to ``spokes=4`` (still green) so the wizard doesn't strand
250
+ the user on a config they didn't want. Red asks the user to explicitly
251
+ acknowledge the cost; on confirmation, we set ``force=True`` so the
252
+ profile builder lets the config through.
253
+ """
254
+ from ignition_stack.profiles import spoke_advisory
255
+
256
+ advisory = spoke_advisory(options.spokes)
257
+ if advisory.tier == "green":
258
+ return options
259
+ if advisory.tier == "yellow":
260
+ confirmed = prompter.confirm(f"{advisory.message}\nProceed?", default=False)
261
+ if confirmed:
262
+ return options
263
+ # Step down to the largest still-green count (4) so the user lands
264
+ # on a usable stack instead of bailing the whole wizard.
265
+ return _with(options, spokes=4)
266
+ # red
267
+ confirmed = prompter.confirm(
268
+ f"{advisory.message}\nAcknowledge and continue anyway?", default=False
269
+ )
270
+ return _with(options, force=True) if confirmed else _with(options, spokes=4)
271
+
272
+
273
+ def _with(options: ProfileOptions, **changes: Any) -> ProfileOptions:
274
+ """Return a new ProfileOptions with ``changes`` applied (frozen dataclass)."""
275
+ from dataclasses import replace
276
+
277
+ return replace(options, **changes)
278
+
279
+
280
+ def _summarize(config: ProjectConfig, profile_slug: str, options: ProfileOptions) -> list[str]:
281
+ lines = [
282
+ f"profile : {profile_slug}",
283
+ f"project name : {config.name}",
284
+ f"gateways : {len(config.gateways)} "
285
+ f"({', '.join(f'{g.name}={g.ignition_edition}' for g in config.gateways)})",
286
+ f"database : {config.database.kind if config.database else 'none'}",
287
+ f"services : {', '.join(config.services) if config.services else '(none)'}",
288
+ f"network split: {'on' if config.network_split else 'off'}",
289
+ "reverse proxy: "
290
+ + (
291
+ f"install Traefik at './{config.reverse_proxy.path}'"
292
+ if config.reverse_proxy
293
+ else "external (plain host-port mapping)"
294
+ ),
295
+ ]
296
+ if config.mcp_dropin:
297
+ lines.append("MCP dropin : modules/dropin/ (EA-gated; see POST-SETUP.md)")
298
+ if options.force:
299
+ lines.append("advisory : --force acknowledged")
300
+ return lines
301
+
302
+
303
+ def _ask_summary_confirm(prompter: Prompter, summary: list[str]) -> bool:
304
+ block = "\n".join(summary)
305
+ return prompter.confirm(f"Ready to generate?\n\n{block}\n", default=True)
306
+
307
+
308
+ # --------------------------------------------------------------------------- #
309
+ # Concrete prompter backed by Questionary
310
+ # --------------------------------------------------------------------------- #
311
+
312
+
313
+ class QuestionaryPrompter:
314
+ """Real-CLI prompter that delegates to ``questionary``.
315
+
316
+ Each method translates the Prompter contract into the equivalent
317
+ Questionary call. Imported lazily so unit tests don't require a TTY.
318
+ """
319
+
320
+ def select(
321
+ self,
322
+ message: str,
323
+ choices: Sequence[tuple[str, str]],
324
+ default: str | None = None,
325
+ ) -> str:
326
+ import questionary
327
+
328
+ # Questionary's `select` takes a list of `Choice` objects with a
329
+ # title (what the user sees) and a value (what we receive). Build
330
+ # the map so we can resolve the answer back to its slug.
331
+ q_choices = [questionary.Choice(title=label, value=value) for value, label in choices]
332
+ # Questionary matches `default` against choice values, not titles, so
333
+ # pass the slug straight through (or None when it isn't a real choice).
334
+ default_value = default if any(value == default for value, _ in choices) else None
335
+ answer = questionary.select(message, choices=q_choices, default=default_value).unsafe_ask()
336
+ return str(answer)
337
+
338
+ def text(self, message: str, default: str = "") -> str:
339
+ import questionary
340
+
341
+ answer = questionary.text(message, default=default).unsafe_ask()
342
+ return str(answer)
343
+
344
+ def confirm(self, message: str, default: bool = False) -> bool:
345
+ import questionary
346
+
347
+ return bool(questionary.confirm(message, default=default).unsafe_ask())
348
+
349
+ def integer(self, message: str, default: int, minimum: int = 0) -> int:
350
+ import questionary
351
+
352
+ def _validate(text: str) -> bool | str:
353
+ try:
354
+ value = int(text)
355
+ except ValueError:
356
+ return "Enter an integer."
357
+ if value < minimum:
358
+ return f"Must be >= {minimum}."
359
+ return True
360
+
361
+ answer = questionary.text(message, default=str(default), validate=_validate).unsafe_ask()
362
+ return int(answer)
@@ -0,0 +1,97 @@
1
+ Metadata-Version: 2.4
2
+ Name: ignition-stack
3
+ Version: 0.1.0
4
+ Summary: CLI that generates ready-to-run Docker Compose stacks for Ignition 8.3 SCADA demos and SE engagements
5
+ Author-email: Ethan Knorr <eknorr@inductiveautomation.com>
6
+ License: Apache-2.0
7
+ Keywords: compose,docker,ignition,sales-engineering,scada
8
+ Classifier: Development Status :: 2 - Pre-Alpha
9
+ Classifier: Environment :: Console
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Topic :: System :: Installation/Setup
13
+ Requires-Python: >=3.11
14
+ Requires-Dist: httpx>=0.27
15
+ Requires-Dist: jinja2>=3.1
16
+ Requires-Dist: psutil>=5.9
17
+ Requires-Dist: pydantic>=2.7
18
+ Requires-Dist: pyyaml>=6.0
19
+ Requires-Dist: questionary>=2.0
20
+ Requires-Dist: rich>=13.7
21
+ Requires-Dist: ruamel-yaml>=0.18
22
+ Requires-Dist: typer>=0.12
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=8.3; extra == 'dev'
25
+ Requires-Dist: ruff>=0.7; extra == 'dev'
26
+ Provides-Extra: poc
27
+ Requires-Dist: httpx>=0.27; extra == 'poc'
28
+ Requires-Dist: playwright>=1.49; extra == 'poc'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # ignition-stack
32
+
33
+ [![CI](https://github.com/ia-eknorr/ignition-stack/actions/workflows/ci.yml/badge.svg)](https://github.com/ia-eknorr/ignition-stack/actions/workflows/ci.yml)
34
+ [![Docs](https://github.com/ia-eknorr/ignition-stack/actions/workflows/docs.yml/badge.svg)](https://github.com/ia-eknorr/ignition-stack/actions/workflows/docs.yml)
35
+ [![Documentation](https://img.shields.io/badge/docs-ia--eknorr.github.io-blue)](https://ia-eknorr.github.io/ignition-stack/)
36
+
37
+ CLI that generates ready-to-run Docker Compose stacks for Ignition 8.3 SCADA demos and SE engagements. Picks an architecture profile, asks a few questions, writes a self-contained project with a hand-readable compose file, env, file-config seed resources, and a `POST-SETUP.md` listing only what could not be pre-seeded.
38
+
39
+ See [`docs/docs/reference/seeding-matrix.md`](docs/docs/reference/seeding-matrix.md) for which Ignition 8.3 connection types can be provisioned from the filesystem and env on a live 8.3.6 gateway. Full documentation lives in the [`docs/`](docs/) Docusaurus site.
40
+
41
+ ## Install
42
+
43
+ ```sh
44
+ pipx install ignition-stack
45
+ ```
46
+
47
+ To install from source instead of PyPI - the latest off `main`, or a specific branch:
48
+
49
+ ```sh
50
+ pipx install git+https://github.com/ia-eknorr/ignition-stack.git
51
+ pipx install git+https://github.com/ia-eknorr/ignition-stack.git@<branch>
52
+ ```
53
+
54
+ ## Quickstart
55
+
56
+ Generate a project and bring it up:
57
+
58
+ ```sh
59
+ ignition-stack init demo
60
+ cd demo
61
+ docker compose up -d
62
+ ```
63
+
64
+ The gateway reaches RUNNING with no UI prompts. The admin user is `admin / password` and the gateway is at `http://localhost:9088`. The default Postgres credentials are `ignition / ignition` on the `db` service.
65
+
66
+ Everything that ships in the generated project is hand-readable: `docker-compose.yaml`, `.env`, `scripts/docker-bootstrap.sh`, and a `services/ignition/` resources tree the gateway reads on first boot.
67
+
68
+ ## Commands
69
+
70
+ | Command | What it does |
71
+ | --- | --- |
72
+ | `init <name>` | Generate a project at `./<name>/` from a profile and a few prompts. |
73
+ | `modules` | Download, verify, and manage the `.modl` / JDBC catalog. |
74
+ | `reset` | Re-run generation from an SE-demo project's recorded config. |
75
+ | `switch-profile` | Reshape an SE-demo project under a different profile. |
76
+ | `wipe` | Remove this project's containers and volumes only. |
77
+
78
+ See the [CLI reference](docs/docs/reference/cli.md) for every command, argument, and option.
79
+
80
+ ## What gets generated
81
+
82
+ ```
83
+ demo/
84
+ docker-compose.yaml # one gateway, one Postgres, one bootstrap init container
85
+ .env # values referenced by docker-compose.yaml
86
+ scripts/
87
+ docker-bootstrap.sh # seeds /data, sets gateway-network UUID, hands ownership to uid 2003
88
+ services/
89
+ ignition/
90
+ config/resources/core/config-mode.json
91
+ config/resources/dev/config-mode.json
92
+ projects/.gitkeep
93
+ ```
94
+
95
+ The bootstrap script is run once per data volume. It copies the gateway's base data into a named volume, layers the project's `services/ignition/` tree on top, sets a deterministic gateway-network UUID derived from the project name, and hands ownership of `/data` to uid 2003 so the gateway can write its resource caches.
96
+
97
+ Commissioning is fully env-driven (`ACCEPT_IGNITION_EULA=Y`, `GATEWAY_ADMIN_PASSWORD`, `IGNITION_EDITION`), so first boot needs no UI.