mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-03 22:51:47 +08:00
feat(plugin): 建立可信来源准入与安装恢复 (#6462)
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
"""插件载荷来源准入与目标身份规划。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from app.application.plugin.identity import (
|
||||
PluginBindingBasis,
|
||||
PluginIdentity,
|
||||
TrustedPluginSourceType,
|
||||
)
|
||||
from app.application.plugin.inventory import normalize_github_plugin_source
|
||||
from app.application.plugin.source import (
|
||||
Candidate,
|
||||
CandidateInventory,
|
||||
PluginLocalCandidate,
|
||||
PluginSelectionStatus,
|
||||
parse_local_plugin_reference,
|
||||
select_plugin_candidate,
|
||||
)
|
||||
|
||||
|
||||
class PluginSourceAdmissionError(RuntimeError):
|
||||
"""插件来源冲突、库存不完整或换源授权无效。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PluginInstallAdmissionRequest:
|
||||
"""一次安装调用中会影响来源选择的显式业务参数。"""
|
||||
|
||||
plugin_id: str
|
||||
generations: Sequence[str]
|
||||
requested_repo_url: str | None = None
|
||||
explicit_source: bool = False
|
||||
source_change: bool = False
|
||||
expected_revision: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PluginInstallAdmission:
|
||||
"""下载前冻结的候选与身份转换决策。"""
|
||||
|
||||
candidate: Candidate
|
||||
identity_before: PluginIdentity | None
|
||||
binding_basis: PluginBindingBasis
|
||||
trusted_source_type: TrustedPluginSourceType
|
||||
trusted_source_key: str | None
|
||||
bound_at: datetime | None
|
||||
|
||||
@property
|
||||
def expected_revision(self) -> int | None:
|
||||
"""返回最终数据库提交必须匹配的身份 revision。"""
|
||||
return self.identity_before.revision if self.identity_before else None
|
||||
|
||||
def build_identity(
|
||||
self,
|
||||
*,
|
||||
payload_receipt: str,
|
||||
applied_at: datetime,
|
||||
declared_version: str | None = None,
|
||||
) -> PluginIdentity:
|
||||
"""在载荷落盘并生成收据后构造唯一数据库提交目标。"""
|
||||
current = self.identity_before
|
||||
plugin_id = current.plugin_id if current else self.candidate.plugin_id
|
||||
metadata = self.candidate.dto if isinstance(self.candidate.dto, Mapping) else {}
|
||||
system_version = metadata.get("system_version")
|
||||
supports_v3 = metadata.get("v3")
|
||||
supports_v3t = metadata.get("v3t")
|
||||
source_binding_changed = (
|
||||
self.trusted_source_type is not TrustedPluginSourceType.UNKNOWN
|
||||
and (
|
||||
current is None
|
||||
or current.trusted_source_type is TrustedPluginSourceType.UNKNOWN
|
||||
or current.trusted_source_type is not self.trusted_source_type
|
||||
or current.trusted_source_key != self.trusted_source_key
|
||||
)
|
||||
)
|
||||
return PluginIdentity(
|
||||
plugin_id=plugin_id,
|
||||
normalized_plugin_id=plugin_id.lower(),
|
||||
trusted_source_type=self.trusted_source_type,
|
||||
trusted_source_key=self.trusted_source_key,
|
||||
binding_basis=self.binding_basis,
|
||||
payload_source_type=self.candidate.payload_source_type,
|
||||
payload_source_key=(
|
||||
None
|
||||
if isinstance(self.candidate, PluginLocalCandidate)
|
||||
else self.candidate.source_key
|
||||
),
|
||||
declared_version=declared_version or self.candidate.plugin_version,
|
||||
package_generation=self.candidate.package_generation,
|
||||
system_version=(
|
||||
system_version if isinstance(system_version, str) else None
|
||||
),
|
||||
supports_v3=supports_v3 if isinstance(supports_v3, bool) else None,
|
||||
supports_v3t=supports_v3t if isinstance(supports_v3t, bool) else None,
|
||||
payload_receipt=payload_receipt,
|
||||
revision=(current.revision + 1) if current else 1,
|
||||
created_at=current.created_at if current else applied_at,
|
||||
updated_at=applied_at,
|
||||
bound_at=applied_at if source_binding_changed else self.bound_at,
|
||||
payload_applied_at=applied_at,
|
||||
)
|
||||
|
||||
|
||||
def admit_plugin_install(
|
||||
inventory: CandidateInventory,
|
||||
*,
|
||||
request: PluginInstallAdmissionRequest,
|
||||
identity: PluginIdentity | None,
|
||||
now: datetime,
|
||||
) -> PluginInstallAdmission:
|
||||
"""选择唯一允许载荷,并冻结最终身份转换的可信来源边界。"""
|
||||
if now.tzinfo is None:
|
||||
raise PluginSourceAdmissionError("插件安装准入时间必须包含时区")
|
||||
if identity is not None and now < identity.updated_at:
|
||||
raise PluginSourceAdmissionError("插件安装准入时间不能早于当前身份更新时间")
|
||||
bound_at: datetime | None
|
||||
if request.source_change:
|
||||
if not request.explicit_source or not request.requested_repo_url:
|
||||
raise PluginSourceAdmissionError("显式换源必须指定目标在线来源")
|
||||
if request.requested_repo_url.startswith("local://"):
|
||||
raise PluginSourceAdmissionError("显式换源只接受在线插件仓库")
|
||||
if identity is None or identity.trusted_source_type is TrustedPluginSourceType.UNKNOWN:
|
||||
raise PluginSourceAdmissionError("显式换源要求插件已经绑定在线来源")
|
||||
if request.expected_revision != identity.revision:
|
||||
raise PluginSourceAdmissionError("显式换源的身份 revision 已失效")
|
||||
elif request.expected_revision is not None:
|
||||
raise PluginSourceAdmissionError("普通安装不能携带换源 revision")
|
||||
|
||||
requested_source_key = None
|
||||
local_candidates = None
|
||||
if request.requested_repo_url:
|
||||
if request.requested_repo_url.startswith("local://"):
|
||||
referenced_plugin_id = parse_local_plugin_reference(
|
||||
request.requested_repo_url
|
||||
)
|
||||
if (
|
||||
referenced_plugin_id is None
|
||||
or referenced_plugin_id.lower() != request.plugin_id.lower()
|
||||
):
|
||||
raise PluginSourceAdmissionError(
|
||||
"明确选择的本地来源与目标插件不一致"
|
||||
)
|
||||
available_local_candidates = inventory.local_candidates_for(
|
||||
request.plugin_id
|
||||
)
|
||||
exact_candidates = tuple(
|
||||
candidate
|
||||
for candidate in available_local_candidates
|
||||
if candidate.repo_url == request.requested_repo_url
|
||||
)
|
||||
local_candidates = exact_candidates or available_local_candidates
|
||||
if not local_candidates:
|
||||
raise PluginSourceAdmissionError("明确选择的本地来源没有当前插件候选")
|
||||
else:
|
||||
requested_source_key, _repo_url = normalize_github_plugin_source(
|
||||
request.requested_repo_url
|
||||
)
|
||||
|
||||
selection = select_plugin_candidate(
|
||||
inventory,
|
||||
plugin_id=request.plugin_id,
|
||||
generations=request.generations,
|
||||
identity=identity,
|
||||
local_candidates=local_candidates,
|
||||
requested_source_key=requested_source_key,
|
||||
explicit_source=request.explicit_source,
|
||||
allow_source_change=request.source_change,
|
||||
)
|
||||
if selection.status is not PluginSelectionStatus.SELECTED or selection.candidate is None:
|
||||
raise PluginSourceAdmissionError(selection.reason or "插件来源准入失败")
|
||||
candidate = selection.candidate
|
||||
if not candidate.plugin_version:
|
||||
raise PluginSourceAdmissionError("插件候选缺少可持久化的版本声明")
|
||||
|
||||
if isinstance(candidate, PluginLocalCandidate):
|
||||
if identity is not None and identity.trusted_source_type is not TrustedPluginSourceType.UNKNOWN:
|
||||
return PluginInstallAdmission(
|
||||
candidate=candidate,
|
||||
identity_before=identity,
|
||||
binding_basis=identity.binding_basis,
|
||||
trusted_source_type=identity.trusted_source_type,
|
||||
trusted_source_key=identity.trusted_source_key,
|
||||
bound_at=identity.bound_at,
|
||||
)
|
||||
return PluginInstallAdmission(
|
||||
candidate=candidate,
|
||||
identity_before=identity,
|
||||
binding_basis=PluginBindingBasis.LOCAL_ONLY,
|
||||
trusted_source_type=TrustedPluginSourceType.UNKNOWN,
|
||||
trusted_source_key=None,
|
||||
bound_at=None,
|
||||
)
|
||||
|
||||
if request.source_change:
|
||||
if (
|
||||
identity is not None
|
||||
and identity.trusted_source_type is candidate.source_type
|
||||
and identity.trusted_source_key == candidate.source_key
|
||||
):
|
||||
raise PluginSourceAdmissionError("显式换源的目标必须不同于当前来源")
|
||||
basis = PluginBindingBasis.EXPLICIT_SOURCE_CHANGE
|
||||
bound_at = now
|
||||
elif identity is not None and identity.trusted_source_type is not TrustedPluginSourceType.UNKNOWN:
|
||||
basis = identity.binding_basis
|
||||
bound_at = identity.bound_at
|
||||
elif request.explicit_source:
|
||||
basis = PluginBindingBasis.EXPLICIT_INSTALL
|
||||
bound_at = now
|
||||
elif candidate.source_type is TrustedPluginSourceType.OFFICIAL:
|
||||
basis = PluginBindingBasis.OFFICIAL_DEFAULT
|
||||
bound_at = now
|
||||
else:
|
||||
basis = PluginBindingBasis.TOFU
|
||||
bound_at = now
|
||||
|
||||
return PluginInstallAdmission(
|
||||
candidate=candidate,
|
||||
identity_before=identity,
|
||||
binding_basis=basis,
|
||||
trusted_source_type=candidate.source_type,
|
||||
trusted_source_key=candidate.source_key,
|
||||
bound_at=bound_at,
|
||||
)
|
||||
@@ -0,0 +1,199 @@
|
||||
"""统一插件安装 Gateway。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Protocol
|
||||
|
||||
from app.application.plugin.admission import (
|
||||
PluginInstallAdmission,
|
||||
PluginInstallAdmissionRequest,
|
||||
PluginSourceAdmissionError,
|
||||
admit_plugin_install,
|
||||
)
|
||||
from app.application.plugin.identity import PluginIdentity
|
||||
from app.application.plugin.install import PluginInstallResult
|
||||
from app.application.plugin.inventory import PLUGIN_V3_GENERATIONS
|
||||
from app.application.plugin.lifecycle import PluginStartupLease, plugin_lifecycle
|
||||
from app.application.plugin.source import (
|
||||
Candidate,
|
||||
CandidateInventory,
|
||||
PluginLocalCandidate,
|
||||
PluginMarketCandidate,
|
||||
PluginSelection,
|
||||
get_effective_local_candidate,
|
||||
list_effective_online_candidates,
|
||||
select_plugin_candidate,
|
||||
)
|
||||
|
||||
InventoryProvider = Callable[[bool], Awaitable[CandidateInventory]]
|
||||
IdentityReader = Callable[[str], Awaitable[PluginIdentity | None]]
|
||||
CandidateCompatibility = Callable[[Candidate], tuple[bool, str]]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PluginSourceInspection:
|
||||
"""前端与 Agent 选择来源所需的只读候选和当前身份快照。"""
|
||||
|
||||
plugin_id: str
|
||||
inventory_complete: bool
|
||||
identity: PluginIdentity | None
|
||||
selection: PluginSelection
|
||||
online_candidates: tuple[PluginMarketCandidate, ...]
|
||||
local_candidate: PluginLocalCandidate | None
|
||||
|
||||
|
||||
class PluginInstallExecutor(Protocol):
|
||||
"""统一 Gateway 调用的可恢复安装执行端口。"""
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
*,
|
||||
admission: PluginInstallAdmission,
|
||||
release_version: str | None,
|
||||
force: bool,
|
||||
local_sync: bool = False,
|
||||
) -> PluginInstallResult:
|
||||
"""执行已通过来源准入的插件载荷事务。"""
|
||||
|
||||
|
||||
class PluginInstallGateway:
|
||||
"""让全部插件载荷写入共享同一来源策略和事务执行器。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
inventory: InventoryProvider,
|
||||
identity: IdentityReader,
|
||||
candidate_compatibility: CandidateCompatibility,
|
||||
executor: PluginInstallExecutor,
|
||||
clock: Callable[[], datetime],
|
||||
) -> None:
|
||||
"""保存候选事实、身份读取、兼容校验、事务执行和时间端口。"""
|
||||
self.__inventory = inventory
|
||||
self.__identity = identity
|
||||
self.__candidate_compatibility = candidate_compatibility
|
||||
self.__executor = executor
|
||||
self.__clock = clock
|
||||
|
||||
async def install(
|
||||
self,
|
||||
*,
|
||||
plugin_id: str,
|
||||
repo_url: str | None,
|
||||
package_version: str | None = None,
|
||||
release_version: str | None = None,
|
||||
force: bool = False,
|
||||
explicit_source: bool = False,
|
||||
source_change: bool = False,
|
||||
expected_revision: int | None = None,
|
||||
startup_token: PluginStartupLease | None = None,
|
||||
local_sync: bool = False,
|
||||
) -> PluginInstallResult:
|
||||
"""读取冻结库存并执行一次不能绕过来源身份的插件写入。"""
|
||||
try:
|
||||
inventory = await self.__inventory(force)
|
||||
async with plugin_lifecycle.hold(plugin_id, startup_token):
|
||||
identity = await self.__identity(plugin_id)
|
||||
admission = admit_plugin_install(
|
||||
inventory,
|
||||
request=PluginInstallAdmissionRequest(
|
||||
plugin_id=plugin_id,
|
||||
generations=_generation_order(package_version),
|
||||
requested_repo_url=repo_url,
|
||||
explicit_source=explicit_source,
|
||||
source_change=source_change,
|
||||
expected_revision=expected_revision,
|
||||
),
|
||||
identity=identity,
|
||||
now=self.__clock(),
|
||||
)
|
||||
compatible, message = self.__candidate_compatibility(
|
||||
admission.candidate
|
||||
)
|
||||
if not compatible:
|
||||
raise PluginSourceAdmissionError(
|
||||
message or "插件候选与当前 MoviePilot 版本不兼容"
|
||||
)
|
||||
return await self.__executor.execute(
|
||||
admission=admission,
|
||||
release_version=release_version,
|
||||
force=force,
|
||||
local_sync=local_sync,
|
||||
)
|
||||
except (TypeError, ValueError, PluginSourceAdmissionError) as error:
|
||||
return PluginInstallResult(
|
||||
success=False,
|
||||
message=str(error),
|
||||
failure_stage="source_admission",
|
||||
)
|
||||
|
||||
async def inspect_source(
|
||||
self,
|
||||
*,
|
||||
plugin_id: str,
|
||||
package_version: str | None = None,
|
||||
force: bool = False,
|
||||
) -> PluginSourceInspection:
|
||||
"""读取与真实安装相同的库存和身份,返回脱敏来源选择快照。"""
|
||||
inventory = await self.__inventory(force)
|
||||
identity = await self.__identity(plugin_id)
|
||||
generations = _generation_order(package_version)
|
||||
selection = select_plugin_candidate(
|
||||
inventory,
|
||||
plugin_id=plugin_id,
|
||||
generations=generations,
|
||||
identity=identity,
|
||||
)
|
||||
return PluginSourceInspection(
|
||||
plugin_id=plugin_id,
|
||||
inventory_complete=inventory.complete,
|
||||
identity=identity,
|
||||
selection=selection,
|
||||
online_candidates=list_effective_online_candidates(
|
||||
inventory,
|
||||
plugin_id=plugin_id,
|
||||
generations=generations,
|
||||
),
|
||||
local_candidate=get_effective_local_candidate(
|
||||
inventory,
|
||||
plugin_id=plugin_id,
|
||||
generations=generations,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
_plugin_install_gateway: PluginInstallGateway | None = None
|
||||
|
||||
|
||||
def configure_plugin_install_service(gateway: PluginInstallGateway) -> None:
|
||||
"""由启动组合根发布当前 lifespan 的唯一插件安装 Gateway。"""
|
||||
global _plugin_install_gateway
|
||||
_plugin_install_gateway = gateway
|
||||
|
||||
|
||||
def get_plugin_install_service() -> PluginInstallGateway:
|
||||
"""返回已装配 Gateway;启动未完成时拒绝任何载荷写入。"""
|
||||
if _plugin_install_gateway is None:
|
||||
raise RuntimeError("插件安装服务尚未完成初始化")
|
||||
return _plugin_install_gateway
|
||||
|
||||
|
||||
def reset_plugin_install_service() -> None:
|
||||
"""清除当前 lifespan 的 Gateway,供停机和隔离测试使用。"""
|
||||
global _plugin_install_gateway
|
||||
_plugin_install_gateway = None
|
||||
|
||||
|
||||
def _generation_order(package_version: str | None) -> tuple[str, ...]:
|
||||
"""把兼容入口的首选代际转换为来源选择优先序。"""
|
||||
normalized = (package_version or "v3").strip().lower()
|
||||
if normalized in {"", "v1"}:
|
||||
return ("v1",)
|
||||
if normalized == "v2":
|
||||
return ("v2", "v1")
|
||||
if normalized == "v3":
|
||||
return PLUGIN_V3_GENERATIONS
|
||||
raise ValueError("插件包代际必须为 v1、v2 或 v3")
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
@@ -295,7 +296,7 @@ def plan_legacy_plugin_identity(
|
||||
trusted_key = None
|
||||
basis = PluginBindingBasis.LEGACY_UNBOUND
|
||||
bound_at = None
|
||||
if market_availability is PluginMarketAvailability.AVAILABLE and official:
|
||||
if official:
|
||||
trusted_type = TrustedPluginSourceType.OFFICIAL
|
||||
trusted_key = official[0].source_key
|
||||
basis = PluginBindingBasis.OFFICIAL_DEFAULT
|
||||
@@ -349,6 +350,21 @@ class PluginIdentityRepository(Protocol):
|
||||
"""按 revision 条件暂存替换,并返回是否赢得竞争。"""
|
||||
|
||||
|
||||
class PluginIdentityStore(Protocol):
|
||||
"""组合根注入的独立来源身份读取与存量迁移端口。"""
|
||||
|
||||
def get(self, plugin_id: str) -> PluginIdentity | None:
|
||||
"""读取一个物理插件的来源身份。"""
|
||||
|
||||
def compare_and_set(
|
||||
self,
|
||||
identity: PluginIdentity,
|
||||
*,
|
||||
expected_revision: int | None,
|
||||
) -> PluginIdentity:
|
||||
"""首次创建或按 revision 更新身份。"""
|
||||
|
||||
|
||||
class PluginIdentityUnitOfWork(Protocol):
|
||||
"""来源身份条件写使用的事务端口。"""
|
||||
|
||||
@@ -431,3 +447,275 @@ class WritePluginIdentityCommand:
|
||||
except Exception:
|
||||
self._unit_of_work.rollback()
|
||||
raise
|
||||
|
||||
|
||||
class ChangePluginIdentitySourceCommand:
|
||||
"""以独立 CAS 合同提交一次明确的在线插件来源转换。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repository: PluginIdentityRepository,
|
||||
unit_of_work: PluginIdentityUnitOfWork,
|
||||
) -> None:
|
||||
"""保存仓储与事务所有者。"""
|
||||
self._repository = repository
|
||||
self._unit_of_work = unit_of_work
|
||||
|
||||
def execute(
|
||||
self,
|
||||
identity: PluginIdentity,
|
||||
*,
|
||||
expected_revision: int,
|
||||
) -> PluginIdentity:
|
||||
"""只允许已有身份按精确 revision 切换到不同在线来源。"""
|
||||
return _execute_identity_transition(
|
||||
self._repository,
|
||||
self._unit_of_work,
|
||||
identity,
|
||||
expected_revision=expected_revision,
|
||||
validate=_validate_identity_source_change,
|
||||
)
|
||||
|
||||
|
||||
class BindOnlinePluginIdentityCommand:
|
||||
"""以独立 CAS 合同为未绑定身份建立在线可信来源。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repository: PluginIdentityRepository,
|
||||
unit_of_work: PluginIdentityUnitOfWork,
|
||||
) -> None:
|
||||
"""保存仓储与事务所有者。"""
|
||||
self._repository = repository
|
||||
self._unit_of_work = unit_of_work
|
||||
|
||||
def execute(
|
||||
self,
|
||||
identity: PluginIdentity,
|
||||
*,
|
||||
expected_revision: int,
|
||||
) -> PluginIdentity:
|
||||
"""只允许未绑定身份按精确 revision 首次绑定在线来源。"""
|
||||
return _execute_identity_transition(
|
||||
self._repository,
|
||||
self._unit_of_work,
|
||||
identity,
|
||||
expected_revision=expected_revision,
|
||||
validate=_validate_online_binding,
|
||||
)
|
||||
|
||||
|
||||
class BindLocalPluginIdentityCommand:
|
||||
"""以独立 CAS 合同把存量未绑定身份转换为本地专属身份。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repository: PluginIdentityRepository,
|
||||
unit_of_work: PluginIdentityUnitOfWork,
|
||||
) -> None:
|
||||
"""保存仓储与事务所有者。"""
|
||||
self._repository = repository
|
||||
self._unit_of_work = unit_of_work
|
||||
|
||||
def execute(
|
||||
self,
|
||||
identity: PluginIdentity,
|
||||
*,
|
||||
expected_revision: int,
|
||||
) -> PluginIdentity:
|
||||
"""只允许 legacy_unbound 身份按精确 revision 绑定本地载荷。"""
|
||||
return _execute_identity_transition(
|
||||
self._repository,
|
||||
self._unit_of_work,
|
||||
identity,
|
||||
expected_revision=expected_revision,
|
||||
validate=_validate_local_binding,
|
||||
)
|
||||
|
||||
|
||||
def _execute_identity_transition(
|
||||
repository: PluginIdentityRepository,
|
||||
unit_of_work: PluginIdentityUnitOfWork,
|
||||
identity: PluginIdentity,
|
||||
*,
|
||||
expected_revision: int,
|
||||
validate: Callable[[PluginIdentity, PluginIdentity], None],
|
||||
) -> PluginIdentity:
|
||||
"""在一个数据库事务中校验并提交专用身份转换。"""
|
||||
try:
|
||||
current = _prepare_identity_transition(
|
||||
repository,
|
||||
identity,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
validate(current, identity)
|
||||
candidate = replace(
|
||||
identity,
|
||||
normalized_plugin_id=current.normalized_plugin_id,
|
||||
revision=current.revision + 1,
|
||||
created_at=current.created_at,
|
||||
)
|
||||
_stage_identity_transition(
|
||||
repository,
|
||||
unit_of_work,
|
||||
candidate,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
return candidate
|
||||
except Exception:
|
||||
unit_of_work.rollback()
|
||||
raise
|
||||
|
||||
|
||||
def _prepare_identity_transition(
|
||||
repository: PluginIdentityRepository,
|
||||
identity: PluginIdentity,
|
||||
*,
|
||||
expected_revision: int,
|
||||
) -> PluginIdentity:
|
||||
"""读取转换基线并保证目标使用同一物理插件和精确 revision。"""
|
||||
if expected_revision < 1:
|
||||
raise PluginIdentityConflictError("插件来源身份 expected_revision 必须从 1 开始")
|
||||
normalized_plugin_id = normalize_physical_plugin_id(identity.plugin_id)
|
||||
current = repository.get(normalized_plugin_id)
|
||||
if current is None or current.revision != expected_revision:
|
||||
raise PluginIdentityConflictError(
|
||||
f"插件 {identity.plugin_id} 的来源身份 revision 已被其他任务更新"
|
||||
)
|
||||
if (
|
||||
identity.plugin_id != current.plugin_id
|
||||
or identity.normalized_plugin_id != current.normalized_plugin_id
|
||||
):
|
||||
raise PluginIdentityConflictError(
|
||||
"插件来源转换不能改变物理插件 ID"
|
||||
)
|
||||
if identity.created_at != current.created_at:
|
||||
raise PluginIdentityConflictError(
|
||||
"插件来源转换必须保留身份创建时间"
|
||||
)
|
||||
if identity.updated_at < current.updated_at:
|
||||
raise PluginIdentityConflictError(
|
||||
"插件身份更新时间不能早于已提交记录"
|
||||
)
|
||||
return current
|
||||
|
||||
|
||||
def _validate_identity_source_change(
|
||||
current: PluginIdentity,
|
||||
candidate: PluginIdentity,
|
||||
) -> None:
|
||||
"""校验显式换源的来源、载荷和实际变化边界。"""
|
||||
if candidate.binding_basis is not PluginBindingBasis.EXPLICIT_SOURCE_CHANGE:
|
||||
raise PluginIdentityConflictError(
|
||||
"显式换源目标必须使用 explicit_source_change 依据"
|
||||
)
|
||||
if candidate.trusted_source_type is TrustedPluginSourceType.UNKNOWN:
|
||||
raise PluginIdentityConflictError("显式换源目标必须是在线可信来源")
|
||||
if candidate.payload_source_type not in {
|
||||
PluginPayloadSourceType.OFFICIAL,
|
||||
PluginPayloadSourceType.THIRD_PARTY,
|
||||
}:
|
||||
raise PluginIdentityConflictError("显式换源目标必须携带在线载荷")
|
||||
if (
|
||||
candidate.trusted_source_type.value != candidate.payload_source_type.value
|
||||
or candidate.trusted_source_key != candidate.payload_source_key
|
||||
):
|
||||
raise PluginIdentityConflictError(
|
||||
"显式换源目标的 trusted 与 payload 来源必须一致"
|
||||
)
|
||||
if (
|
||||
current.trusted_source_type is candidate.trusted_source_type
|
||||
and current.trusted_source_key == candidate.trusted_source_key
|
||||
):
|
||||
raise PluginIdentityConflictError("显式换源的实际来源必须变化")
|
||||
|
||||
|
||||
def _validate_online_binding(
|
||||
current: PluginIdentity,
|
||||
candidate: PluginIdentity,
|
||||
) -> None:
|
||||
"""校验未绑定身份首次建立在线可信来源的转换边界。"""
|
||||
if (
|
||||
current.trusted_source_type is not TrustedPluginSourceType.UNKNOWN
|
||||
or current.binding_basis not in {
|
||||
PluginBindingBasis.LEGACY_UNBOUND,
|
||||
PluginBindingBasis.LOCAL_ONLY,
|
||||
}
|
||||
):
|
||||
raise PluginIdentityConflictError(
|
||||
"在线绑定只允许当前未绑定的存量或本地身份"
|
||||
)
|
||||
if candidate.binding_basis not in {
|
||||
PluginBindingBasis.OFFICIAL_DEFAULT,
|
||||
PluginBindingBasis.TOFU,
|
||||
PluginBindingBasis.EXPLICIT_INSTALL,
|
||||
}:
|
||||
raise PluginIdentityConflictError(
|
||||
"在线绑定目标必须说明官方、TOFU 或显式安装依据"
|
||||
)
|
||||
if candidate.trusted_source_type is TrustedPluginSourceType.UNKNOWN:
|
||||
raise PluginIdentityConflictError("在线绑定目标必须携带可信来源")
|
||||
if candidate.payload_source_type is PluginPayloadSourceType.UNKNOWN:
|
||||
if (
|
||||
current.binding_basis is not PluginBindingBasis.LEGACY_UNBOUND
|
||||
or candidate.binding_basis not in {
|
||||
PluginBindingBasis.OFFICIAL_DEFAULT,
|
||||
PluginBindingBasis.TOFU,
|
||||
}
|
||||
):
|
||||
raise PluginIdentityConflictError(
|
||||
"仅存量未知来源身份可在不声明载荷来源时建立默认在线绑定"
|
||||
)
|
||||
return
|
||||
if candidate.payload_source_type not in {
|
||||
PluginPayloadSourceType.OFFICIAL,
|
||||
PluginPayloadSourceType.THIRD_PARTY,
|
||||
}:
|
||||
raise PluginIdentityConflictError("在线绑定目标必须携带在线载荷")
|
||||
if (
|
||||
candidate.trusted_source_type.value != candidate.payload_source_type.value
|
||||
or candidate.trusted_source_key != candidate.payload_source_key
|
||||
):
|
||||
raise PluginIdentityConflictError(
|
||||
"在线绑定目标的 trusted 与 payload 来源必须一致"
|
||||
)
|
||||
|
||||
|
||||
def _validate_local_binding(
|
||||
current: PluginIdentity,
|
||||
candidate: PluginIdentity,
|
||||
) -> None:
|
||||
"""校验存量未绑定身份到本地身份的唯一转换方向。"""
|
||||
if (
|
||||
current.trusted_source_type is not TrustedPluginSourceType.UNKNOWN
|
||||
or current.binding_basis is not PluginBindingBasis.LEGACY_UNBOUND
|
||||
):
|
||||
raise PluginIdentityConflictError(
|
||||
"本地绑定只允许当前 unknown + legacy_unbound 身份"
|
||||
)
|
||||
if (
|
||||
candidate.trusted_source_type is not TrustedPluginSourceType.UNKNOWN
|
||||
or candidate.binding_basis is not PluginBindingBasis.LOCAL_ONLY
|
||||
or candidate.payload_source_type is not PluginPayloadSourceType.LOCAL
|
||||
):
|
||||
raise PluginIdentityConflictError(
|
||||
"本地绑定目标必须是 unknown + local_only 且携带本地载荷"
|
||||
)
|
||||
|
||||
|
||||
def _stage_identity_transition(
|
||||
repository: PluginIdentityRepository,
|
||||
unit_of_work: PluginIdentityUnitOfWork,
|
||||
candidate: PluginIdentity,
|
||||
*,
|
||||
expected_revision: int,
|
||||
) -> None:
|
||||
"""按数据库 revision 条件暂存转换并提交事务。"""
|
||||
if not repository.stage_replace(
|
||||
candidate,
|
||||
expected_revision=expected_revision,
|
||||
):
|
||||
raise PluginIdentityConflictError(
|
||||
f"插件 {candidate.plugin_id} 的来源身份 revision 已被其他任务更新"
|
||||
)
|
||||
unit_of_work.commit()
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
"""存量插件来源身份的一次性启动迁移。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime
|
||||
from typing import Protocol
|
||||
|
||||
from app.application.plugin.identity import (
|
||||
PluginBindingBasis,
|
||||
PluginIdentity,
|
||||
PluginIdentityConflictError,
|
||||
PluginMarketAvailability,
|
||||
PluginSourceCandidate,
|
||||
TrustedPluginSourceType,
|
||||
normalize_physical_plugin_id,
|
||||
plan_legacy_plugin_identity,
|
||||
)
|
||||
from app.application.plugin.source import CandidateInventory
|
||||
from app.runtime.log import logger
|
||||
|
||||
InventoryProvider = Callable[[bool], Awaitable[CandidateInventory]]
|
||||
InstalledPluginsReader = Callable[[], list[str]]
|
||||
VirtualInstancePredicate = Callable[[str], bool]
|
||||
|
||||
|
||||
class PluginIdentityMigrationPersistence(Protocol):
|
||||
"""存量来源迁移所需的最小异步持久化端口。"""
|
||||
|
||||
async def get_identity(self, plugin_id: str) -> PluginIdentity | None:
|
||||
"""读取一个物理插件的当前身份。"""
|
||||
|
||||
async def migrate_identity(
|
||||
self,
|
||||
identity: PluginIdentity,
|
||||
*,
|
||||
expected_revision: int | None,
|
||||
) -> PluginIdentity:
|
||||
"""创建尚不存在的存量身份。"""
|
||||
|
||||
async def bind_online_identity(
|
||||
self,
|
||||
identity: PluginIdentity,
|
||||
*,
|
||||
expected_revision: int,
|
||||
) -> PluginIdentity:
|
||||
"""把 legacy_unbound 身份绑定到已确认的在线来源。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PluginIdentityMigrationResult:
|
||||
"""一次迁移批次创建、绑定和跳过的物理插件数量。"""
|
||||
|
||||
created: int = 0
|
||||
bound: int = 0
|
||||
unbound: int = 0
|
||||
skipped: int = 0
|
||||
|
||||
|
||||
class PluginIdentityMigrationService:
|
||||
"""在任何自动更新前为已安装物理插件建立最小来源身份。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
persistence: PluginIdentityMigrationPersistence,
|
||||
inventory: InventoryProvider,
|
||||
installed_plugins: InstalledPluginsReader,
|
||||
is_virtual_instance: VirtualInstancePredicate,
|
||||
clock: Callable[[], datetime],
|
||||
) -> None:
|
||||
"""保存库存、安装清单、虚拟实例判定和 CAS 端口。"""
|
||||
self.__persistence = persistence
|
||||
self.__inventory = inventory
|
||||
self.__installed_plugins = installed_plugins
|
||||
self.__is_virtual_instance = is_virtual_instance
|
||||
self.__clock = clock
|
||||
|
||||
async def migrate(self) -> PluginIdentityMigrationResult:
|
||||
"""幂等迁移全部存量身份;数据库异常会阻止后续自动更新。"""
|
||||
inventory = await self.__inventory(False)
|
||||
created = 0
|
||||
bound = 0
|
||||
unbound = 0
|
||||
skipped = 0
|
||||
seen: set[str] = set()
|
||||
|
||||
for plugin_id in self.__installed_plugins() or []:
|
||||
try:
|
||||
normalized_id = normalize_physical_plugin_id(plugin_id)
|
||||
except ValueError as error:
|
||||
logger.warning("跳过插件 %s 的存量来源迁移:%s", plugin_id, error)
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
if normalized_id in seen or self.__is_virtual_instance(plugin_id):
|
||||
skipped += 1
|
||||
continue
|
||||
seen.add(normalized_id)
|
||||
candidates = inventory.candidates_for(plugin_id)
|
||||
planned = plan_legacy_plugin_identity(
|
||||
plugin_id=plugin_id,
|
||||
market_availability=(
|
||||
PluginMarketAvailability.AVAILABLE
|
||||
if inventory.can_use_for_tofu
|
||||
else PluginMarketAvailability.UNAVAILABLE
|
||||
),
|
||||
online_candidates=tuple(
|
||||
PluginSourceCandidate(
|
||||
source_type=candidate.source_type,
|
||||
source_key=candidate.source_key,
|
||||
)
|
||||
for candidate in candidates
|
||||
),
|
||||
is_virtual_instance=False,
|
||||
now=self.__clock(),
|
||||
)
|
||||
existing = await self.__persistence.get_identity(normalized_id)
|
||||
|
||||
if planned is None:
|
||||
skipped += 1
|
||||
continue
|
||||
if existing is None:
|
||||
try:
|
||||
migrated = await self.__persistence.migrate_identity(
|
||||
planned,
|
||||
expected_revision=None,
|
||||
)
|
||||
except PluginIdentityConflictError:
|
||||
if await self.__persistence.get_identity(plugin_id) is None:
|
||||
raise
|
||||
skipped += 1
|
||||
continue
|
||||
created += 1
|
||||
if migrated.trusted_source_type is TrustedPluginSourceType.UNKNOWN:
|
||||
unbound += 1
|
||||
else:
|
||||
bound += 1
|
||||
continue
|
||||
if (
|
||||
existing.binding_basis is not PluginBindingBasis.LEGACY_UNBOUND
|
||||
or planned.trusted_source_type is TrustedPluginSourceType.UNKNOWN
|
||||
):
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
target = replace(
|
||||
planned,
|
||||
plugin_id=existing.plugin_id,
|
||||
normalized_plugin_id=existing.normalized_plugin_id,
|
||||
revision=existing.revision + 1,
|
||||
created_at=existing.created_at,
|
||||
updated_at=self.__clock(),
|
||||
)
|
||||
try:
|
||||
await self.__persistence.bind_online_identity(
|
||||
target,
|
||||
expected_revision=existing.revision,
|
||||
)
|
||||
except PluginIdentityConflictError:
|
||||
current = await self.__persistence.get_identity(plugin_id)
|
||||
if current is None or current.revision == existing.revision:
|
||||
raise
|
||||
skipped += 1
|
||||
continue
|
||||
bound += 1
|
||||
|
||||
result = PluginIdentityMigrationResult(
|
||||
created=created,
|
||||
bound=bound,
|
||||
unbound=unbound,
|
||||
skipped=skipped,
|
||||
)
|
||||
logger.info(
|
||||
"插件来源身份迁移完成:创建=%s,已绑定=%s,未绑定=%s,跳过=%s",
|
||||
result.created,
|
||||
result.bound,
|
||||
result.unbound,
|
||||
result.skipped,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
_IDENTITY_MIGRATION_SERVICE: list[PluginIdentityMigrationService] = []
|
||||
|
||||
|
||||
def configure_plugin_identity_migration(
|
||||
service: PluginIdentityMigrationService,
|
||||
) -> None:
|
||||
"""由组合根登记当前 lifespan 的存量身份迁移服务。"""
|
||||
_IDENTITY_MIGRATION_SERVICE.clear()
|
||||
_IDENTITY_MIGRATION_SERVICE.append(service)
|
||||
|
||||
|
||||
def get_plugin_identity_migration() -> PluginIdentityMigrationService:
|
||||
"""返回已装配迁移服务;缺失时拒绝绕过来源迁移。"""
|
||||
if not _IDENTITY_MIGRATION_SERVICE:
|
||||
raise RuntimeError("插件来源身份迁移服务尚未完成初始化")
|
||||
return _IDENTITY_MIGRATION_SERVICE[0]
|
||||
|
||||
|
||||
def reset_plugin_identity_migration() -> None:
|
||||
"""清除当前 lifespan 的迁移服务,供测试和停机复位。"""
|
||||
_IDENTITY_MIGRATION_SERVICE.clear()
|
||||
+646
-378
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,430 @@
|
||||
"""插件市场候选库存读取与外部事实映射。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable, Iterable, Mapping, Sequence
|
||||
from typing import Any, TypeAlias
|
||||
from urllib.parse import unquote, urlsplit
|
||||
|
||||
from app.application.plugin.identity import (
|
||||
OFFICIAL_PLUGIN_SOURCE_KEY,
|
||||
TrustedPluginSourceType,
|
||||
validate_online_source_key,
|
||||
)
|
||||
from app.application.plugin.source import (
|
||||
CandidateInventory,
|
||||
LocalCandidateRead,
|
||||
MarketRead,
|
||||
PluginLocalCandidate,
|
||||
PluginMarketCandidate,
|
||||
normalize_package_generation,
|
||||
)
|
||||
|
||||
PLUGIN_V3_GENERATIONS = ("v3", "v2", "v1")
|
||||
PluginIndex: TypeAlias = Mapping[str, Mapping[str, Any]]
|
||||
PluginIndexLoaderResult: TypeAlias = PluginIndex | None
|
||||
LocalCandidateLoadPayload: TypeAlias = (
|
||||
Mapping[str, Mapping[str, Any]]
|
||||
| Iterable[Mapping[str, Any]]
|
||||
| None
|
||||
)
|
||||
MarketLoader: TypeAlias = Callable[
|
||||
[str, str | None, bool],
|
||||
PluginIndexLoaderResult,
|
||||
]
|
||||
AsyncMarketLoader: TypeAlias = Callable[
|
||||
[str, str | None, bool],
|
||||
Awaitable[PluginIndexLoaderResult],
|
||||
]
|
||||
LocalCandidateLoader: TypeAlias = Callable[
|
||||
[],
|
||||
LocalCandidateLoadPayload,
|
||||
]
|
||||
|
||||
|
||||
class PluginCandidateInventoryReader:
|
||||
"""按配置市场和 V3 代际顺序保留全部候选及读取终态。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
market_loader: MarketLoader,
|
||||
local_candidate_loader: LocalCandidateLoader | None = None,
|
||||
async_market_loader: AsyncMarketLoader | None = None,
|
||||
generations: Sequence[str] = PLUGIN_V3_GENERATIONS,
|
||||
max_concurrency: int = 12,
|
||||
) -> None:
|
||||
"""保存读取端口,并限制异步市场请求的进程内并发。"""
|
||||
normalized_generations = tuple(
|
||||
normalize_package_generation(generation)
|
||||
for generation in generations
|
||||
)
|
||||
if normalized_generations != PLUGIN_V3_GENERATIONS:
|
||||
raise ValueError("V3 候选库存必须按 v3、v2、v1 顺序读取")
|
||||
if max_concurrency < 1:
|
||||
raise ValueError("插件市场读取并发必须大于 0")
|
||||
self._market_loader = market_loader
|
||||
self._async_market_loader = async_market_loader
|
||||
self._local_candidate_loader = local_candidate_loader
|
||||
self._generations = normalized_generations
|
||||
self._max_concurrency = max_concurrency
|
||||
|
||||
def load(
|
||||
self,
|
||||
markets: Iterable[str],
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> CandidateInventory:
|
||||
"""同步读取全部配置市场,不把失败市场伪装成空仓库。"""
|
||||
normalized_markets = _normalize_markets(markets)
|
||||
reads = tuple(
|
||||
self._read_market_generation(market, generation, force=force)
|
||||
for market in normalized_markets
|
||||
for generation in self._generations
|
||||
)
|
||||
local_read = self._load_local_candidates()
|
||||
return CandidateInventory(
|
||||
reads,
|
||||
local_read.candidates,
|
||||
local_read=local_read,
|
||||
expected_markets=normalized_markets,
|
||||
expected_generations=self._generations,
|
||||
)
|
||||
|
||||
async def async_load(
|
||||
self,
|
||||
markets: Iterable[str],
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> CandidateInventory:
|
||||
"""有界并发读取全部市场,同时保持配置与代际的稳定顺序。"""
|
||||
normalized_markets = _normalize_markets(markets)
|
||||
semaphore = asyncio.Semaphore(self._max_concurrency)
|
||||
|
||||
async def read(market: str, generation: str) -> MarketRead:
|
||||
async with semaphore:
|
||||
return await self._async_read_market_generation(
|
||||
market,
|
||||
generation,
|
||||
force=force,
|
||||
)
|
||||
|
||||
tasks = [
|
||||
asyncio.create_task(
|
||||
read(market, generation),
|
||||
name="plugin.inventory.read",
|
||||
)
|
||||
for market in normalized_markets
|
||||
for generation in self._generations
|
||||
]
|
||||
try:
|
||||
reads = tuple(await asyncio.gather(*tasks)) if tasks else ()
|
||||
finally:
|
||||
for task in tasks:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
local_read = await asyncio.to_thread(self._load_local_candidates)
|
||||
return CandidateInventory(
|
||||
reads,
|
||||
local_read.candidates,
|
||||
local_read=local_read,
|
||||
expected_markets=normalized_markets,
|
||||
expected_generations=self._generations,
|
||||
)
|
||||
|
||||
def _read_market_generation(
|
||||
self,
|
||||
market: str,
|
||||
generation: str,
|
||||
*,
|
||||
force: bool,
|
||||
) -> MarketRead:
|
||||
"""同步读取一个市场代际并映射为候选事实。"""
|
||||
try:
|
||||
source_key, repo_url, source_type = _market_source(market)
|
||||
payload = self._market_loader(
|
||||
repo_url,
|
||||
_package_version(generation),
|
||||
force,
|
||||
)
|
||||
return _successful_read(
|
||||
market,
|
||||
generation,
|
||||
payload,
|
||||
source_key=source_key,
|
||||
source_type=source_type,
|
||||
repo_url=repo_url,
|
||||
)
|
||||
except Exception as error: # noqa: BLE001 - 失败事实必须进入快照
|
||||
return MarketRead.failure(
|
||||
market,
|
||||
_error_message(error),
|
||||
package_generation=generation,
|
||||
)
|
||||
|
||||
async def _async_read_market_generation(
|
||||
self,
|
||||
market: str,
|
||||
generation: str,
|
||||
*,
|
||||
force: bool,
|
||||
) -> MarketRead:
|
||||
"""异步读取一个市场代际并映射为候选事实。"""
|
||||
try:
|
||||
source_key, repo_url, source_type = _market_source(market)
|
||||
if self._async_market_loader is None:
|
||||
payload = await asyncio.to_thread(
|
||||
self._market_loader,
|
||||
repo_url,
|
||||
_package_version(generation),
|
||||
force,
|
||||
)
|
||||
else:
|
||||
payload = await self._async_market_loader(
|
||||
repo_url,
|
||||
_package_version(generation),
|
||||
force,
|
||||
)
|
||||
return _successful_read(
|
||||
market,
|
||||
generation,
|
||||
payload,
|
||||
source_key=source_key,
|
||||
source_type=source_type,
|
||||
repo_url=repo_url,
|
||||
)
|
||||
except Exception as error: # noqa: BLE001 - 失败事实必须进入快照
|
||||
return MarketRead.failure(
|
||||
market,
|
||||
_error_message(error),
|
||||
package_generation=generation,
|
||||
)
|
||||
|
||||
def _load_local_candidates(self) -> LocalCandidateRead:
|
||||
"""映射本地候选,并保留扫描失败而非伪装为空仓库。"""
|
||||
if self._local_candidate_loader is None:
|
||||
return LocalCandidateRead.absent()
|
||||
try:
|
||||
raw_candidates = self._local_candidate_loader()
|
||||
if raw_candidates is None:
|
||||
return LocalCandidateRead.failure(
|
||||
"本地插件仓库读取未返回可判定结果"
|
||||
)
|
||||
except Exception as error: # noqa: BLE001 - 失败事实必须进入快照
|
||||
return LocalCandidateRead.failure(_error_message(error))
|
||||
entries: Iterable[tuple[object, Mapping[str, Any]]]
|
||||
try:
|
||||
if isinstance(raw_candidates, Mapping):
|
||||
entries = raw_candidates.items()
|
||||
else:
|
||||
entries = (
|
||||
(plugin_info.get("id"), plugin_info)
|
||||
for plugin_info in raw_candidates
|
||||
if isinstance(plugin_info, Mapping)
|
||||
)
|
||||
|
||||
candidates: list[PluginLocalCandidate] = []
|
||||
for plugin_id, plugin_info in entries:
|
||||
if not isinstance(plugin_id, str) or not isinstance(plugin_info, Mapping):
|
||||
continue
|
||||
try:
|
||||
candidate = _local_candidate(plugin_id, plugin_info)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if candidate is not None:
|
||||
candidates.append(candidate)
|
||||
except Exception as error: # noqa: BLE001 - 迭代或映射失败也要保留状态
|
||||
return LocalCandidateRead.failure(_error_message(error))
|
||||
return LocalCandidateRead.present(candidates)
|
||||
|
||||
|
||||
def build_plugin_candidate_inventory(
|
||||
markets: Iterable[str],
|
||||
*,
|
||||
market_loader: MarketLoader,
|
||||
local_candidate_loader: LocalCandidateLoader | None = None,
|
||||
force: bool = False,
|
||||
) -> CandidateInventory:
|
||||
"""使用注入的同步读取端口构建一次候选库存。"""
|
||||
return PluginCandidateInventoryReader(
|
||||
market_loader=market_loader,
|
||||
local_candidate_loader=local_candidate_loader,
|
||||
).load(markets, force=force)
|
||||
|
||||
|
||||
def normalize_github_plugin_source(value: str) -> tuple[str, str]:
|
||||
"""把 GitHub 仓库 URL 或来源键归一为持久来源键和公开地址。"""
|
||||
normalized = str(value).strip().rstrip("/")
|
||||
if normalized.lower().startswith("github:"):
|
||||
source_key = validate_online_source_key(normalized)
|
||||
owner, repository = source_key.removeprefix("github:").split("/", 1)
|
||||
return source_key, f"https://github.com/{owner}/{repository}"
|
||||
|
||||
parsed = urlsplit(normalized)
|
||||
if parsed.scheme not in {"http", "https"} or parsed.hostname is None:
|
||||
raise ValueError("插件市场必须是 GitHub 仓库地址")
|
||||
if parsed.hostname.lower() != "github.com":
|
||||
raise ValueError("插件市场必须使用 github.com 仓库地址")
|
||||
parts = [unquote(part) for part in parsed.path.split("/") if part]
|
||||
if len(parts) < 2:
|
||||
raise ValueError("插件市场 GitHub 地址缺少 owner 或 repository")
|
||||
owner, repository = parts[:2]
|
||||
repository = repository.removesuffix(".git")
|
||||
source_key = validate_online_source_key(f"github:{owner}/{repository}")
|
||||
return source_key, f"https://github.com/{owner}/{repository}"
|
||||
|
||||
|
||||
def _normalize_markets(markets: Iterable[str]) -> tuple[str, ...]:
|
||||
"""规范化并去除重复市场,保留无效配置供读取快照报错。"""
|
||||
result: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for market in markets:
|
||||
value = str(market).strip().rstrip("/")
|
||||
if not value:
|
||||
continue
|
||||
key = value.lower()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
result.append(value)
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def _market_source(
|
||||
market: str,
|
||||
) -> tuple[str, str, TrustedPluginSourceType]:
|
||||
"""解析 GitHub 市场来源,并固定官方仓库分类。"""
|
||||
source_key, repo_url = normalize_github_plugin_source(market)
|
||||
source_type = (
|
||||
TrustedPluginSourceType.OFFICIAL
|
||||
if source_key == OFFICIAL_PLUGIN_SOURCE_KEY
|
||||
else TrustedPluginSourceType.THIRD_PARTY
|
||||
)
|
||||
return source_key, repo_url, source_type
|
||||
|
||||
|
||||
def _successful_read(
|
||||
market: str,
|
||||
generation: str,
|
||||
payload: PluginIndexLoaderResult,
|
||||
*,
|
||||
source_key: str,
|
||||
source_type: TrustedPluginSourceType,
|
||||
repo_url: str,
|
||||
) -> MarketRead:
|
||||
"""把 Adapter 读取结果映射为一个市场代际的三态事实。"""
|
||||
if payload is None:
|
||||
return MarketRead.absent(
|
||||
market,
|
||||
package_generation=generation,
|
||||
)
|
||||
if not isinstance(payload, Mapping):
|
||||
raise TypeError("插件市场索引必须是对象")
|
||||
return MarketRead.present(
|
||||
market,
|
||||
_market_candidates(
|
||||
payload,
|
||||
source_key=source_key,
|
||||
source_type=source_type,
|
||||
repo_url=repo_url,
|
||||
package_generation=generation,
|
||||
),
|
||||
package_generation=generation,
|
||||
)
|
||||
|
||||
|
||||
def _market_candidates(
|
||||
payload: PluginIndex,
|
||||
*,
|
||||
source_key: str,
|
||||
source_type: TrustedPluginSourceType,
|
||||
repo_url: str,
|
||||
package_generation: str,
|
||||
) -> tuple[PluginMarketCandidate, ...]:
|
||||
"""过滤并映射一个代际索引中的 V3 可兼容条目。"""
|
||||
result: list[PluginMarketCandidate] = []
|
||||
for index_plugin_id, raw_info in payload.items():
|
||||
if not isinstance(raw_info, Mapping):
|
||||
continue
|
||||
plugin_id = raw_info.get("id") or index_plugin_id
|
||||
if not isinstance(plugin_id, str):
|
||||
continue
|
||||
if not _is_v3_compatible(raw_info, package_generation):
|
||||
continue
|
||||
plugin_version = raw_info.get("version")
|
||||
if plugin_version is None:
|
||||
plugin_version = raw_info.get("plugin_version")
|
||||
if plugin_version == "":
|
||||
plugin_version = None
|
||||
try:
|
||||
result.append(
|
||||
PluginMarketCandidate(
|
||||
plugin_id=plugin_id,
|
||||
source_key=source_key,
|
||||
source_type=source_type,
|
||||
repo_url=repo_url,
|
||||
package_generation=package_generation,
|
||||
plugin_version=plugin_version,
|
||||
dto=dict(raw_info),
|
||||
)
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def _is_v3_compatible(
|
||||
plugin_info: Mapping[str, Any],
|
||||
package_generation: str,
|
||||
) -> bool:
|
||||
"""按宿主 V3、兼容 V2、基础索引顺序判断候选兼容性。"""
|
||||
if plugin_info.get("v3") is False:
|
||||
return False
|
||||
if package_generation in {"v3", "v2"}:
|
||||
return True
|
||||
return plugin_info.get("v3") is True or plugin_info.get("v2") is True
|
||||
|
||||
|
||||
def _local_candidate(
|
||||
plugin_id: str,
|
||||
plugin_info: Mapping[str, Any],
|
||||
) -> PluginLocalCandidate | None:
|
||||
"""把本地插件索引条目转换为应用候选。"""
|
||||
generation = normalize_package_generation(
|
||||
str(
|
||||
plugin_info.get("package_version")
|
||||
or plugin_info.get("package_generation")
|
||||
or "v1"
|
||||
)
|
||||
)
|
||||
if not _is_v3_compatible(plugin_info, generation):
|
||||
return None
|
||||
repo_url = plugin_info.get("repo_url")
|
||||
if not isinstance(repo_url, str) or not repo_url.startswith("local://"):
|
||||
return None
|
||||
plugin_version = plugin_info.get("version")
|
||||
if plugin_version is None:
|
||||
plugin_version = plugin_info.get("plugin_version")
|
||||
if plugin_version == "":
|
||||
plugin_version = None
|
||||
return PluginLocalCandidate(
|
||||
plugin_id=plugin_id,
|
||||
repo_url=repo_url,
|
||||
package_generation=generation,
|
||||
plugin_version=plugin_version,
|
||||
dto=dict(plugin_info),
|
||||
)
|
||||
|
||||
|
||||
def _package_version(package_generation: str) -> str | None:
|
||||
"""把公共代际转换为市场索引文件参数。"""
|
||||
return None if package_generation == "v1" else package_generation
|
||||
|
||||
|
||||
def _error_message(error: Exception) -> str:
|
||||
"""保留可诊断的读取失败说明,并避免空异常丢失状态。"""
|
||||
message = str(error).strip()
|
||||
return message or error.__class__.__name__
|
||||
@@ -4,9 +4,16 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
|
||||
class PluginStartupLease:
|
||||
"""启动 lease 的不透明能力句柄,仅按对象身份由所属协调器认可。"""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
|
||||
class PluginLifecycleCoordinator:
|
||||
"""在事件循环和同步启动线程之间协调插件生命周期操作。"""
|
||||
|
||||
@@ -14,17 +21,29 @@ class PluginLifecycleCoordinator:
|
||||
self._condition = threading.Condition()
|
||||
self._active_plugins: set[str] = set()
|
||||
self._startup_active = False
|
||||
self._startup_token: PluginStartupLease | None = None
|
||||
|
||||
@staticmethod
|
||||
def _normalize(plugin_id: str) -> str:
|
||||
return (plugin_id or "").strip().lower()
|
||||
|
||||
def _try_acquire_plugin(self, plugin_id: str) -> bool:
|
||||
def _try_acquire_plugin(
|
||||
self,
|
||||
plugin_id: str,
|
||||
startup_token: PluginStartupLease | None = None,
|
||||
) -> bool:
|
||||
normalized_id = self._normalize(plugin_id)
|
||||
if not normalized_id:
|
||||
raise ValueError("插件ID不能为空")
|
||||
with self._condition:
|
||||
if self._startup_active or normalized_id in self._active_plugins:
|
||||
startup_token_matches = (
|
||||
startup_token is not None and startup_token is self._startup_token
|
||||
)
|
||||
# 启动期间只有当前 lease 的显式 token 可以取得逐插件资格。
|
||||
if (
|
||||
normalized_id in self._active_plugins
|
||||
or (self._startup_active and not startup_token_matches)
|
||||
):
|
||||
return False
|
||||
self._active_plugins.add(normalized_id)
|
||||
return True
|
||||
@@ -35,22 +54,32 @@ class PluginLifecycleCoordinator:
|
||||
self._active_plugins.discard(normalized_id)
|
||||
self._condition.notify_all()
|
||||
|
||||
def _try_acquire_startup(self) -> bool:
|
||||
def _try_acquire_startup(self) -> PluginStartupLease | None:
|
||||
with self._condition:
|
||||
if self._startup_active or self._active_plugins:
|
||||
return False
|
||||
return None
|
||||
startup_token = PluginStartupLease()
|
||||
self._startup_active = True
|
||||
return True
|
||||
self._startup_token = startup_token
|
||||
return startup_token
|
||||
|
||||
def _release_startup(self) -> None:
|
||||
def _release_startup(self, startup_token: PluginStartupLease) -> None:
|
||||
with self._condition:
|
||||
# 延迟清理不得释放已经由新 owner 持有的启动 lease。
|
||||
if self._startup_token is not startup_token:
|
||||
return
|
||||
self._startup_token = None
|
||||
self._startup_active = False
|
||||
self._condition.notify_all()
|
||||
|
||||
@asynccontextmanager
|
||||
async def hold(self, plugin_id: str):
|
||||
async def hold(
|
||||
self,
|
||||
plugin_id: str,
|
||||
startup_token: PluginStartupLease | None = None,
|
||||
) -> AsyncIterator[None]:
|
||||
"""异步持有单个插件的生命周期资格,不在线程池中等待锁。"""
|
||||
while not self._try_acquire_plugin(plugin_id):
|
||||
while not self._try_acquire_plugin(plugin_id, startup_token):
|
||||
await asyncio.sleep(0.01)
|
||||
try:
|
||||
yield
|
||||
@@ -58,14 +87,18 @@ class PluginLifecycleCoordinator:
|
||||
self._release_plugin(plugin_id)
|
||||
|
||||
@asynccontextmanager
|
||||
async def hold_startup(self):
|
||||
async def hold_startup(self) -> AsyncIterator[PluginStartupLease]:
|
||||
"""异步持有启动同步的全局资格,阻止安装请求穿过启动收口。"""
|
||||
while not self._try_acquire_startup():
|
||||
startup_token: PluginStartupLease | None = None
|
||||
while startup_token is None:
|
||||
startup_token = self._try_acquire_startup()
|
||||
if startup_token is not None:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
try:
|
||||
yield
|
||||
yield startup_token
|
||||
finally:
|
||||
self._release_startup()
|
||||
self._release_startup(startup_token)
|
||||
|
||||
|
||||
plugin_lifecycle = PluginLifecycleCoordinator()
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
"""插件安装 journal 的启动恢复与已提交事务收尾。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
from app.application.plugin.install import (
|
||||
PluginPackageCheckpoint,
|
||||
PluginPackageTransactionPort,
|
||||
)
|
||||
from app.application.plugin.transaction import (
|
||||
PluginInstallationPhase,
|
||||
PluginInstallationRecord,
|
||||
PluginPersistenceService,
|
||||
)
|
||||
from app.runtime.log import logger
|
||||
|
||||
|
||||
class PluginInstallationRecoveryError(RuntimeError):
|
||||
"""恢复材料不足或已提交载荷事实不一致,不能继续导入插件。"""
|
||||
|
||||
|
||||
class PluginRecoveryPackagePort(PluginPackageTransactionPort, Protocol):
|
||||
"""启动恢复在安装包事务端口之上需要的重建与核验能力。"""
|
||||
|
||||
def restore_checkpoint(
|
||||
self,
|
||||
*,
|
||||
plugin_id: str,
|
||||
transaction_id: str,
|
||||
plugin_existed: bool,
|
||||
persistent_backup_existed: bool,
|
||||
) -> PluginPackageCheckpoint:
|
||||
"""只根据 journal 中的受限事实重建恢复路径。"""
|
||||
|
||||
async def async_committed_payload_receipt(
|
||||
self,
|
||||
checkpoint: PluginPackageCheckpoint,
|
||||
) -> str:
|
||||
"""读取当前部署模式下一次已提交载荷的恢复收据。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PluginInstallationRecoveryResult:
|
||||
"""启动恢复批次的 PREPARED 回滚和 COMMITTED 收尾数量。"""
|
||||
|
||||
restored: int = 0
|
||||
finalized: int = 0
|
||||
cleanup_pending: int = 0
|
||||
|
||||
|
||||
class PluginInstallationRecoveryService:
|
||||
"""在插件导入前把跨进程 journal 收敛到完整旧状态或新状态。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
persistence: PluginPersistenceService,
|
||||
packages: PluginRecoveryPackagePort,
|
||||
) -> None:
|
||||
"""保存数据库最终事实和文件恢复端口。"""
|
||||
self.__persistence = persistence
|
||||
self.__packages = packages
|
||||
|
||||
async def replay(self) -> PluginInstallationRecoveryResult:
|
||||
"""按创建顺序恢复全部 journal;关键事实不一致时阻止插件启动。"""
|
||||
restored = 0
|
||||
finalized = 0
|
||||
cleanup_pending = 0
|
||||
for record in await self.__persistence.list_installations():
|
||||
checkpoint = self.__checkpoint(record)
|
||||
if record.phase is PluginInstallationPhase.PREPARED:
|
||||
await self.__restore_prepared(record, checkpoint)
|
||||
restored += 1
|
||||
continue
|
||||
if await self.__finish_committed(record, checkpoint):
|
||||
finalized += 1
|
||||
else:
|
||||
cleanup_pending += 1
|
||||
return PluginInstallationRecoveryResult(
|
||||
restored=restored,
|
||||
finalized=finalized,
|
||||
cleanup_pending=cleanup_pending,
|
||||
)
|
||||
|
||||
def __checkpoint(
|
||||
self,
|
||||
record: PluginInstallationRecord,
|
||||
) -> PluginPackageCheckpoint:
|
||||
"""把 journal 映射为受控恢复路径,不读取任意持久化路径。"""
|
||||
return self.__packages.restore_checkpoint(
|
||||
plugin_id=record.plugin_id,
|
||||
transaction_id=record.transaction_id,
|
||||
plugin_existed=record.package_existed,
|
||||
persistent_backup_existed=record.persistent_backup_existed,
|
||||
)
|
||||
|
||||
async def __restore_prepared(
|
||||
self,
|
||||
record: PluginInstallationRecord,
|
||||
checkpoint: PluginPackageCheckpoint,
|
||||
) -> None:
|
||||
"""恢复数据库提交前的文件状态,再释放 journal 所有权。"""
|
||||
try:
|
||||
await self.__packages.async_restore(checkpoint)
|
||||
await self.__persistence.delete_installation(
|
||||
record.transaction_id,
|
||||
expected_phase=PluginInstallationPhase.PREPARED,
|
||||
)
|
||||
except Exception as error:
|
||||
raise PluginInstallationRecoveryError(
|
||||
f"插件 {record.plugin_id} 的未提交安装恢复失败:{error}"
|
||||
) from error
|
||||
try:
|
||||
await self.__packages.async_cleanup(checkpoint)
|
||||
except Exception as error: # journal 已删除,孤儿材料不改变业务终态
|
||||
logger.warning(
|
||||
"插件安装事务 %s 已恢复,但恢复材料清理失败:%s",
|
||||
record.transaction_id,
|
||||
error,
|
||||
)
|
||||
|
||||
async def __finish_committed(
|
||||
self,
|
||||
record: PluginInstallationRecord,
|
||||
checkpoint: PluginPackageCheckpoint,
|
||||
) -> bool:
|
||||
"""核验并收尾已提交载荷;非关键清理失败留待下一次启动。"""
|
||||
identity = await self.__persistence.get_identity(record.plugin_id)
|
||||
if identity is None or identity.revision != record.identity_target_revision:
|
||||
raise PluginInstallationRecoveryError(
|
||||
f"插件 {record.plugin_id} 的已提交身份与安装 journal 不一致"
|
||||
)
|
||||
try:
|
||||
receipt = await self.__packages.async_committed_payload_receipt(
|
||||
checkpoint
|
||||
)
|
||||
except Exception as error:
|
||||
raise PluginInstallationRecoveryError(
|
||||
f"插件 {record.plugin_id} 的已提交载荷无法核验:{error}"
|
||||
) from error
|
||||
if receipt != identity.payload_receipt:
|
||||
raise PluginInstallationRecoveryError(
|
||||
f"插件 {record.plugin_id} 的已提交载荷收据不一致"
|
||||
)
|
||||
try:
|
||||
await self.__packages.async_finalize_persistent_backup(checkpoint)
|
||||
except Exception as error:
|
||||
raise PluginInstallationRecoveryError(
|
||||
f"插件 {record.plugin_id} 的持久备份终态不完整:{error}"
|
||||
) from error
|
||||
try:
|
||||
await self.__packages.async_commit(checkpoint)
|
||||
await self.__persistence.delete_installation(
|
||||
record.transaction_id,
|
||||
expected_phase=PluginInstallationPhase.COMMITTED,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
"插件安装事务 %s 已提交但收尾仍待重试:%s",
|
||||
record.transaction_id,
|
||||
error,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
_RECOVERY_SERVICE: list[PluginInstallationRecoveryService] = []
|
||||
|
||||
|
||||
def configure_plugin_installation_recovery(
|
||||
service: PluginInstallationRecoveryService,
|
||||
) -> None:
|
||||
"""由组合根登记当前 lifespan 的安装恢复服务。"""
|
||||
_RECOVERY_SERVICE.clear()
|
||||
_RECOVERY_SERVICE.append(service)
|
||||
|
||||
|
||||
def get_plugin_installation_recovery() -> PluginInstallationRecoveryService:
|
||||
"""返回已装配恢复服务;启动顺序错误时拒绝跳过恢复。"""
|
||||
if not _RECOVERY_SERVICE:
|
||||
raise RuntimeError("插件安装恢复服务尚未完成初始化")
|
||||
return _RECOVERY_SERVICE[0]
|
||||
|
||||
|
||||
def reset_plugin_installation_recovery() -> None:
|
||||
"""清除当前 lifespan 的恢复服务。"""
|
||||
_RECOVERY_SERVICE.clear()
|
||||
@@ -0,0 +1,824 @@
|
||||
"""插件市场候选事实与来源选择策略。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
from typing import Any, TypeAlias
|
||||
from urllib.parse import unquote, urlsplit
|
||||
|
||||
from app.application.plugin.identity import (
|
||||
PluginIdentity,
|
||||
PluginPayloadSourceType,
|
||||
TrustedPluginSourceType,
|
||||
normalize_physical_plugin_id,
|
||||
validate_online_source_key,
|
||||
)
|
||||
from app.application.plugin.identity import (
|
||||
PluginSourceCandidate as IdentitySourceCandidate,
|
||||
)
|
||||
from app.foundation.version import compare_version
|
||||
|
||||
PLUGIN_GENERATIONS = ("v1", "v2", "v3")
|
||||
|
||||
|
||||
class MarketReadStatus(StrEnum):
|
||||
"""一次市场索引读取的最终状态。"""
|
||||
|
||||
PRESENT = "present"
|
||||
ABSENT = "absent"
|
||||
FAILED = "failed"
|
||||
|
||||
class PluginSelectionStatus(StrEnum):
|
||||
"""插件候选选择的可观察结果。"""
|
||||
|
||||
SELECTED = "selected"
|
||||
UNAVAILABLE = "unavailable"
|
||||
CONFLICT = "conflict"
|
||||
INCOMPLETE = "incomplete"
|
||||
|
||||
|
||||
class PluginSourceSelectionError(RuntimeError):
|
||||
"""插件来源选择的策略错误。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PluginMarketCandidate:
|
||||
"""一个在线市场条目的原始候选事实。"""
|
||||
|
||||
plugin_id: str
|
||||
source_key: str
|
||||
source_type: TrustedPluginSourceType
|
||||
repo_url: str
|
||||
package_generation: str
|
||||
plugin_version: str | None
|
||||
dto: Any = None
|
||||
normalized_plugin_id: str = field(init=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""校验候选身份,并把外部来源键归一为持久化合同使用的形式。"""
|
||||
normalized_id = normalize_physical_plugin_id(self.plugin_id)
|
||||
source_type = _coerce_online_source_type(self.source_type)
|
||||
source_key = validate_online_source_key(self.source_key)
|
||||
# IdentitySourceCandidate 复用官方仓库与来源类型的双向约束。
|
||||
IdentitySourceCandidate(source_type=source_type, source_key=source_key)
|
||||
repo_url = _normalize_repo_url(self.repo_url)
|
||||
package_generation = normalize_package_generation(self.package_generation)
|
||||
plugin_version = _normalize_plugin_version(self.plugin_version)
|
||||
object.__setattr__(self, "normalized_plugin_id", normalized_id)
|
||||
object.__setattr__(self, "source_type", source_type)
|
||||
object.__setattr__(self, "source_key", source_key)
|
||||
object.__setattr__(self, "repo_url", repo_url)
|
||||
object.__setattr__(self, "package_generation", package_generation)
|
||||
object.__setattr__(self, "plugin_version", plugin_version)
|
||||
|
||||
@property
|
||||
def payload_source_type(self) -> PluginPayloadSourceType:
|
||||
"""返回用于载荷审计的在线来源类型。"""
|
||||
return PluginPayloadSourceType(self.source_type.value)
|
||||
|
||||
def public_dict(self) -> dict[str, Any]:
|
||||
"""生成不携带原始元数据的公共候选投影。"""
|
||||
return {
|
||||
"plugin_id": self.plugin_id,
|
||||
"source_key": self.source_key,
|
||||
"source_type": self.source_type.value,
|
||||
"repo_url": self.repo_url,
|
||||
"package_generation": self.package_generation,
|
||||
"plugin_version": self.plugin_version,
|
||||
}
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PluginLocalCandidate:
|
||||
"""一个本地插件载荷候选,与在线来源身份保持独立。"""
|
||||
|
||||
plugin_id: str
|
||||
repo_url: str
|
||||
package_generation: str
|
||||
plugin_version: str | None
|
||||
dto: Any = None
|
||||
normalized_plugin_id: str = field(init=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""校验本地载荷的插件与版本事实。"""
|
||||
normalized_id = normalize_physical_plugin_id(self.plugin_id)
|
||||
repo_url = _normalize_repo_url(self.repo_url)
|
||||
package_generation = normalize_package_generation(self.package_generation)
|
||||
plugin_version = _normalize_plugin_version(self.plugin_version)
|
||||
object.__setattr__(self, "normalized_plugin_id", normalized_id)
|
||||
object.__setattr__(self, "repo_url", repo_url)
|
||||
object.__setattr__(self, "package_generation", package_generation)
|
||||
object.__setattr__(self, "plugin_version", plugin_version)
|
||||
|
||||
@property
|
||||
def payload_source_type(self) -> PluginPayloadSourceType:
|
||||
"""返回本地载荷类型,不把本地路径伪装成在线来源。"""
|
||||
return PluginPayloadSourceType.LOCAL
|
||||
|
||||
@property
|
||||
def source_type(self) -> PluginPayloadSourceType:
|
||||
"""返回独立的本地载荷类型,不伪造在线可信来源。"""
|
||||
return PluginPayloadSourceType.LOCAL
|
||||
|
||||
@property
|
||||
def source_key(self) -> None:
|
||||
"""本地载荷没有可绑定的在线来源键。"""
|
||||
return None
|
||||
|
||||
def public_dict(self) -> dict[str, Any]:
|
||||
"""生成本地候选的公共投影,永不暴露仓库路径或原始 metadata。"""
|
||||
return {
|
||||
"plugin_id": self.plugin_id,
|
||||
"source_type": PluginPayloadSourceType.LOCAL.value,
|
||||
"package_generation": self.package_generation,
|
||||
"plugin_version": self.plugin_version,
|
||||
}
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MarketRead:
|
||||
"""记录一个配置市场的读取状态及其全部在线候选。"""
|
||||
|
||||
market: str
|
||||
status: MarketReadStatus
|
||||
candidates: tuple[PluginMarketCandidate, ...] = ()
|
||||
error: str | None = None
|
||||
package_generation: str = "v1"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""拒绝把失败读取伪装成空成功结果。"""
|
||||
market = _normalize_market(self.market)
|
||||
status = MarketReadStatus(self.status)
|
||||
candidates = tuple(self.candidates)
|
||||
package_generation = normalize_package_generation(self.package_generation)
|
||||
if status is MarketReadStatus.FAILED:
|
||||
if candidates:
|
||||
raise ValueError("失败的插件市场读取不能携带候选")
|
||||
if not self.error or not self.error.strip():
|
||||
raise ValueError("失败的插件市场读取必须保留错误说明")
|
||||
else:
|
||||
if self.error:
|
||||
raise ValueError("已判定的插件市场读取不能携带错误说明")
|
||||
if status is MarketReadStatus.ABSENT and candidates:
|
||||
raise ValueError("不存在的插件市场索引不能携带候选")
|
||||
if any(not isinstance(candidate, PluginMarketCandidate) for candidate in candidates):
|
||||
raise TypeError("市场读取候选必须是在线插件候选")
|
||||
object.__setattr__(self, "market", market)
|
||||
object.__setattr__(self, "status", status)
|
||||
object.__setattr__(self, "candidates", candidates)
|
||||
object.__setattr__(self, "error", self.error.strip() if self.error else None)
|
||||
object.__setattr__(self, "package_generation", package_generation)
|
||||
|
||||
@classmethod
|
||||
def present(
|
||||
cls,
|
||||
market: str,
|
||||
candidates: Iterable[PluginMarketCandidate] = (),
|
||||
*,
|
||||
package_generation: str = "v1",
|
||||
) -> "MarketRead":
|
||||
"""构造存在的索引读取;真实空索引可以没有候选。"""
|
||||
return cls(
|
||||
market=market,
|
||||
status=MarketReadStatus.PRESENT,
|
||||
candidates=tuple(candidates),
|
||||
package_generation=package_generation,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def absent(
|
||||
cls,
|
||||
market: str,
|
||||
*,
|
||||
package_generation: str = "v1",
|
||||
) -> "MarketRead":
|
||||
"""构造已确认不存在的代际索引。"""
|
||||
return cls(
|
||||
market=market,
|
||||
status=MarketReadStatus.ABSENT,
|
||||
package_generation=package_generation,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def failure(
|
||||
cls,
|
||||
market: str,
|
||||
error: str,
|
||||
*,
|
||||
package_generation: str = "v1",
|
||||
) -> "MarketRead":
|
||||
"""构造失败读取,并保留可诊断但不用于选择的错误说明。"""
|
||||
return cls(
|
||||
market=market,
|
||||
status=MarketReadStatus.FAILED,
|
||||
error=error,
|
||||
package_generation=package_generation,
|
||||
)
|
||||
|
||||
@property
|
||||
def succeeded(self) -> bool:
|
||||
"""判断该索引是否得到存在或不存在的确定结论。"""
|
||||
return self.status is not MarketReadStatus.FAILED
|
||||
|
||||
@property
|
||||
def present_index(self) -> bool:
|
||||
"""判断该代际索引是否真实存在。"""
|
||||
return self.status is MarketReadStatus.PRESENT
|
||||
|
||||
def public_dict(self) -> dict[str, Any]:
|
||||
"""生成市场读取的脱敏投影,保留状态、代际和候选事实。"""
|
||||
return {
|
||||
"market": self.market,
|
||||
"package_generation": self.package_generation,
|
||||
"status": self.status.value,
|
||||
"error": self.error,
|
||||
"candidates": [candidate.public_dict() for candidate in self.candidates],
|
||||
}
|
||||
|
||||
|
||||
class LocalCandidateReadStatus(StrEnum):
|
||||
"""一次本地插件仓库扫描的可观察终态。"""
|
||||
|
||||
PRESENT = "present"
|
||||
ABSENT = "absent"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LocalCandidateRead:
|
||||
"""记录本地候选扫描状态,避免扫描失败伪装成空仓库。"""
|
||||
|
||||
status: LocalCandidateReadStatus
|
||||
candidates: tuple[PluginLocalCandidate, ...] = ()
|
||||
error: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""保证本地扫描状态、候选和错误说明相互一致。"""
|
||||
status = LocalCandidateReadStatus(self.status)
|
||||
candidates = tuple(self.candidates)
|
||||
if any(not isinstance(candidate, PluginLocalCandidate) for candidate in candidates):
|
||||
raise TypeError("本地扫描候选必须是 PluginLocalCandidate")
|
||||
if status is LocalCandidateReadStatus.FAILED:
|
||||
if candidates:
|
||||
raise ValueError("失败的本地扫描不能携带候选")
|
||||
if not self.error or not self.error.strip():
|
||||
raise ValueError("失败的本地扫描必须保留错误说明")
|
||||
else:
|
||||
if self.error:
|
||||
raise ValueError("已判定的本地扫描不能携带错误说明")
|
||||
if status is LocalCandidateReadStatus.ABSENT and candidates:
|
||||
raise ValueError("不存在的本地扫描不能携带候选")
|
||||
object.__setattr__(self, "status", status)
|
||||
object.__setattr__(self, "candidates", candidates)
|
||||
object.__setattr__(self, "error", self.error.strip() if self.error else None)
|
||||
|
||||
@classmethod
|
||||
def present(
|
||||
cls,
|
||||
candidates: Iterable[PluginLocalCandidate] = (),
|
||||
) -> "LocalCandidateRead":
|
||||
"""构造扫描成功的本地候选快照,空结果仍表示扫描成功。"""
|
||||
return cls(
|
||||
status=LocalCandidateReadStatus.PRESENT,
|
||||
candidates=tuple(candidates),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def absent(cls) -> "LocalCandidateRead":
|
||||
"""构造没有配置本地仓库的结果。"""
|
||||
return cls(status=LocalCandidateReadStatus.ABSENT)
|
||||
|
||||
@classmethod
|
||||
def failure(cls, error: str) -> "LocalCandidateRead":
|
||||
"""构造无法完成本地扫描的结果。"""
|
||||
return cls(status=LocalCandidateReadStatus.FAILED, error=error)
|
||||
|
||||
def public_dict(self) -> dict[str, Any]:
|
||||
"""生成不泄漏本地路径的扫描投影。"""
|
||||
return {
|
||||
"status": self.status.value,
|
||||
"error": self.error,
|
||||
"candidates": [candidate.public_dict() for candidate in self.candidates],
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CandidateInventory:
|
||||
"""一次短生命周期市场快照,保留配置市场状态和全部候选。"""
|
||||
|
||||
market_reads: tuple[MarketRead, ...]
|
||||
local_candidates: tuple[PluginLocalCandidate, ...] = ()
|
||||
expected_markets: tuple[str, ...] | None = None
|
||||
expected_generations: tuple[str, ...] | None = None
|
||||
local_read: LocalCandidateRead | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""冻结快照输入,避免后续市场刷新改变选择依据。"""
|
||||
market_reads = tuple(self.market_reads)
|
||||
local_candidates = tuple(self.local_candidates)
|
||||
expected_markets = (
|
||||
tuple(_normalize_market(market) for market in self.expected_markets)
|
||||
if self.expected_markets is not None
|
||||
else None
|
||||
)
|
||||
expected_generations = (
|
||||
tuple(normalize_package_generation(generation) for generation in self.expected_generations)
|
||||
if self.expected_generations is not None
|
||||
else None
|
||||
)
|
||||
local_read = self.local_read
|
||||
if local_read is None:
|
||||
local_read = (
|
||||
LocalCandidateRead.present(local_candidates)
|
||||
if local_candidates
|
||||
else LocalCandidateRead.absent()
|
||||
)
|
||||
if not isinstance(local_read, LocalCandidateRead):
|
||||
raise TypeError("候选清单的本地读取必须由 LocalCandidateRead 组成")
|
||||
if local_read.candidates != local_candidates:
|
||||
raise ValueError("候选清单的本地读取与本地候选必须一致")
|
||||
if any(not isinstance(read, MarketRead) for read in market_reads):
|
||||
raise TypeError("候选清单必须由 MarketRead 组成")
|
||||
if any(not isinstance(candidate, PluginLocalCandidate) for candidate in local_candidates):
|
||||
raise TypeError("本地候选清单必须由 PluginLocalCandidate 组成")
|
||||
read_keys = [(read.market, read.package_generation) for read in market_reads]
|
||||
if len(read_keys) != len(set(read_keys)):
|
||||
raise ValueError("候选清单不能重复记录同一个市场代际")
|
||||
if expected_markets is not None and len(expected_markets) != len(set(expected_markets)):
|
||||
raise ValueError("候选清单的预期市场不能重复")
|
||||
if expected_generations is not None:
|
||||
if not expected_generations or len(expected_generations) != len(set(expected_generations)):
|
||||
raise ValueError("候选清单的预期代际必须唯一且非空")
|
||||
object.__setattr__(self, "market_reads", market_reads)
|
||||
object.__setattr__(self, "local_candidates", local_candidates)
|
||||
object.__setattr__(self, "expected_markets", expected_markets)
|
||||
object.__setattr__(self, "expected_generations", expected_generations)
|
||||
object.__setattr__(self, "local_read", local_read)
|
||||
|
||||
@property
|
||||
def configured_markets(self) -> tuple[str, ...]:
|
||||
"""按配置顺序返回本轮预期读取的市场。"""
|
||||
if self.expected_markets is not None:
|
||||
return self.expected_markets
|
||||
return tuple(dict.fromkeys(read.market for read in self.market_reads))
|
||||
|
||||
def reads_for(self, market: str) -> tuple[MarketRead, ...]:
|
||||
"""返回一个市场的全部代际读取事实。"""
|
||||
normalized_market = _normalize_market(market)
|
||||
return tuple(read for read in self.market_reads if read.market == normalized_market)
|
||||
|
||||
def read_for(self, market: str, package_generation: str) -> MarketRead | None:
|
||||
"""返回一个市场和代际的读取事实。"""
|
||||
normalized_generation = normalize_package_generation(package_generation)
|
||||
return next(
|
||||
(
|
||||
read
|
||||
for read in self.reads_for(market)
|
||||
if read.package_generation == normalized_generation
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
@property
|
||||
def complete(self) -> bool:
|
||||
"""只有预期市场与代际均可证明已成功读取时才算完整。"""
|
||||
if not self.market_reads or not all(read.succeeded for read in self.market_reads):
|
||||
return False
|
||||
expected_markets = self.expected_markets
|
||||
expected_generations = self.expected_generations
|
||||
if (expected_markets is None) != (expected_generations is None):
|
||||
return False
|
||||
if expected_markets is None or expected_generations is None:
|
||||
return True
|
||||
expected = {
|
||||
(market, generation)
|
||||
for market in expected_markets
|
||||
for generation in expected_generations
|
||||
}
|
||||
actual = {(read.market, read.package_generation) for read in self.market_reads}
|
||||
return expected <= actual
|
||||
|
||||
@property
|
||||
def can_use_for_tofu(self) -> bool:
|
||||
"""判断快照是否足以证明唯一第三方来源。"""
|
||||
local_read = self.local_read
|
||||
return (
|
||||
self.complete
|
||||
and local_read is not None
|
||||
and local_read.status is not LocalCandidateReadStatus.FAILED
|
||||
)
|
||||
|
||||
@property
|
||||
def online_candidates(self) -> tuple[PluginMarketCandidate, ...]:
|
||||
"""按配置市场顺序返回全部在线候选,不按 ID 或版本去重。"""
|
||||
return tuple(
|
||||
candidate
|
||||
for read in self.market_reads
|
||||
if read.present_index
|
||||
for candidate in read.candidates
|
||||
)
|
||||
|
||||
def candidates_for(self, plugin_id: str) -> tuple[PluginMarketCandidate, ...]:
|
||||
"""读取一个插件 ID 的全部在线候选。"""
|
||||
normalized_id = normalize_physical_plugin_id(plugin_id)
|
||||
return tuple(
|
||||
candidate
|
||||
for candidate in self.online_candidates
|
||||
if candidate.normalized_plugin_id == normalized_id
|
||||
)
|
||||
|
||||
def local_candidates_for(self, plugin_id: str) -> tuple[PluginLocalCandidate, ...]:
|
||||
"""读取一个插件 ID 的全部本地候选。"""
|
||||
normalized_id = normalize_physical_plugin_id(plugin_id)
|
||||
return tuple(
|
||||
candidate
|
||||
for candidate in self.local_candidates
|
||||
if candidate.normalized_plugin_id == normalized_id
|
||||
)
|
||||
|
||||
def public_dict(self) -> dict[str, Any]:
|
||||
"""生成完整库存的脱敏投影,不泄漏本地路径或原始 DTO。"""
|
||||
local_read = self.local_read
|
||||
if local_read is None:
|
||||
raise RuntimeError("候选清单缺少本地读取终态")
|
||||
return {
|
||||
"markets": [read.public_dict() for read in self.market_reads],
|
||||
"local_candidates": [candidate.public_dict() for candidate in self.local_candidates],
|
||||
"local_read": local_read.public_dict(),
|
||||
"complete": self.complete,
|
||||
}
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PluginSelection:
|
||||
"""候选选择结果,冲突和不完整状态均不降级为静默空值。"""
|
||||
|
||||
status: PluginSelectionStatus
|
||||
candidate: PluginMarketCandidate | PluginLocalCandidate | None = None
|
||||
conflict_source_keys: tuple[str, ...] = ()
|
||||
reason: str = ""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""保证选择状态与载荷及冲突信息相互一致。"""
|
||||
status = PluginSelectionStatus(self.status)
|
||||
conflict_source_keys = tuple(sorted(set(self.conflict_source_keys)))
|
||||
if status is PluginSelectionStatus.SELECTED and self.candidate is None:
|
||||
raise ValueError("selected 结果必须携带候选")
|
||||
if status is not PluginSelectionStatus.SELECTED and self.candidate is not None:
|
||||
raise ValueError("未选中结果不能携带候选")
|
||||
if status is not PluginSelectionStatus.CONFLICT and conflict_source_keys:
|
||||
raise ValueError("只有 conflict 结果能携带冲突来源")
|
||||
object.__setattr__(self, "status", status)
|
||||
object.__setattr__(self, "conflict_source_keys", conflict_source_keys)
|
||||
|
||||
@property
|
||||
def selected(self) -> bool:
|
||||
"""判断是否已经选择出一个载荷候选。"""
|
||||
return self.status is PluginSelectionStatus.SELECTED
|
||||
|
||||
def public_dict(self) -> dict[str, Any]:
|
||||
"""生成安全的选择结果投影,不透传本地路径或原始 DTO。"""
|
||||
result: dict[str, Any] = {
|
||||
"status": self.status.value,
|
||||
"reason": self.reason,
|
||||
}
|
||||
if self.conflict_source_keys:
|
||||
result["conflict_source_keys"] = list(self.conflict_source_keys)
|
||||
if self.candidate is not None:
|
||||
result["candidate"] = self.candidate.public_dict()
|
||||
return result
|
||||
|
||||
Candidate: TypeAlias = PluginMarketCandidate | PluginLocalCandidate
|
||||
|
||||
|
||||
def normalize_package_generation(package_generation: str) -> str:
|
||||
"""校验并归一插件包代际。"""
|
||||
value = str(package_generation).strip().lower()
|
||||
if value not in PLUGIN_GENERATIONS:
|
||||
raise ValueError("插件包代际必须为 v1、v2 或 v3")
|
||||
return value
|
||||
|
||||
|
||||
def _select_local_candidate(
|
||||
inventory: CandidateInventory,
|
||||
*,
|
||||
plugin_id: str,
|
||||
normalized_id: str,
|
||||
generation_order: tuple[str, ...],
|
||||
local_candidates: Iterable[PluginLocalCandidate] | None,
|
||||
) -> PluginSelection | None:
|
||||
"""优先选择本地载荷;读取失败时阻止自动降级到在线来源。"""
|
||||
local = (
|
||||
tuple(local_candidates)
|
||||
if local_candidates is not None
|
||||
else inventory.local_candidates_for(plugin_id)
|
||||
)
|
||||
if any(not isinstance(candidate, PluginLocalCandidate) for candidate in local):
|
||||
raise TypeError("本地候选必须是 PluginLocalCandidate")
|
||||
if any(candidate.normalized_plugin_id != normalized_id for candidate in local):
|
||||
raise ValueError("本地候选的插件 ID 必须与选择目标一致")
|
||||
local_read = inventory.local_read
|
||||
if (
|
||||
not local
|
||||
and local_read is not None
|
||||
and local_read.status is LocalCandidateReadStatus.FAILED
|
||||
):
|
||||
return PluginSelection(
|
||||
status=PluginSelectionStatus.INCOMPLETE,
|
||||
reason="本地插件仓库读取失败,不能自动选择在线载荷",
|
||||
)
|
||||
if not local:
|
||||
return None
|
||||
selected_local = _select_best(local, generation_order)
|
||||
if selected_local is None:
|
||||
return PluginSelection(
|
||||
status=PluginSelectionStatus.UNAVAILABLE,
|
||||
reason="本地候选没有符合当前运行代际的版本",
|
||||
)
|
||||
return PluginSelection(
|
||||
status=PluginSelectionStatus.SELECTED,
|
||||
candidate=selected_local,
|
||||
reason="优先使用本地载荷",
|
||||
)
|
||||
|
||||
|
||||
def select_plugin_candidate(
|
||||
inventory: CandidateInventory,
|
||||
*,
|
||||
plugin_id: str,
|
||||
generations: Sequence[str],
|
||||
identity: PluginIdentity | None = None,
|
||||
local_candidates: Iterable[PluginLocalCandidate] | None = None,
|
||||
requested_source_key: str | None = None,
|
||||
explicit_source: bool = False,
|
||||
allow_source_change: bool = False,
|
||||
) -> PluginSelection:
|
||||
"""
|
||||
按允许来源、运行代际和同源版本选择一个插件载荷。
|
||||
|
||||
:param inventory: 本轮市场读取快照
|
||||
:param plugin_id: 要选择的物理插件 ID
|
||||
:param generations: 调用方按优先级传入的代际顺序
|
||||
:param identity: 已安装插件来源身份;为空表示未安装
|
||||
:param local_candidates: 可选的本地载荷候选,优先于在线候选
|
||||
:param requested_source_key: 调用方提供的规范在线来源;非显式调用不能绕过本地载荷
|
||||
:param explicit_source: 本次调用是否代表管理员明确选源
|
||||
:param allow_source_change: 是否是带 revision 的显式换源命令
|
||||
:return: 带明确冲突或不完整状态的选择结果
|
||||
"""
|
||||
normalized_id = normalize_physical_plugin_id(plugin_id)
|
||||
generation_order = _normalize_generation_order(generations)
|
||||
requested_source = (
|
||||
validate_online_source_key(requested_source_key)
|
||||
if requested_source_key is not None
|
||||
else None
|
||||
)
|
||||
if requested_source is None or not (explicit_source or allow_source_change):
|
||||
local_selection = _select_local_candidate(
|
||||
inventory,
|
||||
plugin_id=plugin_id,
|
||||
normalized_id=normalized_id,
|
||||
generation_order=generation_order,
|
||||
local_candidates=local_candidates,
|
||||
)
|
||||
if local_selection is not None:
|
||||
return local_selection
|
||||
|
||||
online = inventory.candidates_for(plugin_id)
|
||||
if not online:
|
||||
return PluginSelection(
|
||||
status=PluginSelectionStatus.UNAVAILABLE,
|
||||
reason=f"没有找到插件 {plugin_id} 的在线候选",
|
||||
)
|
||||
|
||||
allowed_source = _allowed_source(identity, normalized_id)
|
||||
if requested_source is not None:
|
||||
requested_online = tuple(
|
||||
candidate
|
||||
for candidate in online
|
||||
if candidate.source_key == requested_source
|
||||
)
|
||||
if not requested_online:
|
||||
return PluginSelection(
|
||||
status=PluginSelectionStatus.UNAVAILABLE,
|
||||
reason="明确选择的在线来源没有当前插件候选",
|
||||
)
|
||||
if allowed_source is not None:
|
||||
_source_type, allowed_key = allowed_source
|
||||
if requested_source != allowed_key and not allow_source_change:
|
||||
return PluginSelection(
|
||||
status=PluginSelectionStatus.CONFLICT,
|
||||
conflict_source_keys=(allowed_key, requested_source),
|
||||
reason="普通安装不能改变已绑定的在线来源",
|
||||
)
|
||||
if explicit_source or allow_source_change:
|
||||
selected_requested = _select_best(requested_online, generation_order)
|
||||
if selected_requested is None:
|
||||
return PluginSelection(
|
||||
status=PluginSelectionStatus.UNAVAILABLE,
|
||||
reason="明确选择的来源没有符合当前运行代际的版本",
|
||||
)
|
||||
return PluginSelection(
|
||||
status=PluginSelectionStatus.SELECTED,
|
||||
candidate=selected_requested,
|
||||
reason=(
|
||||
"按显式换源目标选择在线载荷"
|
||||
if allow_source_change
|
||||
else "按管理员明确选择的来源安装在线载荷"
|
||||
),
|
||||
)
|
||||
if allowed_source is not None:
|
||||
source_type, source_key = allowed_source
|
||||
online = tuple(
|
||||
candidate
|
||||
for candidate in online
|
||||
if candidate.source_type is source_type and candidate.source_key == source_key
|
||||
)
|
||||
if not online:
|
||||
return PluginSelection(
|
||||
status=PluginSelectionStatus.UNAVAILABLE,
|
||||
reason="当前来源身份没有可用候选",
|
||||
)
|
||||
selected_online = _select_best(online, generation_order)
|
||||
if selected_online is None:
|
||||
return PluginSelection(
|
||||
status=PluginSelectionStatus.UNAVAILABLE,
|
||||
reason="在线候选没有符合当前运行代际的版本",
|
||||
)
|
||||
return PluginSelection(
|
||||
status=PluginSelectionStatus.SELECTED,
|
||||
candidate=selected_online,
|
||||
reason="按已绑定来源选择在线载荷",
|
||||
)
|
||||
|
||||
if identity is not None:
|
||||
return PluginSelection(
|
||||
status=PluginSelectionStatus.INCOMPLETE,
|
||||
reason="插件来源身份尚未绑定,不能自动选择在线载荷",
|
||||
)
|
||||
|
||||
source_pairs = {(candidate.source_type, candidate.source_key) for candidate in online}
|
||||
if len(source_pairs) > 1:
|
||||
return PluginSelection(
|
||||
status=PluginSelectionStatus.CONFLICT,
|
||||
conflict_source_keys=tuple(source_key for _source_type, source_key in source_pairs),
|
||||
reason="未安装插件存在多个在线来源,不能静默选择",
|
||||
)
|
||||
|
||||
source_type = next(iter(source_pairs))[0]
|
||||
if source_type is TrustedPluginSourceType.THIRD_PARTY and not inventory.can_use_for_tofu:
|
||||
return PluginSelection(
|
||||
status=PluginSelectionStatus.INCOMPLETE,
|
||||
reason="市场读取不完整,不能建立唯一第三方来源的 TOFU",
|
||||
)
|
||||
selected_online = _select_best(online, generation_order)
|
||||
if selected_online is None:
|
||||
return PluginSelection(
|
||||
status=PluginSelectionStatus.UNAVAILABLE,
|
||||
reason="在线候选没有符合当前运行代际的版本",
|
||||
)
|
||||
return PluginSelection(
|
||||
status=PluginSelectionStatus.SELECTED,
|
||||
candidate=selected_online,
|
||||
reason="唯一在线来源候选",
|
||||
)
|
||||
|
||||
|
||||
def list_effective_online_candidates(
|
||||
inventory: CandidateInventory,
|
||||
*,
|
||||
plugin_id: str,
|
||||
generations: Sequence[str],
|
||||
) -> tuple[PluginMarketCandidate, ...]:
|
||||
"""按来源列出当前运行代际实际可安装的最高版本候选。"""
|
||||
generation_order = _normalize_generation_order(generations)
|
||||
grouped: dict[
|
||||
tuple[TrustedPluginSourceType, str],
|
||||
list[PluginMarketCandidate],
|
||||
] = {}
|
||||
for candidate in inventory.candidates_for(plugin_id):
|
||||
grouped.setdefault(
|
||||
(candidate.source_type, candidate.source_key),
|
||||
[],
|
||||
).append(candidate)
|
||||
|
||||
selected: list[PluginMarketCandidate] = []
|
||||
for candidates in grouped.values():
|
||||
selected_candidate = _select_best(candidates, generation_order)
|
||||
if isinstance(selected_candidate, PluginMarketCandidate):
|
||||
selected.append(selected_candidate)
|
||||
return tuple(selected)
|
||||
|
||||
|
||||
def get_effective_local_candidate(
|
||||
inventory: CandidateInventory,
|
||||
*,
|
||||
plugin_id: str,
|
||||
generations: Sequence[str],
|
||||
) -> PluginLocalCandidate | None:
|
||||
"""返回本地插件目录中当前运行代际优先级最高的安全候选。"""
|
||||
candidate = _select_best(
|
||||
inventory.local_candidates_for(plugin_id),
|
||||
_normalize_generation_order(generations),
|
||||
)
|
||||
return candidate if isinstance(candidate, PluginLocalCandidate) else None
|
||||
|
||||
|
||||
def parse_local_plugin_reference(repo_url: str) -> str | None:
|
||||
"""从不透明本地来源标识中提取插件 ID,不读取或暴露宿主路径。"""
|
||||
if not str(repo_url).startswith("local://"):
|
||||
return None
|
||||
try:
|
||||
parsed = urlsplit(repo_url)
|
||||
plugin_id = unquote(parsed.netloc or parsed.path.strip("/"))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return plugin_id or None
|
||||
|
||||
|
||||
def _coerce_online_source_type(source_type: TrustedPluginSourceType) -> TrustedPluginSourceType:
|
||||
"""把外部字符串来源类型转换为可信在线来源枚举。"""
|
||||
value = TrustedPluginSourceType(source_type)
|
||||
if value is TrustedPluginSourceType.UNKNOWN:
|
||||
raise ValueError("未知来源不能作为在线市场候选")
|
||||
return value
|
||||
|
||||
|
||||
def _normalize_market(market: str) -> str:
|
||||
"""校验市场标识,保留其作为本轮快照的显示值。"""
|
||||
value = str(market).strip()
|
||||
if not value:
|
||||
raise ValueError("插件市场标识不能为空")
|
||||
return value
|
||||
|
||||
|
||||
def _normalize_repo_url(repo_url: str) -> str:
|
||||
"""保留仓库地址作为安装事实,但移除无意义的外围空白。"""
|
||||
value = str(repo_url).strip()
|
||||
if not value:
|
||||
raise ValueError("插件仓库地址不能为空")
|
||||
return value
|
||||
|
||||
|
||||
def _normalize_plugin_version(plugin_version: str | None) -> str | None:
|
||||
"""标准化可选插件声明版本,并保持缺失版本可观察。"""
|
||||
if plugin_version is None:
|
||||
return None
|
||||
value = str(plugin_version).strip()
|
||||
if not value:
|
||||
raise ValueError("插件声明版本不能为空字符串")
|
||||
if len(value) > 64:
|
||||
raise ValueError("插件声明版本长度不能超过 64")
|
||||
return value
|
||||
|
||||
|
||||
def _normalize_generation_order(generations: Sequence[str]) -> tuple[str, ...]:
|
||||
"""校验调用方提供的代际优先序,并拒绝重复项。"""
|
||||
normalized = tuple(normalize_package_generation(generation) for generation in generations)
|
||||
if not normalized:
|
||||
raise ValueError("至少需要一个当前运行代际")
|
||||
if len(normalized) != len(set(normalized)):
|
||||
raise ValueError("当前运行代际优先序不能重复")
|
||||
return normalized
|
||||
|
||||
|
||||
def _allowed_source(
|
||||
identity: PluginIdentity | None,
|
||||
normalized_plugin_id: str,
|
||||
) -> tuple[TrustedPluginSourceType, str] | None:
|
||||
"""从已安装身份读取不可变的允许在线来源。"""
|
||||
if identity is None:
|
||||
return None
|
||||
if identity.normalized_plugin_id != normalized_plugin_id:
|
||||
raise ValueError("来源身份的插件 ID 与选择目标不一致")
|
||||
if identity.trusted_source_type is TrustedPluginSourceType.UNKNOWN:
|
||||
return None
|
||||
if not identity.trusted_source_key:
|
||||
raise PluginSourceSelectionError("已绑定来源身份缺少规范来源键")
|
||||
return identity.trusted_source_type, identity.trusted_source_key
|
||||
|
||||
|
||||
def _select_best(
|
||||
candidates: Sequence[Candidate],
|
||||
generation_order: Sequence[str],
|
||||
) -> Candidate | None:
|
||||
"""在已完成来源过滤后按代际和同源版本选择最高候选。"""
|
||||
for generation in generation_order:
|
||||
generation_candidates = tuple(
|
||||
candidate
|
||||
for candidate in candidates
|
||||
if candidate.package_generation == generation
|
||||
)
|
||||
if generation_candidates:
|
||||
return _select_highest_version(generation_candidates)
|
||||
return None
|
||||
|
||||
|
||||
def _select_highest_version(candidates: Sequence[Candidate]) -> Candidate:
|
||||
"""使用宿主既有版本比较语义选择同源最高版本,平级保留先读候选。"""
|
||||
selected = candidates[0]
|
||||
for candidate in candidates[1:]:
|
||||
selected_version = selected.plugin_version or "0"
|
||||
candidate_version = candidate.plugin_version or "0"
|
||||
if compare_version(candidate_version, ">", selected_version):
|
||||
selected = candidate
|
||||
return selected
|
||||
@@ -0,0 +1,325 @@
|
||||
"""插件安装事务的持久化端口与可逆状态记录。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from functools import partial
|
||||
from typing import Protocol, TypeVar
|
||||
|
||||
from app.application.database import AsyncDatabaseExecutor
|
||||
from app.application.plugin.identity import PluginIdentity
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class PluginInstallationPhase(StrEnum):
|
||||
"""安装事务在持久化协调器中的两个数据库阶段。"""
|
||||
|
||||
PREPARED = "prepared"
|
||||
COMMITTED = "committed"
|
||||
|
||||
|
||||
class PluginInstallationConflictError(RuntimeError):
|
||||
"""事务不存在、阶段竞争或实际状态发生漂移。"""
|
||||
|
||||
|
||||
class PluginInstallationRecordError(ValueError):
|
||||
"""事务记录不符合可持久化和恢复合同。"""
|
||||
|
||||
|
||||
_TRANSACTION_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
|
||||
INSTALLATION_JOURNAL_SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
def _validate_revision(value: int | None, *, field_name: str) -> None:
|
||||
"""校验身份 CAS revision;``None`` 表示对应身份行不存在。"""
|
||||
if value is None:
|
||||
return
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
||||
raise PluginInstallationRecordError(
|
||||
f"{field_name} 必须是大于等于 1 的整数或 null"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PluginInstallationRecord:
|
||||
"""可跨进程恢复的插件安装事务状态。"""
|
||||
|
||||
transaction_id: str
|
||||
plugin_id: str
|
||||
phase: PluginInstallationPhase
|
||||
membership_before: bool
|
||||
membership_target: bool | None
|
||||
identity_before_revision: int | None
|
||||
identity_target_revision: int | None
|
||||
package_existed: bool
|
||||
persistent_backup_existed: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
schema_version: int = INSTALLATION_JOURNAL_SCHEMA_VERSION
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""拒绝不能作为 CAS 或崩溃恢复依据的事务记录。"""
|
||||
if not _TRANSACTION_ID_PATTERN.fullmatch(self.transaction_id):
|
||||
raise PluginInstallationRecordError("transaction_id 格式不合法")
|
||||
if not self.plugin_id or self.plugin_id != self.plugin_id.strip():
|
||||
raise PluginInstallationRecordError("plugin_id 不能为空或带首尾空格")
|
||||
if not isinstance(self.phase, PluginInstallationPhase):
|
||||
try:
|
||||
object.__setattr__(self, "phase", PluginInstallationPhase(self.phase))
|
||||
except ValueError as error:
|
||||
raise PluginInstallationRecordError("未知安装事务 phase") from error
|
||||
|
||||
if not isinstance(self.membership_before, bool):
|
||||
raise PluginInstallationRecordError("membership_before 必须是布尔值")
|
||||
if not isinstance(self.package_existed, bool):
|
||||
raise PluginInstallationRecordError("package_existed 必须是布尔值")
|
||||
if not isinstance(self.persistent_backup_existed, bool):
|
||||
raise PluginInstallationRecordError(
|
||||
"persistent_backup_existed 必须是布尔值"
|
||||
)
|
||||
if self.membership_target is not None and not isinstance(
|
||||
self.membership_target,
|
||||
bool,
|
||||
):
|
||||
raise PluginInstallationRecordError("membership_target 必须是布尔值或 null")
|
||||
if (
|
||||
self.phase is PluginInstallationPhase.COMMITTED
|
||||
and self.membership_target is None
|
||||
):
|
||||
raise PluginInstallationRecordError(
|
||||
"COMMITTED 事务必须包含 membership target"
|
||||
)
|
||||
|
||||
_validate_revision(
|
||||
self.identity_before_revision,
|
||||
field_name="identity_before_revision",
|
||||
)
|
||||
_validate_revision(
|
||||
self.identity_target_revision,
|
||||
field_name="identity_target_revision",
|
||||
)
|
||||
if self.created_at.tzinfo is None or self.updated_at.tzinfo is None:
|
||||
raise PluginInstallationRecordError("事务时间必须包含时区")
|
||||
if self.updated_at < self.created_at:
|
||||
raise PluginInstallationRecordError("事务更新时间不能早于创建时间")
|
||||
if (
|
||||
isinstance(self.schema_version, bool)
|
||||
or not isinstance(self.schema_version, int)
|
||||
or self.schema_version != INSTALLATION_JOURNAL_SCHEMA_VERSION
|
||||
):
|
||||
raise PluginInstallationRecordError(
|
||||
f"不支持的插件安装事务快照版本: {self.schema_version}"
|
||||
)
|
||||
|
||||
|
||||
class PluginInstallationStore(Protocol):
|
||||
"""安装 Gateway 使用的同步持久化端口。"""
|
||||
|
||||
def create(self, record: PluginInstallationRecord) -> PluginInstallationRecord:
|
||||
"""创建一条 PREPARED 安装事务。"""
|
||||
|
||||
def get(self, transaction_id: str) -> PluginInstallationRecord | None:
|
||||
"""按事务 ID 读取记录。"""
|
||||
|
||||
def list(
|
||||
self,
|
||||
*,
|
||||
plugin_id: str | None = None,
|
||||
) -> list[PluginInstallationRecord]:
|
||||
"""列出事务记录。"""
|
||||
|
||||
def set_target(
|
||||
self,
|
||||
transaction_id: str,
|
||||
*,
|
||||
membership_target: bool,
|
||||
identity_target: PluginIdentity | None,
|
||||
expected_phase: PluginInstallationPhase,
|
||||
) -> PluginInstallationRecord:
|
||||
"""在 PREPARED 阶段登记目标 membership 和身份 revision。"""
|
||||
|
||||
def commit_target(
|
||||
self,
|
||||
transaction_id: str,
|
||||
*,
|
||||
identity_target: PluginIdentity | None,
|
||||
expected_phase: PluginInstallationPhase,
|
||||
) -> PluginInstallationRecord:
|
||||
"""在同一同步事务中完成身份、membership 和 COMMITTED phase。"""
|
||||
|
||||
def delete(
|
||||
self,
|
||||
transaction_id: str,
|
||||
*,
|
||||
expected_phase: PluginInstallationPhase,
|
||||
) -> bool:
|
||||
"""按 phase CAS 删除已处理的事务记录。"""
|
||||
|
||||
|
||||
class PluginIdentityPersistence(Protocol):
|
||||
"""插件来源身份读取与存量迁移使用的同步窄端口。"""
|
||||
|
||||
def get(self, plugin_id: str) -> PluginIdentity | None:
|
||||
"""读取一个物理插件的来源身份。"""
|
||||
|
||||
def compare_and_set(
|
||||
self,
|
||||
identity: PluginIdentity,
|
||||
*,
|
||||
expected_revision: int | None,
|
||||
) -> PluginIdentity:
|
||||
"""仅供存量迁移首次创建或按 revision 更新身份。"""
|
||||
|
||||
def bind_online(
|
||||
self,
|
||||
identity: PluginIdentity,
|
||||
*,
|
||||
expected_revision: int,
|
||||
) -> PluginIdentity:
|
||||
"""把存量未绑定身份按 revision 绑定到在线来源。"""
|
||||
|
||||
|
||||
class PluginPersistenceService:
|
||||
"""通过有界数据库 worker 暴露插件专用异步持久化能力。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
executor: AsyncDatabaseExecutor,
|
||||
identities: PluginIdentityPersistence,
|
||||
installations: PluginInstallationStore,
|
||||
) -> None:
|
||||
"""保存身份、安装事务和唯一同步数据库执行边界。"""
|
||||
self.__executor = executor
|
||||
self.__identities = identities
|
||||
self.__installations = installations
|
||||
|
||||
async def get_identity(self, plugin_id: str) -> PluginIdentity | None:
|
||||
"""在数据库 worker 中读取插件来源身份。"""
|
||||
return await self.__executor.run(partial(self.__identities.get, plugin_id))
|
||||
|
||||
async def migrate_identity(
|
||||
self,
|
||||
identity: PluginIdentity,
|
||||
*,
|
||||
expected_revision: int | None,
|
||||
) -> PluginIdentity:
|
||||
"""在数据库 worker 中提交存量身份迁移。"""
|
||||
return await self.__executor.run(
|
||||
partial(
|
||||
self.__identities.compare_and_set,
|
||||
identity,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
)
|
||||
|
||||
async def bind_online_identity(
|
||||
self,
|
||||
identity: PluginIdentity,
|
||||
*,
|
||||
expected_revision: int,
|
||||
) -> PluginIdentity:
|
||||
"""在数据库 worker 中绑定存量身份的可信在线来源。"""
|
||||
return await self.__executor.run(
|
||||
partial(
|
||||
self.__identities.bind_online,
|
||||
identity,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
)
|
||||
|
||||
async def create_installation(
|
||||
self,
|
||||
record: PluginInstallationRecord,
|
||||
) -> PluginInstallationRecord:
|
||||
"""创建 PREPARED journal。"""
|
||||
return await self.__executor.run(
|
||||
partial(self.__installations.create, record)
|
||||
)
|
||||
|
||||
async def list_installations(self) -> list[PluginInstallationRecord]:
|
||||
"""列出全部待恢复或待清理的安装 journal。"""
|
||||
return await self.__executor.run(self.__installations.list)
|
||||
|
||||
async def get_installation(
|
||||
self,
|
||||
transaction_id: str,
|
||||
) -> PluginInstallationRecord | None:
|
||||
"""读取一次提交结果确认所需的安装 journal。"""
|
||||
return await self.__executor.run(
|
||||
partial(self.__installations.get, transaction_id)
|
||||
)
|
||||
|
||||
async def set_installation_target(
|
||||
self,
|
||||
transaction_id: str,
|
||||
*,
|
||||
membership_target: bool,
|
||||
identity_target: PluginIdentity | None,
|
||||
) -> PluginInstallationRecord:
|
||||
"""在 PREPARED journal 中登记最终数据库目标。"""
|
||||
return await self.__executor.run(
|
||||
partial(
|
||||
self.__installations.set_target,
|
||||
transaction_id,
|
||||
membership_target=membership_target,
|
||||
identity_target=identity_target,
|
||||
expected_phase=PluginInstallationPhase.PREPARED,
|
||||
)
|
||||
)
|
||||
|
||||
async def commit_installation(
|
||||
self,
|
||||
transaction_id: str,
|
||||
*,
|
||||
identity_target: PluginIdentity | None,
|
||||
) -> PluginInstallationRecord:
|
||||
"""原子提交 membership、身份和 COMMITTED phase。"""
|
||||
return await self.__executor.run(
|
||||
partial(
|
||||
self.__installations.commit_target,
|
||||
transaction_id,
|
||||
identity_target=identity_target,
|
||||
expected_phase=PluginInstallationPhase.PREPARED,
|
||||
)
|
||||
)
|
||||
|
||||
async def delete_installation(
|
||||
self,
|
||||
transaction_id: str,
|
||||
*,
|
||||
expected_phase: PluginInstallationPhase,
|
||||
) -> bool:
|
||||
"""按 phase 删除已恢复或已收尾的 journal。"""
|
||||
return await self.__executor.run(
|
||||
partial(
|
||||
self.__installations.delete,
|
||||
transaction_id,
|
||||
expected_phase=expected_phase,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
_PLUGIN_PERSISTENCE: list[PluginPersistenceService] = []
|
||||
|
||||
|
||||
def configure_plugin_persistence(service: PluginPersistenceService) -> None:
|
||||
"""由启动组合根登记当前 lifespan 的插件持久化服务。"""
|
||||
_PLUGIN_PERSISTENCE.clear()
|
||||
_PLUGIN_PERSISTENCE.append(service)
|
||||
|
||||
|
||||
def get_plugin_persistence() -> PluginPersistenceService:
|
||||
"""返回当前插件持久化服务,未装配时拒绝数据库操作。"""
|
||||
if not _PLUGIN_PERSISTENCE:
|
||||
raise RuntimeError("插件持久化服务尚未完成初始化")
|
||||
return _PLUGIN_PERSISTENCE[0]
|
||||
|
||||
|
||||
def reset_plugin_persistence() -> None:
|
||||
"""清除当前 lifespan 的插件持久化服务。"""
|
||||
_PLUGIN_PERSISTENCE.clear()
|
||||
Reference in New Issue
Block a user