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 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Module + JDBC-driver catalog: schema, loader, verification, download."""
|
|
2
|
+
|
|
3
|
+
from ignition_stack.catalog.schema import (
|
|
4
|
+
Catalog,
|
|
5
|
+
CatalogEntry,
|
|
6
|
+
JdbcDriverEntry,
|
|
7
|
+
ModuleEntry,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = ["Catalog", "CatalogEntry", "JdbcDriverEntry", "ModuleEntry"]
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Host-side cache writer for catalog entries.
|
|
2
|
+
|
|
3
|
+
The cache lives at ``<project>/modules/cache/`` inside generated projects
|
|
4
|
+
and at any user-supplied path for the standalone ``modules download``
|
|
5
|
+
command. Each artifact is named by its in-container filename so the
|
|
6
|
+
compose-layer mount line is trivial.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import shutil
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from enum import StrEnum
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from ignition_stack.catalog.schema import SHA256_UNPINNED, CatalogEntry
|
|
19
|
+
from ignition_stack.catalog.verify import sha256_of_file
|
|
20
|
+
|
|
21
|
+
DOWNLOAD_TIMEOUT_SECONDS = 60.0
|
|
22
|
+
DEFAULT_CACHE_DIR = Path("modules/cache")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class DownloadOutcome(StrEnum):
|
|
26
|
+
DOWNLOADED = "downloaded"
|
|
27
|
+
COPIED_FROM_LOCAL = "copied-from-local"
|
|
28
|
+
SKIPPED_MANUAL = "skipped-manual"
|
|
29
|
+
SKIPPED_CACHED = "skipped-cached"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class DownloadResult:
|
|
34
|
+
entry_name: str
|
|
35
|
+
outcome: DownloadOutcome
|
|
36
|
+
path: Path | None
|
|
37
|
+
message: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class DownloadError(Exception):
|
|
41
|
+
"""Raised when a network download cannot be completed or fails sha256."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def download_entry(
|
|
45
|
+
entry: CatalogEntry,
|
|
46
|
+
cache_dir: Path,
|
|
47
|
+
*,
|
|
48
|
+
client: httpx.Client,
|
|
49
|
+
offline: bool = False,
|
|
50
|
+
) -> DownloadResult:
|
|
51
|
+
"""Materialise ``entry`` into ``cache_dir``.
|
|
52
|
+
|
|
53
|
+
Behaviour matrix:
|
|
54
|
+
- manual + local_source_path exists -> copy from local
|
|
55
|
+
- manual + local_source_path missing -> warn-and-skip (config drift)
|
|
56
|
+
- manual + no local_source_path -> skip with explanation
|
|
57
|
+
- already cached + sha matches -> skip (idempotent)
|
|
58
|
+
- offline -> error if not already cached
|
|
59
|
+
- normal -> http download + sha verify
|
|
60
|
+
"""
|
|
61
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
target = cache_dir / entry.cache_filename()
|
|
63
|
+
|
|
64
|
+
if entry.requires_manual_download:
|
|
65
|
+
return _handle_manual(entry, target)
|
|
66
|
+
|
|
67
|
+
if (
|
|
68
|
+
target.exists()
|
|
69
|
+
and entry.sha256 != SHA256_UNPINNED
|
|
70
|
+
and sha256_of_file(target) == entry.sha256
|
|
71
|
+
):
|
|
72
|
+
return DownloadResult(
|
|
73
|
+
entry.name,
|
|
74
|
+
DownloadOutcome.SKIPPED_CACHED,
|
|
75
|
+
target,
|
|
76
|
+
f"already cached at {target}",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
if offline:
|
|
80
|
+
raise DownloadError(
|
|
81
|
+
f"{entry.name}: --offline set but artifact not in cache "
|
|
82
|
+
f"({target}). Pre-populate the cache or drop --offline.",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
if entry.download_url is None:
|
|
86
|
+
raise DownloadError(
|
|
87
|
+
f"{entry.name}: no download_url and not marked manual-download.",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
_http_download(str(entry.download_url), target, client=client)
|
|
91
|
+
|
|
92
|
+
if entry.sha256 != SHA256_UNPINNED:
|
|
93
|
+
actual = sha256_of_file(target)
|
|
94
|
+
if actual != entry.sha256:
|
|
95
|
+
target.unlink(missing_ok=True)
|
|
96
|
+
raise DownloadError(
|
|
97
|
+
f"{entry.name}: sha256 mismatch after download "
|
|
98
|
+
f"(expected {entry.sha256}, got {actual}). Cached file removed.",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
return DownloadResult(
|
|
102
|
+
entry.name,
|
|
103
|
+
DownloadOutcome.DOWNLOADED,
|
|
104
|
+
target,
|
|
105
|
+
f"downloaded {entry.download_url} -> {target}",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _handle_manual(entry: CatalogEntry, target: Path) -> DownloadResult:
|
|
110
|
+
if entry.local_source_path is None:
|
|
111
|
+
return DownloadResult(
|
|
112
|
+
entry.name,
|
|
113
|
+
DownloadOutcome.SKIPPED_MANUAL,
|
|
114
|
+
None,
|
|
115
|
+
f"{entry.name} requires manual download (see POST-SETUP.md).",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
source = Path(entry.local_source_path)
|
|
119
|
+
if not source.is_file():
|
|
120
|
+
return DownloadResult(
|
|
121
|
+
entry.name,
|
|
122
|
+
DownloadOutcome.SKIPPED_MANUAL,
|
|
123
|
+
None,
|
|
124
|
+
(
|
|
125
|
+
f"WARN: {entry.name} local_source_path missing ({source}). "
|
|
126
|
+
"Skipping: requires manual download. "
|
|
127
|
+
"See POST-SETUP.md for instructions."
|
|
128
|
+
),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
shutil.copy2(source, target)
|
|
132
|
+
return DownloadResult(
|
|
133
|
+
entry.name,
|
|
134
|
+
DownloadOutcome.COPIED_FROM_LOCAL,
|
|
135
|
+
target,
|
|
136
|
+
f"copied local source {source} -> {target}",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _http_download(url: str, target: Path, *, client: httpx.Client) -> None:
|
|
141
|
+
with client.stream("GET", url, follow_redirects=True, timeout=DOWNLOAD_TIMEOUT_SECONDS) as r:
|
|
142
|
+
r.raise_for_status()
|
|
143
|
+
with target.open("wb") as fp:
|
|
144
|
+
for chunk in r.iter_bytes(chunk_size=1024 * 1024):
|
|
145
|
+
fp.write(chunk)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Load and validate modules.yaml into the pydantic Catalog model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib import resources
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
from pydantic import ValidationError
|
|
10
|
+
|
|
11
|
+
from ignition_stack.catalog.schema import Catalog
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CatalogLoadError(Exception):
|
|
15
|
+
"""Raised when modules.yaml cannot be read or fails schema validation."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
DEFAULT_CATALOG_NAME = "modules.yaml"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_catalog(path: Path | None = None) -> Catalog:
|
|
22
|
+
"""Load and validate the catalog.
|
|
23
|
+
|
|
24
|
+
When ``path`` is None, the catalog shipped with the installed package is
|
|
25
|
+
loaded. Otherwise the file at ``path`` is used (test fixtures, alternate
|
|
26
|
+
catalogs).
|
|
27
|
+
"""
|
|
28
|
+
yaml_text = _read_yaml_text(path)
|
|
29
|
+
try:
|
|
30
|
+
raw = yaml.safe_load(yaml_text)
|
|
31
|
+
except yaml.YAMLError as exc:
|
|
32
|
+
raise CatalogLoadError(f"modules.yaml is not valid YAML: {exc}") from exc
|
|
33
|
+
|
|
34
|
+
if not isinstance(raw, dict):
|
|
35
|
+
raise CatalogLoadError("modules.yaml top-level must be a mapping.")
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
return Catalog.model_validate(raw)
|
|
39
|
+
except ValidationError as exc:
|
|
40
|
+
raise CatalogLoadError(f"modules.yaml failed schema validation:\n{exc}") from exc
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _read_yaml_text(path: Path | None) -> str:
|
|
44
|
+
if path is not None:
|
|
45
|
+
if not path.is_file():
|
|
46
|
+
raise CatalogLoadError(f"Catalog not found at {path}.")
|
|
47
|
+
return path.read_text(encoding="utf-8")
|
|
48
|
+
|
|
49
|
+
# Installed wheels: modules.yaml is force-included as package data at
|
|
50
|
+
# ignition_stack/modules.yaml. Editable dev installs: it lives at the
|
|
51
|
+
# repo root next to pyproject.toml.
|
|
52
|
+
try:
|
|
53
|
+
bundled = resources.files("ignition_stack").joinpath(DEFAULT_CATALOG_NAME)
|
|
54
|
+
if bundled.is_file():
|
|
55
|
+
return bundled.read_text(encoding="utf-8")
|
|
56
|
+
except (FileNotFoundError, OSError, ModuleNotFoundError):
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
repo_root = Path(__file__).resolve().parents[2]
|
|
60
|
+
dev_path = repo_root / DEFAULT_CATALOG_NAME
|
|
61
|
+
if not dev_path.is_file():
|
|
62
|
+
raise CatalogLoadError(
|
|
63
|
+
f"Bundled catalog not found (looked for {dev_path}).",
|
|
64
|
+
)
|
|
65
|
+
return dev_path.read_text(encoding="utf-8")
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Pydantic schema for modules.yaml.
|
|
2
|
+
|
|
3
|
+
The catalog is the single source of truth for which .modl modules and JDBC
|
|
4
|
+
drivers the CLI knows how to download, cache-verify, and wire into a
|
|
5
|
+
generated stack. One file (`modules.yaml` at the repo root) is read at
|
|
6
|
+
both `modules` subcommand time and `init` time.
|
|
7
|
+
|
|
8
|
+
Two entry kinds: third-party Ignition modules (.modl) and JDBC drivers
|
|
9
|
+
(.jar). They are distinct shapes because Phase 1 confirmed the module
|
|
10
|
+
accept env vars (`ACCEPT_MODULE_LICENSES`, `ACCEPT_MODULE_CERTS`) take
|
|
11
|
+
**fully-qualified module identifiers, not paths** - while the
|
|
12
|
+
volume-mount needs the in-container install path.
|
|
13
|
+
JDBC drivers have no identifier and no accept-license env vars; they
|
|
14
|
+
are simply files in `user-lib/jdbc/`.
|
|
15
|
+
|
|
16
|
+
A discriminated union (`kind: module | jdbc_driver`) keeps the two
|
|
17
|
+
shapes statically separable so the compose generator emits the right
|
|
18
|
+
env vars for each.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from pathlib import PurePosixPath
|
|
24
|
+
from typing import Annotated, Literal
|
|
25
|
+
|
|
26
|
+
from pydantic import BaseModel, ConfigDict, Field, HttpUrl
|
|
27
|
+
|
|
28
|
+
# Sentinel for catalog entries whose sha256 has not yet been pinned by a
|
|
29
|
+
# maintainer (e.g. fresh release the maintainer is in the middle of
|
|
30
|
+
# bumping). `modules validate` rejects this so a half-bumped catalog
|
|
31
|
+
# cannot ship.
|
|
32
|
+
SHA256_UNPINNED = "UNPINNED"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _EntryBase(BaseModel):
|
|
36
|
+
"""Fields shared by every catalog entry."""
|
|
37
|
+
|
|
38
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
39
|
+
|
|
40
|
+
name: Annotated[
|
|
41
|
+
str,
|
|
42
|
+
Field(
|
|
43
|
+
min_length=1,
|
|
44
|
+
pattern=r"^[a-z0-9][a-z0-9-]*$",
|
|
45
|
+
description="Slug used in CLI output and as the cache filename stem.",
|
|
46
|
+
),
|
|
47
|
+
]
|
|
48
|
+
vendor: Annotated[str, Field(min_length=1)]
|
|
49
|
+
ignition_versions: Annotated[
|
|
50
|
+
list[str],
|
|
51
|
+
Field(
|
|
52
|
+
min_length=1,
|
|
53
|
+
description=(
|
|
54
|
+
"Exact Ignition versions this entry is verified against "
|
|
55
|
+
"(e.g. ['8.3.6', '8.3.7']). Resolution is exact-match: bump "
|
|
56
|
+
"the list when a new Ignition patch is validated."
|
|
57
|
+
),
|
|
58
|
+
),
|
|
59
|
+
]
|
|
60
|
+
download_url: HttpUrl | None = Field(
|
|
61
|
+
default=None,
|
|
62
|
+
description=("Public download URL. Required unless requires_manual_download is true."),
|
|
63
|
+
)
|
|
64
|
+
sha256: Annotated[
|
|
65
|
+
str,
|
|
66
|
+
Field(
|
|
67
|
+
pattern=rf"^([0-9a-f]{{64}}|{SHA256_UNPINNED})$",
|
|
68
|
+
description=(
|
|
69
|
+
f"Lowercase hex sha256 of the artifact, or '{SHA256_UNPINNED}' "
|
|
70
|
+
"while a maintainer is mid-bump (rejected by `modules validate`)."
|
|
71
|
+
),
|
|
72
|
+
),
|
|
73
|
+
]
|
|
74
|
+
install_path: Annotated[
|
|
75
|
+
str,
|
|
76
|
+
Field(
|
|
77
|
+
min_length=1,
|
|
78
|
+
description=(
|
|
79
|
+
"Fully-qualified in-container destination path. The compose "
|
|
80
|
+
"layer mounts/copies the cached artifact here."
|
|
81
|
+
),
|
|
82
|
+
),
|
|
83
|
+
]
|
|
84
|
+
requires_license_env: str | None = Field(
|
|
85
|
+
default=None,
|
|
86
|
+
description=(
|
|
87
|
+
"Name of an env var the user must set with their license key. "
|
|
88
|
+
"None for community-usable modules and unlicensed drivers."
|
|
89
|
+
),
|
|
90
|
+
)
|
|
91
|
+
requires_manual_download: bool = Field(
|
|
92
|
+
default=False,
|
|
93
|
+
description=(
|
|
94
|
+
"True when the artifact has no public URL (e.g. EA-gated). "
|
|
95
|
+
"`modules download` skips these unless local_source_path is set "
|
|
96
|
+
"and points at an existing file."
|
|
97
|
+
),
|
|
98
|
+
)
|
|
99
|
+
local_source_path: str | None = Field(
|
|
100
|
+
default=None,
|
|
101
|
+
description=(
|
|
102
|
+
"Optional maintainer-configured absolute path on the host to a "
|
|
103
|
+
"locally-stored copy. Used only when requires_manual_download is "
|
|
104
|
+
"true; when present and the file exists, `download` copies it "
|
|
105
|
+
"into the cache instead of fetching."
|
|
106
|
+
),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def cache_filename(self) -> str:
|
|
110
|
+
"""Filename used inside the host-side cache dir."""
|
|
111
|
+
return PurePosixPath(self.install_path).name
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class ModuleEntry(_EntryBase):
|
|
115
|
+
"""A third-party Ignition `.modl` module."""
|
|
116
|
+
|
|
117
|
+
kind: Literal["module"] = "module"
|
|
118
|
+
module_identifier: Annotated[
|
|
119
|
+
str,
|
|
120
|
+
Field(
|
|
121
|
+
min_length=1,
|
|
122
|
+
pattern=r"^[a-z0-9.]+$",
|
|
123
|
+
description=(
|
|
124
|
+
"Fully-qualified module identifier (e.g. "
|
|
125
|
+
"'com.cirruslink.mqtt.engine.gateway'). Used verbatim in "
|
|
126
|
+
"ACCEPT_MODULE_LICENSES and ACCEPT_MODULE_CERTS. NOT a path."
|
|
127
|
+
),
|
|
128
|
+
),
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class JdbcDriverEntry(_EntryBase):
|
|
133
|
+
"""A JDBC driver `.jar` dropped into `user-lib/jdbc/`."""
|
|
134
|
+
|
|
135
|
+
kind: Literal["jdbc_driver"] = "jdbc_driver"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
CatalogEntry = Annotated[ModuleEntry | JdbcDriverEntry, Field(discriminator="kind")]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class Catalog(BaseModel):
|
|
142
|
+
"""Top-level shape of modules.yaml."""
|
|
143
|
+
|
|
144
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
145
|
+
|
|
146
|
+
version: Annotated[int, Field(ge=1, description="Schema version; bump on breaking change.")]
|
|
147
|
+
entries: Annotated[list[CatalogEntry], Field(min_length=1)]
|
|
148
|
+
|
|
149
|
+
def by_name(self, name: str) -> CatalogEntry:
|
|
150
|
+
"""Look up an entry by slug; raises KeyError if absent."""
|
|
151
|
+
for entry in self.entries:
|
|
152
|
+
if entry.name == name:
|
|
153
|
+
return entry
|
|
154
|
+
raise KeyError(name)
|
|
155
|
+
|
|
156
|
+
def for_ignition(self, ignition_version: str) -> list[CatalogEntry]:
|
|
157
|
+
"""Entries verified against the given exact Ignition version."""
|
|
158
|
+
return [e for e in self.entries if ignition_version in e.ignition_versions]
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Reachability + sha256 verification for catalog entries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from ignition_stack.catalog.schema import SHA256_UNPINNED, CatalogEntry
|
|
12
|
+
|
|
13
|
+
REACHABILITY_TIMEOUT_SECONDS = 10.0
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class VerifyIssue:
|
|
18
|
+
"""One problem found while validating an entry."""
|
|
19
|
+
|
|
20
|
+
entry_name: str
|
|
21
|
+
reason: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def verify_reachable(entry: CatalogEntry, client: httpx.Client) -> VerifyIssue | None:
|
|
25
|
+
"""HEAD-check the entry's download URL. None means reachable.
|
|
26
|
+
|
|
27
|
+
Entries that are manual-download are skipped (no URL to check). The
|
|
28
|
+
response must be 2xx; many CDNs reject HEAD with 405 but accept GET,
|
|
29
|
+
so a 405 falls through to a small range GET before declaring failure.
|
|
30
|
+
"""
|
|
31
|
+
if entry.requires_manual_download:
|
|
32
|
+
return None
|
|
33
|
+
if entry.download_url is None:
|
|
34
|
+
return VerifyIssue(entry.name, "no download_url and not marked manual")
|
|
35
|
+
|
|
36
|
+
url = str(entry.download_url)
|
|
37
|
+
try:
|
|
38
|
+
response = client.head(url, follow_redirects=True, timeout=REACHABILITY_TIMEOUT_SECONDS)
|
|
39
|
+
if response.status_code == 405:
|
|
40
|
+
response = client.get(
|
|
41
|
+
url,
|
|
42
|
+
follow_redirects=True,
|
|
43
|
+
timeout=REACHABILITY_TIMEOUT_SECONDS,
|
|
44
|
+
headers={"Range": "bytes=0-0"},
|
|
45
|
+
)
|
|
46
|
+
if response.status_code >= 400:
|
|
47
|
+
return VerifyIssue(entry.name, f"HTTP {response.status_code} for {url}")
|
|
48
|
+
except httpx.HTTPError as exc:
|
|
49
|
+
return VerifyIssue(entry.name, f"unreachable: {exc} ({url})")
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def sha256_of_file(path: Path) -> str:
|
|
54
|
+
"""Lowercase hex sha256 digest of a file on disk."""
|
|
55
|
+
h = hashlib.sha256()
|
|
56
|
+
with path.open("rb") as fp:
|
|
57
|
+
for chunk in iter(lambda: fp.read(1024 * 1024), b""):
|
|
58
|
+
h.update(chunk)
|
|
59
|
+
return h.hexdigest()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def verify_checksum(entry: CatalogEntry, file_path: Path) -> VerifyIssue | None:
|
|
63
|
+
"""Check the file at ``file_path`` matches the entry's pinned sha256."""
|
|
64
|
+
if entry.sha256 == SHA256_UNPINNED:
|
|
65
|
+
return VerifyIssue(entry.name, "sha256 is UNPINNED (maintainer must pin before release)")
|
|
66
|
+
actual = sha256_of_file(file_path)
|
|
67
|
+
if actual != entry.sha256:
|
|
68
|
+
return VerifyIssue(
|
|
69
|
+
entry.name,
|
|
70
|
+
f"sha256 mismatch: expected {entry.sha256}, got {actual}",
|
|
71
|
+
)
|
|
72
|
+
return None
|