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
ignition_stack/cli.py ADDED
@@ -0,0 +1,354 @@
1
+ """Top-level Typer application.
2
+
3
+ Phase 2 implements ``init`` for the standalone+Postgres walking skeleton.
4
+ Phase 3 wires the real ``modules`` sub-app (catalog list/validate/download);
5
+ ``reset`` and ``wipe`` remain visible placeholders so the command surface
6
+ is stable from day one, with later phases filling them in.
7
+
8
+ Phase 6 widens ``init`` with ``--profile``, ``--spokes``, ``--force``, and
9
+ ``--edge-role`` for the four architecture profiles, and falls into the
10
+ interactive wizard when no profile is named.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import subprocess
16
+ from pathlib import Path
17
+
18
+ import typer
19
+ from rich.console import Console
20
+
21
+ from ignition_stack import __version__
22
+ from ignition_stack.commands.modules import modules_app
23
+ from ignition_stack.completion import complete_edge_role, complete_profile
24
+ from ignition_stack.compose import write_project
25
+ from ignition_stack.config import ProjectConfig
26
+ from ignition_stack.lifecycle import (
27
+ LIFECYCLE_DIR,
28
+ RECORD_NAME,
29
+ CleanupError,
30
+ LifecycleError,
31
+ project_name,
32
+ read_record,
33
+ wipe_command,
34
+ )
35
+ from ignition_stack.lifecycle.regenerate import regenerate
36
+ from ignition_stack.profiles import (
37
+ ProfileError,
38
+ ProfileOptions,
39
+ build_profile,
40
+ get_profile,
41
+ list_profiles,
42
+ )
43
+ from ignition_stack.wizard import run_wizard
44
+
45
+ app = typer.Typer(
46
+ name="ignition-stack",
47
+ help="Generate ready-to-run Docker Compose stacks for Ignition 8.3 SCADA demos.",
48
+ rich_markup_mode="rich",
49
+ )
50
+ app.add_typer(modules_app, name="modules")
51
+
52
+ console = Console()
53
+
54
+
55
+ # invoke_without_command=True lets the eager --version handler fire on bare
56
+ # `ignition-stack --version`. Typer's `no_args_is_help` short-circuits the
57
+ # callback when no subcommand is given, which swallowed --version and made
58
+ # it look like the command was missing; handling the "no subcommand" case
59
+ # manually here keeps both behaviours: --version prints, bare call shows help.
60
+ @app.callback(invoke_without_command=True)
61
+ def _root(
62
+ ctx: typer.Context,
63
+ version: bool = typer.Option(
64
+ False,
65
+ "--version",
66
+ help="Show ignition-stack version and exit.",
67
+ is_eager=True,
68
+ ),
69
+ ) -> None:
70
+ if version:
71
+ console.print(f"ignition-stack {__version__}")
72
+ raise typer.Exit()
73
+ if ctx.invoked_subcommand is None:
74
+ console.print(ctx.get_help())
75
+ raise typer.Exit()
76
+
77
+
78
+ def _profile_help() -> str:
79
+ """Format the available profiles for ``--profile`` help text."""
80
+ lines = ["Architecture profile to materialize (skips the wizard):"]
81
+ for p in list_profiles():
82
+ lines.append(f" - {p.slug}: {p.summary}")
83
+ return "\n".join(lines)
84
+
85
+
86
+ @app.command()
87
+ def init(
88
+ name: str = typer.Argument(
89
+ ...,
90
+ help="Project name. Becomes the directory, the compose project, and the gateway name.",
91
+ ),
92
+ profile: str | None = typer.Option(
93
+ None,
94
+ "--profile",
95
+ "-p",
96
+ help=_profile_help(),
97
+ autocompletion=complete_profile,
98
+ ),
99
+ spokes: int = typer.Option(
100
+ 3,
101
+ "--spokes",
102
+ help="Spoke gateway count for the hub-and-spoke profile (ignored otherwise).",
103
+ min=0,
104
+ ),
105
+ force: bool = typer.Option(
106
+ False,
107
+ "--force",
108
+ help="Bypass the hub-and-spoke red-tier RAM advisory.",
109
+ ),
110
+ edge_role: str | None = typer.Option(
111
+ None,
112
+ "--edge-role",
113
+ help=(
114
+ "Gateway role that runs the Ignition Edge edition. Scaleout defaults "
115
+ "to 'frontend'; hub-and-spoke defaults its spokes to Edge. Pass 'none' "
116
+ "to disable the profile's edge default; pass a role name ('hub', "
117
+ "'gateway', ...) to opt that specific role in."
118
+ ),
119
+ autocompletion=complete_edge_role,
120
+ ),
121
+ keep_cli: bool = typer.Option(
122
+ False,
123
+ "--keep-cli",
124
+ help=(
125
+ "SE-demo mode: keep the lifecycle primitives in .ignition-stack/ so "
126
+ "`ignition-stack reset` / `switch-profile` can regenerate the project. "
127
+ "The default (one-shot) leaves a self-contained project with no CLI "
128
+ "primitives behind."
129
+ ),
130
+ ),
131
+ output_dir: Path | None = typer.Option( # noqa: B008 - Typer pattern
132
+ None,
133
+ "--output-dir",
134
+ "-o",
135
+ help="Parent directory the project is written into. Defaults to the current directory.",
136
+ ),
137
+ ) -> None:
138
+ """Generate a new Ignition stack at ``<output-dir>/<name>``.
139
+
140
+ With ``--profile``, runs non-interactively from the named profile and its
141
+ flags. Without ``--profile``, walks the interactive wizard.
142
+ """
143
+ target = ((output_dir or Path.cwd()) / name).resolve()
144
+
145
+ # Name validation runs before either the wizard or the profile build so
146
+ # invalid names fail fast with a clear exit code (2), instead of bubbling
147
+ # through the wizard's first prompt or the profile's deep model_validate.
148
+ try:
149
+ ProjectConfig(name=name)
150
+ except ValueError as exc:
151
+ console.print(f"[red]error[/red]: invalid project name: {exc}")
152
+ raise typer.Exit(code=2) from exc
153
+
154
+ if profile is None:
155
+ config = _run_wizard_or_exit(name)
156
+ else:
157
+ config = _build_from_profile(name, profile, spokes, force, edge_role)
158
+
159
+ try:
160
+ files = write_project(config, target, keep_cli=keep_cli)
161
+ except FileExistsError as exc:
162
+ console.print(f"[red]error[/red]: {exc}")
163
+ raise typer.Exit(code=1) from exc
164
+
165
+ mode = "SE-demo" if keep_cli else "one-shot"
166
+ console.print(f"[green]created[/green] {target} ([cyan]{mode}[/cyan])")
167
+ console.print(f" {len(files)} file(s) written")
168
+ console.print()
169
+ console.print("Next steps:")
170
+ console.print(f" cd {target}")
171
+ console.print(" docker compose up -d")
172
+ console.print(
173
+ f" open http://localhost:{config.gateways[0].http_port} (admin / {config.admin_password})"
174
+ )
175
+ if keep_cli:
176
+ console.print()
177
+ console.print(
178
+ f" primitives kept in {LIFECYCLE_DIR}/ - run `ignition-stack reset` to "
179
+ "regenerate or `switch-profile <name>` to reshape this stack."
180
+ )
181
+
182
+
183
+ def _build_from_profile(
184
+ name: str, profile: str, spokes: int, force: bool, edge_role: str | None
185
+ ) -> ProjectConfig:
186
+ """Materialize a config from the named profile + CLI flags, or exit cleanly."""
187
+ try:
188
+ get_profile(profile)
189
+ except KeyError as exc:
190
+ console.print(f"[red]error[/red]: {exc}")
191
+ raise typer.Exit(code=2) from exc
192
+
193
+ options = ProfileOptions(spokes=spokes, force=force, edge_role=edge_role)
194
+ try:
195
+ config = build_profile(profile, name, options)
196
+ except ProfileError as exc:
197
+ # Red-tier advisory: exit code 3 keeps it distinguishable from a config
198
+ # error (2) or a generic write failure (1), so callers and tests can
199
+ # branch on it explicitly.
200
+ console.print(f"[red]advisory[/red]: {exc}")
201
+ raise typer.Exit(code=3) from exc
202
+ except ValueError as exc:
203
+ console.print(f"[red]error[/red]: {exc}")
204
+ raise typer.Exit(code=2) from exc
205
+ return config
206
+
207
+
208
+ def _run_wizard_or_exit(name: str) -> ProjectConfig:
209
+ """Run the interactive wizard, surfacing cancellation as a clean non-zero exit."""
210
+ try:
211
+ return run_wizard(name)
212
+ except KeyboardInterrupt as exc:
213
+ console.print("[yellow]cancelled[/yellow]")
214
+ raise typer.Exit(code=130) from exc
215
+
216
+
217
+ @app.command()
218
+ def reset(
219
+ project_dir: Path = typer.Option( # noqa: B008 - Typer pattern
220
+ Path("."),
221
+ "--project-dir",
222
+ "-C",
223
+ help="The generated SE-demo project to reset. Defaults to the current directory.",
224
+ ),
225
+ ) -> None:
226
+ """Regenerate an SE-demo project from its recorded config.
227
+
228
+ Reads ``.ignition-stack/config.json``, clears the generated tree (keeping the
229
+ record and the modules cache), and re-runs generation. Only works on SE-demo
230
+ projects (``init --keep-cli``); a one-shot project has no record to reset from.
231
+ """
232
+ project_dir = project_dir.resolve()
233
+ try:
234
+ config = read_record(project_dir)
235
+ except LifecycleError as exc:
236
+ console.print(f"[red]error[/red]: {exc}")
237
+ raise typer.Exit(code=2) from exc
238
+
239
+ files = regenerate(project_dir, config)
240
+ console.print(f"[green]reset[/green] {project_dir}")
241
+ console.print(f" {len(files)} file(s) regenerated from {LIFECYCLE_DIR}/{RECORD_NAME}")
242
+
243
+
244
+ @app.command(name="switch-profile")
245
+ def switch_profile(
246
+ profile: str = typer.Argument(
247
+ ...,
248
+ help="Architecture profile to switch this stack to.",
249
+ autocompletion=complete_profile,
250
+ ),
251
+ project_dir: Path = typer.Option( # noqa: B008 - Typer pattern
252
+ Path("."),
253
+ "--project-dir",
254
+ "-C",
255
+ help="The generated SE-demo project to reshape. Defaults to the current directory.",
256
+ ),
257
+ ) -> None:
258
+ """Reshape an SE-demo project under a different architecture profile.
259
+
260
+ Carries the recorded database, services, reverse-proxy, and edge intent over
261
+ to the new profile, then regenerates in place and re-records the result.
262
+ """
263
+ project_dir = project_dir.resolve()
264
+ try:
265
+ current = read_record(project_dir)
266
+ except LifecycleError as exc:
267
+ console.print(f"[red]error[/red]: {exc}")
268
+ raise typer.Exit(code=2) from exc
269
+
270
+ try:
271
+ get_profile(profile)
272
+ except KeyError as exc:
273
+ console.print(f"[red]error[/red]: {exc}")
274
+ raise typer.Exit(code=2) from exc
275
+
276
+ options = _options_from_config(current)
277
+ try:
278
+ new_config = build_profile(profile, current.name, options)
279
+ except ProfileError as exc:
280
+ console.print(f"[red]advisory[/red]: {exc}")
281
+ raise typer.Exit(code=3) from exc
282
+ except ValueError as exc:
283
+ console.print(f"[red]error[/red]: {exc}")
284
+ raise typer.Exit(code=2) from exc
285
+
286
+ files = regenerate(project_dir, new_config)
287
+ console.print(f"[green]switched[/green] {current.profile or 'custom'} -> {profile}")
288
+ console.print(f" {len(files)} file(s) regenerated")
289
+
290
+
291
+ def _options_from_config(config: ProjectConfig) -> ProfileOptions:
292
+ """Recover the profile inputs a switch should carry over from a recorded config.
293
+
294
+ Edge intent is recovered from whichever gateway runs the Edge edition (or
295
+ 'none' to keep the new profile from re-introducing its edge default); the
296
+ spoke count from the number of spoke-role gateways.
297
+ """
298
+ edge_roles = [gw.role or gw.name for gw in config.gateways if gw.ignition_edition == "edge"]
299
+ spoke_count = sum(1 for gw in config.gateways if (gw.role or "") == "spoke")
300
+ return ProfileOptions(
301
+ spokes=spoke_count or 3,
302
+ edge_role=edge_roles[0] if edge_roles else "none",
303
+ reverse_proxy=config.reverse_proxy,
304
+ database_kind=config.database.kind if config.database else None,
305
+ services=tuple(config.services),
306
+ )
307
+
308
+
309
+ @app.command()
310
+ def wipe(
311
+ project_dir: Path = typer.Option( # noqa: B008 - Typer pattern
312
+ Path("."),
313
+ "--project-dir",
314
+ "-C",
315
+ help="The generated project to wipe. Defaults to the current directory.",
316
+ ),
317
+ dry_run: bool = typer.Option(
318
+ False,
319
+ "--dry-run",
320
+ help="Print the scoped teardown command without running it.",
321
+ ),
322
+ ) -> None:
323
+ """Remove only this project's containers, networks, and volumes.
324
+
325
+ Runs ``docker compose -p <project> down -v --remove-orphans``; the ``-p``
326
+ pin scopes the teardown to resources labelled with this compose project, so
327
+ unrelated Docker resources on the host are never touched.
328
+ """
329
+ project_dir = project_dir.resolve()
330
+ try:
331
+ name = project_name(project_dir)
332
+ except CleanupError as exc:
333
+ console.print(f"[red]error[/red]: {exc}")
334
+ raise typer.Exit(code=2) from exc
335
+
336
+ command = wipe_command(name)
337
+ if dry_run:
338
+ console.print(" ".join(command))
339
+ return
340
+
341
+ try:
342
+ completed = subprocess.run(command, cwd=project_dir, check=False)
343
+ except FileNotFoundError as exc:
344
+ console.print("[red]error[/red]: docker not found on PATH; cannot wipe.")
345
+ raise typer.Exit(code=1) from exc
346
+
347
+ if completed.returncode != 0:
348
+ console.print(f"[red]error[/red]: `{' '.join(command)}` exited {completed.returncode}")
349
+ raise typer.Exit(code=completed.returncode)
350
+ console.print(f"[green]wiped[/green] project '{name}'")
351
+
352
+
353
+ if __name__ == "__main__": # pragma: no cover
354
+ app()
File without changes
@@ -0,0 +1,178 @@
1
+ """`ignition-stack modules` subcommands: list, validate, download."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ import httpx
9
+ import typer
10
+ from rich.console import Console
11
+ from rich.table import Table
12
+
13
+ from ignition_stack.catalog.download import (
14
+ DEFAULT_CACHE_DIR,
15
+ DownloadError,
16
+ DownloadOutcome,
17
+ download_entry,
18
+ )
19
+ from ignition_stack.catalog.loader import CatalogLoadError, load_catalog
20
+ from ignition_stack.catalog.schema import SHA256_UNPINNED, ModuleEntry
21
+ from ignition_stack.catalog.verify import (
22
+ REACHABILITY_TIMEOUT_SECONDS,
23
+ VerifyIssue,
24
+ verify_reachable,
25
+ )
26
+ from ignition_stack.completion import complete_module_name
27
+
28
+ modules_app = typer.Typer(help="Inspect and prepare the module + driver catalog.")
29
+ console = Console()
30
+ err_console = Console(stderr=True)
31
+
32
+ CatalogOpt = Annotated[
33
+ Path | None,
34
+ typer.Option("--catalog", help="Path to a modules.yaml. Defaults to the bundled catalog."),
35
+ ]
36
+ IgnVerOpt = Annotated[
37
+ str | None,
38
+ typer.Option("--ignition-version", help="Filter to entries verified for this exact version."),
39
+ ]
40
+
41
+
42
+ @modules_app.command("list")
43
+ def list_entries(
44
+ catalog_path: CatalogOpt = None,
45
+ ignition_version: IgnVerOpt = None,
46
+ ) -> None:
47
+ """Show every catalog entry as a table."""
48
+ catalog = _load(catalog_path)
49
+ entries = catalog.for_ignition(ignition_version) if ignition_version else catalog.entries
50
+ table = Table(title="ignition-stack catalog")
51
+ table.add_column("name")
52
+ table.add_column("kind")
53
+ table.add_column("vendor")
54
+ table.add_column("ignition")
55
+ table.add_column("identifier / driver")
56
+ table.add_column("manual?")
57
+ for e in entries:
58
+ identifier = e.module_identifier if isinstance(e, ModuleEntry) else "(jdbc)"
59
+ table.add_row(
60
+ e.name,
61
+ e.kind,
62
+ e.vendor,
63
+ ", ".join(e.ignition_versions),
64
+ identifier,
65
+ "yes" if e.requires_manual_download else "no",
66
+ )
67
+ console.print(table)
68
+
69
+
70
+ @modules_app.command("validate")
71
+ def validate(
72
+ catalog_path: CatalogOpt = None,
73
+ skip_network: Annotated[
74
+ bool,
75
+ typer.Option("--skip-network", help="Only validate the schema; skip URL reachability."),
76
+ ] = False,
77
+ ) -> None:
78
+ """Confirm schema integrity, pinned shas, and (optionally) URL reachability."""
79
+ catalog = _load(catalog_path)
80
+ issues: list[VerifyIssue] = []
81
+
82
+ for entry in catalog.entries:
83
+ # Manual-download entries (e.g. EA-gated MCP) cannot be sha-pinned
84
+ # while gated; flipping requires_manual_download to false at GA is
85
+ # the moment the maintainer is expected to pin the sha.
86
+ if entry.sha256 == SHA256_UNPINNED and not entry.requires_manual_download:
87
+ issues.append(VerifyIssue(entry.name, "sha256 is UNPINNED"))
88
+
89
+ if not skip_network:
90
+ with httpx.Client(timeout=REACHABILITY_TIMEOUT_SECONDS) as client:
91
+ for entry in catalog.entries:
92
+ issue = verify_reachable(entry, client)
93
+ if issue is not None:
94
+ issues.append(issue)
95
+
96
+ if issues:
97
+ err_console.print("[bold red]Catalog validation failed:[/bold red]")
98
+ for issue in issues:
99
+ err_console.print(f" - {issue.entry_name}: {issue.reason}")
100
+ raise typer.Exit(code=1)
101
+
102
+ console.print(
103
+ f"[green]OK[/green]: {len(catalog.entries)} entries valid"
104
+ + (" (schema only)" if skip_network else " (schema + reachability)"),
105
+ )
106
+
107
+
108
+ @modules_app.command("download")
109
+ def download(
110
+ names: Annotated[
111
+ list[str] | None,
112
+ typer.Argument(
113
+ help="Entries to download. Omit to download every non-manual entry.",
114
+ autocompletion=complete_module_name,
115
+ ),
116
+ ] = None,
117
+ catalog_path: CatalogOpt = None,
118
+ ignition_version: IgnVerOpt = None,
119
+ cache_dir: Annotated[
120
+ Path,
121
+ typer.Option("--cache-dir", help="Destination directory for cached artifacts."),
122
+ ] = DEFAULT_CACHE_DIR,
123
+ offline: Annotated[
124
+ bool,
125
+ typer.Option(
126
+ "--offline",
127
+ help="No network calls. Fails if any selected entry is missing from the cache.",
128
+ ),
129
+ ] = False,
130
+ ) -> None:
131
+ """Materialise selected catalog entries into the host-side cache."""
132
+ catalog = _load(catalog_path)
133
+
134
+ selected = list(catalog.entries)
135
+ if ignition_version:
136
+ selected = [e for e in selected if ignition_version in e.ignition_versions]
137
+ if names:
138
+ wanted = set(names)
139
+ present = {e.name for e in selected}
140
+ missing = wanted - present
141
+ if missing:
142
+ err_console.print(
143
+ f"[red]Unknown catalog entries:[/red] {', '.join(sorted(missing))}",
144
+ )
145
+ raise typer.Exit(code=2)
146
+ selected = [e for e in selected if e.name in wanted]
147
+
148
+ if not selected:
149
+ err_console.print("[yellow]No catalog entries match the filters.[/yellow]")
150
+ raise typer.Exit(code=2)
151
+
152
+ had_error = False
153
+ with httpx.Client() as client:
154
+ for entry in selected:
155
+ try:
156
+ result = download_entry(entry, cache_dir, client=client, offline=offline)
157
+ except DownloadError as exc:
158
+ err_console.print(f"[red]ERROR[/red] {exc}")
159
+ had_error = True
160
+ continue
161
+ style = {
162
+ DownloadOutcome.DOWNLOADED: "green",
163
+ DownloadOutcome.COPIED_FROM_LOCAL: "green",
164
+ DownloadOutcome.SKIPPED_CACHED: "cyan",
165
+ DownloadOutcome.SKIPPED_MANUAL: "yellow",
166
+ }[result.outcome]
167
+ console.print(f"[{style}]{result.outcome.value}[/{style}] {result.message}")
168
+
169
+ if had_error:
170
+ raise typer.Exit(code=1)
171
+
172
+
173
+ def _load(catalog_path: Path | None):
174
+ try:
175
+ return load_catalog(catalog_path)
176
+ except CatalogLoadError as exc:
177
+ err_console.print(f"[red]{exc}[/red]")
178
+ raise typer.Exit(code=2) from exc
@@ -0,0 +1,46 @@
1
+ """Shell-completion callbacks for dynamic CLI values.
2
+
3
+ Typer re-invokes the program on every ``<TAB>``, so these callbacks must be
4
+ cheap and must never raise - an exception here would surface as noise on the
5
+ user's shell line. They read only the in-memory profile registry and the
6
+ local bundled ``modules.yaml``; they never touch the network.
7
+
8
+ Each callback takes the partial value the user has typed and returns the
9
+ matching candidates. Returning ``(value, help)`` tuples gives the richer
10
+ two-column completion menu that zsh and fish render.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from ignition_stack.profiles import list_profiles
16
+
17
+
18
+ def complete_profile(incomplete: str) -> list[tuple[str, str]]:
19
+ """Profile slugs (with their one-line summary) matching the typed prefix."""
20
+ return [(p.slug, p.summary) for p in list_profiles() if p.slug.startswith(incomplete)]
21
+
22
+
23
+ # The Ignition Edge role names the profiles recognise. These are the string
24
+ # literals the profile builders match ``edge_role`` against (see
25
+ # profiles/*.py); duplicated here as the completion vocabulary because there
26
+ # is no single registry of role names. 'none' is the sentinel that disables a
27
+ # profile's default Edge role.
28
+ EDGE_ROLE_VALUES = ("frontend", "backend", "hub", "spoke", "gateway", "standalone", "none")
29
+
30
+
31
+ def complete_edge_role(incomplete: str) -> list[str]:
32
+ """Edge role names matching the typed prefix."""
33
+ return [role for role in EDGE_ROLE_VALUES if role.startswith(incomplete)]
34
+
35
+
36
+ def complete_module_name(incomplete: str) -> list[str]:
37
+ """Catalog entry slugs from the bundled catalog matching the typed prefix."""
38
+ try:
39
+ from ignition_stack.catalog.loader import load_catalog
40
+
41
+ entries = load_catalog(None).entries
42
+ except Exception:
43
+ # Completion runs on every keystroke-with-TAB; a missing or malformed
44
+ # catalog must degrade to "no suggestions", never break the shell line.
45
+ return []
46
+ return [entry.name for entry in entries if entry.name.startswith(incomplete)]
@@ -0,0 +1,4 @@
1
+ from ignition_stack.compose.engine import render_compose
2
+ from ignition_stack.compose.writer import write_project
3
+
4
+ __all__ = ["render_compose", "write_project"]