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
|
@@ -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."
|
|
File without changes
|
ignition_stack/wizard.py
ADDED
|
@@ -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
|
+
[](https://github.com/ia-eknorr/ignition-stack/actions/workflows/ci.yml)
|
|
34
|
+
[](https://github.com/ia-eknorr/ignition-stack/actions/workflows/docs.yml)
|
|
35
|
+
[](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.
|