ignition-stack 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ignition_stack/__init__.py +1 -0
- ignition_stack/catalog/__init__.py +10 -0
- ignition_stack/catalog/download.py +145 -0
- ignition_stack/catalog/loader.py +65 -0
- ignition_stack/catalog/schema.py +158 -0
- ignition_stack/catalog/verify.py +72 -0
- ignition_stack/cli.py +354 -0
- ignition_stack/commands/__init__.py +0 -0
- ignition_stack/commands/modules.py +178 -0
- ignition_stack/completion.py +46 -0
- ignition_stack/compose/__init__.py +4 -0
- ignition_stack/compose/engine.py +397 -0
- ignition_stack/compose/templates/footer.yaml.j2 +12 -0
- ignition_stack/compose/templates/header.yaml.j2 +14 -0
- ignition_stack/compose/templates/services/bootstrap.yaml.j2 +19 -0
- ignition_stack/compose/templates/services/ignition.yaml.j2 +35 -0
- ignition_stack/compose/writer.py +428 -0
- ignition_stack/config/__init__.py +8 -0
- ignition_stack/config/schema.py +311 -0
- ignition_stack/lifecycle/__init__.py +31 -0
- ignition_stack/lifecycle/cleanup.py +71 -0
- ignition_stack/lifecycle/record.py +67 -0
- ignition_stack/lifecycle/regenerate.py +62 -0
- ignition_stack/modules.yaml +83 -0
- ignition_stack/postsetup/__init__.py +3 -0
- ignition_stack/postsetup/generator.py +187 -0
- ignition_stack/profiles/__init__.py +27 -0
- ignition_stack/profiles/advisory.py +132 -0
- ignition_stack/profiles/base.py +108 -0
- ignition_stack/profiles/hub_and_spoke.py +87 -0
- ignition_stack/profiles/mcp_n8n.py +55 -0
- ignition_stack/profiles/scaleout.py +65 -0
- ignition_stack/profiles/standalone.py +44 -0
- ignition_stack/services/__init__.py +25 -0
- ignition_stack/services/loader.py +69 -0
- ignition_stack/services/manifest.py +106 -0
- ignition_stack/services/resolver.py +133 -0
- ignition_stack/templates/__init__.py +0 -0
- ignition_stack/templates/post-setup/_default.md.j2 +12 -0
- ignition_stack/templates/post-setup/device-connection.md.j2 +11 -0
- ignition_stack/templates/post-setup/gateway-network-link.md.j2 +18 -0
- ignition_stack/templates/post-setup/identity-provider.md.j2 +13 -0
- ignition_stack/templates/post-setup/kafka-connector.md.j2 +11 -0
- ignition_stack/templates/post-setup/mcp-module.md.j2 +11 -0
- ignition_stack/templates/post-setup/mqtt-engine-connection.md.j2 +11 -0
- ignition_stack/templates/post-setup/opc-ua-connection.md.j2 +11 -0
- ignition_stack/templates/post-setup/reverse-proxy.md.j2 +8 -0
- ignition_stack/templates/services/chariot/compose.yaml.j2 +17 -0
- ignition_stack/templates/services/chariot/manifest.yaml +22 -0
- ignition_stack/templates/services/chariot/seed/service/USAGE.md +14 -0
- ignition_stack/templates/services/emqx/compose.yaml.j2 +16 -0
- ignition_stack/templates/services/emqx/manifest.yaml +21 -0
- ignition_stack/templates/services/emqx/seed/service/USAGE.md +11 -0
- ignition_stack/templates/services/hivemq/compose.yaml.j2 +12 -0
- ignition_stack/templates/services/hivemq/manifest.yaml +19 -0
- ignition_stack/templates/services/hivemq/seed/service/USAGE.md +16 -0
- ignition_stack/templates/services/kafka/compose.yaml.j2 +27 -0
- ignition_stack/templates/services/kafka/manifest.yaml +20 -0
- ignition_stack/templates/services/kafka/seed/service/USAGE.md +17 -0
- ignition_stack/templates/services/keycloak/compose.yaml.j2 +31 -0
- ignition_stack/templates/services/keycloak/manifest.yaml +25 -0
- ignition_stack/templates/services/keycloak/seed/service/import/ignition-realm.json +31 -0
- ignition_stack/templates/services/mariadb/compose.yaml.j2 +26 -0
- ignition_stack/templates/services/mariadb/manifest.yaml +15 -0
- ignition_stack/templates/services/mariadb/seed/service/initdb/00-create-extra-databases.sh +19 -0
- ignition_stack/templates/services/modbus-sim/compose.yaml.j2 +12 -0
- ignition_stack/templates/services/modbus-sim/manifest.yaml +19 -0
- ignition_stack/templates/services/modbus-sim/seed/service/USAGE.md +10 -0
- ignition_stack/templates/services/mongo/compose.yaml.j2 +21 -0
- ignition_stack/templates/services/mongo/manifest.yaml +14 -0
- ignition_stack/templates/services/mongo/seed/service/initdb/01-demo-collection.js +10 -0
- ignition_stack/templates/services/mysql/compose.yaml.j2 +26 -0
- ignition_stack/templates/services/mysql/manifest.yaml +15 -0
- ignition_stack/templates/services/mysql/seed/service/initdb/00-create-extra-databases.sh +19 -0
- ignition_stack/templates/services/n8n/compose.yaml.j2 +16 -0
- ignition_stack/templates/services/n8n/manifest.yaml +16 -0
- ignition_stack/templates/services/n8n/seed/service/USAGE.md +11 -0
- ignition_stack/templates/services/opcua-sim/compose.yaml.j2 +13 -0
- ignition_stack/templates/services/opcua-sim/manifest.yaml +21 -0
- ignition_stack/templates/services/opcua-sim/seed/service/USAGE.md +11 -0
- ignition_stack/templates/services/postgres/compose.yaml.j2 +26 -0
- ignition_stack/templates/services/postgres/manifest.yaml +21 -0
- ignition_stack/templates/services/postgres/seed/gateway-resources/config/resources/core/ignition/database-connection/db/config.json +32 -0
- ignition_stack/templates/services/postgres/seed/gateway-resources/config/resources/core/ignition/database-connection/db/resource.json +19 -0
- ignition_stack/templates/services/postgres/seed/gateway-resources/config/resources/core/ignition/secret-provider/internal-secret-provider/config.json +19 -0
- ignition_stack/templates/services/postgres/seed/gateway-resources/config/resources/core/ignition/secret-provider/internal-secret-provider/resource.json +19 -0
- ignition_stack/templates/services/postgres/seed/service/initdb/00-create-extra-databases.sh +17 -0
- ignition_stack/templates/services/rabbitmq/compose.yaml.j2 +19 -0
- ignition_stack/templates/services/rabbitmq/manifest.yaml +23 -0
- ignition_stack/templates/services/rabbitmq/seed/service/enabled_plugins +1 -0
- ignition_stack/templates/standalone-postgres/docker-compose.yaml +62 -0
- ignition_stack/templates/standalone-postgres/scripts/docker-bootstrap.sh +78 -0
- ignition_stack/templates/standalone-postgres/services/ignition/config/resources/core/config-mode.json +7 -0
- ignition_stack/templates/standalone-postgres/services/ignition/config/resources/dev/config-mode.json +7 -0
- ignition_stack/templates/standalone-postgres/services/ignition/projects/.gitkeep +0 -0
- ignition_stack/wizard.py +362 -0
- ignition_stack-0.1.0.dist-info/METADATA +97 -0
- ignition_stack-0.1.0.dist-info/RECORD +100 -0
- ignition_stack-0.1.0.dist-info/WHEEL +4 -0
- ignition_stack-0.1.0.dist-info/entry_points.txt +2 -0
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)]
|