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,44 @@
|
|
|
1
|
+
"""Standalone profile: one full Ignition gateway + optional SQL DB.
|
|
2
|
+
|
|
3
|
+
This is the Phase-2 walking skeleton's shape, surfaced as a named profile
|
|
4
|
+
so the wizard can offer it alongside the multi-gateway profiles. The only
|
|
5
|
+
knobs are the database choice (defaults to Postgres) and the optional
|
|
6
|
+
reverse-proxy scaffold.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from ignition_stack.config import DatabaseConfig, GatewayConfig, ProjectConfig
|
|
14
|
+
from ignition_stack.profiles.base import Profile, ProfileOptions, register
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class StandaloneProfile:
|
|
19
|
+
slug: str = "standalone"
|
|
20
|
+
summary: str = "One full Ignition 8.3 gateway + Postgres. The default starter stack."
|
|
21
|
+
|
|
22
|
+
def build(self, name: str, options: ProfileOptions) -> ProjectConfig:
|
|
23
|
+
gateway = GatewayConfig()
|
|
24
|
+
if options.edge_role in {"gateway", "standalone"}:
|
|
25
|
+
gateway = gateway.model_copy(update={"ignition_edition": "edge"})
|
|
26
|
+
|
|
27
|
+
return ProjectConfig(
|
|
28
|
+
name=name,
|
|
29
|
+
profile=self.slug,
|
|
30
|
+
gateways=[gateway],
|
|
31
|
+
database=_database(options),
|
|
32
|
+
services=list(options.services),
|
|
33
|
+
reverse_proxy=options.reverse_proxy,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _database(options: ProfileOptions) -> DatabaseConfig | None:
|
|
38
|
+
if options.database_kind is None:
|
|
39
|
+
return None
|
|
40
|
+
return DatabaseConfig(kind=options.database_kind)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Side-effect: registers this profile when the module is imported.
|
|
44
|
+
profile: Profile = register(StandaloneProfile())
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Service catalog: per-service manifests, loader, and dependency resolver.
|
|
2
|
+
|
|
3
|
+
Phase 5 turns every supported service (databases, MQTT brokers, Keycloak,
|
|
4
|
+
simulators, Kafka, n8n) into a self-contained template directory under
|
|
5
|
+
``ignition_stack/templates/services/<name>/`` holding a ``manifest.yaml``
|
|
6
|
+
(metadata + capability declarations), a ``compose.yaml.j2`` fragment, and a
|
|
7
|
+
``seed/`` tree. This package reads those manifests and resolves the implicit
|
|
8
|
+
dependencies between services (Keycloak needs a SQL database; MySQL needs its
|
|
9
|
+
JDBC driver) into a fully-expanded :class:`ProjectConfig` before the compose
|
|
10
|
+
engine renders anything.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from ignition_stack.services.loader import load_all_services, load_service, service_dir
|
|
14
|
+
from ignition_stack.services.manifest import PostSetupItem, ServiceManifest
|
|
15
|
+
from ignition_stack.services.resolver import ResolveError, resolve
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"PostSetupItem",
|
|
19
|
+
"ResolveError",
|
|
20
|
+
"ServiceManifest",
|
|
21
|
+
"load_all_services",
|
|
22
|
+
"load_service",
|
|
23
|
+
"resolve",
|
|
24
|
+
"service_dir",
|
|
25
|
+
]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Load service manifests from the bundled ``templates/services/`` catalog."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from functools import lru_cache
|
|
6
|
+
from importlib import resources
|
|
7
|
+
from importlib.resources.abc import Traversable
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
from pydantic import ValidationError
|
|
11
|
+
|
|
12
|
+
from ignition_stack.services.manifest import ServiceManifest
|
|
13
|
+
|
|
14
|
+
_SERVICES_PACKAGE = "ignition_stack.templates"
|
|
15
|
+
_SERVICES_SUBDIR = "services"
|
|
16
|
+
_MANIFEST_NAME = "manifest.yaml"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ServiceLoadError(Exception):
|
|
20
|
+
"""Raised when a service manifest is missing or fails schema validation."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def services_root() -> Traversable:
|
|
24
|
+
"""The ``templates/services/`` directory inside the installed package."""
|
|
25
|
+
return resources.files(_SERVICES_PACKAGE) / _SERVICES_SUBDIR
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def service_dir(name: str) -> Traversable:
|
|
29
|
+
"""The ``templates/services/<name>/`` directory for one service."""
|
|
30
|
+
return services_root() / name
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_service(name: str) -> ServiceManifest:
|
|
34
|
+
"""Load and validate one service's manifest by catalog slug."""
|
|
35
|
+
manifest_file = service_dir(name) / _MANIFEST_NAME
|
|
36
|
+
if not manifest_file.is_file():
|
|
37
|
+
raise ServiceLoadError(f"no manifest for service '{name}' (looked for {manifest_file}).")
|
|
38
|
+
try:
|
|
39
|
+
raw = yaml.safe_load(manifest_file.read_text(encoding="utf-8"))
|
|
40
|
+
except yaml.YAMLError as exc:
|
|
41
|
+
raise ServiceLoadError(f"service '{name}' manifest is not valid YAML: {exc}") from exc
|
|
42
|
+
try:
|
|
43
|
+
manifest = ServiceManifest.model_validate(raw)
|
|
44
|
+
except ValidationError as exc:
|
|
45
|
+
raise ServiceLoadError(f"service '{name}' manifest failed validation:\n{exc}") from exc
|
|
46
|
+
if manifest.name != name:
|
|
47
|
+
raise ServiceLoadError(
|
|
48
|
+
f"service '{name}' manifest declares name '{manifest.name}'; they must match."
|
|
49
|
+
)
|
|
50
|
+
return manifest
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@lru_cache(maxsize=1)
|
|
54
|
+
def load_all_services() -> dict[str, ServiceManifest]:
|
|
55
|
+
"""Load every service manifest, keyed by slug.
|
|
56
|
+
|
|
57
|
+
Cached because the catalog is read-only package data: a single process
|
|
58
|
+
never sees it change. Tests that need a fresh read can call
|
|
59
|
+
``load_all_services.cache_clear()``.
|
|
60
|
+
"""
|
|
61
|
+
catalog: dict[str, ServiceManifest] = {}
|
|
62
|
+
for entry in services_root().iterdir():
|
|
63
|
+
if not entry.is_dir():
|
|
64
|
+
continue
|
|
65
|
+
if not (entry / _MANIFEST_NAME).is_file():
|
|
66
|
+
continue
|
|
67
|
+
manifest = load_service(entry.name)
|
|
68
|
+
catalog[manifest.name] = manifest
|
|
69
|
+
return catalog
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Pydantic schema for a service ``manifest.yaml``.
|
|
2
|
+
|
|
3
|
+
Each catalog service ships one manifest describing what the compose engine and
|
|
4
|
+
the dependency resolver need to know about it without reading its Jinja2
|
|
5
|
+
fragment:
|
|
6
|
+
|
|
7
|
+
- **identity** - the catalog slug (also the compose service key and the
|
|
8
|
+
``templates/services/<name>/`` directory name) and the human kind.
|
|
9
|
+
- **image** - the default ``image:tag`` plus the ``.env`` key that overrides it.
|
|
10
|
+
- **capabilities** - ``provides`` / ``requires`` capability tags the resolver
|
|
11
|
+
uses to wire implicit dependencies (Keycloak ``requires: [sql-database]``).
|
|
12
|
+
- **env** - the preset ``.env`` keys this service contributes, with default
|
|
13
|
+
values, so the ``.env`` writer is data-driven instead of hardcoding each
|
|
14
|
+
service's credentials.
|
|
15
|
+
- **seeding** - whether the service ships a ``seed/gateway-resources/`` tree
|
|
16
|
+
that the writer overlays onto every gateway's file-config resources (per the
|
|
17
|
+
Phase-1 seedability matrix), and which connections it cannot pre-seed and
|
|
18
|
+
must defer to ``POST-SETUP.md`` (Phase 7).
|
|
19
|
+
|
|
20
|
+
The fragment itself references ``${ENV_KEY}`` values directly, so the manifest
|
|
21
|
+
never duplicates the compose body - it only carries the metadata the engine
|
|
22
|
+
cannot infer from YAML text.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from typing import Literal
|
|
28
|
+
|
|
29
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
30
|
+
|
|
31
|
+
# A service either lives on the user-facing ``frontend`` network or the private
|
|
32
|
+
# ``backend`` network when ``network_split`` is on. Databases, brokers, IDPs,
|
|
33
|
+
# simulators, and streaming brokers are backend; the only frontend service is
|
|
34
|
+
# n8n (it exposes a UI users hit directly). Gateways always join both.
|
|
35
|
+
NetworkTier = Literal["frontend", "backend"]
|
|
36
|
+
|
|
37
|
+
ServiceKind = Literal[
|
|
38
|
+
"database",
|
|
39
|
+
"mqtt-broker",
|
|
40
|
+
"idp",
|
|
41
|
+
"simulator",
|
|
42
|
+
"streaming",
|
|
43
|
+
"automation",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class PostSetupItem(BaseModel):
|
|
48
|
+
"""A connection this service cannot fully pre-seed from files.
|
|
49
|
+
|
|
50
|
+
Phase 7's ``POST-SETUP.md`` generator turns each of these into a manual
|
|
51
|
+
step. The ``connection`` matches a row in the Phase-1 seedability matrix;
|
|
52
|
+
``reason`` quotes why the file-seeding path stops short (usually a secret
|
|
53
|
+
or a handshake the matrix marked ``no`` / ``partial``).
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
57
|
+
|
|
58
|
+
connection: str = Field(min_length=1)
|
|
59
|
+
reason: str = Field(min_length=1)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ServiceManifest(BaseModel):
|
|
63
|
+
"""Declarative metadata for one catalog service."""
|
|
64
|
+
|
|
65
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
66
|
+
|
|
67
|
+
name: str = Field(
|
|
68
|
+
min_length=1,
|
|
69
|
+
pattern=r"^[a-z][a-z0-9-]*$",
|
|
70
|
+
description="Catalog slug; also the compose service key and the directory name.",
|
|
71
|
+
)
|
|
72
|
+
kind: ServiceKind
|
|
73
|
+
summary: str = Field(default="", description="One-line description for docs and headers.")
|
|
74
|
+
image: str = Field(min_length=1, description="Default image:tag (overridable via image_env).")
|
|
75
|
+
image_env: str = Field(
|
|
76
|
+
min_length=1,
|
|
77
|
+
pattern=r"^[A-Z][A-Z0-9_]*$",
|
|
78
|
+
description="The .env key the compose fragment reads for this service's image.",
|
|
79
|
+
)
|
|
80
|
+
network: NetworkTier = Field(
|
|
81
|
+
default="backend",
|
|
82
|
+
description="Which network the service joins when network_split is on.",
|
|
83
|
+
)
|
|
84
|
+
provides: list[str] = Field(
|
|
85
|
+
default_factory=list,
|
|
86
|
+
description="Capability tags this service satisfies (e.g. 'sql-database').",
|
|
87
|
+
)
|
|
88
|
+
requires: list[str] = Field(
|
|
89
|
+
default_factory=list,
|
|
90
|
+
description="Capability tags this service needs; the resolver auto-adds a provider.",
|
|
91
|
+
)
|
|
92
|
+
env: dict[str, str] = Field(
|
|
93
|
+
default_factory=dict,
|
|
94
|
+
description="Preset .env keys -> default values this service contributes.",
|
|
95
|
+
)
|
|
96
|
+
seeds_gateway_resources: bool = Field(
|
|
97
|
+
default=False,
|
|
98
|
+
description=(
|
|
99
|
+
"True when the service ships seed/gateway-resources/ that the writer "
|
|
100
|
+
"overlays onto every gateway's file-config resource tree."
|
|
101
|
+
),
|
|
102
|
+
)
|
|
103
|
+
post_setup: list[PostSetupItem] = Field(
|
|
104
|
+
default_factory=list,
|
|
105
|
+
description="Connections this service defers to POST-SETUP.md (Phase 7).",
|
|
106
|
+
)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Dependency resolver: expand a user's service selections into a full config.
|
|
2
|
+
|
|
3
|
+
This is a **pure transformation**: given the raw :class:`ProjectConfig` the
|
|
4
|
+
user (or wizard, in Phase 6) built, it returns a new, fully-resolved
|
|
5
|
+
``ProjectConfig`` with every implicit dependency made explicit. The compose
|
|
6
|
+
engine then renders that resolved config verbatim and never re-resolves at
|
|
7
|
+
render time, which keeps the resolution rules unit-testable in isolation.
|
|
8
|
+
|
|
9
|
+
Two kinds of rule run here, per the hybrid-resolution decision in the design:
|
|
10
|
+
|
|
11
|
+
1. **Declarative** - each service manifest declares ``requires:`` capability
|
|
12
|
+
tags. If nothing in the stack provides a required capability, the resolver
|
|
13
|
+
auto-adds the default provider (today only databases are auto-addable:
|
|
14
|
+
Keycloak ``requires: [sql-database]`` -> a Postgres database appears).
|
|
15
|
+
|
|
16
|
+
2. **Imperative** - a small ruleset for couplings that capability tags don't
|
|
17
|
+
express cleanly:
|
|
18
|
+
- Keycloak gets its own logical ``keycloak`` database created on the SQL
|
|
19
|
+
server it lands on.
|
|
20
|
+
- A MySQL database attaches the ``mysql-jdbc`` driver to every gateway so
|
|
21
|
+
the connector ``.jar`` lands in ``user-lib/jdbc/``.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from ignition_stack.config.schema import DatabaseConfig, ProjectConfig
|
|
27
|
+
from ignition_stack.services.loader import load_all_services
|
|
28
|
+
from ignition_stack.services.manifest import ServiceManifest
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ResolveError(Exception):
|
|
32
|
+
"""Raised when a selection can't be satisfied (unknown service, DB conflict)."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Capability tags each database kind satisfies.
|
|
36
|
+
_DB_CAPABILITIES: dict[str, set[str]] = {
|
|
37
|
+
"postgres": {"sql-database", "postgres-compatible"},
|
|
38
|
+
"mysql": {"sql-database", "mysql-compatible"},
|
|
39
|
+
"mariadb": {"sql-database", "mysql-compatible"},
|
|
40
|
+
"mongo": {"document-store"},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
# When a required database capability is provided by nothing, add this kind.
|
|
44
|
+
_DEFAULT_DB_FOR_CAPABILITY: dict[str, str] = {
|
|
45
|
+
"sql-database": "postgres",
|
|
46
|
+
"postgres-compatible": "postgres",
|
|
47
|
+
"mysql-compatible": "mysql",
|
|
48
|
+
"document-store": "mongo",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
# Database kinds whose driver is not built into Ignition and must be attached
|
|
52
|
+
# to gateways as a catalog JDBC entry (slug -> the modules.yaml driver slug).
|
|
53
|
+
_DB_JDBC_DRIVER: dict[str, str] = {"mysql": "mysql-jdbc"}
|
|
54
|
+
|
|
55
|
+
# Database kinds that host a per-application logical database for Keycloak.
|
|
56
|
+
_KEYCLOAK_SQL_KINDS = {"postgres", "mysql", "mariadb"}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def resolve(config: ProjectConfig) -> ProjectConfig:
|
|
60
|
+
"""Return a deep copy of ``config`` with implicit dependencies expanded."""
|
|
61
|
+
catalog = load_all_services()
|
|
62
|
+
resolved = config.model_copy(deep=True)
|
|
63
|
+
|
|
64
|
+
_validate_services(resolved, catalog)
|
|
65
|
+
_satisfy_required_capabilities(resolved, catalog)
|
|
66
|
+
_apply_keycloak_database(resolved)
|
|
67
|
+
_apply_jdbc_drivers(resolved)
|
|
68
|
+
|
|
69
|
+
return resolved
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _validate_services(config: ProjectConfig, catalog: dict[str, ServiceManifest]) -> None:
|
|
73
|
+
for svc in config.services:
|
|
74
|
+
if svc not in catalog:
|
|
75
|
+
known = ", ".join(sorted(catalog))
|
|
76
|
+
raise ResolveError(f"unknown service '{svc}'; known services: {known}")
|
|
77
|
+
if catalog[svc].kind == "database":
|
|
78
|
+
raise ResolveError(
|
|
79
|
+
f"'{svc}' is a database; set it as the project's 'database', not in 'services'"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _satisfy_required_capabilities(
|
|
84
|
+
config: ProjectConfig, catalog: dict[str, ServiceManifest]
|
|
85
|
+
) -> None:
|
|
86
|
+
required: set[str] = set()
|
|
87
|
+
for svc in config.services:
|
|
88
|
+
required.update(catalog[svc].requires)
|
|
89
|
+
|
|
90
|
+
for cap in sorted(required):
|
|
91
|
+
if _capability_satisfied(cap, config, catalog):
|
|
92
|
+
continue
|
|
93
|
+
db_kind = _DEFAULT_DB_FOR_CAPABILITY.get(cap)
|
|
94
|
+
if db_kind is None:
|
|
95
|
+
raise ResolveError(
|
|
96
|
+
f"required capability '{cap}' is provided by no selected service "
|
|
97
|
+
"and cannot be auto-added"
|
|
98
|
+
)
|
|
99
|
+
if config.database is not None:
|
|
100
|
+
raise ResolveError(
|
|
101
|
+
f"a '{config.database.kind}' database is selected, but capability "
|
|
102
|
+
f"'{cap}' needs a different database and only one database is "
|
|
103
|
+
"supported per stack; choose a compatible database"
|
|
104
|
+
)
|
|
105
|
+
config.database = DatabaseConfig(kind=db_kind)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _capability_satisfied(
|
|
109
|
+
cap: str, config: ProjectConfig, catalog: dict[str, ServiceManifest]
|
|
110
|
+
) -> bool:
|
|
111
|
+
if config.database is not None and cap in _DB_CAPABILITIES.get(config.database.kind, set()):
|
|
112
|
+
return True
|
|
113
|
+
return any(cap in catalog[svc].provides for svc in config.services)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _apply_keycloak_database(config: ProjectConfig) -> None:
|
|
117
|
+
if "keycloak" not in config.services or config.database is None:
|
|
118
|
+
return
|
|
119
|
+
if config.database.kind not in _KEYCLOAK_SQL_KINDS:
|
|
120
|
+
return
|
|
121
|
+
if "keycloak" not in config.database.extra_databases:
|
|
122
|
+
config.database.extra_databases.append("keycloak")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _apply_jdbc_drivers(config: ProjectConfig) -> None:
|
|
126
|
+
if config.database is None:
|
|
127
|
+
return
|
|
128
|
+
driver = _DB_JDBC_DRIVER.get(config.database.kind)
|
|
129
|
+
if driver is None:
|
|
130
|
+
return
|
|
131
|
+
for gw in config.gateways:
|
|
132
|
+
if driver not in gw.modules:
|
|
133
|
+
gw.modules.append(driver)
|
|
File without changes
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
## Finish the {{ connection.replace('-', ' ') }} connection
|
|
2
|
+
|
|
3
|
+
{{ reason }}
|
|
4
|
+
|
|
5
|
+
- **Open the gateway UI:** {{ gateway_url }}
|
|
6
|
+
- **Navigate to:** Config -> Connections, then open the `{{ connection }}` entry.
|
|
7
|
+
{%- if env_vars %}
|
|
8
|
+
- **Copy from `.env`:**
|
|
9
|
+
{%- for key, value in env_vars %}
|
|
10
|
+
- `{{ key }}` = `{{ value }}`
|
|
11
|
+
{%- endfor %}
|
|
12
|
+
{%- endif %}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
## Set up the device connection ({{ service }})
|
|
2
|
+
|
|
3
|
+
{{ reason }}
|
|
4
|
+
|
|
5
|
+
- **Open the gateway UI:** {{ gateway_url }}
|
|
6
|
+
- **Navigate to:** Config -> Device Connections -> Devices -> Create new Device, choose
|
|
7
|
+
the Modbus TCP driver, and point it at `{{ service }}` once the simulator is reachable.
|
|
8
|
+
- **Copy from `.env`:**
|
|
9
|
+
{%- for key, value in env_vars %}
|
|
10
|
+
- `{{ key }}` = `{{ value }}`
|
|
11
|
+
{%- endfor %}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
## Approve the gateway-network link
|
|
2
|
+
|
|
3
|
+
{{ reason }}
|
|
4
|
+
|
|
5
|
+
This stack ships two gateways that aggregate over the **gateway-network**. The
|
|
6
|
+
`gateway-network-link` row of the seedability matrix is partial, so approve the
|
|
7
|
+
link by hand:
|
|
8
|
+
{% for gw in gateways %}
|
|
9
|
+
- **{{ gw.role }} gateway** (`{{ gw.name }}`): {{ gw.url }}
|
|
10
|
+
{%- endfor %}
|
|
11
|
+
|
|
12
|
+
1. Open the **frontend** gateway, go to Config -> Networking -> Gateway Network, and
|
|
13
|
+
add an outgoing connection to `${COMPOSE_PROJECT_NAME}-backend` on port 8060.
|
|
14
|
+
2. Open the **backend** gateway and approve the incoming connection request.
|
|
15
|
+
- **Copy from `.env`:**
|
|
16
|
+
{%- for key, value in env_vars %}
|
|
17
|
+
- `{{ key }}` = `{{ value }}`
|
|
18
|
+
{%- endfor %}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
## Finish the OIDC identity provider (Keycloak)
|
|
2
|
+
|
|
3
|
+
{{ reason }}
|
|
4
|
+
|
|
5
|
+
- **Open Keycloak:** http://localhost:{{ env_map['KEYCLOAK_HTTP_PORT'] }}
|
|
6
|
+
(admin console -> Clients -> `ignition-gateway` -> Credentials -> copy the client secret)
|
|
7
|
+
- **Open the gateway UI:** {{ gateway_url }}
|
|
8
|
+
- **Navigate to:** Config -> Security -> Identity Providers, then add/edit the OIDC IdP.
|
|
9
|
+
- **Copy from `.env`:**
|
|
10
|
+
{%- for key, value in env_vars %}
|
|
11
|
+
- `{{ key }}` = `{{ value }}`
|
|
12
|
+
{%- endfor %}
|
|
13
|
+
- Paste the client secret you copied from Keycloak into the gateway IdP's **Client Secret** field.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
## Configure the Kafka connector
|
|
2
|
+
|
|
3
|
+
{{ reason }}
|
|
4
|
+
|
|
5
|
+
- **Open the gateway UI:** {{ gateway_url }}
|
|
6
|
+
- **Navigate to:** Config -> Kafka -> Connectors -> Create new Kafka connector, and
|
|
7
|
+
point its bootstrap servers at `kafka:9092`.
|
|
8
|
+
- **Copy from `.env`:**
|
|
9
|
+
{%- for key, value in env_vars %}
|
|
10
|
+
- `{{ key }}` = `{{ value }}`
|
|
11
|
+
{%- endfor %}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
## Drop in the Ignition MCP module
|
|
2
|
+
|
|
3
|
+
{{ reason }}
|
|
4
|
+
|
|
5
|
+
- **Request the module:** https://inductiveautomation.com/early-access (Ignition MCP,
|
|
6
|
+
Early-Access).
|
|
7
|
+
- **Drop the file here:** `{{ dropin_dir }}/<filename>.modl`
|
|
8
|
+
- Re-run `docker compose up -d`; the bootstrap copies any `.modl` in the drop-in dir
|
|
9
|
+
into the gateway's `user-lib/modules/` on startup.
|
|
10
|
+
- **Open the gateway UI:** {{ gateway_url }}
|
|
11
|
+
- **Navigate to:** Config -> Modules to confirm the MCP module loaded.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
## Link the gateway to the MQTT broker ({{ service }})
|
|
2
|
+
|
|
3
|
+
{{ reason }}
|
|
4
|
+
|
|
5
|
+
- **Open the gateway UI:** {{ gateway_url }}
|
|
6
|
+
- **Navigate to:** Config -> MQTT Engine -> Servers -> Settings (use MQTT Transmission
|
|
7
|
+
on an edge gateway instead), then add an MQTT server pointing at `{{ service }}`.
|
|
8
|
+
- **Copy from `.env`:**
|
|
9
|
+
{%- for key, value in env_vars %}
|
|
10
|
+
- `{{ key }}` = `{{ value }}`
|
|
11
|
+
{%- endfor %}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
## Confirm the OPC-UA connection ({{ service }})
|
|
2
|
+
|
|
3
|
+
{{ reason }}
|
|
4
|
+
|
|
5
|
+
- **Open the gateway UI:** {{ gateway_url }}
|
|
6
|
+
- **Navigate to:** Config -> OPC Connections -> Servers, then open the seeded OPC-UA
|
|
7
|
+
connection and confirm its endpoint URL and security mode against the running simulator.
|
|
8
|
+
- **Copy from `.env`:**
|
|
9
|
+
{%- for key, value in env_vars %}
|
|
10
|
+
- `{{ key }}` = `{{ value }}`
|
|
11
|
+
{%- endfor %}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
## Install the reverse proxy
|
|
2
|
+
|
|
3
|
+
{{ reason }}
|
|
4
|
+
|
|
5
|
+
- **Read the scaffolded README:** `{{ proxy_path }}/README.md`
|
|
6
|
+
- Clone ia-eknorr/traefik-reverse-proxy into `{{ proxy_path }}/` and follow its routing
|
|
7
|
+
and TLS instructions to put it in front of the gateway.
|
|
8
|
+
- **Open the gateway UI:** {{ gateway_url }} (today a plain host-port mapping).
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{{ name }}:
|
|
2
|
+
image: {{ image_ref }}
|
|
3
|
+
hostname: {{ name }}
|
|
4
|
+
container_name: {{ container_name_ref }}
|
|
5
|
+
environment:
|
|
6
|
+
TZ: ${TZ}
|
|
7
|
+
ACCEPT_EULA: "true"
|
|
8
|
+
ADMIN_PASSWORD: ${CHARIOT_ADMIN_PASSWORD}
|
|
9
|
+
ports:
|
|
10
|
+
- "${CHARIOT_HTTP_PORT}:8080"
|
|
11
|
+
- "${CHARIOT_MQTT_PORT}:1883"
|
|
12
|
+
{%- if networks %}
|
|
13
|
+
networks:
|
|
14
|
+
{%- for net in networks %}
|
|
15
|
+
- {{ net }}
|
|
16
|
+
{%- endfor %}
|
|
17
|
+
{%- endif %}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Chariot - the Cirrus Link MQTT broker, the primary pick for Sparkplug SCADA
|
|
2
|
+
# demos. Web UI + MQTT, commissioned from env on first boot.
|
|
3
|
+
name: chariot
|
|
4
|
+
kind: mqtt-broker
|
|
5
|
+
summary: Cirrus Link Chariot MQTT broker (Sparkplug-aware, web UI).
|
|
6
|
+
image: cirruslink/chariot:3.0.0
|
|
7
|
+
image_env: CHARIOT_IMAGE
|
|
8
|
+
network: backend
|
|
9
|
+
provides:
|
|
10
|
+
- mqtt-broker
|
|
11
|
+
requires: []
|
|
12
|
+
env:
|
|
13
|
+
CHARIOT_ADMIN_PASSWORD: password
|
|
14
|
+
CHARIOT_HTTP_PORT: "8090"
|
|
15
|
+
CHARIOT_MQTT_PORT: "1886"
|
|
16
|
+
seeds_gateway_resources: false
|
|
17
|
+
post_setup:
|
|
18
|
+
- connection: mqtt-engine-connection
|
|
19
|
+
reason: >-
|
|
20
|
+
Linking an Ignition gateway to the broker needs the Cirrus Link MQTT
|
|
21
|
+
Engine/Transmission module plus an MQTT server endpoint in the gateway,
|
|
22
|
+
configured once the stack is up.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Chariot MQTT Broker
|
|
2
|
+
|
|
3
|
+
Sparkplug-aware MQTT broker from Cirrus Link, the primary broker for Ignition
|
|
4
|
+
SCADA demos.
|
|
5
|
+
|
|
6
|
+
- Web UI: http://localhost:${CHARIOT_HTTP_PORT} (user `admin`, password
|
|
7
|
+
`${CHARIOT_ADMIN_PASSWORD}`).
|
|
8
|
+
- MQTT from another container: `tcp://chariot:1883`. From the host:
|
|
9
|
+
`tcp://localhost:${CHARIOT_MQTT_PORT}`.
|
|
10
|
+
|
|
11
|
+
The EULA is auto-accepted (`ACCEPT_EULA=true`) so the broker commissions itself
|
|
12
|
+
on first boot. To bridge an Ignition gateway, add the Cirrus Link MQTT
|
|
13
|
+
Transmission/Engine modules and point them at `tcp://chariot:1883`
|
|
14
|
+
(see POST-SETUP.md).
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{{ name }}:
|
|
2
|
+
image: {{ image_ref }}
|
|
3
|
+
hostname: {{ name }}
|
|
4
|
+
container_name: {{ container_name_ref }}
|
|
5
|
+
environment:
|
|
6
|
+
TZ: ${TZ}
|
|
7
|
+
EMQX_DASHBOARD__DEFAULT_PASSWORD: ${EMQX_DASHBOARD_PASSWORD}
|
|
8
|
+
ports:
|
|
9
|
+
- "${EMQX_MQTT_PORT}:1883"
|
|
10
|
+
- "${EMQX_DASHBOARD_PORT}:18083"
|
|
11
|
+
{%- if networks %}
|
|
12
|
+
networks:
|
|
13
|
+
{%- for net in networks %}
|
|
14
|
+
- {{ net }}
|
|
15
|
+
{%- endfor %}
|
|
16
|
+
{%- endif %}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# EMQX - MQTT broker with a web dashboard.
|
|
2
|
+
name: emqx
|
|
3
|
+
kind: mqtt-broker
|
|
4
|
+
summary: EMQX MQTT broker with management dashboard.
|
|
5
|
+
image: emqx/emqx:6.2.0
|
|
6
|
+
image_env: EMQX_IMAGE
|
|
7
|
+
network: backend
|
|
8
|
+
provides:
|
|
9
|
+
- mqtt-broker
|
|
10
|
+
requires: []
|
|
11
|
+
env:
|
|
12
|
+
EMQX_MQTT_PORT: "1884"
|
|
13
|
+
EMQX_DASHBOARD_PORT: "18083"
|
|
14
|
+
EMQX_DASHBOARD_PASSWORD: public
|
|
15
|
+
seeds_gateway_resources: false
|
|
16
|
+
post_setup:
|
|
17
|
+
- connection: mqtt-engine-connection
|
|
18
|
+
reason: >-
|
|
19
|
+
Linking an Ignition gateway to the broker needs the Cirrus Link MQTT
|
|
20
|
+
Engine/Transmission module plus an MQTT server endpoint in the gateway,
|
|
21
|
+
configured once the stack is up.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# EMQX
|
|
2
|
+
|
|
3
|
+
MQTT broker on `1883` (host port `${EMQX_MQTT_PORT}`) with a dashboard on
|
|
4
|
+
`18083` (host port `${EMQX_DASHBOARD_PORT}`).
|
|
5
|
+
|
|
6
|
+
- Dashboard: http://localhost:${EMQX_DASHBOARD_PORT} (user `admin`, password
|
|
7
|
+
`${EMQX_DASHBOARD_PASSWORD}`).
|
|
8
|
+
- MQTT from another container: `tcp://emqx:1883`.
|
|
9
|
+
|
|
10
|
+
To bridge an Ignition gateway, add the Cirrus Link MQTT Engine module and point
|
|
11
|
+
its server endpoint at `tcp://emqx:1883` (see POST-SETUP.md).
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# HiveMQ CE - lightweight MQTT broker, anonymous access by default.
|
|
2
|
+
name: hivemq
|
|
3
|
+
kind: mqtt-broker
|
|
4
|
+
summary: HiveMQ Community Edition MQTT broker.
|
|
5
|
+
image: hivemq/hivemq-ce:2025.5
|
|
6
|
+
image_env: HIVEMQ_IMAGE
|
|
7
|
+
network: backend
|
|
8
|
+
provides:
|
|
9
|
+
- mqtt-broker
|
|
10
|
+
requires: []
|
|
11
|
+
env:
|
|
12
|
+
HIVEMQ_MQTT_PORT: "1883"
|
|
13
|
+
seeds_gateway_resources: false
|
|
14
|
+
post_setup:
|
|
15
|
+
- connection: mqtt-engine-connection
|
|
16
|
+
reason: >-
|
|
17
|
+
Linking an Ignition gateway to the broker needs the Cirrus Link MQTT
|
|
18
|
+
Engine/Transmission module plus an MQTT server endpoint in the gateway,
|
|
19
|
+
configured once the stack is up.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# HiveMQ CE
|
|
2
|
+
|
|
3
|
+
Anonymous MQTT broker listening on `1883` (host port `${HIVEMQ_MQTT_PORT}`).
|
|
4
|
+
|
|
5
|
+
From another container on the stack, connect to `tcp://hivemq:1883`. From the
|
|
6
|
+
host, use `tcp://localhost:${HIVEMQ_MQTT_PORT}`.
|
|
7
|
+
|
|
8
|
+
Quick check with the Mosquitto CLI:
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
mosquitto_sub -h localhost -p ${HIVEMQ_MQTT_PORT} -t 'demo/#' &
|
|
12
|
+
mosquitto_pub -h localhost -p ${HIVEMQ_MQTT_PORT} -t 'demo/hello' -m 'from ignition-stack'
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
To bridge an Ignition gateway to this broker, add the Cirrus Link MQTT Engine
|
|
16
|
+
module and point its server endpoint at `tcp://hivemq:1883` (see POST-SETUP.md).
|