|
|
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
|
|
|
from collections import defaultdict
|
|
|
|
|
from collections.abc import Iterable, Mapping
|
|
|
|
|
from contextlib import AbstractContextManager, closing
|
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
|
from dataclasses import dataclass, field, replace
|
|
|
|
|
from datetime import UTC, datetime
|
|
|
|
|
from importlib import metadata
|
|
|
|
|
import hashlib
|
|
|
|
@@ -18,6 +18,7 @@ import sqlite3
|
|
|
|
|
import stat
|
|
|
|
|
import subprocess # nosec B404 - installer commands are structured and policy-validated before execution.
|
|
|
|
|
import sys
|
|
|
|
|
import tempfile
|
|
|
|
|
import tomllib
|
|
|
|
|
from typing import Any, Literal
|
|
|
|
|
import time
|
|
|
|
@@ -62,6 +63,7 @@ MIGRATION_TASK_PHASES = (
|
|
|
|
|
MIGRATION_TASK_MUTATING_PHASES = {"pre_migration_prepare", "post_migration_backfill"}
|
|
|
|
|
MIGRATION_TASK_REVIEW_SAFETY = {"requires_review", "forward_only", "destructive"}
|
|
|
|
|
MIGRATION_TASK_BLOCKING_SAFETY = {"forward_only", "destructive"}
|
|
|
|
|
MAX_PACKAGE_ARTIFACT_BYTES = 512 * 1024 * 1024
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
|
|
@@ -464,14 +466,20 @@ def _package_target_action_preflight_issues(
|
|
|
|
|
"Python installs must include the distribution package name so rollback can uninstall newly added packages.",
|
|
|
|
|
item.module_id,
|
|
|
|
|
))
|
|
|
|
|
if item.python_ref and not _looks_pinned_dependency_ref(item.python_ref):
|
|
|
|
|
if item.python_ref and not (
|
|
|
|
|
_looks_pinned_dependency_ref(item.python_ref)
|
|
|
|
|
or _artifact_ref_is_digest_pinned(item, "python", item.python_ref)
|
|
|
|
|
):
|
|
|
|
|
issues.append(ModuleInstallerIssue(
|
|
|
|
|
"blocker",
|
|
|
|
|
"unpinned_python_ref",
|
|
|
|
|
"Python install refs must be pinned to an exact version or tagged git ref.",
|
|
|
|
|
item.module_id,
|
|
|
|
|
))
|
|
|
|
|
if item.webui_ref and not _looks_pinned_dependency_ref(item.webui_ref):
|
|
|
|
|
if item.webui_ref and not (
|
|
|
|
|
_looks_pinned_dependency_ref(item.webui_ref)
|
|
|
|
|
or _artifact_ref_is_digest_pinned(item, "webui", item.webui_ref)
|
|
|
|
|
):
|
|
|
|
|
issues.append(ModuleInstallerIssue(
|
|
|
|
|
"blocker",
|
|
|
|
|
"unpinned_webui_ref",
|
|
|
|
@@ -481,6 +489,23 @@ def _package_target_action_preflight_issues(
|
|
|
|
|
return tuple(issues)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _artifact_ref_is_digest_pinned(
|
|
|
|
|
item: ModuleInstallPlanItem,
|
|
|
|
|
kind: str,
|
|
|
|
|
package_ref: str,
|
|
|
|
|
) -> bool:
|
|
|
|
|
metadata = _artifact_metadata(item.artifact_integrity, kind)
|
|
|
|
|
if metadata is None:
|
|
|
|
|
return False
|
|
|
|
|
expected_ref = _artifact_text(metadata, "ref") or _artifact_text(metadata, "expected_ref")
|
|
|
|
|
sha256 = _artifact_text(metadata, "sha256")
|
|
|
|
|
return bool(
|
|
|
|
|
expected_ref == package_ref
|
|
|
|
|
and sha256
|
|
|
|
|
and re.fullmatch(r"[0-9a-f]{64}", sha256.lower())
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _frontend_rebuild_preflight_issues(
|
|
|
|
|
*,
|
|
|
|
|
frontend_rebuild_required: bool,
|
|
|
|
@@ -519,6 +544,7 @@ def run_module_install_plan(
|
|
|
|
|
) -> ModuleInstallerRunResult:
|
|
|
|
|
maintenance_mode = saved_maintenance_mode(session)
|
|
|
|
|
effective_runtime_dir = runtime_dir or default_installer_runtime_dir(database_url)
|
|
|
|
|
effective_plan = plan
|
|
|
|
|
preflight = module_install_preflight(
|
|
|
|
|
plan=plan,
|
|
|
|
|
available=available,
|
|
|
|
@@ -532,9 +558,30 @@ def run_module_install_plan(
|
|
|
|
|
if not preflight.allowed:
|
|
|
|
|
raise ModuleInstallerError("Install preflight is blocked: " + "; ".join(issue.message for issue in preflight.issues if issue.severity == "blocker"))
|
|
|
|
|
|
|
|
|
|
if not dry_run:
|
|
|
|
|
effective_plan = acquire_catalog_package_artifacts(
|
|
|
|
|
plan,
|
|
|
|
|
runtime_dir=effective_runtime_dir,
|
|
|
|
|
)
|
|
|
|
|
preflight = module_install_preflight(
|
|
|
|
|
plan=effective_plan,
|
|
|
|
|
available=available,
|
|
|
|
|
current_enabled=current_enabled,
|
|
|
|
|
desired_enabled=desired_enabled,
|
|
|
|
|
maintenance_mode=maintenance_mode.enabled,
|
|
|
|
|
session=session,
|
|
|
|
|
webui_root=webui_root,
|
|
|
|
|
runtime_dir=effective_runtime_dir,
|
|
|
|
|
)
|
|
|
|
|
if not preflight.allowed:
|
|
|
|
|
raise ModuleInstallerError(
|
|
|
|
|
"Install preflight is blocked after artifact acquisition: "
|
|
|
|
|
+ "; ".join(issue.message for issue in preflight.issues if issue.severity == "blocker")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
state = _prepare_module_install_run(
|
|
|
|
|
session=session,
|
|
|
|
|
plan=plan,
|
|
|
|
|
plan=effective_plan,
|
|
|
|
|
preflight=preflight,
|
|
|
|
|
database_url=database_url,
|
|
|
|
|
effective_runtime_dir=effective_runtime_dir,
|
|
|
|
@@ -556,7 +603,7 @@ def run_module_install_plan(
|
|
|
|
|
|
|
|
|
|
executed, failed_error = _execute_module_install_run(
|
|
|
|
|
session=session,
|
|
|
|
|
plan=plan,
|
|
|
|
|
plan=effective_plan,
|
|
|
|
|
available=available,
|
|
|
|
|
effective_runtime_dir=effective_runtime_dir,
|
|
|
|
|
state=state,
|
|
|
|
@@ -566,7 +613,7 @@ def run_module_install_plan(
|
|
|
|
|
return _failed_module_install_run_result(
|
|
|
|
|
session=session,
|
|
|
|
|
state=state,
|
|
|
|
|
plan=plan,
|
|
|
|
|
plan=effective_plan,
|
|
|
|
|
executed=executed,
|
|
|
|
|
failed_error=failed_error,
|
|
|
|
|
effective_runtime_dir=effective_runtime_dir,
|
|
|
|
@@ -578,7 +625,7 @@ def run_module_install_plan(
|
|
|
|
|
|
|
|
|
|
return _applied_module_install_run_result(
|
|
|
|
|
session=session,
|
|
|
|
|
plan=plan,
|
|
|
|
|
plan=effective_plan,
|
|
|
|
|
desired_enabled=desired_enabled,
|
|
|
|
|
activate_installed_modules=activate_installed_modules,
|
|
|
|
|
remove_uninstalled_modules_from_desired=remove_uninstalled_modules_from_desired,
|
|
|
|
@@ -1568,9 +1615,11 @@ def _structured_item_commands(
|
|
|
|
|
webui_changed = False
|
|
|
|
|
if item.action in PACKAGE_TARGET_ACTIONS:
|
|
|
|
|
if item.python_ref:
|
|
|
|
|
commands.append(_structured_command([sys.executable, "-m", "pip", "install", item.python_ref], source="module-plan.python"))
|
|
|
|
|
python_source = _verified_artifact_install_ref(item, "python") or item.python_ref
|
|
|
|
|
commands.append(_structured_command([sys.executable, "-m", "pip", "install", python_source], source="module-plan.python"))
|
|
|
|
|
if item.webui_package and item.webui_ref and webui_root is not None:
|
|
|
|
|
commands.append(_structured_command([npm_bin, "pkg", "set", f"dependencies.{item.webui_package}={item.webui_ref}"], cwd=webui_root, source="module-plan.webui"))
|
|
|
|
|
webui_source = _verified_artifact_install_ref(item, "webui") or item.webui_ref
|
|
|
|
|
commands.append(_structured_command([npm_bin, "pkg", "set", f"dependencies.{item.webui_package}={webui_source}"], cwd=webui_root, source="module-plan.webui"))
|
|
|
|
|
webui_changed = True
|
|
|
|
|
elif item.action == "uninstall":
|
|
|
|
|
if item.python_package:
|
|
|
|
@@ -1581,6 +1630,14 @@ def _structured_item_commands(
|
|
|
|
|
return tuple(commands), webui_changed
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _verified_artifact_install_ref(item: ModuleInstallPlanItem, kind: str) -> str | None:
|
|
|
|
|
metadata = _artifact_metadata(item.artifact_integrity, kind)
|
|
|
|
|
path = _artifact_path(metadata) if metadata is not None else None
|
|
|
|
|
if path is None:
|
|
|
|
|
return None
|
|
|
|
|
return path.as_uri() if kind == "webui" else str(path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _structured_webui_followup_commands(
|
|
|
|
|
*,
|
|
|
|
|
webui_changed: bool,
|
|
|
|
@@ -1932,9 +1989,7 @@ def _package_catalog_preflight_issues(
|
|
|
|
|
return ()
|
|
|
|
|
catalog_items = tuple(item for item in package_items if item.source == "catalog")
|
|
|
|
|
try:
|
|
|
|
|
from govoplan_core.core.module_package_catalog import validate_module_package_catalog
|
|
|
|
|
|
|
|
|
|
result = validate_module_package_catalog()
|
|
|
|
|
result = _validate_catalog_for_plan(catalog_items)
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
return _catalog_validation_exception_issues(exc, catalog_items=bool(catalog_items))
|
|
|
|
|
issues = list(_catalog_validation_result_issues(result, catalog_items=bool(catalog_items)))
|
|
|
|
@@ -1942,10 +1997,33 @@ def _package_catalog_preflight_issues(
|
|
|
|
|
return tuple(issues)
|
|
|
|
|
issues.extend(_catalog_warning_issues(result))
|
|
|
|
|
if catalog_items:
|
|
|
|
|
issues.extend(_catalog_plan_binding_issues(catalog_items, result))
|
|
|
|
|
issues.extend(_selected_catalog_interface_issues(catalog_items, result, available))
|
|
|
|
|
return tuple(issues)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _validate_catalog_for_plan(
|
|
|
|
|
catalog_items: tuple[ModuleInstallPlanItem, ...],
|
|
|
|
|
) -> dict[str, object]:
|
|
|
|
|
from govoplan_core.core.module_package_catalog import (
|
|
|
|
|
OFFICIAL_MODULE_PACKAGE_CATALOG_URL,
|
|
|
|
|
validate_module_package_catalog,
|
|
|
|
|
validate_official_module_package_catalog,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
configured = validate_module_package_catalog()
|
|
|
|
|
if configured.get("configured") or not catalog_items:
|
|
|
|
|
return configured
|
|
|
|
|
sources = {
|
|
|
|
|
str(item.catalog.get("source") or "")
|
|
|
|
|
for item in catalog_items
|
|
|
|
|
if isinstance(item.catalog, Mapping)
|
|
|
|
|
}
|
|
|
|
|
if sources == {OFFICIAL_MODULE_PACKAGE_CATALOG_URL}:
|
|
|
|
|
return validate_official_module_package_catalog()
|
|
|
|
|
return configured
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _catalog_validation_exception_issues(exc: Exception, *, catalog_items: bool) -> tuple[ModuleInstallerIssue, ...]:
|
|
|
|
|
severity: IssueSeverity = "blocker" if catalog_items else "warning"
|
|
|
|
|
return (ModuleInstallerIssue(
|
|
|
|
@@ -1985,6 +2063,97 @@ def _catalog_warning_issues(result: Mapping[str, object]) -> tuple[ModuleInstall
|
|
|
|
|
return tuple(ModuleInstallerIssue("warning", "catalog_warning", str(warning)) for warning in warnings)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _catalog_plan_binding_issues(
|
|
|
|
|
items: tuple[ModuleInstallPlanItem, ...],
|
|
|
|
|
validation: Mapping[str, object],
|
|
|
|
|
) -> tuple[ModuleInstallerIssue, ...]:
|
|
|
|
|
"""Require every trusted plan row to match its signed catalog entry exactly."""
|
|
|
|
|
|
|
|
|
|
modules = _catalog_modules_by_id(validation)
|
|
|
|
|
issues: list[ModuleInstallerIssue] = []
|
|
|
|
|
for item in items:
|
|
|
|
|
entry = modules.get(item.module_id)
|
|
|
|
|
if entry is None or entry.get("action") not in PACKAGE_TARGET_ACTIONS:
|
|
|
|
|
issues.append(ModuleInstallerIssue(
|
|
|
|
|
"blocker",
|
|
|
|
|
"catalog_plan_entry_missing",
|
|
|
|
|
f"The validated catalog no longer contains an install or update entry for {item.module_id!r}.",
|
|
|
|
|
item.module_id,
|
|
|
|
|
))
|
|
|
|
|
continue
|
|
|
|
|
mismatches = _catalog_plan_entry_mismatches(item, entry, validation)
|
|
|
|
|
if mismatches:
|
|
|
|
|
issues.append(ModuleInstallerIssue(
|
|
|
|
|
"blocker",
|
|
|
|
|
"catalog_plan_binding_mismatch",
|
|
|
|
|
(
|
|
|
|
|
"The saved package plan differs from its validated signed catalog entry "
|
|
|
|
|
f"for: {', '.join(mismatches)}. Remove and add the catalog item again."
|
|
|
|
|
),
|
|
|
|
|
item.module_id,
|
|
|
|
|
))
|
|
|
|
|
return tuple(issues)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _catalog_plan_entry_mismatches(
|
|
|
|
|
item: ModuleInstallPlanItem,
|
|
|
|
|
entry: Mapping[str, object],
|
|
|
|
|
validation: Mapping[str, object],
|
|
|
|
|
) -> tuple[str, ...]:
|
|
|
|
|
mismatches: list[str] = []
|
|
|
|
|
for attribute in ("python_package", "python_ref", "webui_package", "webui_ref"):
|
|
|
|
|
if getattr(item, attribute) != _catalog_optional_string(entry, attribute):
|
|
|
|
|
mismatches.append(attribute)
|
|
|
|
|
|
|
|
|
|
if _catalog_integrity_identity(item.artifact_integrity) != _catalog_integrity_identity(
|
|
|
|
|
entry.get("artifact_integrity")
|
|
|
|
|
):
|
|
|
|
|
mismatches.append("artifact_integrity")
|
|
|
|
|
|
|
|
|
|
catalog = item.catalog if isinstance(item.catalog, Mapping) else {}
|
|
|
|
|
expected_snapshot = {
|
|
|
|
|
"source": validation.get("source") or validation.get("path"),
|
|
|
|
|
"channel": validation.get("channel"),
|
|
|
|
|
"sequence": validation.get("sequence"),
|
|
|
|
|
"signed": bool(validation.get("signed")),
|
|
|
|
|
"trusted": bool(validation.get("trusted")),
|
|
|
|
|
"key_id": validation.get("key_id"),
|
|
|
|
|
}
|
|
|
|
|
for attribute, expected in expected_snapshot.items():
|
|
|
|
|
actual = catalog.get(attribute)
|
|
|
|
|
if actual != expected:
|
|
|
|
|
mismatches.append(f"catalog.{attribute}")
|
|
|
|
|
return tuple(mismatches)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _catalog_integrity_identity(value: object) -> dict[str, dict[str, object]]:
|
|
|
|
|
if not isinstance(value, Mapping):
|
|
|
|
|
return {}
|
|
|
|
|
identity: dict[str, dict[str, object]] = {}
|
|
|
|
|
for kind in ("python", "webui"):
|
|
|
|
|
raw = value.get(kind)
|
|
|
|
|
if not isinstance(raw, Mapping):
|
|
|
|
|
continue
|
|
|
|
|
identity[kind] = {
|
|
|
|
|
field: raw.get(field)
|
|
|
|
|
for field in (
|
|
|
|
|
"ref",
|
|
|
|
|
"url",
|
|
|
|
|
"filename",
|
|
|
|
|
"sha256",
|
|
|
|
|
"size",
|
|
|
|
|
"integrity",
|
|
|
|
|
"sbom_url",
|
|
|
|
|
"provenance_url",
|
|
|
|
|
"registry_identity",
|
|
|
|
|
"git_ref",
|
|
|
|
|
"source_commit",
|
|
|
|
|
)
|
|
|
|
|
if raw.get(field) is not None
|
|
|
|
|
}
|
|
|
|
|
return identity
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _module_install_target_plan(
|
|
|
|
|
plan: ModuleInstallPlan,
|
|
|
|
|
available: Mapping[str, ModuleManifest],
|
|
|
|
@@ -2817,12 +2986,15 @@ def _topological_cycle_ids(incoming: Mapping[str, set[str]]) -> tuple[str, ...]:
|
|
|
|
|
def _catalog_modules_for_target_plan(
|
|
|
|
|
planned_items: tuple[ModuleInstallPlanItem, ...],
|
|
|
|
|
) -> dict[str, Mapping[str, object]]:
|
|
|
|
|
if not any(item.source == "catalog" and item.action in PACKAGE_TARGET_ACTIONS for item in planned_items):
|
|
|
|
|
catalog_items = tuple(
|
|
|
|
|
item
|
|
|
|
|
for item in planned_items
|
|
|
|
|
if item.source == "catalog" and item.action in PACKAGE_TARGET_ACTIONS
|
|
|
|
|
)
|
|
|
|
|
if not catalog_items:
|
|
|
|
|
return {}
|
|
|
|
|
try:
|
|
|
|
|
from govoplan_core.core.module_package_catalog import validate_module_package_catalog
|
|
|
|
|
|
|
|
|
|
result = validate_module_package_catalog()
|
|
|
|
|
result = _validate_catalog_for_plan(catalog_items)
|
|
|
|
|
except Exception:
|
|
|
|
|
return {}
|
|
|
|
|
if result.get("valid") is not True:
|
|
|
|
@@ -3772,6 +3944,156 @@ def _configured_require_artifact_integrity() -> bool:
|
|
|
|
|
return os.getenv("GOVOPLAN_MODULE_INSTALLER_REQUIRE_ARTIFACT_INTEGRITY", "").strip().lower() in {"1", "true", "yes", "on"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def acquire_catalog_package_artifacts(
|
|
|
|
|
plan: ModuleInstallPlan,
|
|
|
|
|
*,
|
|
|
|
|
runtime_dir: Path,
|
|
|
|
|
) -> ModuleInstallPlan:
|
|
|
|
|
"""Materialize trusted catalog archives before package mutation."""
|
|
|
|
|
|
|
|
|
|
items: list[ModuleInstallPlanItem] = []
|
|
|
|
|
for item in plan.items:
|
|
|
|
|
if item.status != "planned" or item.action not in PACKAGE_TARGET_ACTIONS:
|
|
|
|
|
items.append(item)
|
|
|
|
|
continue
|
|
|
|
|
raw_integrity = item.artifact_integrity
|
|
|
|
|
if not isinstance(raw_integrity, Mapping):
|
|
|
|
|
items.append(item)
|
|
|
|
|
continue
|
|
|
|
|
integrity: dict[str, object] = dict(raw_integrity)
|
|
|
|
|
changed = False
|
|
|
|
|
for kind in ("python", "webui"):
|
|
|
|
|
metadata = _artifact_metadata(integrity, kind)
|
|
|
|
|
if metadata is None or _artifact_path(metadata) is not None:
|
|
|
|
|
continue
|
|
|
|
|
if not _catalog_artifact_acquisition_ready(item, metadata):
|
|
|
|
|
continue
|
|
|
|
|
updated = dict(metadata)
|
|
|
|
|
updated["artifact_path"] = str(
|
|
|
|
|
_acquire_package_artifact(
|
|
|
|
|
metadata,
|
|
|
|
|
runtime_dir=runtime_dir,
|
|
|
|
|
module_id=item.module_id,
|
|
|
|
|
kind=kind,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
integrity[kind] = updated
|
|
|
|
|
changed = True
|
|
|
|
|
items.append(replace(item, artifact_integrity=integrity) if changed else item)
|
|
|
|
|
return replace(plan, items=tuple(items))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _catalog_artifact_acquisition_ready(
|
|
|
|
|
item: ModuleInstallPlanItem,
|
|
|
|
|
metadata: Mapping[str, object],
|
|
|
|
|
) -> bool:
|
|
|
|
|
catalog = item.catalog
|
|
|
|
|
if (
|
|
|
|
|
item.source != "catalog"
|
|
|
|
|
or not isinstance(catalog, Mapping)
|
|
|
|
|
or catalog.get("signed") is not True
|
|
|
|
|
or catalog.get("trusted") is not True
|
|
|
|
|
):
|
|
|
|
|
return False
|
|
|
|
|
url = _artifact_text(metadata, "url")
|
|
|
|
|
filename = _artifact_text(metadata, "filename")
|
|
|
|
|
sha256 = _artifact_text(metadata, "sha256")
|
|
|
|
|
size = metadata.get("size")
|
|
|
|
|
return bool(
|
|
|
|
|
url
|
|
|
|
|
and url.startswith("https://")
|
|
|
|
|
and filename
|
|
|
|
|
and Path(filename).name == filename
|
|
|
|
|
and sha256
|
|
|
|
|
and re.fullmatch(r"[0-9a-f]{64}", sha256.lower())
|
|
|
|
|
and isinstance(size, int)
|
|
|
|
|
and not isinstance(size, bool)
|
|
|
|
|
and 0 < size <= MAX_PACKAGE_ARTIFACT_BYTES
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _acquire_package_artifact(
|
|
|
|
|
metadata: Mapping[str, object],
|
|
|
|
|
*,
|
|
|
|
|
runtime_dir: Path,
|
|
|
|
|
module_id: str,
|
|
|
|
|
kind: str,
|
|
|
|
|
) -> Path:
|
|
|
|
|
url = validate_http_url(_artifact_text(metadata, "url") or "", label=f"{kind.capitalize()} package URL")
|
|
|
|
|
if not url.startswith("https://"):
|
|
|
|
|
raise ModuleInstallerError(f"{kind.capitalize()} package URL must use HTTPS.")
|
|
|
|
|
filename = _artifact_text(metadata, "filename") or ""
|
|
|
|
|
expected_sha256 = (_artifact_text(metadata, "sha256") or "").lower()
|
|
|
|
|
expected_size = metadata.get("size")
|
|
|
|
|
if (
|
|
|
|
|
not filename
|
|
|
|
|
or Path(filename).name != filename
|
|
|
|
|
or re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._+!-]{0,255}", filename) is None
|
|
|
|
|
or re.fullmatch(r"[0-9a-f]{64}", expected_sha256) is None
|
|
|
|
|
or not isinstance(expected_size, int)
|
|
|
|
|
or isinstance(expected_size, bool)
|
|
|
|
|
or not 0 < expected_size <= MAX_PACKAGE_ARTIFACT_BYTES
|
|
|
|
|
):
|
|
|
|
|
raise ModuleInstallerError(f"Catalog artifact metadata is incomplete for {module_id}/{kind}.")
|
|
|
|
|
|
|
|
|
|
cache_root = runtime_dir / "artifacts"
|
|
|
|
|
_ensure_private_artifact_directory(cache_root)
|
|
|
|
|
digest_root = cache_root / expected_sha256
|
|
|
|
|
_ensure_private_artifact_directory(digest_root)
|
|
|
|
|
target = digest_root / filename
|
|
|
|
|
if target.exists() or target.is_symlink():
|
|
|
|
|
if target.is_symlink() or not target.is_file():
|
|
|
|
|
raise ModuleInstallerError(f"Cached package artifact is not a regular file: {target}")
|
|
|
|
|
if target.stat().st_size != expected_size or _sha256_file(target) != expected_sha256:
|
|
|
|
|
raise ModuleInstallerError(f"Cached package artifact does not match its catalog identity: {target}")
|
|
|
|
|
return target
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
response = fetch_http(
|
|
|
|
|
url,
|
|
|
|
|
timeout=float(os.getenv("GOVOPLAN_MODULE_INSTALLER_DOWNLOAD_TIMEOUT_SECONDS", "120")),
|
|
|
|
|
label=f"{module_id} {kind} package URL",
|
|
|
|
|
max_bytes=min(expected_size + 1, MAX_PACKAGE_ARTIFACT_BYTES),
|
|
|
|
|
)
|
|
|
|
|
except (OSError, ValueError) as exc:
|
|
|
|
|
raise ModuleInstallerError(f"Could not download {module_id} {kind} package: {exc}") from exc
|
|
|
|
|
if response.status < 200 or response.status >= 300:
|
|
|
|
|
raise ModuleInstallerError(f"Could not download {module_id} {kind} package: HTTP {response.status}.")
|
|
|
|
|
if len(response.body) != expected_size or hashlib.sha256(response.body).hexdigest() != expected_sha256:
|
|
|
|
|
raise ModuleInstallerError(f"Downloaded {module_id} {kind} package does not match its signed catalog identity.")
|
|
|
|
|
|
|
|
|
|
temporary_path: Path | None = None
|
|
|
|
|
try:
|
|
|
|
|
with tempfile.NamedTemporaryFile(
|
|
|
|
|
mode="wb",
|
|
|
|
|
prefix=f".{filename}.",
|
|
|
|
|
suffix=".tmp",
|
|
|
|
|
dir=digest_root,
|
|
|
|
|
delete=False,
|
|
|
|
|
) as handle:
|
|
|
|
|
temporary_path = Path(handle.name)
|
|
|
|
|
handle.write(response.body)
|
|
|
|
|
handle.flush()
|
|
|
|
|
os.fsync(handle.fileno())
|
|
|
|
|
temporary_path.chmod(0o600)
|
|
|
|
|
os.replace(temporary_path, target)
|
|
|
|
|
target.chmod(0o600)
|
|
|
|
|
except OSError as exc:
|
|
|
|
|
if temporary_path is not None:
|
|
|
|
|
temporary_path.unlink(missing_ok=True)
|
|
|
|
|
raise ModuleInstallerError(f"Could not cache {module_id} {kind} package.") from exc
|
|
|
|
|
return target
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ensure_private_artifact_directory(path: Path) -> None:
|
|
|
|
|
if path.is_symlink():
|
|
|
|
|
raise ModuleInstallerError(f"Installer artifact cache must not be a symlink: {path}")
|
|
|
|
|
path.mkdir(parents=True, mode=0o700, exist_ok=True)
|
|
|
|
|
path.chmod(0o700)
|
|
|
|
|
if not path.is_dir() or stat.S_IMODE(path.stat().st_mode) != 0o700:
|
|
|
|
|
raise ModuleInstallerError(f"Installer artifact cache is not private: {path}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _verify_artifact_integrity(
|
|
|
|
|
planned_items: tuple[ModuleInstallPlanItem, ...],
|
|
|
|
|
*,
|
|
|
|
@@ -3839,7 +4161,17 @@ def _verify_artifact_metadata(
|
|
|
|
|
}
|
|
|
|
|
if package_name:
|
|
|
|
|
record["package"] = package_name
|
|
|
|
|
for key in ("sha256", "sbom_url", "provenance_url", "registry_identity", "git_ref"):
|
|
|
|
|
for key in (
|
|
|
|
|
"sha256",
|
|
|
|
|
"url",
|
|
|
|
|
"filename",
|
|
|
|
|
"integrity",
|
|
|
|
|
"sbom_url",
|
|
|
|
|
"provenance_url",
|
|
|
|
|
"registry_identity",
|
|
|
|
|
"git_ref",
|
|
|
|
|
"source_commit",
|
|
|
|
|
):
|
|
|
|
|
value = _artifact_text(metadata, key)
|
|
|
|
|
if value:
|
|
|
|
|
record[key] = value
|
|
|
|
@@ -3860,6 +4192,15 @@ def _verify_artifact_metadata(
|
|
|
|
|
item.module_id,
|
|
|
|
|
))
|
|
|
|
|
return record, tuple(issues)
|
|
|
|
|
if artifact_path is None and _catalog_artifact_acquisition_ready(item, metadata):
|
|
|
|
|
record["acquisition_pending"] = True
|
|
|
|
|
issues.append(ModuleInstallerIssue(
|
|
|
|
|
"info",
|
|
|
|
|
"artifact_acquisition_pending",
|
|
|
|
|
f"{kind.capitalize()} artifact will be downloaded and verified by the installer daemon before package mutation.",
|
|
|
|
|
item.module_id,
|
|
|
|
|
))
|
|
|
|
|
return record, tuple(issues)
|
|
|
|
|
if artifact_path is None:
|
|
|
|
|
issues.append(ModuleInstallerIssue(
|
|
|
|
|
"blocker" if require_verified else "warning",
|
|
|
|
|