ignition-stack 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. ignition_stack/__init__.py +1 -0
  2. ignition_stack/catalog/__init__.py +10 -0
  3. ignition_stack/catalog/download.py +145 -0
  4. ignition_stack/catalog/loader.py +65 -0
  5. ignition_stack/catalog/schema.py +158 -0
  6. ignition_stack/catalog/verify.py +72 -0
  7. ignition_stack/cli.py +354 -0
  8. ignition_stack/commands/__init__.py +0 -0
  9. ignition_stack/commands/modules.py +178 -0
  10. ignition_stack/completion.py +46 -0
  11. ignition_stack/compose/__init__.py +4 -0
  12. ignition_stack/compose/engine.py +397 -0
  13. ignition_stack/compose/templates/footer.yaml.j2 +12 -0
  14. ignition_stack/compose/templates/header.yaml.j2 +14 -0
  15. ignition_stack/compose/templates/services/bootstrap.yaml.j2 +19 -0
  16. ignition_stack/compose/templates/services/ignition.yaml.j2 +35 -0
  17. ignition_stack/compose/writer.py +428 -0
  18. ignition_stack/config/__init__.py +8 -0
  19. ignition_stack/config/schema.py +311 -0
  20. ignition_stack/lifecycle/__init__.py +31 -0
  21. ignition_stack/lifecycle/cleanup.py +71 -0
  22. ignition_stack/lifecycle/record.py +67 -0
  23. ignition_stack/lifecycle/regenerate.py +62 -0
  24. ignition_stack/modules.yaml +83 -0
  25. ignition_stack/postsetup/__init__.py +3 -0
  26. ignition_stack/postsetup/generator.py +187 -0
  27. ignition_stack/profiles/__init__.py +27 -0
  28. ignition_stack/profiles/advisory.py +132 -0
  29. ignition_stack/profiles/base.py +108 -0
  30. ignition_stack/profiles/hub_and_spoke.py +87 -0
  31. ignition_stack/profiles/mcp_n8n.py +55 -0
  32. ignition_stack/profiles/scaleout.py +65 -0
  33. ignition_stack/profiles/standalone.py +44 -0
  34. ignition_stack/services/__init__.py +25 -0
  35. ignition_stack/services/loader.py +69 -0
  36. ignition_stack/services/manifest.py +106 -0
  37. ignition_stack/services/resolver.py +133 -0
  38. ignition_stack/templates/__init__.py +0 -0
  39. ignition_stack/templates/post-setup/_default.md.j2 +12 -0
  40. ignition_stack/templates/post-setup/device-connection.md.j2 +11 -0
  41. ignition_stack/templates/post-setup/gateway-network-link.md.j2 +18 -0
  42. ignition_stack/templates/post-setup/identity-provider.md.j2 +13 -0
  43. ignition_stack/templates/post-setup/kafka-connector.md.j2 +11 -0
  44. ignition_stack/templates/post-setup/mcp-module.md.j2 +11 -0
  45. ignition_stack/templates/post-setup/mqtt-engine-connection.md.j2 +11 -0
  46. ignition_stack/templates/post-setup/opc-ua-connection.md.j2 +11 -0
  47. ignition_stack/templates/post-setup/reverse-proxy.md.j2 +8 -0
  48. ignition_stack/templates/services/chariot/compose.yaml.j2 +17 -0
  49. ignition_stack/templates/services/chariot/manifest.yaml +22 -0
  50. ignition_stack/templates/services/chariot/seed/service/USAGE.md +14 -0
  51. ignition_stack/templates/services/emqx/compose.yaml.j2 +16 -0
  52. ignition_stack/templates/services/emqx/manifest.yaml +21 -0
  53. ignition_stack/templates/services/emqx/seed/service/USAGE.md +11 -0
  54. ignition_stack/templates/services/hivemq/compose.yaml.j2 +12 -0
  55. ignition_stack/templates/services/hivemq/manifest.yaml +19 -0
  56. ignition_stack/templates/services/hivemq/seed/service/USAGE.md +16 -0
  57. ignition_stack/templates/services/kafka/compose.yaml.j2 +27 -0
  58. ignition_stack/templates/services/kafka/manifest.yaml +20 -0
  59. ignition_stack/templates/services/kafka/seed/service/USAGE.md +17 -0
  60. ignition_stack/templates/services/keycloak/compose.yaml.j2 +31 -0
  61. ignition_stack/templates/services/keycloak/manifest.yaml +25 -0
  62. ignition_stack/templates/services/keycloak/seed/service/import/ignition-realm.json +31 -0
  63. ignition_stack/templates/services/mariadb/compose.yaml.j2 +26 -0
  64. ignition_stack/templates/services/mariadb/manifest.yaml +15 -0
  65. ignition_stack/templates/services/mariadb/seed/service/initdb/00-create-extra-databases.sh +19 -0
  66. ignition_stack/templates/services/modbus-sim/compose.yaml.j2 +12 -0
  67. ignition_stack/templates/services/modbus-sim/manifest.yaml +19 -0
  68. ignition_stack/templates/services/modbus-sim/seed/service/USAGE.md +10 -0
  69. ignition_stack/templates/services/mongo/compose.yaml.j2 +21 -0
  70. ignition_stack/templates/services/mongo/manifest.yaml +14 -0
  71. ignition_stack/templates/services/mongo/seed/service/initdb/01-demo-collection.js +10 -0
  72. ignition_stack/templates/services/mysql/compose.yaml.j2 +26 -0
  73. ignition_stack/templates/services/mysql/manifest.yaml +15 -0
  74. ignition_stack/templates/services/mysql/seed/service/initdb/00-create-extra-databases.sh +19 -0
  75. ignition_stack/templates/services/n8n/compose.yaml.j2 +16 -0
  76. ignition_stack/templates/services/n8n/manifest.yaml +16 -0
  77. ignition_stack/templates/services/n8n/seed/service/USAGE.md +11 -0
  78. ignition_stack/templates/services/opcua-sim/compose.yaml.j2 +13 -0
  79. ignition_stack/templates/services/opcua-sim/manifest.yaml +21 -0
  80. ignition_stack/templates/services/opcua-sim/seed/service/USAGE.md +11 -0
  81. ignition_stack/templates/services/postgres/compose.yaml.j2 +26 -0
  82. ignition_stack/templates/services/postgres/manifest.yaml +21 -0
  83. ignition_stack/templates/services/postgres/seed/gateway-resources/config/resources/core/ignition/database-connection/db/config.json +32 -0
  84. ignition_stack/templates/services/postgres/seed/gateway-resources/config/resources/core/ignition/database-connection/db/resource.json +19 -0
  85. ignition_stack/templates/services/postgres/seed/gateway-resources/config/resources/core/ignition/secret-provider/internal-secret-provider/config.json +19 -0
  86. ignition_stack/templates/services/postgres/seed/gateway-resources/config/resources/core/ignition/secret-provider/internal-secret-provider/resource.json +19 -0
  87. ignition_stack/templates/services/postgres/seed/service/initdb/00-create-extra-databases.sh +17 -0
  88. ignition_stack/templates/services/rabbitmq/compose.yaml.j2 +19 -0
  89. ignition_stack/templates/services/rabbitmq/manifest.yaml +23 -0
  90. ignition_stack/templates/services/rabbitmq/seed/service/enabled_plugins +1 -0
  91. ignition_stack/templates/standalone-postgres/docker-compose.yaml +62 -0
  92. ignition_stack/templates/standalone-postgres/scripts/docker-bootstrap.sh +78 -0
  93. ignition_stack/templates/standalone-postgres/services/ignition/config/resources/core/config-mode.json +7 -0
  94. ignition_stack/templates/standalone-postgres/services/ignition/config/resources/dev/config-mode.json +7 -0
  95. ignition_stack/templates/standalone-postgres/services/ignition/projects/.gitkeep +0 -0
  96. ignition_stack/wizard.py +362 -0
  97. ignition_stack-0.1.0.dist-info/METADATA +97 -0
  98. ignition_stack-0.1.0.dist-info/RECORD +100 -0
  99. ignition_stack-0.1.0.dist-info/WHEEL +4 -0
  100. ignition_stack-0.1.0.dist-info/entry_points.txt +2 -0
@@ -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