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,311 @@
1
+ """Pydantic schema for resolved project configuration.
2
+
3
+ Phase 4 generalizes the Phase 2 single-gateway shape into a multi-gateway
4
+ model with per-gateway overrides and an opt-in network split. The defaults
5
+ are tuned so a bare ``ProjectConfig(name="demo")`` still resolves to exactly
6
+ the Phase 2 walking skeleton (one standalone gateway + Postgres on a single
7
+ bridge network).
8
+
9
+ Phases 5-6 extend this with the service catalog and profile-class shaping.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from typing import Literal
16
+
17
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
18
+
19
+ # Lowercase letters, digits, hyphen, underscore. Must start with a letter
20
+ # because container_name / hostname need to be DNS-safe and Postgres
21
+ # database names follow the same shape.
22
+ _NAME_RE = re.compile(r"^[a-z][a-z0-9_-]*$")
23
+
24
+ # Supported database kinds and their default images + .env image keys. The
25
+ # default image is filled in when DatabaseConfig.image is left blank so a bare
26
+ # DatabaseConfig(kind="mysql") resolves to a runnable image. Postgres stays
27
+ # pinned to 18.1 to match the Phase-2 walking-skeleton golden; the others track
28
+ # the current major tag (a demo tool, not a production pin).
29
+ _DB_DEFAULT_IMAGE = {
30
+ "postgres": "postgres:18.1",
31
+ "mysql": "mysql:9",
32
+ "mariadb": "mariadb:11",
33
+ "mongo": "mongo:7",
34
+ }
35
+ _DB_IMAGE_ENV = {
36
+ "postgres": "POSTGRES_IMAGE",
37
+ "mysql": "MYSQL_IMAGE",
38
+ "mariadb": "MARIADB_IMAGE",
39
+ "mongo": "MONGO_IMAGE",
40
+ }
41
+
42
+
43
+ class GatewayConfig(BaseModel):
44
+ """A single Ignition gateway in the stack.
45
+
46
+ ``name`` doubles as the compose service key for this gateway, the
47
+ directory name under ``services/`` that holds its file-config
48
+ resources, and the prefix for its per-gateway env vars in the
49
+ generated ``.env``. The Phase-2 default ``name="gateway"`` keeps the
50
+ walking skeleton's existing layout intact.
51
+ """
52
+
53
+ model_config = ConfigDict(extra="forbid")
54
+
55
+ name: str = Field(default="gateway")
56
+ role: str | None = Field(
57
+ default=None,
58
+ description=(
59
+ "Optional role tag used by the network-split logic and profile "
60
+ "classes (e.g. 'frontend', 'backend', 'hub', 'spoke'). When "
61
+ "network_split is on the role decides which network the "
62
+ "gateway joins."
63
+ ),
64
+ )
65
+ ignition_edition: str = Field(
66
+ default="standard",
67
+ description="Value of the IGNITION_EDITION env var: 'standard' or 'edge'.",
68
+ )
69
+ memory_mb: int = Field(default=2048, ge=256)
70
+ http_port: int = Field(default=9088, ge=1, le=65535)
71
+ modules: list[str] = Field(
72
+ default_factory=list,
73
+ description=(
74
+ "Module catalog entry names (slugs) to attach to this gateway. "
75
+ "The compose engine wires each cached .modl into the gateway "
76
+ "volume AND enumerates it in ACCEPT_MODULE_LICENSES + "
77
+ "ACCEPT_MODULE_CERTS per the resolved q-module-install finding "
78
+ "(GATEWAY_MODULES_ENABLED is omitted - it quarantines built-ins)."
79
+ ),
80
+ )
81
+
82
+ @field_validator("name")
83
+ @classmethod
84
+ def _validate_name(cls, v: str) -> str:
85
+ if not _NAME_RE.match(v):
86
+ raise ValueError(
87
+ "gateway name must start with a lowercase letter and contain only "
88
+ "lowercase letters, digits, hyphens, or underscores"
89
+ )
90
+ return v
91
+
92
+ @field_validator("ignition_edition")
93
+ @classmethod
94
+ def _validate_edition(cls, v: str) -> str:
95
+ if v not in {"standard", "edge"}:
96
+ raise ValueError("ignition_edition must be 'standard' or 'edge'")
97
+ return v
98
+
99
+ @property
100
+ def env_prefix(self) -> str:
101
+ """Uppercase prefix for this gateway's per-gateway env-var keys.
102
+
103
+ Used by the compose engine when there are 2+ gateways to scope each
104
+ gateway's HTTP port and other per-instance settings in ``.env``.
105
+ For the single-gateway Phase-2 default (name == "gateway") the
106
+ prefix collapses to just "GATEWAY" so the walking skeleton's .env
107
+ keys stay unchanged.
108
+ """
109
+ if self.name == "gateway":
110
+ return "GATEWAY"
111
+ scrubbed = self.name.upper().replace("-", "_")
112
+ if scrubbed.startswith("GATEWAY_"):
113
+ return scrubbed
114
+ return f"GATEWAY_{scrubbed}"
115
+
116
+
117
+ class DatabaseConfig(BaseModel):
118
+ """A single database service.
119
+
120
+ Phase 5 widens ``kind`` to the four catalog databases. ``image`` is filled
121
+ from the kind's default when left blank so the resolver can auto-add a
122
+ database (DatabaseConfig(kind="postgres")) without knowing the tag.
123
+ """
124
+
125
+ model_config = ConfigDict(extra="forbid")
126
+
127
+ name: str = Field(default="db")
128
+ kind: str = Field(default="postgres")
129
+ image: str = Field(default="", description="image:tag; filled from the kind default if blank.")
130
+ user: str = Field(default="ignition")
131
+ password: str = Field(default="ignition")
132
+ extra_databases: list[str] = Field(
133
+ default_factory=list,
134
+ description=(
135
+ "Additional logical databases to create on first init beyond the "
136
+ "default one named after the user. The resolver appends 'keycloak' "
137
+ "here when Keycloak is selected against this database."
138
+ ),
139
+ )
140
+
141
+ @field_validator("kind")
142
+ @classmethod
143
+ def _validate_kind(cls, v: str) -> str:
144
+ if v not in _DB_DEFAULT_IMAGE:
145
+ supported = ", ".join(sorted(_DB_DEFAULT_IMAGE))
146
+ raise ValueError(f"unsupported database kind '{v}'; supported: {supported}")
147
+ return v
148
+
149
+ @model_validator(mode="after")
150
+ def _fill_default_image(self) -> DatabaseConfig:
151
+ if not self.image:
152
+ self.image = _DB_DEFAULT_IMAGE[self.kind]
153
+ return self
154
+
155
+ @property
156
+ def image_env(self) -> str:
157
+ """The ``.env`` key the database fragment reads for its image."""
158
+ return _DB_IMAGE_ENV[self.kind]
159
+
160
+
161
+ class ReverseProxyConfig(BaseModel):
162
+ """Optional reverse-proxy scaffolding.
163
+
164
+ Default behavior (``ProjectConfig.reverse_proxy is None``) emits a plain
165
+ host-port mapping on each gateway and assumes the user already runs Traefik,
166
+ nginx, or another proxy somewhere - or doesn't need one at all. Setting
167
+ this to a :class:`ReverseProxyConfig` lays down the ``ia-eknorr/traefik-
168
+ reverse-proxy`` README at ``path`` and adds a POST-SETUP entry pointing at
169
+ that repo. The CLI never silently bundles a proxy.
170
+ """
171
+
172
+ model_config = ConfigDict(extra="forbid")
173
+
174
+ kind: Literal["traefik"] = Field(
175
+ default="traefik",
176
+ description="Reverse-proxy flavor. Only Traefik is supported today.",
177
+ )
178
+ path: str = Field(
179
+ default="reverse-proxy",
180
+ description=(
181
+ "Relative directory under the project root that holds the proxy "
182
+ "README + install instructions (e.g. 'reverse-proxy', "
183
+ "'infra/proxy'). Must be a non-empty relative POSIX path."
184
+ ),
185
+ )
186
+
187
+ @field_validator("path")
188
+ @classmethod
189
+ def _validate_path(cls, v: str) -> str:
190
+ stripped = v.strip()
191
+ if not stripped:
192
+ raise ValueError("reverse-proxy path must not be empty")
193
+ if stripped.startswith("/") or "\\" in stripped:
194
+ raise ValueError(
195
+ "reverse-proxy path must be a relative POSIX path "
196
+ "(no leading '/' and no backslashes)"
197
+ )
198
+ # Normalize "./foo" -> "foo" so the writer can join cleanly.
199
+ return stripped.removeprefix("./")
200
+
201
+
202
+ class ProjectConfig(BaseModel):
203
+ """Resolved configuration for a single generated project."""
204
+
205
+ model_config = ConfigDict(extra="forbid")
206
+
207
+ name: str = Field(description="Project name; used for compose project and gateway naming.")
208
+ ignition_image: str = Field(default="inductiveautomation/ignition:8.3.6")
209
+ timezone: str = Field(default="UTC")
210
+ admin_username: str = Field(default="admin")
211
+ admin_password: str = Field(default="password")
212
+
213
+ gateways: list[GatewayConfig] = Field(
214
+ default_factory=lambda: [GatewayConfig()],
215
+ min_length=1,
216
+ description="One or more Ignition gateways. Default is a single standard gateway.",
217
+ )
218
+ database: DatabaseConfig | None = Field(
219
+ default_factory=DatabaseConfig,
220
+ description="Database service. Set to None for a gateway-only stack.",
221
+ )
222
+ services: list[str] = Field(
223
+ default_factory=list,
224
+ description=(
225
+ "Non-database catalog services to include (e.g. 'keycloak', "
226
+ "'hivemq', 'opcua-sim'). Slugs are validated against the service "
227
+ "catalog by the resolver, which also auto-adds implicit "
228
+ "dependencies (Keycloak -> a SQL database)."
229
+ ),
230
+ )
231
+ network_split: bool = Field(
232
+ default=False,
233
+ description=(
234
+ "When False (default), all services share a single bridge network. "
235
+ "When True, Ignition + reverse-proxy services land on 'frontend' and "
236
+ "DB + broker services land on 'backend' (per 02-design.md)."
237
+ ),
238
+ )
239
+ reverse_proxy: ReverseProxyConfig | None = Field(
240
+ default=None,
241
+ description=(
242
+ "Reverse-proxy scaffolding. None (default) emits plain host-port "
243
+ "mappings. Set when the user accepts the wizard's offer to install "
244
+ "ia-eknorr/traefik-reverse-proxy at a chosen path."
245
+ ),
246
+ )
247
+ mcp_dropin: bool = Field(
248
+ default=False,
249
+ description=(
250
+ "True when the project should scaffold modules/dropin/ for the "
251
+ "EA-gated MCP module. Set by the mcp-n8n profile."
252
+ ),
253
+ )
254
+ profile: str | None = Field(
255
+ default=None,
256
+ description=(
257
+ "Slug of the architecture profile that produced this config "
258
+ "('standalone', 'scaleout', 'hub-and-spoke', 'mcp-n8n'). "
259
+ "Informational - the compose engine reads from the resolved "
260
+ "fields, not this slug - but lets generated files (header "
261
+ "comment, lifecycle records) name the profile they came from."
262
+ ),
263
+ )
264
+
265
+ # Phase 2 compatibility shims: these fields used to live on ProjectConfig
266
+ # itself. Resolving them through gateways[0] / database keeps the CLI's
267
+ # existing single-gateway output identical to Phase 2.
268
+ @property
269
+ def gateway_http_port(self) -> int:
270
+ return self.gateways[0].http_port
271
+
272
+ @property
273
+ def db_user(self) -> str:
274
+ return self.database.user if self.database else ""
275
+
276
+ @property
277
+ def db_password(self) -> str:
278
+ return self.database.password if self.database else ""
279
+
280
+ @property
281
+ def postgres_image(self) -> str:
282
+ return self.database.image if self.database else ""
283
+
284
+ @property
285
+ def is_multi_gateway(self) -> bool:
286
+ return len(self.gateways) > 1
287
+
288
+ @field_validator("name")
289
+ @classmethod
290
+ def _validate_name(cls, v: str) -> str:
291
+ if not _NAME_RE.match(v):
292
+ raise ValueError(
293
+ "name must start with a lowercase letter and contain only "
294
+ "lowercase letters, digits, hyphens, or underscores"
295
+ )
296
+ return v
297
+
298
+ @model_validator(mode="after")
299
+ def _unique_gateway_names(self) -> ProjectConfig:
300
+ names = [g.name for g in self.gateways]
301
+ if len(set(names)) != len(names):
302
+ dupes = sorted({n for n in names if names.count(n) > 1})
303
+ raise ValueError(f"gateway names must be unique; duplicates: {dupes}")
304
+ return self
305
+
306
+ @model_validator(mode="after")
307
+ def _unique_services(self) -> ProjectConfig:
308
+ if len(set(self.services)) != len(self.services):
309
+ dupes = sorted({s for s in self.services if self.services.count(s) > 1})
310
+ raise ValueError(f"services must be unique; duplicates: {dupes}")
311
+ return self
@@ -0,0 +1,31 @@
1
+ """Lifecycle primitives: SE-demo record, scoped cleanup, in-place regeneration.
2
+
3
+ Only the light modules (``record``, ``cleanup``) are re-exported here; both
4
+ depend on the config schema alone. ``regenerate`` pulls in the compose writer,
5
+ so import it from its submodule to keep this package free of an import cycle
6
+ (the writer imports :mod:`ignition_stack.lifecycle.record`).
7
+ """
8
+
9
+ from ignition_stack.lifecycle.cleanup import CleanupError, project_name, wipe_command
10
+ from ignition_stack.lifecycle.record import (
11
+ LIFECYCLE_DIR,
12
+ RECORD_NAME,
13
+ LifecycleError,
14
+ has_record,
15
+ read_record,
16
+ record_path,
17
+ write_record,
18
+ )
19
+
20
+ __all__ = [
21
+ "LIFECYCLE_DIR",
22
+ "RECORD_NAME",
23
+ "CleanupError",
24
+ "LifecycleError",
25
+ "has_record",
26
+ "project_name",
27
+ "read_record",
28
+ "record_path",
29
+ "wipe_command",
30
+ "write_record",
31
+ ]
@@ -0,0 +1,71 @@
1
+ """Scoped cleanup: build the one ``docker compose`` command that removes a
2
+ project's resources and nothing else.
3
+
4
+ Docker Compose labels every container, network, and named volume it creates
5
+ with ``com.docker.compose.project=<project>``. ``down -v`` only touches
6
+ resources carrying the project's label, so naming the project explicitly
7
+ (``-p <name>``) is what makes ``wipe`` provably scoped: unrelated containers
8
+ and volumes on the same host are never matched.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+
15
+ from ignition_stack.lifecycle.record import LIFECYCLE_DIR, has_record, read_record
16
+
17
+ _ENV_PROJECT_KEY = "COMPOSE_PROJECT_NAME"
18
+
19
+
20
+ class CleanupError(Exception):
21
+ """Raised when the project name to scope the wipe to can't be determined."""
22
+
23
+
24
+ def wipe_command(project_name: str) -> list[str]:
25
+ """The scoped teardown for ``project_name``.
26
+
27
+ ``-p`` pins the compose project so ``down -v`` removes only that project's
28
+ containers, networks, and named volumes; ``--remove-orphans`` clears any of
29
+ its containers no longer in the compose file. Nothing global (no
30
+ ``system prune``, no unfiltered ``volume rm``) is ever issued.
31
+ """
32
+ return [
33
+ "docker",
34
+ "compose",
35
+ "-p",
36
+ project_name,
37
+ "down",
38
+ "-v",
39
+ "--remove-orphans",
40
+ ]
41
+
42
+
43
+ def project_name(project_dir: Path) -> str:
44
+ """Resolve the compose project name for a generated project.
45
+
46
+ Prefers the SE-demo record (authoritative), then ``COMPOSE_PROJECT_NAME``
47
+ from the generated ``.env`` so one-shot projects can still be wiped.
48
+ """
49
+ project_dir = Path(project_dir)
50
+ if has_record(project_dir):
51
+ return read_record(project_dir).name
52
+
53
+ name = _project_name_from_env(project_dir / ".env")
54
+ if name is not None:
55
+ return name
56
+
57
+ raise CleanupError(
58
+ f"could not determine the compose project name in {project_dir}: no "
59
+ f"{LIFECYCLE_DIR} record and no {_ENV_PROJECT_KEY} in .env. Run from a "
60
+ "generated project directory."
61
+ )
62
+
63
+
64
+ def _project_name_from_env(env_file: Path) -> str | None:
65
+ if not env_file.is_file():
66
+ return None
67
+ for line in env_file.read_text(encoding="utf-8").splitlines():
68
+ stripped = line.strip()
69
+ if stripped.startswith(f"{_ENV_PROJECT_KEY}="):
70
+ return stripped.split("=", 1)[1].strip() or None
71
+ return None
@@ -0,0 +1,67 @@
1
+ """SE-demo lifecycle record.
2
+
3
+ In SE-demo mode (``init --keep-cli``) the generated project keeps a copy of
4
+ its resolved :class:`~ignition_stack.config.schema.ProjectConfig` under
5
+ ``.ignition-stack/config.json``. That record is the one primitive ``reset``
6
+ and ``switch-profile`` need: they read it back, optionally reshape it, and
7
+ re-run generation - no second trip through the wizard.
8
+
9
+ One-shot projects (the default) never write this directory, which is how
10
+ ``reset`` tells the two modes apart: no record means the project deliberately
11
+ self-deleted its primitives and cannot be regenerated in place.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from pathlib import Path
17
+
18
+ from ignition_stack.config.schema import ProjectConfig
19
+
20
+ # The directory the SE-demo primitives live in, at the project root. Dot-prefixed
21
+ # so it stays out of the way of the hand-readable compose/.env/Makefile tree.
22
+ LIFECYCLE_DIR = ".ignition-stack"
23
+ RECORD_NAME = "config.json"
24
+
25
+
26
+ class LifecycleError(Exception):
27
+ """Raised when a lifecycle operation can't find or read its record."""
28
+
29
+
30
+ def record_path(project_dir: Path) -> Path:
31
+ """Where the recorded config lives for a project rooted at ``project_dir``."""
32
+ return Path(project_dir) / LIFECYCLE_DIR / RECORD_NAME
33
+
34
+
35
+ def has_record(project_dir: Path) -> bool:
36
+ """True when ``project_dir`` is an SE-demo project (carries a record)."""
37
+ return record_path(project_dir).is_file()
38
+
39
+
40
+ def write_record(config: ProjectConfig, project_dir: Path) -> Path:
41
+ """Persist ``config`` as the project's recorded config. Returns its path.
42
+
43
+ The JSON is what ``reset`` reads back, so it is the *resolved* config (the
44
+ caller passes the same object it handed to the writer); pydantic's
45
+ round-trip via :meth:`ProjectConfig.model_validate_json` reproduces it
46
+ exactly.
47
+ """
48
+ dst = record_path(project_dir)
49
+ dst.parent.mkdir(parents=True, exist_ok=True)
50
+ dst.write_bytes((config.model_dump_json(indent=2) + "\n").encode("utf-8"))
51
+ return dst
52
+
53
+
54
+ def read_record(project_dir: Path) -> ProjectConfig:
55
+ """Load the recorded config for an SE-demo project.
56
+
57
+ Raises :class:`LifecycleError` when the project has no record (a one-shot
58
+ project, or a directory that was never generated by this CLI).
59
+ """
60
+ src = record_path(project_dir)
61
+ if not src.is_file():
62
+ raise LifecycleError(
63
+ f"no lifecycle record at {src}. This looks like a one-shot project "
64
+ "(generated without --keep-cli); reset/switch-profile need an "
65
+ "SE-demo project."
66
+ )
67
+ return ProjectConfig.model_validate_json(src.read_text(encoding="utf-8"))
@@ -0,0 +1,62 @@
1
+ """Regenerate an SE-demo project in place.
2
+
3
+ ``reset`` and ``switch-profile`` both clear the previously-generated tree and
4
+ re-run the writer. The lifecycle record (``.ignition-stack/``) and the modules
5
+ cache are preserved across the rewrite: the record because it is the input to
6
+ the regeneration, the cache because re-downloading pinned ``.modl`` files on
7
+ every reset would be wasteful and offline-hostile.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import shutil
13
+ from pathlib import Path
14
+
15
+ from ignition_stack.compose import write_project
16
+ from ignition_stack.config.schema import ProjectConfig
17
+ from ignition_stack.lifecycle.record import LIFECYCLE_DIR
18
+
19
+ # Generated subtrees kept across a regenerate. Everything else under the project
20
+ # root is removed before the writer re-runs.
21
+ _PRESERVE = frozenset({LIFECYCLE_DIR})
22
+ _PRESERVE_NESTED = (("modules", "cache"),)
23
+
24
+
25
+ def regenerate(project_dir: Path, config: ProjectConfig) -> list[Path]:
26
+ """Clear the generated tree (keeping primitives) and re-run the writer."""
27
+ project_dir = Path(project_dir).resolve()
28
+ _clear_generated(project_dir)
29
+ return write_project(config, project_dir, keep_cli=True, overwrite=True)
30
+
31
+
32
+ def _clear_generated(project_dir: Path) -> None:
33
+ preserved_cache = _stash_cache(project_dir)
34
+ for entry in project_dir.iterdir():
35
+ if entry.name in _PRESERVE:
36
+ continue
37
+ if entry.is_dir():
38
+ shutil.rmtree(entry)
39
+ else:
40
+ entry.unlink()
41
+ _restore_cache(project_dir, preserved_cache)
42
+
43
+
44
+ def _stash_cache(project_dir: Path) -> Path | None:
45
+ """Move modules/cache aside so it survives the rmtree, returning its temp path."""
46
+ cache = project_dir / Path(*_PRESERVE_NESTED[0])
47
+ if not cache.is_dir():
48
+ return None
49
+ stash = project_dir / LIFECYCLE_DIR / "_cache-stash"
50
+ if stash.exists():
51
+ shutil.rmtree(stash)
52
+ stash.parent.mkdir(parents=True, exist_ok=True)
53
+ shutil.move(str(cache), str(stash))
54
+ return stash
55
+
56
+
57
+ def _restore_cache(project_dir: Path, stash: Path | None) -> None:
58
+ if stash is None:
59
+ return
60
+ dst = project_dir / Path(*_PRESERVE_NESTED[0])
61
+ dst.parent.mkdir(parents=True, exist_ok=True)
62
+ shutil.move(str(stash), str(dst))
@@ -0,0 +1,83 @@
1
+ # Catalog of third-party Ignition modules and JDBC drivers the CLI knows how
2
+ # to download, sha-verify, cache, and wire into a generated stack.
3
+ #
4
+ # This is the single source of truth a maintainer bumps per Ignition release.
5
+ # Schema: ignition_stack/catalog/schema.py (pydantic-validated at load).
6
+ #
7
+ # Pinning a new entry:
8
+ # 1. Set ignition_versions to the exact 8.3.x versions you have verified.
9
+ # 2. Download the artifact and compute sha256: `shasum -a 256 <file>`.
10
+ # Until pinned, set sha256 to 'UNPINNED'; `modules validate` rejects
11
+ # this so a half-bumped catalog cannot ship to users.
12
+ # 3. For modules, set module_identifier to the FQID found in module.xml
13
+ # inside the .modl zip (NOT the install path).
14
+ # 4. install_path is the absolute path inside the gateway container.
15
+ # Modules go under user-lib/modules/, drivers under user-lib/jdbc/.
16
+ #
17
+ # Phase-1 finding (see scripts/seeding-poc/experiments/module-install/):
18
+ # GATEWAY_MODULES_ENABLED, ACCEPT_MODULE_LICENSES, and ACCEPT_MODULE_CERTS
19
+ # take comma-delimited fully-qualified module IDENTIFIERS, not paths.
20
+ # The path is only used for the volume-mount / bootstrap copy.
21
+
22
+ version: 1
23
+
24
+ entries:
25
+ - name: mqtt-engine
26
+ kind: module
27
+ vendor: cirrus-link
28
+ ignition_versions: ["8.3.6"]
29
+ module_identifier: com.cirruslink.mqtt.engine.gateway
30
+ download_url: https://inductiveautomation.com/downloads/third-party-modules/8.3.6/MQTT-Engine-signed.modl
31
+ sha256: UNPINNED
32
+ install_path: /usr/local/bin/ignition/user-lib/modules/MQTT-Engine.modl
33
+ requires_license_env: null
34
+ requires_manual_download: false
35
+
36
+ - name: mqtt-transmission
37
+ kind: module
38
+ vendor: cirrus-link
39
+ ignition_versions: ["8.3.6"]
40
+ module_identifier: com.cirruslink.mqtt.transmission.gateway
41
+ download_url: https://inductiveautomation.com/downloads/third-party-modules/8.3.6/MQTT-Transmission-signed.modl
42
+ sha256: UNPINNED
43
+ install_path: /usr/local/bin/ignition/user-lib/modules/MQTT-Transmission.modl
44
+ requires_license_env: null
45
+ requires_manual_download: false
46
+
47
+ - name: mqtt-distributor
48
+ kind: module
49
+ vendor: cirrus-link
50
+ ignition_versions: ["8.3.6"]
51
+ module_identifier: com.cirruslink.mqtt.distributor.gateway
52
+ download_url: https://inductiveautomation.com/downloads/third-party-modules/8.3.6/MQTT-Distributor-signed.modl
53
+ sha256: UNPINNED
54
+ install_path: /usr/local/bin/ignition/user-lib/modules/MQTT-Distributor.modl
55
+ requires_license_env: null
56
+ requires_manual_download: false
57
+
58
+ - name: mysql-jdbc
59
+ kind: jdbc_driver
60
+ vendor: oracle
61
+ ignition_versions: ["8.3.6"]
62
+ download_url: https://repo1.maven.org/maven2/com/mysql/mysql-connector-j/9.1.0/mysql-connector-j-9.1.0.jar
63
+ sha256: UNPINNED
64
+ install_path: /usr/local/bin/ignition/user-lib/jdbc/mysql-connector-j-9.1.0.jar
65
+ requires_license_env: null
66
+ requires_manual_download: false
67
+
68
+ # MCP module is Early Access (survey-gated); no public download URL exists.
69
+ # See q-mcp-delivery in 02-design.md: catalog flag + optional maintainer
70
+ # local_source_path. Until GA, `modules download` skips this entry (or
71
+ # copies from local_source_path if the maintainer has configured one and
72
+ # the file exists on disk).
73
+ - name: mcp-module
74
+ kind: module
75
+ vendor: inductive-automation
76
+ ignition_versions: ["8.3.6"]
77
+ module_identifier: com.inductiveautomation.mcp.gateway
78
+ download_url: null
79
+ sha256: UNPINNED
80
+ install_path: /usr/local/bin/ignition/user-lib/modules/MCP.modl
81
+ requires_license_env: null
82
+ requires_manual_download: true
83
+ local_source_path: null
@@ -0,0 +1,3 @@
1
+ from ignition_stack.postsetup.generator import generate_post_setup
2
+
3
+ __all__ = ["generate_post_setup"]