feat(plugin): 建立可信来源准入与安装恢复 (#6462)

This commit is contained in:
InfinityPacer
2026-08-26 07:52:00 +08:00
committed by GitHub
parent 3272f72823
commit 71db425c07
58 changed files with 11471 additions and 1493 deletions
+227
View File
@@ -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,
)
+199
View File
@@ -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")
+289 -1
View File
@@ -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()
File diff suppressed because it is too large Load Diff
+430
View File
@@ -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__
+45 -12
View File
@@ -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()
+189
View File
@@ -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()
+824
View File
@@ -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
+325
View File
@@ -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()