mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 07:27:15 +08:00
feat(plugin): persist declared metadata snapshots (#6467)
This commit is contained in:
+43
-22
@@ -28,6 +28,7 @@ from app.api.principal import ApiPrincipal
|
||||
from app.api.response import ResponseAPIRouter
|
||||
from app.application.commands import init_commands
|
||||
from app.application.configuration import get_api_runtime_config_snapshot, get_configured_system_config
|
||||
from app.application.plugin.catalog import apply_declared_metadata_fallback
|
||||
from app.application.plugin.config import PluginConfigCommand
|
||||
from app.application.plugin.folders import remove_plugin_from_folders
|
||||
from app.application.plugin.gateway import get_plugin_install_service
|
||||
@@ -234,14 +235,21 @@ async def _get_plugin_history_detail(
|
||||
installed_plugin = next(
|
||||
(
|
||||
plugin
|
||||
for plugin in plugin_manager.get_local_plugins()
|
||||
if plugin.id == plugin_id and plugin.installed
|
||||
for plugin in plugin_manager.get_installed_plugins()
|
||||
if plugin.id == plugin_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not installed_plugin:
|
||||
return None
|
||||
|
||||
identity = await get_plugin_persistence().get_identity(plugin_id)
|
||||
if identity is not None:
|
||||
installed_plugin = apply_declared_metadata_fallback(
|
||||
[installed_plugin],
|
||||
{identity.normalized_plugin_id: identity},
|
||||
)[0]
|
||||
|
||||
local_repo_plugin = next(
|
||||
(plugin for plugin in plugin_manager.get_local_repo_plugins() if plugin.id == plugin_id),
|
||||
None,
|
||||
@@ -249,27 +257,35 @@ async def _get_plugin_history_detail(
|
||||
if local_repo_plugin:
|
||||
return _merge_plugin_market_metadata(installed_plugin, local_repo_plugin)
|
||||
|
||||
if installed_plugin.repo_url:
|
||||
trusted_repo_url = None
|
||||
if identity is not None and identity.trusted_source_key:
|
||||
owner_repo = identity.trusted_source_key.removeprefix("github:")
|
||||
trusted_repo_url = f"https://github.com/{owner_repo}"
|
||||
|
||||
if trusted_repo_url:
|
||||
market_plugin = await _get_market_plugin_from_repo(
|
||||
plugin_manager, plugin_id, installed_plugin.repo_url, force
|
||||
plugin_manager, plugin_id, trusted_repo_url, force
|
||||
)
|
||||
if not market_plugin:
|
||||
logger.debug(f"插件 {plugin_id} 未从来源仓库获取到更新说明,返回本地插件信息")
|
||||
return installed_plugin
|
||||
return _merge_plugin_market_metadata(installed_plugin, market_plugin)
|
||||
|
||||
market_plugin = next(
|
||||
(
|
||||
plugin
|
||||
for plugin in await plugin_manager.async_get_online_plugins(force=force)
|
||||
if plugin.id == plugin_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not market_plugin:
|
||||
return installed_plugin
|
||||
return installed_plugin
|
||||
|
||||
return _merge_plugin_market_metadata(installed_plugin, market_plugin)
|
||||
|
||||
async def _installed_plugins_with_declared_metadata(
|
||||
plugin_manager: PluginRuntime,
|
||||
) -> list[_SchemaPlugin]:
|
||||
"""批量读取身份,并为未加载插件补齐已提交的展示声明。"""
|
||||
plugins = plugin_manager.get_installed_plugins()
|
||||
identities = await get_plugin_persistence().list_identities(
|
||||
[plugin.id for plugin in plugins if plugin.id]
|
||||
)
|
||||
return apply_declared_metadata_fallback(
|
||||
plugins,
|
||||
{identity.normalized_plugin_id: identity for identity in identities},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", summary="所有插件", response_model=List[_SchemaPlugin])
|
||||
@@ -281,14 +297,13 @@ async def all_plugins(
|
||||
"""
|
||||
查询所有插件清单,包括本地插件和在线插件,插件状态:installed, market, all
|
||||
"""
|
||||
# 本地插件
|
||||
plugin_manager = get_plugin_manager()
|
||||
local_plugins = plugin_manager.get_local_plugins()
|
||||
# 已安装插件
|
||||
installed_plugins = [plugin for plugin in local_plugins if plugin.installed]
|
||||
installed_plugins = plugin_manager.get_installed_plugins()
|
||||
if state == "installed":
|
||||
return plugin_manager.get_installed_plugins()
|
||||
return await _installed_plugins_with_declared_metadata(plugin_manager)
|
||||
|
||||
# 本地插件
|
||||
local_plugins = plugin_manager.get_local_plugins()
|
||||
# 未安装的本地插件
|
||||
not_installed_plugins = [plugin for plugin in local_plugins if not plugin.installed]
|
||||
# 本地插件仓库目录中的插件
|
||||
@@ -305,7 +320,10 @@ async def all_plugins(
|
||||
if state == "market":
|
||||
# 返回未安装的本地插件
|
||||
return not_installed_plugins
|
||||
return local_plugins
|
||||
return (
|
||||
await _installed_plugins_with_declared_metadata(plugin_manager)
|
||||
+ not_installed_plugins
|
||||
)
|
||||
|
||||
# 插件市场插件清单
|
||||
market_plugins = []
|
||||
@@ -328,7 +346,10 @@ async def all_plugins(
|
||||
return market_plugins
|
||||
|
||||
# 返回所有插件
|
||||
return installed_plugins + market_plugins
|
||||
return (
|
||||
await _installed_plugins_with_declared_metadata(plugin_manager)
|
||||
+ market_plugins
|
||||
)
|
||||
|
||||
|
||||
@router.get("/installed", summary="已安装插件", response_model=List[str])
|
||||
|
||||
@@ -6,6 +6,7 @@ from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from app.application.plugin.declaration import PluginDeclaredMetadata
|
||||
from app.application.plugin.identity import (
|
||||
PluginBindingBasis,
|
||||
PluginIdentity,
|
||||
@@ -60,14 +61,13 @@ class PluginInstallAdmission:
|
||||
payload_receipt: str,
|
||||
applied_at: datetime,
|
||||
declared_version: str | None = None,
|
||||
manifest_matches_payload: bool = True,
|
||||
) -> 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")
|
||||
installed_version = declared_version or self.candidate.plugin_version
|
||||
source_binding_changed = (
|
||||
self.trusted_source_type is not TrustedPluginSourceType.UNKNOWN
|
||||
and (
|
||||
@@ -89,13 +89,13 @@ class PluginInstallAdmission:
|
||||
if isinstance(self.candidate, PluginLocalCandidate)
|
||||
else self.candidate.source_key
|
||||
),
|
||||
declared_version=declared_version or self.candidate.plugin_version,
|
||||
declared_version=installed_version,
|
||||
package_generation=self.candidate.package_generation,
|
||||
system_version=(
|
||||
system_version if isinstance(system_version, str) else None
|
||||
declared_metadata=PluginDeclaredMetadata.from_package(
|
||||
metadata,
|
||||
declaration_version=self.candidate.plugin_version,
|
||||
manifest_matches_payload=manifest_matches_payload,
|
||||
),
|
||||
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,
|
||||
|
||||
@@ -4,9 +4,11 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
from collections.abc import Awaitable, Callable
|
||||
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.application.plugin.identity import PluginIdentity
|
||||
from app.schemas.plugin import Plugin
|
||||
|
||||
MarketLoader = Callable[[str, Optional[str], bool], Optional[dict[str, dict]]]
|
||||
AsyncMarketLoader = Callable[
|
||||
@@ -17,6 +19,44 @@ PluginMapper = Callable[[str, dict, str, list[str], int, Optional[str]], Any]
|
||||
ProgressCallback = Callable[..., Any]
|
||||
|
||||
|
||||
def apply_declared_metadata_fallback(
|
||||
plugins: Sequence[Plugin],
|
||||
identities: Mapping[str, PluginIdentity],
|
||||
) -> list[Plugin]:
|
||||
"""用已提交快照补齐加载失败插件,不覆盖真实运行态字段。"""
|
||||
result: list[Plugin] = []
|
||||
for plugin in plugins:
|
||||
identity = identities.get((plugin.id or "").lower())
|
||||
if (
|
||||
identity is None
|
||||
or identity.declared_metadata is None
|
||||
or identity.declared_version is None
|
||||
):
|
||||
result.append(plugin)
|
||||
continue
|
||||
fallback = identity.declared_metadata.display_fallback(
|
||||
installed_version=identity.declared_version
|
||||
)
|
||||
updates: dict[str, str] = {}
|
||||
if not plugin.plugin_version:
|
||||
updates["plugin_version"] = fallback["plugin_version"]
|
||||
if (
|
||||
(not plugin.plugin_name or plugin.plugin_name == plugin.id)
|
||||
and "plugin_name" in fallback
|
||||
):
|
||||
updates["plugin_name"] = fallback["plugin_name"]
|
||||
if not plugin.plugin_desc and "plugin_desc" in fallback:
|
||||
updates["plugin_desc"] = fallback["plugin_desc"]
|
||||
if not plugin.plugin_icon and "plugin_icon" in fallback:
|
||||
updates["plugin_icon"] = fallback["plugin_icon"]
|
||||
if not plugin.plugin_author and "plugin_author" in fallback:
|
||||
updates["plugin_author"] = fallback["plugin_author"]
|
||||
if not plugin.plugin_label and "plugin_label" in fallback:
|
||||
updates["plugin_label"] = fallback["plugin_label"]
|
||||
result.append(plugin.model_copy(update=updates) if updates else plugin)
|
||||
return result
|
||||
|
||||
|
||||
class PluginCatalogService:
|
||||
"""负责插件市场索引映射、并发收集、代际合并和来源去重。"""
|
||||
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
"""已提交插件载荷的版本化 package 声明快照。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
_SCHEMA_VERSION = 1
|
||||
_RUNTIME_FIELD_PATTERN = re.compile(r"^v[1-9][0-9]*t?$")
|
||||
_MANIFEST_TEXT_FIELDS = (
|
||||
"name",
|
||||
"description",
|
||||
"icon",
|
||||
"author",
|
||||
"system_version",
|
||||
)
|
||||
|
||||
|
||||
def _optional_text(
|
||||
value: object,
|
||||
*,
|
||||
max_length: int | None = None,
|
||||
) -> str | None:
|
||||
"""把外部可选文本规范为非空字符串。"""
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
normalized = value.strip()
|
||||
if max_length is not None and len(normalized) > max_length:
|
||||
raise ValueError(f"插件声明文本长度不能超过 {max_length}")
|
||||
return normalized or None
|
||||
|
||||
|
||||
def _labels(value: object) -> list[str]:
|
||||
"""把历史逗号字符串和字符串数组统一为有序标签列表。"""
|
||||
if isinstance(value, str):
|
||||
items = value.split(",")
|
||||
elif isinstance(value, list):
|
||||
items = value
|
||||
else:
|
||||
return []
|
||||
return [
|
||||
normalized
|
||||
for item in items
|
||||
if isinstance(item, str) and (normalized := item.strip())
|
||||
]
|
||||
|
||||
|
||||
def _normalize_manifest(value: object) -> dict[str, object]:
|
||||
"""仅保留当前载荷展示和声明消费者需要的 package 字段。"""
|
||||
if not isinstance(value, Mapping):
|
||||
return {}
|
||||
manifest: dict[str, object] = {}
|
||||
for field_name in _MANIFEST_TEXT_FIELDS:
|
||||
if normalized := _optional_text(
|
||||
value.get(field_name),
|
||||
max_length=128 if field_name == "system_version" else None,
|
||||
):
|
||||
manifest[field_name] = normalized
|
||||
labels = _labels(value.get("labels"))
|
||||
if labels:
|
||||
manifest["labels"] = labels
|
||||
level = value.get("level")
|
||||
if isinstance(level, int) and not isinstance(level, bool):
|
||||
manifest["level"] = level
|
||||
release = value.get("release")
|
||||
if isinstance(release, bool):
|
||||
manifest["release"] = release
|
||||
return manifest
|
||||
|
||||
|
||||
def _normalize_runtime(value: object) -> dict[str, bool]:
|
||||
"""保留可向后扩展的宿主代际和运行时变体布尔声明。"""
|
||||
if not isinstance(value, Mapping):
|
||||
return {}
|
||||
return {
|
||||
str(field_name): field_value
|
||||
for field_name, field_value in value.items()
|
||||
if (
|
||||
isinstance(field_name, str)
|
||||
and _RUNTIME_FIELD_PATTERN.fullmatch(field_name)
|
||||
and isinstance(field_value, bool)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def _encode(value: Mapping[str, object]) -> str:
|
||||
"""生成不可从外部原地修改的稳定内部表示。"""
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PluginDeclaredMetadata:
|
||||
"""封装一份受限、版本化且不参与来源授信的 package 声明。"""
|
||||
|
||||
_encoded: str = field(repr=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""拒绝绕过工厂构造的非规范内部状态。"""
|
||||
normalized = self._normalize_storage(json.loads(self._encoded))
|
||||
object.__setattr__(self, "_encoded", _encode(normalized))
|
||||
|
||||
@classmethod
|
||||
def from_package(
|
||||
cls,
|
||||
package: Mapping[str, Any],
|
||||
*,
|
||||
declaration_version: str | None,
|
||||
manifest_matches_payload: bool,
|
||||
) -> "PluginDeclaredMetadata":
|
||||
"""从安装候选生成与载荷一起提交的受限声明快照。"""
|
||||
runtime = _normalize_runtime(package)
|
||||
value = {
|
||||
"schema_version": _SCHEMA_VERSION,
|
||||
"declaration_version": _optional_text(declaration_version),
|
||||
"manifest_matches_payload": manifest_matches_payload,
|
||||
"manifest": _normalize_manifest(package),
|
||||
"runtime": runtime,
|
||||
}
|
||||
return cls(_encode(value))
|
||||
|
||||
@classmethod
|
||||
def from_storage(cls, value: object) -> "PluginDeclaredMetadata":
|
||||
"""校验数据库 JSON,并忽略当前合同未消费的额外字段。"""
|
||||
return cls(_encode(cls._normalize_storage(value)))
|
||||
|
||||
@staticmethod
|
||||
def _normalize_storage(value: object) -> dict[str, object]:
|
||||
"""验证快照结构,同时宽容丢弃非法可选展示字段。"""
|
||||
if not isinstance(value, Mapping):
|
||||
raise ValueError("插件声明快照必须是 JSON 对象")
|
||||
if value.get("schema_version") != _SCHEMA_VERSION:
|
||||
raise ValueError("插件声明快照 schema_version 不受支持")
|
||||
manifest_matches_payload = value.get("manifest_matches_payload")
|
||||
if not isinstance(manifest_matches_payload, bool):
|
||||
raise ValueError("插件声明快照必须说明 manifest 是否对应当前载荷")
|
||||
return {
|
||||
"schema_version": _SCHEMA_VERSION,
|
||||
"declaration_version": _optional_text(
|
||||
value.get("declaration_version")
|
||||
),
|
||||
"manifest_matches_payload": manifest_matches_payload,
|
||||
"manifest": _normalize_manifest(value.get("manifest")),
|
||||
"runtime": _normalize_runtime(value.get("runtime")),
|
||||
}
|
||||
|
||||
@property
|
||||
def declaration_version(self) -> str | None:
|
||||
"""返回生成该快照的 package 条目版本。"""
|
||||
return _optional_text(self.to_json().get("declaration_version"))
|
||||
|
||||
@property
|
||||
def manifest_matches_payload(self) -> bool:
|
||||
"""说明该 package 声明是否与当前已提交载荷对应。"""
|
||||
return bool(self.to_json()["manifest_matches_payload"])
|
||||
|
||||
def runtime_support(self, runtime_name: str) -> bool | None:
|
||||
"""读取一个规范运行时声明;缺省保持未声明语义。"""
|
||||
if not _RUNTIME_FIELD_PATTERN.fullmatch(runtime_name):
|
||||
raise ValueError("运行时声明必须使用 v<数字> 或 v<数字>t")
|
||||
runtime = self.to_json().get("runtime")
|
||||
if not isinstance(runtime, dict):
|
||||
return None
|
||||
value = runtime.get(runtime_name)
|
||||
return value if isinstance(value, bool) else None
|
||||
|
||||
def display_fallback(self, *, installed_version: str) -> dict[str, str]:
|
||||
"""生成插件加载失败时可安全补齐的展示字段。"""
|
||||
manifest = self.to_json().get("manifest")
|
||||
fallback = {
|
||||
"plugin_version": installed_version,
|
||||
}
|
||||
if not isinstance(manifest, dict):
|
||||
return fallback
|
||||
display_fields = {
|
||||
"name": "plugin_name",
|
||||
"description": "plugin_desc",
|
||||
"icon": "plugin_icon",
|
||||
"author": "plugin_author",
|
||||
}
|
||||
for source_name, target_name in display_fields.items():
|
||||
value = manifest.get(source_name)
|
||||
if isinstance(value, str):
|
||||
fallback[target_name] = value
|
||||
labels = manifest.get("labels")
|
||||
if isinstance(labels, list):
|
||||
label = ",".join(item for item in labels if isinstance(item, str))
|
||||
if label:
|
||||
fallback["plugin_label"] = label
|
||||
return fallback
|
||||
|
||||
def to_json(self) -> dict[str, object]:
|
||||
"""返回可持久化且与内部状态相互隔离的 JSON 副本。"""
|
||||
value: object = json.loads(self._encoded)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("插件声明快照内部状态必须是 JSON 对象")
|
||||
return {
|
||||
str(field_name): field_value
|
||||
for field_name, field_value in value.items()
|
||||
}
|
||||
@@ -3,12 +3,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from typing import Protocol
|
||||
|
||||
from app.application.plugin.declaration import PluginDeclaredMetadata
|
||||
|
||||
_PLUGIN_ID_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9]{0,127}$")
|
||||
_ONLINE_SOURCE_KEY_PATTERN = re.compile(
|
||||
r"^github:[a-z0-9](?:[a-z0-9-]{0,38})/"
|
||||
@@ -116,9 +118,7 @@ class PluginIdentity:
|
||||
payload_source_key: str | None
|
||||
declared_version: str | None
|
||||
package_generation: str | None
|
||||
system_version: str | None
|
||||
supports_v3: bool | None
|
||||
supports_v3t: bool | None
|
||||
declared_metadata: PluginDeclaredMetadata | None
|
||||
payload_receipt: str | None
|
||||
revision: int
|
||||
created_at: datetime
|
||||
@@ -200,9 +200,7 @@ class PluginIdentity:
|
||||
if any((
|
||||
self.declared_version,
|
||||
self.package_generation,
|
||||
self.system_version,
|
||||
self.supports_v3 is not None,
|
||||
self.supports_v3t is not None,
|
||||
self.declared_metadata,
|
||||
self.payload_receipt,
|
||||
self.payload_applied_at,
|
||||
)):
|
||||
@@ -210,6 +208,8 @@ class PluginIdentity:
|
||||
else:
|
||||
if not self.declared_version or not self.package_generation:
|
||||
raise ValueError("已知载荷必须携带声明版本和包代际")
|
||||
if self.declared_metadata is None:
|
||||
raise ValueError("已知载荷必须携带已提交 package 声明快照")
|
||||
if self.payload_applied_at is None or self.payload_receipt is None:
|
||||
raise ValueError("已知载荷必须携带应用时间和内容收据")
|
||||
if (
|
||||
@@ -234,11 +234,6 @@ class PluginIdentity:
|
||||
field_name="插件声明版本",
|
||||
max_length=64,
|
||||
)
|
||||
_validate_optional_text(
|
||||
self.system_version,
|
||||
field_name="插件系统版本要求",
|
||||
max_length=128,
|
||||
)
|
||||
object.__setattr__(self, "trusted_source_key", trusted_key)
|
||||
object.__setattr__(self, "payload_source_key", payload_key)
|
||||
object.__setattr__(self, "payload_receipt", _validate_receipt(self.payload_receipt))
|
||||
@@ -320,9 +315,7 @@ def plan_legacy_plugin_identity(
|
||||
payload_source_key=None,
|
||||
declared_version=None,
|
||||
package_generation=None,
|
||||
system_version=None,
|
||||
supports_v3=None,
|
||||
supports_v3t=None,
|
||||
declared_metadata=None,
|
||||
payload_receipt=None,
|
||||
revision=1,
|
||||
created_at=now,
|
||||
@@ -356,6 +349,9 @@ class PluginIdentityStore(Protocol):
|
||||
def get(self, plugin_id: str) -> PluginIdentity | None:
|
||||
"""读取一个物理插件的来源身份。"""
|
||||
|
||||
def list(self, plugin_ids: Sequence[str]) -> list[PluginIdentity]:
|
||||
"""批量读取指定物理插件的来源身份。"""
|
||||
|
||||
def compare_and_set(
|
||||
self,
|
||||
identity: PluginIdentity,
|
||||
|
||||
@@ -356,6 +356,10 @@ class PluginInstallCommand:
|
||||
payload_receipt=receipt,
|
||||
applied_at=self.__clock(),
|
||||
declared_version=release_version,
|
||||
manifest_matches_payload=(
|
||||
release_version is None
|
||||
or release_version == candidate.plugin_version
|
||||
),
|
||||
)
|
||||
await self.__await_side_effect(
|
||||
self.__persistence.set_installation_target(
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
@@ -166,6 +167,9 @@ class PluginIdentityPersistence(Protocol):
|
||||
def get(self, plugin_id: str) -> PluginIdentity | None:
|
||||
"""读取一个物理插件的来源身份。"""
|
||||
|
||||
def list(self, plugin_ids: Sequence[str]) -> list[PluginIdentity]:
|
||||
"""批量读取指定物理插件的来源身份。"""
|
||||
|
||||
def compare_and_set(
|
||||
self,
|
||||
identity: PluginIdentity,
|
||||
@@ -202,6 +206,15 @@ class PluginPersistenceService:
|
||||
"""在数据库 worker 中读取插件来源身份。"""
|
||||
return await self.__executor.run(partial(self.__identities.get, plugin_id))
|
||||
|
||||
async def list_identities(
|
||||
self,
|
||||
plugin_ids: Sequence[str],
|
||||
) -> list[PluginIdentity]:
|
||||
"""在一次数据库任务中批量读取插件来源身份。"""
|
||||
return await self.__executor.run(
|
||||
partial(self.__identities.list, tuple(plugin_ids))
|
||||
)
|
||||
|
||||
async def migrate_identity(
|
||||
self,
|
||||
identity: PluginIdentity,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"""插件来源身份 Application Port 的 SQLAlchemy 实现。"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Sequence
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.application.plugin.declaration import PluginDeclaredMetadata
|
||||
from app.application.plugin.identity import (
|
||||
BindLocalPluginIdentityCommand,
|
||||
BindOnlinePluginIdentityCommand,
|
||||
@@ -40,9 +41,11 @@ def _to_record(model: IdentityModel) -> PluginIdentity:
|
||||
payload_source_key=model.payload_source_key,
|
||||
declared_version=model.declared_version,
|
||||
package_generation=model.package_generation,
|
||||
system_version=model.system_version,
|
||||
supports_v3=model.supports_v3,
|
||||
supports_v3t=model.supports_v3t,
|
||||
declared_metadata=(
|
||||
PluginDeclaredMetadata.from_storage(model.declared_metadata)
|
||||
if model.declared_metadata is not None
|
||||
else None
|
||||
),
|
||||
payload_receipt=model.payload_receipt,
|
||||
revision=model.revision,
|
||||
created_at=datetime.fromisoformat(model.created_at),
|
||||
@@ -64,9 +67,11 @@ def _to_model(identity: PluginIdentity) -> IdentityModel:
|
||||
payload_source_key=identity.payload_source_key,
|
||||
declared_version=identity.declared_version,
|
||||
package_generation=identity.package_generation,
|
||||
system_version=identity.system_version,
|
||||
supports_v3=identity.supports_v3,
|
||||
supports_v3t=identity.supports_v3t,
|
||||
declared_metadata=(
|
||||
identity.declared_metadata.to_json()
|
||||
if identity.declared_metadata is not None
|
||||
else None
|
||||
),
|
||||
payload_receipt=identity.payload_receipt,
|
||||
revision=identity.revision,
|
||||
created_at=identity.created_at.isoformat(),
|
||||
@@ -92,6 +97,13 @@ class _SqlAlchemyIdentityRepository:
|
||||
model = self._oper.get_by_plugin_id(plugin_id)
|
||||
return _to_record(model) if model else None
|
||||
|
||||
def list(self, plugin_ids: Sequence[str]) -> list[PluginIdentity]:
|
||||
"""批量读取并映射指定来源身份。"""
|
||||
return [
|
||||
_to_record(model)
|
||||
for model in self._oper.list_by_plugin_ids(plugin_ids)
|
||||
]
|
||||
|
||||
def stage_create(self, identity: PluginIdentity) -> None:
|
||||
"""暂存首次身份。"""
|
||||
try:
|
||||
@@ -131,6 +143,20 @@ class TransactionalPluginIdentityStore:
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def list(self, plugin_ids: Sequence[str]) -> list[PluginIdentity]:
|
||||
"""在一个短会话内批量读取规范化插件身份。"""
|
||||
normalized_ids = tuple(
|
||||
dict.fromkeys(
|
||||
normalize_physical_plugin_id(plugin_id)
|
||||
for plugin_id in plugin_ids
|
||||
)
|
||||
)
|
||||
session = self._session_factory()
|
||||
try:
|
||||
return _SqlAlchemyIdentityRepository(session).list(normalized_ids)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def compare_and_set(
|
||||
self,
|
||||
identity: PluginIdentity,
|
||||
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.application.plugin.declaration import PluginDeclaredMetadata
|
||||
from app.application.plugin.identity import (
|
||||
PluginBindingBasis,
|
||||
PluginIdentity,
|
||||
@@ -50,9 +51,11 @@ def _identity_from_model(model: IdentityModel) -> PluginIdentity:
|
||||
payload_source_key=model.payload_source_key,
|
||||
declared_version=model.declared_version,
|
||||
package_generation=model.package_generation,
|
||||
system_version=model.system_version,
|
||||
supports_v3=model.supports_v3,
|
||||
supports_v3t=model.supports_v3t,
|
||||
declared_metadata=(
|
||||
PluginDeclaredMetadata.from_storage(model.declared_metadata)
|
||||
if model.declared_metadata is not None
|
||||
else None
|
||||
),
|
||||
payload_receipt=model.payload_receipt,
|
||||
revision=model.revision,
|
||||
created_at=datetime.fromisoformat(model.created_at),
|
||||
@@ -82,9 +85,11 @@ def _identity_model_values(identity: PluginIdentity) -> dict[str, object]:
|
||||
"payload_source_key": identity.payload_source_key,
|
||||
"declared_version": identity.declared_version,
|
||||
"package_generation": identity.package_generation,
|
||||
"system_version": identity.system_version,
|
||||
"supports_v3": identity.supports_v3,
|
||||
"supports_v3t": identity.supports_v3t,
|
||||
"declared_metadata": (
|
||||
identity.declared_metadata.to_json()
|
||||
if identity.declared_metadata is not None
|
||||
else None
|
||||
),
|
||||
"payload_receipt": identity.payload_receipt,
|
||||
"revision": identity.revision,
|
||||
"created_at": identity.created_at.isoformat(),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""已安装物理插件来源身份模型。"""
|
||||
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import Boolean, CheckConstraint, Integer, String, UniqueConstraint
|
||||
from sqlalchemy import JSON, CheckConstraint, Integer, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, get_id_column
|
||||
@@ -21,9 +21,9 @@ class PluginIdentity(Base):
|
||||
payload_source_key: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
declared_version: Mapped[Optional[str]] = mapped_column(String(64))
|
||||
package_generation: Mapped[Optional[str]] = mapped_column(String(8))
|
||||
system_version: Mapped[Optional[str]] = mapped_column(String(128))
|
||||
supports_v3: Mapped[Optional[bool]] = mapped_column(Boolean)
|
||||
supports_v3t: Mapped[Optional[bool]] = mapped_column(Boolean)
|
||||
declared_metadata: Mapped[Optional[dict[str, Any]]] = mapped_column(
|
||||
JSON(none_as_null=True)
|
||||
)
|
||||
payload_receipt: Mapped[Optional[str]] = mapped_column(String(71))
|
||||
revision: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
created_at: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""插件来源身份的数据访问原语。"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import cast
|
||||
|
||||
from sqlalchemy import select, update
|
||||
@@ -25,6 +26,23 @@ class PluginIdentityOper(DbOper):
|
||||
)
|
||||
)
|
||||
|
||||
def list_by_plugin_ids(
|
||||
self,
|
||||
plugin_ids: Sequence[str],
|
||||
) -> list[PluginIdentity]:
|
||||
"""批量读取规范化物理插件 ID 对应的身份。"""
|
||||
if not plugin_ids:
|
||||
return []
|
||||
return list(
|
||||
self._execute_sync_query(
|
||||
lambda session: session.execute(
|
||||
select(PluginIdentity).where(
|
||||
PluginIdentity.normalized_plugin_id.in_(plugin_ids)
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
)
|
||||
|
||||
def stage_create(self, identity: PluginIdentity) -> None:
|
||||
"""暂存首次身份并立即暴露数据库唯一键竞争。"""
|
||||
def stage(session: Session) -> None:
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"""3.0.12 consolidate committed plugin package declarations.
|
||||
|
||||
Revision ID: 5f2a9c1e7b4d
|
||||
Revises: a6c8e2f4b1d3
|
||||
Create Date: 2026-08-26
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "5f2a9c1e7b4d"
|
||||
down_revision = "a6c8e2f4b1d3"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_LEGACY_COLUMNS = {
|
||||
"system_version",
|
||||
"supports_v3",
|
||||
"supports_v3t",
|
||||
}
|
||||
|
||||
|
||||
def _column_names() -> set[str]:
|
||||
"""返回当前插件身份表字段集合。"""
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if "pluginidentity" not in inspector.get_table_names():
|
||||
return set()
|
||||
return {
|
||||
column["name"]
|
||||
for column in inspector.get_columns("pluginidentity")
|
||||
}
|
||||
|
||||
|
||||
def _legacy_snapshot(row: Mapping[str, object]) -> dict[str, object] | None:
|
||||
"""把旧固定声明列保守回填为仅供展示的版本化快照。"""
|
||||
if row["payload_source_type"] == "unknown":
|
||||
return None
|
||||
manifest: dict[str, object] = {}
|
||||
system_version = row.get("system_version")
|
||||
if isinstance(system_version, str) and system_version.strip():
|
||||
manifest["system_version"] = system_version.strip()
|
||||
runtime = {
|
||||
field_name.removeprefix("supports_"): row[field_name]
|
||||
for field_name in ("supports_v3", "supports_v3t")
|
||||
if isinstance(row.get(field_name), bool)
|
||||
}
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"declaration_version": None,
|
||||
"manifest_matches_payload": False,
|
||||
"manifest": manifest,
|
||||
"runtime": runtime,
|
||||
}
|
||||
|
||||
|
||||
def _backfill_declared_metadata() -> None:
|
||||
"""只为仍有旧载荷事实且尚无快照的身份生成保守声明。"""
|
||||
identity = sa.table(
|
||||
"pluginidentity",
|
||||
sa.column("id", sa.Integer()),
|
||||
sa.column("payload_source_type", sa.String()),
|
||||
sa.column("system_version", sa.String()),
|
||||
sa.column("supports_v3", sa.Boolean()),
|
||||
sa.column("supports_v3t", sa.Boolean()),
|
||||
sa.column("declared_metadata", sa.JSON(none_as_null=True)),
|
||||
)
|
||||
connection = op.get_bind()
|
||||
rows = connection.execute(
|
||||
sa.select(
|
||||
identity.c.id,
|
||||
identity.c.payload_source_type,
|
||||
identity.c.system_version,
|
||||
identity.c.supports_v3,
|
||||
identity.c.supports_v3t,
|
||||
).where(identity.c.declared_metadata.is_(None))
|
||||
).mappings().all()
|
||||
for row in rows:
|
||||
connection.execute(
|
||||
identity.update()
|
||||
.where(identity.c.id == row["id"])
|
||||
.values(declared_metadata=_legacy_snapshot(row))
|
||||
)
|
||||
|
||||
|
||||
def _legacy_values(value: object) -> dict[str, object]:
|
||||
"""从版本化快照恢复旧版可表达的三个固定声明字段。"""
|
||||
if not isinstance(value, Mapping) or value.get("schema_version") != 1:
|
||||
return {
|
||||
"system_version": None,
|
||||
"supports_v3": None,
|
||||
"supports_v3t": None,
|
||||
}
|
||||
manifest = value.get("manifest")
|
||||
runtime = value.get("runtime")
|
||||
return {
|
||||
"system_version": (
|
||||
manifest.get("system_version")
|
||||
if isinstance(manifest, Mapping)
|
||||
and isinstance(manifest.get("system_version"), str)
|
||||
else None
|
||||
),
|
||||
"supports_v3": (
|
||||
runtime.get("v3")
|
||||
if isinstance(runtime, Mapping)
|
||||
and isinstance(runtime.get("v3"), bool)
|
||||
else None
|
||||
),
|
||||
"supports_v3t": (
|
||||
runtime.get("v3t")
|
||||
if isinstance(runtime, Mapping)
|
||||
and isinstance(runtime.get("v3t"), bool)
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _restore_legacy_columns() -> None:
|
||||
"""在降级前把当前快照投影回旧版固定声明列。"""
|
||||
identity = sa.table(
|
||||
"pluginidentity",
|
||||
sa.column("id", sa.Integer()),
|
||||
sa.column("declared_metadata", sa.JSON(none_as_null=True)),
|
||||
sa.column("system_version", sa.String()),
|
||||
sa.column("supports_v3", sa.Boolean()),
|
||||
sa.column("supports_v3t", sa.Boolean()),
|
||||
)
|
||||
connection = op.get_bind()
|
||||
rows = connection.execute(
|
||||
sa.select(identity.c.id, identity.c.declared_metadata)
|
||||
).mappings().all()
|
||||
for row in rows:
|
||||
connection.execute(
|
||||
identity.update()
|
||||
.where(identity.c.id == row["id"])
|
||||
.values(**_legacy_values(row["declared_metadata"]))
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""新增声明快照、离线回填并移除会随 package 扩张的固定列。"""
|
||||
columns = _column_names()
|
||||
if not columns:
|
||||
return
|
||||
legacy_columns = _LEGACY_COLUMNS & columns
|
||||
if legacy_columns and legacy_columns != _LEGACY_COLUMNS:
|
||||
raise RuntimeError("pluginidentity 旧声明字段不完整,拒绝继续迁移")
|
||||
if "declared_metadata" not in columns:
|
||||
op.add_column(
|
||||
"pluginidentity",
|
||||
sa.Column(
|
||||
"declared_metadata",
|
||||
sa.JSON(none_as_null=True),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
columns.add("declared_metadata")
|
||||
if _LEGACY_COLUMNS <= columns:
|
||||
_backfill_declared_metadata()
|
||||
if legacy_columns:
|
||||
with op.batch_alter_table("pluginidentity") as batch_op:
|
||||
for column_name in sorted(legacy_columns):
|
||||
batch_op.drop_column(column_name)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""恢复旧版固定声明列,并删除版本化声明快照。"""
|
||||
columns = _column_names()
|
||||
if not columns or "declared_metadata" not in columns:
|
||||
return
|
||||
missing_legacy = _LEGACY_COLUMNS - columns
|
||||
if missing_legacy:
|
||||
with op.batch_alter_table("pluginidentity") as batch_op:
|
||||
if "system_version" in missing_legacy:
|
||||
batch_op.add_column(
|
||||
sa.Column("system_version", sa.String(length=128))
|
||||
)
|
||||
if "supports_v3" in missing_legacy:
|
||||
batch_op.add_column(sa.Column("supports_v3", sa.Boolean()))
|
||||
if "supports_v3t" in missing_legacy:
|
||||
batch_op.add_column(sa.Column("supports_v3t", sa.Boolean()))
|
||||
_restore_legacy_columns()
|
||||
with op.batch_alter_table("pluginidentity") as batch_op:
|
||||
batch_op.drop_column("declared_metadata")
|
||||
+16
-3
@@ -13,8 +13,8 @@
|
||||
"runtime_to_db": [],
|
||||
"workflow_to_db": []
|
||||
},
|
||||
"edge_count": 6773,
|
||||
"edge_sha256": "3ba25180753ef7e15d9d08b7af25b66191306e8c0b465a27bed5613ed8d30c5d",
|
||||
"edge_count": 6785,
|
||||
"edge_sha256": "077e003bf195195e4e75dc65c9596e0b5a45c0e33441e3a22829f0cdb8d98e45",
|
||||
"edges": [
|
||||
"app -> app.runtime",
|
||||
"app -> app.runtime.compat",
|
||||
@@ -2112,6 +2112,7 @@
|
||||
"app.api.endpoints.plugin -> app.application.commands",
|
||||
"app.api.endpoints.plugin -> app.application.configuration",
|
||||
"app.api.endpoints.plugin -> app.application.plugin",
|
||||
"app.api.endpoints.plugin -> app.application.plugin.catalog",
|
||||
"app.api.endpoints.plugin -> app.application.plugin.config",
|
||||
"app.api.endpoints.plugin -> app.application.plugin.folders",
|
||||
"app.api.endpoints.plugin -> app.application.plugin.gateway",
|
||||
@@ -2744,9 +2745,15 @@
|
||||
"app.application.outbox -> app.schemas.types",
|
||||
"app.application.plugin.admission -> app.application",
|
||||
"app.application.plugin.admission -> app.application.plugin",
|
||||
"app.application.plugin.admission -> app.application.plugin.declaration",
|
||||
"app.application.plugin.admission -> app.application.plugin.identity",
|
||||
"app.application.plugin.admission -> app.application.plugin.inventory",
|
||||
"app.application.plugin.admission -> app.application.plugin.source",
|
||||
"app.application.plugin.catalog -> app.application",
|
||||
"app.application.plugin.catalog -> app.application.plugin",
|
||||
"app.application.plugin.catalog -> app.application.plugin.identity",
|
||||
"app.application.plugin.catalog -> app.schemas",
|
||||
"app.application.plugin.catalog -> app.schemas.plugin",
|
||||
"app.application.plugin.config -> app.schemas",
|
||||
"app.application.plugin.config -> app.schemas.exception",
|
||||
"app.application.plugin.folders -> app.application",
|
||||
@@ -2763,6 +2770,9 @@
|
||||
"app.application.plugin.gateway -> app.application.plugin.inventory",
|
||||
"app.application.plugin.gateway -> app.application.plugin.lifecycle",
|
||||
"app.application.plugin.gateway -> app.application.plugin.source",
|
||||
"app.application.plugin.identity -> app.application",
|
||||
"app.application.plugin.identity -> app.application.plugin",
|
||||
"app.application.plugin.identity -> app.application.plugin.declaration",
|
||||
"app.application.plugin.identity_migration -> app.application",
|
||||
"app.application.plugin.identity_migration -> app.application.plugin",
|
||||
"app.application.plugin.identity_migration -> app.application.plugin.identity",
|
||||
@@ -3666,6 +3676,7 @@
|
||||
"app.db.adapters.outbox -> app.db.models.outbox",
|
||||
"app.db.adapters.pluginidentity -> app.application",
|
||||
"app.db.adapters.pluginidentity -> app.application.plugin",
|
||||
"app.db.adapters.pluginidentity -> app.application.plugin.declaration",
|
||||
"app.db.adapters.pluginidentity -> app.application.plugin.identity",
|
||||
"app.db.adapters.pluginidentity -> app.db",
|
||||
"app.db.adapters.pluginidentity -> app.db.models",
|
||||
@@ -3675,6 +3686,7 @@
|
||||
"app.db.adapters.pluginidentity -> app.db.uow",
|
||||
"app.db.adapters.plugininstallation -> app.application",
|
||||
"app.db.adapters.plugininstallation -> app.application.plugin",
|
||||
"app.db.adapters.plugininstallation -> app.application.plugin.declaration",
|
||||
"app.db.adapters.plugininstallation -> app.application.plugin.identity",
|
||||
"app.db.adapters.plugininstallation -> app.application.plugin.transaction",
|
||||
"app.db.adapters.plugininstallation -> app.db",
|
||||
@@ -6790,7 +6802,7 @@
|
||||
"app.workflow.actions.transfer_file -> app.workflow",
|
||||
"app.workflow.actions.transfer_file -> app.workflow.actions"
|
||||
],
|
||||
"module_count": 833,
|
||||
"module_count": 834,
|
||||
"modules": [
|
||||
"app",
|
||||
"app.adapters",
|
||||
@@ -7085,6 +7097,7 @@
|
||||
"app.application.plugin.catalog",
|
||||
"app.application.plugin.config",
|
||||
"app.application.plugin.data",
|
||||
"app.application.plugin.declaration",
|
||||
"app.application.plugin.folders",
|
||||
"app.application.plugin.gateway",
|
||||
"app.application.plugin.identity",
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
"""插件 package 声明快照和值对象展示回退测试。"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.plugin.catalog import apply_declared_metadata_fallback
|
||||
from app.application.plugin.declaration import PluginDeclaredMetadata
|
||||
from app.application.plugin.identity import (
|
||||
PluginBindingBasis,
|
||||
PluginIdentity,
|
||||
PluginPayloadSourceType,
|
||||
TrustedPluginSourceType,
|
||||
)
|
||||
from app.schemas.plugin import Plugin, PluginRuntimeStatus
|
||||
|
||||
NOW = datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc)
|
||||
OFFICIAL_SOURCE = "github:jxxghp/moviepilot-plugins"
|
||||
|
||||
|
||||
def _metadata(
|
||||
package: dict[str, object],
|
||||
*,
|
||||
version: str = "1.0.0",
|
||||
matches_payload: bool = True,
|
||||
) -> PluginDeclaredMetadata:
|
||||
"""构造一份可用于身份测试的 package 声明快照。"""
|
||||
return PluginDeclaredMetadata.from_package(
|
||||
package,
|
||||
declaration_version=version,
|
||||
manifest_matches_payload=matches_payload,
|
||||
)
|
||||
|
||||
|
||||
def _identity(
|
||||
metadata: PluginDeclaredMetadata,
|
||||
*,
|
||||
version: str = "1.0.0",
|
||||
plugin_id: str = "DemoPlugin",
|
||||
) -> PluginIdentity:
|
||||
"""构造带已提交声明快照的官方在线身份。"""
|
||||
return PluginIdentity(
|
||||
plugin_id=plugin_id,
|
||||
normalized_plugin_id=plugin_id.lower(),
|
||||
trusted_source_type=TrustedPluginSourceType.OFFICIAL,
|
||||
trusted_source_key=OFFICIAL_SOURCE,
|
||||
binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT,
|
||||
payload_source_type=PluginPayloadSourceType.OFFICIAL,
|
||||
payload_source_key=OFFICIAL_SOURCE,
|
||||
declared_version=version,
|
||||
package_generation="v3",
|
||||
declared_metadata=metadata,
|
||||
payload_receipt="sha256:" + "0" * 64,
|
||||
revision=1,
|
||||
created_at=NOW,
|
||||
updated_at=NOW,
|
||||
bound_at=NOW,
|
||||
payload_applied_at=NOW,
|
||||
)
|
||||
|
||||
|
||||
def test_declared_metadata_round_trips_package_and_storage_without_extra_fields() -> None:
|
||||
"""package 声明保存后应可稳定往返,并丢弃未纳入合同的字段。"""
|
||||
package = {
|
||||
"name": "Demo",
|
||||
"description": "A demo plugin",
|
||||
"icon": "demo.svg",
|
||||
"author": "MoviePilot",
|
||||
"system_version": ">=3.0.0",
|
||||
"labels": "alpha, beta",
|
||||
"level": 2,
|
||||
"release": True,
|
||||
"v4": True,
|
||||
"v4t": False,
|
||||
"unknown_field": "ignored",
|
||||
"repo_url": "https://github.com/attacker/repo",
|
||||
"source": "github:attacker/repo",
|
||||
"token": "secret",
|
||||
"plugin_public_key": "public-key",
|
||||
}
|
||||
|
||||
snapshot = _metadata(package, version="2.0.0")
|
||||
restored = PluginDeclaredMetadata.from_storage(snapshot.to_json())
|
||||
stored = restored.to_json()
|
||||
|
||||
assert restored == snapshot
|
||||
assert restored.declaration_version == "2.0.0"
|
||||
assert restored.manifest_matches_payload is True
|
||||
assert restored.runtime_support("v4") is True
|
||||
assert restored.runtime_support("v4t") is False
|
||||
assert stored["manifest"] == {
|
||||
"author": "MoviePilot",
|
||||
"description": "A demo plugin",
|
||||
"icon": "demo.svg",
|
||||
"labels": ["alpha", "beta"],
|
||||
"level": 2,
|
||||
"name": "Demo",
|
||||
"release": True,
|
||||
"system_version": ">=3.0.0",
|
||||
}
|
||||
assert stored["runtime"] == {"v4": True, "v4t": False}
|
||||
assert "unknown_field" not in str(stored)
|
||||
assert "attacker" not in str(stored)
|
||||
assert "secret" not in str(stored)
|
||||
assert "public-key" not in str(stored)
|
||||
|
||||
|
||||
def test_declared_metadata_preserves_system_version_storage_limit() -> None:
|
||||
"""系统版本声明继续遵守旧数据库列的长度合同,保证迁移可降级。"""
|
||||
with pytest.raises(ValueError, match="长度不能超过 128"):
|
||||
_metadata({"system_version": "v" * 129})
|
||||
|
||||
|
||||
def test_declared_metadata_ignores_unknown_runtime_shapes_and_is_deep_copied() -> None:
|
||||
"""未知代际和外部可变对象不能污染已提交快照。"""
|
||||
labels = ["alpha"]
|
||||
package = {
|
||||
"name": "Demo",
|
||||
"labels": labels,
|
||||
"v4": True,
|
||||
"v4t": False,
|
||||
"v5": "not-a-bool",
|
||||
"vX": True,
|
||||
}
|
||||
snapshot = _metadata(package)
|
||||
labels.append("mutated-after-build")
|
||||
exported = snapshot.to_json()
|
||||
exported["manifest"]["labels"].append("mutated-export")
|
||||
|
||||
assert snapshot.to_json()["manifest"]["labels"] == ["alpha"]
|
||||
assert snapshot.runtime_support("v4") is True
|
||||
assert snapshot.runtime_support("v4t") is False
|
||||
assert snapshot.runtime_support("v5") is None
|
||||
|
||||
|
||||
def test_declared_metadata_records_current_and_historical_release_truth() -> None:
|
||||
"""当前 package 与历史 Release 的声明对应关系必须可区分。"""
|
||||
current = _metadata({"name": "Demo", "v3": True}, version="2.0.0")
|
||||
historical = _metadata(
|
||||
{"name": "Demo", "v3": True},
|
||||
version="1.0.0",
|
||||
matches_payload=False,
|
||||
)
|
||||
|
||||
assert current.manifest_matches_payload is True
|
||||
assert historical.manifest_matches_payload is False
|
||||
assert historical.declaration_version == "1.0.0"
|
||||
|
||||
|
||||
def test_declared_metadata_fallback_is_batch_safe_and_preserves_runtime_fields() -> None:
|
||||
"""批量展示回退只补空展示字段,不覆盖运行态或另一插件身份。"""
|
||||
first = _identity(
|
||||
_metadata(
|
||||
{
|
||||
"name": "Demo from snapshot",
|
||||
"description": "Saved description",
|
||||
"icon": "saved.svg",
|
||||
"author": "Saved author",
|
||||
"labels": ["saved", "plugin"],
|
||||
},
|
||||
version="2.0.0",
|
||||
),
|
||||
version="2.0.0",
|
||||
plugin_id="DemoPlugin",
|
||||
)
|
||||
second = _identity(
|
||||
_metadata({"name": "Other from snapshot"}, version="3.0.0"),
|
||||
version="3.0.0",
|
||||
plugin_id="OtherPlugin",
|
||||
)
|
||||
plugins = [
|
||||
Plugin(
|
||||
id="DemoPlugin",
|
||||
plugin_name="DemoPlugin",
|
||||
plugin_version=None,
|
||||
runtime_status=PluginRuntimeStatus.LOAD_FAILED,
|
||||
state=True,
|
||||
plugin_desc=None,
|
||||
),
|
||||
Plugin(
|
||||
id="OtherPlugin",
|
||||
plugin_name="Runtime name",
|
||||
plugin_desc="Runtime description",
|
||||
plugin_version="loaded-version",
|
||||
runtime_status=PluginRuntimeStatus.READY,
|
||||
),
|
||||
Plugin(id="MissingPlugin", plugin_name="MissingPlugin"),
|
||||
]
|
||||
|
||||
result = apply_declared_metadata_fallback(
|
||||
plugins,
|
||||
{"demoplugin": first, "otherplugin": second},
|
||||
)
|
||||
|
||||
assert result[0].plugin_name == "Demo from snapshot"
|
||||
assert result[0].plugin_desc == "Saved description"
|
||||
assert result[0].plugin_icon == "saved.svg"
|
||||
assert result[0].plugin_author == "Saved author"
|
||||
assert result[0].plugin_version == "2.0.0"
|
||||
assert result[0].plugin_label == "saved,plugin"
|
||||
assert result[0].runtime_status is PluginRuntimeStatus.LOAD_FAILED
|
||||
assert result[0].state is True
|
||||
assert result[1].plugin_name == "Runtime name"
|
||||
assert result[1].plugin_desc == "Runtime description"
|
||||
assert result[1].plugin_version == "loaded-version"
|
||||
assert result[1].runtime_status is PluginRuntimeStatus.READY
|
||||
assert result[2].plugin_name == "MissingPlugin"
|
||||
+127
-24
@@ -1,18 +1,29 @@
|
||||
import asyncio
|
||||
from contextlib import nullcontext
|
||||
from unittest.mock import ANY, AsyncMock, MagicMock, patch
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from app.api.endpoints import plugin as plugin_endpoint
|
||||
from app import schemas
|
||||
from app.api.endpoints.plugin import plugin_history
|
||||
from app.api.endpoints.plugin import plugin_releases
|
||||
from app.api.endpoints.plugin import reset_plugin
|
||||
from app.api.endpoints.plugin import reload_plugin
|
||||
from app.api.endpoints.plugin import runtime_status
|
||||
from app.api.endpoints.plugin import plugin_static_file
|
||||
from app.api.endpoints.plugin import uninstall_plugin
|
||||
from app.api.endpoints import plugin as plugin_endpoint
|
||||
from app.api.endpoints.plugin import (
|
||||
plugin_history,
|
||||
plugin_releases,
|
||||
plugin_static_file,
|
||||
reload_plugin,
|
||||
reset_plugin,
|
||||
runtime_status,
|
||||
uninstall_plugin,
|
||||
)
|
||||
from app.api.endpoints.system import sync_plugin_market_from_wiki
|
||||
from app.application.plugin.config import PluginConfigCommand
|
||||
from app.application.plugin.declaration import PluginDeclaredMetadata
|
||||
from app.application.plugin.identity import (
|
||||
PluginBindingBasis,
|
||||
PluginIdentity,
|
||||
PluginPayloadSourceType,
|
||||
TrustedPluginSourceType,
|
||||
)
|
||||
from app.foundation.singleton import Singleton
|
||||
from app.runtime.config import settings
|
||||
from app.runtime.extensions.plugin.admission import PluginMutationAdmission
|
||||
from app.runtime.extensions.plugin_manager import PluginManager
|
||||
@@ -20,7 +31,47 @@ from app.runtime.tasks import TaskRegistry
|
||||
from app.schemas.event import PluginDataResetEventData
|
||||
from app.schemas.plugin import PluginInstance, PluginRuntimeStatus
|
||||
from app.schemas.types import ChainEventType, SystemConfigKey
|
||||
from app.foundation.singleton import Singleton
|
||||
|
||||
NOW = datetime(2026, 8, 26, 12, 0, tzinfo=timezone.utc)
|
||||
SOURCE_KEY = "github:demo/plugins"
|
||||
SOURCE_URL = "https://github.com/demo/plugins"
|
||||
|
||||
|
||||
def _plugin_identity(
|
||||
*,
|
||||
metadata: PluginDeclaredMetadata | None = None,
|
||||
) -> PluginIdentity:
|
||||
"""构造绑定到测试仓库的插件身份。"""
|
||||
has_payload = metadata is not None
|
||||
return PluginIdentity(
|
||||
plugin_id="DemoPlugin",
|
||||
normalized_plugin_id="demoplugin",
|
||||
trusted_source_type=TrustedPluginSourceType.THIRD_PARTY,
|
||||
trusted_source_key=SOURCE_KEY,
|
||||
binding_basis=PluginBindingBasis.EXPLICIT_INSTALL,
|
||||
payload_source_type=(
|
||||
PluginPayloadSourceType.THIRD_PARTY
|
||||
if has_payload
|
||||
else PluginPayloadSourceType.UNKNOWN
|
||||
),
|
||||
payload_source_key=SOURCE_KEY if has_payload else None,
|
||||
declared_version="1.0.0" if has_payload else None,
|
||||
package_generation="v3" if has_payload else None,
|
||||
declared_metadata=metadata,
|
||||
payload_receipt="sha256:" + "0" * 64 if has_payload else None,
|
||||
revision=1,
|
||||
created_at=NOW,
|
||||
updated_at=NOW,
|
||||
bound_at=NOW,
|
||||
payload_applied_at=NOW if has_payload else None,
|
||||
)
|
||||
|
||||
|
||||
def _persistence(identity: PluginIdentity) -> MagicMock:
|
||||
"""构造只暴露身份读取合同的异步持久化替身。"""
|
||||
persistence = MagicMock()
|
||||
persistence.get_identity = AsyncMock(return_value=identity)
|
||||
return persistence
|
||||
|
||||
|
||||
def test_plugin_history_merges_remote_metadata():
|
||||
@@ -36,24 +87,31 @@ def test_plugin_history_merges_remote_metadata():
|
||||
)
|
||||
market_plugin = schemas.Plugin(
|
||||
id="DemoPlugin",
|
||||
repo_url="https://github.com/demo/plugins",
|
||||
repo_url=SOURCE_URL,
|
||||
history={"v1.1.0": "- 新增更新说明"},
|
||||
system_version=">=2.0.0",
|
||||
system_version_compatible=True,
|
||||
has_update=True,
|
||||
)
|
||||
plugin_manager = MagicMock()
|
||||
plugin_manager.get_local_plugins.return_value = [installed_plugin]
|
||||
plugin_manager.get_installed_plugins.return_value = [installed_plugin]
|
||||
plugin_manager.get_local_repo_plugins.return_value = []
|
||||
plugin_manager.async_get_online_plugins = AsyncMock(return_value=[market_plugin])
|
||||
plugin_manager.async_get_plugins_from_market = AsyncMock(return_value=[market_plugin])
|
||||
persistence = _persistence(_plugin_identity())
|
||||
|
||||
with patch("app.api.endpoints.plugin.get_plugin_manager", return_value=plugin_manager):
|
||||
with (
|
||||
patch("app.api.endpoints.plugin.get_plugin_manager", return_value=plugin_manager),
|
||||
patch("app.api.endpoints.plugin.get_plugin_persistence", return_value=persistence),
|
||||
):
|
||||
result = asyncio.run(plugin_history("DemoPlugin", None, True))
|
||||
|
||||
assert result.repo_url == "https://github.com/demo/plugins"
|
||||
assert result.history == {"v1.1.0": "- 新增更新说明"}
|
||||
assert result.system_version == ">=2.0.0"
|
||||
assert result.has_update
|
||||
plugin_manager.async_get_plugins_from_market.assert_awaited_once_with(
|
||||
SOURCE_URL, settings.VERSION_FLAG, True
|
||||
)
|
||||
|
||||
|
||||
def test_runtime_status_reports_pending_and_terminal_counts():
|
||||
@@ -103,49 +161,94 @@ def test_plugin_history_returns_installed_plugin_when_remote_missing():
|
||||
installed=True,
|
||||
)
|
||||
plugin_manager = MagicMock()
|
||||
plugin_manager.get_local_plugins.return_value = [installed_plugin]
|
||||
plugin_manager.get_installed_plugins.return_value = [installed_plugin]
|
||||
plugin_manager.get_local_repo_plugins.return_value = []
|
||||
plugin_manager.async_get_online_plugins = AsyncMock(return_value=[])
|
||||
plugin_manager.async_get_plugins_from_market = AsyncMock(return_value=[])
|
||||
persistence = _persistence(_plugin_identity())
|
||||
|
||||
with patch("app.api.endpoints.plugin.get_plugin_manager", return_value=plugin_manager):
|
||||
with (
|
||||
patch("app.api.endpoints.plugin.get_plugin_manager", return_value=plugin_manager),
|
||||
patch("app.api.endpoints.plugin.get_plugin_persistence", return_value=persistence),
|
||||
):
|
||||
result = asyncio.run(plugin_history("DemoPlugin", None, True))
|
||||
|
||||
assert result.id == "DemoPlugin"
|
||||
assert result.history == {}
|
||||
|
||||
|
||||
def test_plugin_history_uses_installed_repo_without_refreshing_all_markets():
|
||||
def test_plugin_history_uses_bound_repo_without_refreshing_all_markets():
|
||||
"""
|
||||
已安装插件记录了来源仓库时,更新说明只刷新该仓库,避免弹窗触发全市场慢刷新。
|
||||
更新说明只读取持久化绑定仓库,不信任运行态 DTO 中可漂移的来源地址。
|
||||
"""
|
||||
installed_plugin = schemas.Plugin(
|
||||
id="DemoPlugin",
|
||||
plugin_name="Demo Plugin",
|
||||
plugin_version="1.0.0",
|
||||
repo_url="https://github.com/demo/plugins",
|
||||
repo_url="https://github.com/attacker/plugins",
|
||||
installed=True,
|
||||
)
|
||||
market_plugin = schemas.Plugin(
|
||||
id="DemoPlugin",
|
||||
repo_url="https://github.com/demo/plugins",
|
||||
repo_url=SOURCE_URL,
|
||||
history={"v1.1.0": "- 新增更新说明"},
|
||||
)
|
||||
plugin_manager = MagicMock()
|
||||
plugin_manager.get_local_plugins.return_value = [installed_plugin]
|
||||
plugin_manager.get_installed_plugins.return_value = [installed_plugin]
|
||||
plugin_manager.get_local_repo_plugins.return_value = []
|
||||
plugin_manager.async_get_plugins_from_market = AsyncMock(return_value=[market_plugin])
|
||||
plugin_manager.async_get_online_plugins = AsyncMock(return_value=[])
|
||||
persistence = _persistence(_plugin_identity())
|
||||
|
||||
with patch("app.api.endpoints.plugin.get_plugin_manager", return_value=plugin_manager):
|
||||
with (
|
||||
patch("app.api.endpoints.plugin.get_plugin_manager", return_value=plugin_manager),
|
||||
patch("app.api.endpoints.plugin.get_plugin_persistence", return_value=persistence),
|
||||
):
|
||||
result = asyncio.run(plugin_history("DemoPlugin", None, True))
|
||||
|
||||
assert result.history == {"v1.1.0": "- 新增更新说明"}
|
||||
plugin_manager.async_get_plugins_from_market.assert_awaited_once_with(
|
||||
"https://github.com/demo/plugins", settings.VERSION_FLAG, True
|
||||
SOURCE_URL, settings.VERSION_FLAG, True
|
||||
)
|
||||
plugin_manager.async_get_online_plugins.assert_not_awaited()
|
||||
|
||||
|
||||
def test_plugin_history_uses_declared_metadata_when_bound_market_is_unavailable():
|
||||
"""加载失败且绑定市场不可用时,详情仍返回已提交载荷的展示信息。"""
|
||||
installed_plugin = schemas.Plugin(
|
||||
id="DemoPlugin",
|
||||
plugin_name="DemoPlugin",
|
||||
installed=True,
|
||||
runtime_status=PluginRuntimeStatus.LOAD_FAILED,
|
||||
)
|
||||
metadata = PluginDeclaredMetadata.from_package(
|
||||
{
|
||||
"name": "Saved Demo",
|
||||
"description": "Saved description",
|
||||
"author": "Saved author",
|
||||
"v3": True,
|
||||
},
|
||||
declaration_version="1.0.0",
|
||||
manifest_matches_payload=True,
|
||||
)
|
||||
plugin_manager = MagicMock()
|
||||
plugin_manager.get_installed_plugins.return_value = [installed_plugin]
|
||||
plugin_manager.get_local_repo_plugins.return_value = []
|
||||
plugin_manager.async_get_plugins_from_market = AsyncMock(return_value=[])
|
||||
persistence = _persistence(_plugin_identity(metadata=metadata))
|
||||
|
||||
with (
|
||||
patch("app.api.endpoints.plugin.get_plugin_manager", return_value=plugin_manager),
|
||||
patch("app.api.endpoints.plugin.get_plugin_persistence", return_value=persistence),
|
||||
):
|
||||
result = asyncio.run(plugin_history("DemoPlugin", None, True))
|
||||
|
||||
assert result.plugin_name == "Saved Demo"
|
||||
assert result.plugin_desc == "Saved description"
|
||||
assert result.plugin_author == "Saved author"
|
||||
assert result.plugin_version == "1.0.0"
|
||||
assert result.runtime_status is PluginRuntimeStatus.LOAD_FAILED
|
||||
|
||||
|
||||
def test_plugin_releases_returns_supported_versions_with_latest_and_current(monkeypatch):
|
||||
"""
|
||||
release 列表接口返回可安装版本,并标记当前 package 最新版本与本地已安装版本。
|
||||
|
||||
@@ -9,6 +9,7 @@ import pytest
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.application.plugin.declaration import PluginDeclaredMetadata
|
||||
from app.application.plugin.identity import (
|
||||
PluginBindingBasis,
|
||||
PluginIdentity,
|
||||
@@ -34,6 +35,7 @@ def _identity(
|
||||
declared_version: str | None = None,
|
||||
) -> PluginIdentity:
|
||||
"""构造一份已从官方仓成功安装的物理插件身份。"""
|
||||
installed_version = declared_version or "1.0.0"
|
||||
return PluginIdentity(
|
||||
plugin_id=plugin_id,
|
||||
normalized_plugin_id=plugin_id.lower(),
|
||||
@@ -42,11 +44,19 @@ def _identity(
|
||||
binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT,
|
||||
payload_source_type=PluginPayloadSourceType.OFFICIAL,
|
||||
payload_source_key=OFFICIAL_SOURCE,
|
||||
declared_version=declared_version or "1.0.0",
|
||||
declared_version=installed_version,
|
||||
package_generation="v3",
|
||||
system_version=None,
|
||||
supports_v3=None,
|
||||
supports_v3t=None,
|
||||
declared_metadata=PluginDeclaredMetadata.from_package(
|
||||
{
|
||||
"name": "Demo",
|
||||
"description": "Demo plugin",
|
||||
"v3": True,
|
||||
"v3t": False,
|
||||
"release": True,
|
||||
},
|
||||
declaration_version=installed_version,
|
||||
manifest_matches_payload=True,
|
||||
),
|
||||
payload_receipt="sha256:" + "0" * 64,
|
||||
revision=1,
|
||||
created_at=NOW,
|
||||
@@ -197,9 +207,15 @@ def test_local_payload_preserves_trusted_online_binding() -> None:
|
||||
payload_source_key=None,
|
||||
declared_version="2.0.0-dev",
|
||||
package_generation="v3",
|
||||
system_version=">=3.0.0",
|
||||
supports_v3=True,
|
||||
supports_v3t=False,
|
||||
declared_metadata=PluginDeclaredMetadata.from_package(
|
||||
{
|
||||
"name": "Demo local",
|
||||
"v3": True,
|
||||
"v3t": False,
|
||||
},
|
||||
declaration_version="2.0.0-dev",
|
||||
manifest_matches_payload=True,
|
||||
),
|
||||
payload_receipt="sha256:" + "a" * 64,
|
||||
payload_applied_at=NOW + timedelta(seconds=1),
|
||||
updated_at=NOW + timedelta(seconds=1),
|
||||
@@ -321,7 +337,6 @@ def test_plugin_identity_rejects_noncanonical_physical_id(plugin_id) -> None:
|
||||
("field_name", "value", "message"),
|
||||
(
|
||||
("declared_version", "v" * 65, "插件声明版本"),
|
||||
("system_version", ">" * 129, "插件系统版本要求"),
|
||||
),
|
||||
)
|
||||
def test_plugin_identity_rejects_values_longer_than_database_columns(
|
||||
|
||||
@@ -22,12 +22,18 @@ except ModuleNotFoundError:
|
||||
|
||||
from app.db.models.pluginidentity import PluginIdentity
|
||||
|
||||
MIGRATION = "database.versions.d2e4f6a8b0c1_3_0_9"
|
||||
BASE_MIGRATION = "database.versions.d2e4f6a8b0c1_3_0_9"
|
||||
INSTALLATION_MIGRATION = "database.versions.e4f7a1b2c3d5_3_0_10"
|
||||
DECLARATION_MIGRATION = "database.versions.5f2a9c1e7b4d_3_0_12"
|
||||
|
||||
|
||||
def _bind_migration(monkeypatch, connection):
|
||||
def _bind_migration(
|
||||
monkeypatch,
|
||||
connection,
|
||||
module_name: str = BASE_MIGRATION,
|
||||
):
|
||||
"""把迁移绑定到隔离数据库连接。"""
|
||||
migration = importlib.import_module(MIGRATION)
|
||||
migration = importlib.import_module(module_name)
|
||||
context = MigrationContext.configure(connection)
|
||||
monkeypatch.setattr(migration, "op", Operations(context))
|
||||
return migration
|
||||
@@ -36,13 +42,27 @@ def _bind_migration(monkeypatch, connection):
|
||||
def test_plugin_identity_migration_upgrades_twice_and_downgrades(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""旧 SQLite schema 应可重复升级并完整删除 dormant 身份表。"""
|
||||
"""SQLite 应可从旧身份表升级到声明快照并按逆序完整回滚。"""
|
||||
engine = sa.create_engine("sqlite://")
|
||||
with engine.begin() as connection:
|
||||
migration = _bind_migration(monkeypatch, connection)
|
||||
|
||||
migration.upgrade()
|
||||
migration.upgrade()
|
||||
installation = _bind_migration(
|
||||
monkeypatch,
|
||||
connection,
|
||||
INSTALLATION_MIGRATION,
|
||||
)
|
||||
installation.upgrade()
|
||||
installation.upgrade()
|
||||
declaration = _bind_migration(
|
||||
monkeypatch,
|
||||
connection,
|
||||
DECLARATION_MIGRATION,
|
||||
)
|
||||
declaration.upgrade()
|
||||
declaration.upgrade()
|
||||
|
||||
inspector = sa.inspect(connection)
|
||||
assert "pluginidentity" in inspector.get_table_names()
|
||||
@@ -65,9 +85,23 @@ def test_plugin_identity_migration_upgrades_twice_and_downgrades(
|
||||
"ck_pluginidentity_normalized_plugin_id",
|
||||
"ck_pluginidentity_revision",
|
||||
}
|
||||
assert "plugininstallation" in inspector.get_table_names()
|
||||
|
||||
declaration.downgrade()
|
||||
installation.downgrade()
|
||||
migration.downgrade()
|
||||
assert "pluginidentity" not in sa.inspect(connection).get_table_names()
|
||||
assert not {
|
||||
"pluginidentity",
|
||||
"plugininstallation",
|
||||
} & set(sa.inspect(connection).get_table_names())
|
||||
|
||||
migration.upgrade()
|
||||
installation.upgrade()
|
||||
declaration.upgrade()
|
||||
assert {
|
||||
column["name"]
|
||||
for column in sa.inspect(connection).get_columns("pluginidentity")
|
||||
} == {column.name for column in PluginIdentity.__table__.columns}
|
||||
|
||||
|
||||
def test_plugin_identity_migration_accepts_fresh_current_schema(monkeypatch) -> None:
|
||||
@@ -75,7 +109,11 @@ def test_plugin_identity_migration_accepts_fresh_current_schema(monkeypatch) ->
|
||||
engine = sa.create_engine("sqlite://")
|
||||
with engine.begin() as connection:
|
||||
PluginIdentity.__table__.create(connection)
|
||||
migration = _bind_migration(monkeypatch, connection)
|
||||
migration = _bind_migration(
|
||||
monkeypatch,
|
||||
connection,
|
||||
DECLARATION_MIGRATION,
|
||||
)
|
||||
|
||||
migration.upgrade()
|
||||
migration.upgrade()
|
||||
@@ -86,9 +124,126 @@ def test_plugin_identity_migration_accepts_fresh_current_schema(monkeypatch) ->
|
||||
} == {column.name for column in PluginIdentity.__table__.columns}
|
||||
|
||||
|
||||
def test_plugin_identity_declaration_migration_backfills_and_restores_legacy_fields(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""声明迁移应保守回填旧字段,并能在降级时恢复旧投影。"""
|
||||
engine = sa.create_engine("sqlite://")
|
||||
with engine.begin() as connection:
|
||||
base = _bind_migration(monkeypatch, connection)
|
||||
base.upgrade()
|
||||
table = sa.Table(
|
||||
"pluginidentity",
|
||||
sa.MetaData(),
|
||||
autoload_with=connection,
|
||||
)
|
||||
connection.execute(
|
||||
table.insert().values(
|
||||
plugin_id="DemoPlugin",
|
||||
normalized_plugin_id="demoplugin",
|
||||
trusted_source_type="official",
|
||||
trusted_source_key="github:jxxghp/moviepilot-plugins",
|
||||
binding_basis="official_default",
|
||||
payload_source_type="official",
|
||||
payload_source_key="github:jxxghp/moviepilot-plugins",
|
||||
declared_version="1.0.0",
|
||||
package_generation="v3",
|
||||
system_version=">=3.0.0",
|
||||
supports_v3=True,
|
||||
supports_v3t=False,
|
||||
payload_receipt="sha256:" + "0" * 64,
|
||||
revision=1,
|
||||
created_at="2026-08-25T12:00:00+00:00",
|
||||
updated_at="2026-08-25T12:00:00+00:00",
|
||||
bound_at="2026-08-25T12:00:00+00:00",
|
||||
payload_applied_at="2026-08-25T12:00:00+00:00",
|
||||
)
|
||||
)
|
||||
unknown_id = connection.execute(
|
||||
table.insert().values(
|
||||
plugin_id="UnknownPlugin",
|
||||
normalized_plugin_id="unknownplugin",
|
||||
trusted_source_type="unknown",
|
||||
binding_basis="legacy_unbound",
|
||||
payload_source_type="unknown",
|
||||
revision=1,
|
||||
created_at="2026-08-25T12:00:00+00:00",
|
||||
updated_at="2026-08-25T12:00:00+00:00",
|
||||
).returning(table.c.id)
|
||||
).scalar_one()
|
||||
|
||||
declaration = _bind_migration(
|
||||
monkeypatch,
|
||||
connection,
|
||||
DECLARATION_MIGRATION,
|
||||
)
|
||||
declaration.upgrade()
|
||||
current = sa.Table(
|
||||
"pluginidentity",
|
||||
sa.MetaData(),
|
||||
autoload_with=connection,
|
||||
)
|
||||
row = connection.execute(
|
||||
sa.select(current).where(current.c.plugin_id == "DemoPlugin")
|
||||
).mappings().one()
|
||||
assert row["declared_metadata"] == {
|
||||
"schema_version": 1,
|
||||
"declaration_version": None,
|
||||
"manifest_matches_payload": False,
|
||||
"manifest": {"system_version": ">=3.0.0"},
|
||||
"runtime": {"v3": True, "v3t": False},
|
||||
}
|
||||
assert connection.execute(
|
||||
sa.select(current.c.id).where(current.c.declared_metadata.is_(None))
|
||||
).scalars().all() == [unknown_id]
|
||||
|
||||
declaration.downgrade()
|
||||
restored = sa.Table(
|
||||
"pluginidentity",
|
||||
sa.MetaData(),
|
||||
autoload_with=connection,
|
||||
)
|
||||
restored_row = connection.execute(
|
||||
sa.select(restored).where(restored.c.plugin_id == "DemoPlugin")
|
||||
).mappings().one()
|
||||
assert restored_row["system_version"] == ">=3.0.0"
|
||||
assert restored_row["supports_v3"] is True
|
||||
assert restored_row["supports_v3t"] is False
|
||||
assert "declared_metadata" not in restored.c
|
||||
|
||||
|
||||
def test_plugin_identity_declaration_migration_rejects_partial_legacy_schema(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""非 canonical 旧字段集合必须停止迁移,不能静默丢弃残余声明。"""
|
||||
engine = sa.create_engine("sqlite://")
|
||||
with engine.begin() as connection:
|
||||
base = _bind_migration(monkeypatch, connection)
|
||||
base.upgrade()
|
||||
with Operations(MigrationContext.configure(connection)).batch_alter_table(
|
||||
"pluginidentity"
|
||||
) as batch_op:
|
||||
batch_op.drop_column("supports_v3t")
|
||||
|
||||
declaration = _bind_migration(
|
||||
monkeypatch,
|
||||
connection,
|
||||
DECLARATION_MIGRATION,
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="旧声明字段不完整"):
|
||||
declaration.upgrade()
|
||||
|
||||
columns = {
|
||||
column["name"]
|
||||
for column in sa.inspect(connection).get_columns("pluginidentity")
|
||||
}
|
||||
assert "declared_metadata" not in columns
|
||||
assert {"system_version", "supports_v3"} <= columns
|
||||
|
||||
|
||||
def test_plugin_identity_migration_matches_postgresql_identity() -> None:
|
||||
"""独立 Alembic 路径应保留 PostgreSQL 循环 Identity 主键。"""
|
||||
migration = importlib.import_module(MIGRATION)
|
||||
migration = importlib.import_module(BASE_MIGRATION)
|
||||
metadata = sa.MetaData()
|
||||
table = sa.Table(
|
||||
"pluginidentity",
|
||||
@@ -148,25 +303,80 @@ def test_plugin_identity_migration_runs_on_postgresql(monkeypatch) -> None:
|
||||
migration = _bind_migration(monkeypatch, connection)
|
||||
migration.upgrade()
|
||||
migration.upgrade()
|
||||
|
||||
table = sa.Table(
|
||||
installation = _bind_migration(
|
||||
monkeypatch,
|
||||
connection,
|
||||
INSTALLATION_MIGRATION,
|
||||
)
|
||||
installation.upgrade()
|
||||
legacy_table = sa.Table(
|
||||
"pluginidentity",
|
||||
sa.MetaData(),
|
||||
autoload_with=connection,
|
||||
)
|
||||
inserted_id = connection.execute(
|
||||
table.insert().values(
|
||||
legacy_table.insert().values(
|
||||
plugin_id="DemoPlugin",
|
||||
normalized_plugin_id="demoplugin",
|
||||
trusted_source_type="official",
|
||||
trusted_source_key="github:jxxghp/moviepilot-plugins",
|
||||
binding_basis="official_default",
|
||||
payload_source_type="official",
|
||||
payload_source_key="github:jxxghp/moviepilot-plugins",
|
||||
declared_version="1.0.0",
|
||||
package_generation="v3",
|
||||
system_version=">=3.0.0",
|
||||
supports_v3=True,
|
||||
supports_v3t=False,
|
||||
payload_receipt="sha256:" + "0" * 64,
|
||||
revision=1,
|
||||
created_at="2026-08-25T12:00:00+00:00",
|
||||
updated_at="2026-08-25T12:00:00+00:00",
|
||||
bound_at="2026-08-25T12:00:00+00:00",
|
||||
payload_applied_at="2026-08-25T12:00:00+00:00",
|
||||
).returning(legacy_table.c.id)
|
||||
).scalar_one()
|
||||
assert inserted_id == 1
|
||||
unknown_id = connection.execute(
|
||||
legacy_table.insert().values(
|
||||
plugin_id="UnknownPlugin",
|
||||
normalized_plugin_id="unknownplugin",
|
||||
trusted_source_type="unknown",
|
||||
binding_basis="legacy_unbound",
|
||||
payload_source_type="unknown",
|
||||
revision=1,
|
||||
created_at="2026-08-25T12:00:00+00:00",
|
||||
updated_at="2026-08-25T12:00:00+00:00",
|
||||
).returning(table.c.id)
|
||||
).returning(legacy_table.c.id)
|
||||
).scalar_one()
|
||||
assert inserted_id == 1
|
||||
assert unknown_id == 2
|
||||
declaration = _bind_migration(
|
||||
monkeypatch,
|
||||
connection,
|
||||
DECLARATION_MIGRATION,
|
||||
)
|
||||
declaration.upgrade()
|
||||
declaration.upgrade()
|
||||
|
||||
table = sa.Table(
|
||||
"pluginidentity",
|
||||
sa.MetaData(),
|
||||
autoload_with=connection,
|
||||
)
|
||||
row = connection.execute(
|
||||
sa.select(table).where(table.c.id == inserted_id)
|
||||
).mappings().one()
|
||||
assert row["declared_metadata"] == {
|
||||
"schema_version": 1,
|
||||
"declaration_version": None,
|
||||
"manifest_matches_payload": False,
|
||||
"manifest": {"system_version": ">=3.0.0"},
|
||||
"runtime": {"v3": True, "v3t": False},
|
||||
}
|
||||
unknown_row = connection.execute(
|
||||
sa.select(table).where(table.c.id == unknown_id)
|
||||
).mappings().one()
|
||||
assert unknown_row["declared_metadata"] is None
|
||||
|
||||
with pytest.raises(sa.exc.IntegrityError):
|
||||
with connection.begin_nested():
|
||||
@@ -183,6 +393,35 @@ def test_plugin_identity_migration_runs_on_postgresql(monkeypatch) -> None:
|
||||
)
|
||||
)
|
||||
|
||||
declaration.downgrade()
|
||||
restored = sa.Table(
|
||||
"pluginidentity",
|
||||
sa.MetaData(),
|
||||
autoload_with=connection,
|
||||
)
|
||||
restored_row = connection.execute(
|
||||
sa.select(restored).where(restored.c.id == inserted_id)
|
||||
).mappings().one()
|
||||
assert restored_row["system_version"] == ">=3.0.0"
|
||||
assert restored_row["supports_v3"] is True
|
||||
assert restored_row["supports_v3t"] is False
|
||||
|
||||
declaration.upgrade()
|
||||
reupgraded = sa.Table(
|
||||
"pluginidentity",
|
||||
sa.MetaData(),
|
||||
autoload_with=connection,
|
||||
)
|
||||
reupgraded_row = connection.execute(
|
||||
sa.select(reupgraded).where(reupgraded.c.id == inserted_id)
|
||||
).mappings().one()
|
||||
assert reupgraded_row["declared_metadata"]["runtime"] == {
|
||||
"v3": True,
|
||||
"v3t": False,
|
||||
}
|
||||
|
||||
declaration.downgrade()
|
||||
installation.downgrade()
|
||||
migration.downgrade()
|
||||
assert "pluginidentity" not in sa.inspect(connection).get_table_names()
|
||||
finally:
|
||||
|
||||
@@ -10,6 +10,7 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.plugin.declaration import PluginDeclaredMetadata
|
||||
from app.application.plugin.identity import (
|
||||
PluginBindingBasis,
|
||||
PluginIdentity,
|
||||
@@ -34,6 +35,15 @@ THIRD_PARTY_REPO = "https://github.com/example/MoviePilot-Plugins"
|
||||
THIRD_PARTY_SOURCE = "github:example/moviepilot-plugins"
|
||||
|
||||
|
||||
def _metadata(version: str, *, matches_payload: bool = True) -> PluginDeclaredMetadata:
|
||||
"""构造测试用 package 声明快照。"""
|
||||
return PluginDeclaredMetadata.from_package(
|
||||
{"name": "Demo", "v3": True, "v3t": False},
|
||||
declaration_version=version,
|
||||
manifest_matches_payload=matches_payload,
|
||||
)
|
||||
|
||||
|
||||
class _Persistence:
|
||||
"""提供可观察 CAS 竞争的内存迁移持久化端口。"""
|
||||
|
||||
@@ -140,9 +150,7 @@ def _legacy(plugin_id: str = "DemoPlugin") -> PluginIdentity:
|
||||
payload_source_key=None,
|
||||
declared_version=None,
|
||||
package_generation=None,
|
||||
system_version=None,
|
||||
supports_v3=None,
|
||||
supports_v3t=None,
|
||||
declared_metadata=None,
|
||||
payload_receipt=None,
|
||||
revision=1,
|
||||
created_at=NOW,
|
||||
@@ -356,6 +364,7 @@ async def test_migration_does_not_replace_existing_bound_or_local_identity() ->
|
||||
payload_source_type=PluginPayloadSourceType.LOCAL,
|
||||
declared_version="1.0.0-dev",
|
||||
package_generation="v3",
|
||||
declared_metadata=_metadata("1.0.0-dev"),
|
||||
payload_receipt="sha256:" + "1" * 64,
|
||||
payload_applied_at=NOW,
|
||||
)
|
||||
@@ -399,6 +408,7 @@ async def test_collect_online_restore_plugins_requires_trust_and_local_payload()
|
||||
payload_source_type=PluginPayloadSourceType.LOCAL,
|
||||
declared_version="9.9.10",
|
||||
package_generation="v3",
|
||||
declared_metadata=_metadata("9.9.10"),
|
||||
payload_receipt="sha256:" + "2" * 64,
|
||||
bound_at=NOW,
|
||||
payload_applied_at=NOW,
|
||||
@@ -409,6 +419,7 @@ async def test_collect_online_restore_plugins_requires_trust_and_local_payload()
|
||||
payload_source_type=PluginPayloadSourceType.LOCAL,
|
||||
declared_version="1.0.0-dev",
|
||||
package_generation="v3",
|
||||
declared_metadata=_metadata("1.0.0-dev"),
|
||||
payload_receipt="sha256:" + "3" * 64,
|
||||
payload_applied_at=NOW,
|
||||
)
|
||||
@@ -421,6 +432,7 @@ async def test_collect_online_restore_plugins_requires_trust_and_local_payload()
|
||||
payload_source_key=OFFICIAL_SOURCE,
|
||||
declared_version="1.2.0",
|
||||
package_generation="v3",
|
||||
declared_metadata=_metadata("1.2.0"),
|
||||
payload_receipt="sha256:" + "4" * 64,
|
||||
bound_at=NOW,
|
||||
payload_applied_at=NOW,
|
||||
@@ -470,6 +482,7 @@ async def test_sync_runs_identity_migration_before_automatic_install(
|
||||
payload_source_type=PluginPayloadSourceType.LOCAL,
|
||||
declared_version="9.9.10",
|
||||
package_generation="v3",
|
||||
declared_metadata=_metadata("9.9.10"),
|
||||
payload_receipt="sha256:" + "5" * 64,
|
||||
bound_at=NOW,
|
||||
payload_applied_at=NOW,
|
||||
|
||||
@@ -7,6 +7,7 @@ import pytest
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.application.plugin.declaration import PluginDeclaredMetadata
|
||||
from app.application.plugin.identity import (
|
||||
PluginBindingBasis,
|
||||
PluginIdentity,
|
||||
@@ -23,6 +24,15 @@ OFFICIAL_SOURCE = "github:jxxghp/moviepilot-plugins"
|
||||
THIRD_PARTY_SOURCE = "github:example/moviepilot-plugins"
|
||||
|
||||
|
||||
def _metadata(version: str, *, matches_payload: bool = True) -> PluginDeclaredMetadata:
|
||||
"""构造测试用 package 声明快照。"""
|
||||
return PluginDeclaredMetadata.from_package(
|
||||
{"name": "Demo", "v3": True, "v3t": False},
|
||||
declaration_version=version,
|
||||
manifest_matches_payload=matches_payload,
|
||||
)
|
||||
|
||||
|
||||
def _identity(
|
||||
plugin_id: str = "DemoPlugin",
|
||||
*,
|
||||
@@ -43,9 +53,13 @@ def _identity(
|
||||
payload_source_key=payload_source_key,
|
||||
declared_version="1.0.0",
|
||||
package_generation="v3",
|
||||
system_version=None,
|
||||
supports_v3=None,
|
||||
supports_v3t=None,
|
||||
declared_metadata=PluginDeclaredMetadata.from_package(
|
||||
{"name": "Demo", "v3": True, "v3t": False},
|
||||
declaration_version="1.0.0",
|
||||
manifest_matches_payload=True,
|
||||
)
|
||||
if payload_source_type is not PluginPayloadSourceType.UNKNOWN
|
||||
else None,
|
||||
payload_receipt="sha256:" + "0" * 64,
|
||||
revision=1,
|
||||
created_at=NOW,
|
||||
@@ -95,9 +109,7 @@ def _legacy_identity(plugin_id: str = "DemoPlugin") -> PluginIdentity:
|
||||
payload_source_key=None,
|
||||
declared_version=None,
|
||||
package_generation=None,
|
||||
system_version=None,
|
||||
supports_v3=None,
|
||||
supports_v3t=None,
|
||||
declared_metadata=None,
|
||||
payload_receipt=None,
|
||||
revision=1,
|
||||
created_at=NOW,
|
||||
@@ -124,6 +136,7 @@ def _online_binding_target(
|
||||
payload_source_key=source_key,
|
||||
declared_version="2.0.0",
|
||||
package_generation="v3",
|
||||
declared_metadata=_metadata("2.0.0"),
|
||||
payload_receipt="sha256:" + "2" * 64,
|
||||
updated_at=updated_at,
|
||||
bound_at=updated_at,
|
||||
@@ -214,6 +227,7 @@ def test_bind_local_commits_only_legacy_unbound_transition(identity_store) -> No
|
||||
payload_source_type=PluginPayloadSourceType.LOCAL,
|
||||
declared_version="2.0.0-dev",
|
||||
package_generation="v3",
|
||||
declared_metadata=_metadata("2.0.0-dev"),
|
||||
payload_receipt="sha256:" + "1" * 64,
|
||||
updated_at=NOW + timedelta(seconds=1),
|
||||
payload_applied_at=NOW + timedelta(seconds=1),
|
||||
@@ -249,6 +263,7 @@ def test_bind_online_commits_legacy_and_local_first_bindings(identity_store) ->
|
||||
payload_source_type=PluginPayloadSourceType.LOCAL,
|
||||
declared_version="2.0.0-dev",
|
||||
package_generation="v3",
|
||||
declared_metadata=_metadata("2.0.0-dev"),
|
||||
payload_receipt="sha256:" + "1" * 64,
|
||||
updated_at=NOW + timedelta(seconds=1),
|
||||
payload_applied_at=NOW + timedelta(seconds=1),
|
||||
@@ -302,6 +317,7 @@ def test_first_local_install_still_uses_ordinary_create(identity_store) -> None:
|
||||
payload_source_type=PluginPayloadSourceType.LOCAL,
|
||||
payload_source_key=None,
|
||||
declared_version="2.0.0-dev",
|
||||
declared_metadata=_metadata("2.0.0-dev"),
|
||||
payload_receipt="sha256:" + "1" * 64,
|
||||
bound_at=None,
|
||||
)
|
||||
@@ -325,6 +341,7 @@ def test_bind_local_rejects_nonlegacy_state_and_stale_revision(identity_store) -
|
||||
payload_source_key=None,
|
||||
bound_at=None,
|
||||
declared_version="2.0.0-dev",
|
||||
declared_metadata=_metadata("2.0.0-dev"),
|
||||
payload_receipt="sha256:" + "1" * 64,
|
||||
updated_at=NOW + timedelta(seconds=1),
|
||||
payload_applied_at=NOW + timedelta(seconds=1),
|
||||
@@ -341,6 +358,7 @@ def test_bind_local_rejects_nonlegacy_state_and_stale_revision(identity_store) -
|
||||
payload_source_key=None,
|
||||
declared_version=None,
|
||||
package_generation=None,
|
||||
declared_metadata=None,
|
||||
payload_receipt=None,
|
||||
bound_at=None,
|
||||
payload_applied_at=None,
|
||||
@@ -352,6 +370,7 @@ def test_bind_local_rejects_nonlegacy_state_and_stale_revision(identity_store) -
|
||||
payload_source_type=PluginPayloadSourceType.LOCAL,
|
||||
declared_version="2.0.0-dev",
|
||||
package_generation="v3",
|
||||
declared_metadata=_metadata("2.0.0-dev"),
|
||||
payload_receipt="sha256:" + "1" * 64,
|
||||
updated_at=NOW + timedelta(seconds=1),
|
||||
payload_applied_at=NOW + timedelta(seconds=1),
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.application.plugin.admission import (
|
||||
PluginSourceAdmissionError,
|
||||
admit_plugin_install,
|
||||
)
|
||||
from app.application.plugin.declaration import PluginDeclaredMetadata
|
||||
from app.application.plugin.identity import (
|
||||
PluginBindingBasis,
|
||||
PluginIdentity,
|
||||
@@ -58,9 +59,11 @@ def _identity() -> PluginIdentity:
|
||||
payload_source_key=OFFICIAL,
|
||||
declared_version="1.0.0",
|
||||
package_generation="v3",
|
||||
system_version=None,
|
||||
supports_v3=True,
|
||||
supports_v3t=None,
|
||||
declared_metadata=PluginDeclaredMetadata.from_package(
|
||||
{"name": "Demo", "v3": True, "v3t": False},
|
||||
declaration_version="1.0.0",
|
||||
manifest_matches_payload=True,
|
||||
),
|
||||
payload_receipt="sha256:" + "0" * 64,
|
||||
revision=3,
|
||||
created_at=NOW,
|
||||
@@ -104,6 +107,55 @@ def test_same_source_update_preserves_binding_and_advances_payload() -> None:
|
||||
assert target.declared_version == "2.0.0"
|
||||
|
||||
|
||||
def test_current_release_marks_declaration_as_matching_payload() -> None:
|
||||
"""按当前 package 版本安装时,声明快照应标记为对应当前载荷。"""
|
||||
admission = admit_plugin_install(
|
||||
_inventory(_online_candidate()),
|
||||
request=PluginInstallAdmissionRequest(
|
||||
plugin_id="DemoPlugin",
|
||||
generations=("v3", "v2", "v1"),
|
||||
requested_repo_url="https://github.com/jxxghp/MoviePilot-Plugins",
|
||||
),
|
||||
identity=None,
|
||||
now=NOW,
|
||||
)
|
||||
|
||||
target = admission.build_identity(
|
||||
payload_receipt="sha256:" + "7" * 64,
|
||||
applied_at=NOW,
|
||||
)
|
||||
|
||||
assert target.declared_metadata is not None
|
||||
assert target.declared_metadata.declaration_version == "2.0.0"
|
||||
assert target.declared_metadata.manifest_matches_payload is True
|
||||
|
||||
|
||||
def test_historical_release_marks_declaration_as_not_matching_payload() -> None:
|
||||
"""安装历史 Release 时必须保留其声明版本,并标记与当前载荷不对应。"""
|
||||
admission = admit_plugin_install(
|
||||
_inventory(_online_candidate()),
|
||||
request=PluginInstallAdmissionRequest(
|
||||
plugin_id="DemoPlugin",
|
||||
generations=("v3", "v2", "v1"),
|
||||
requested_repo_url="https://github.com/jxxghp/MoviePilot-Plugins",
|
||||
),
|
||||
identity=None,
|
||||
now=NOW,
|
||||
)
|
||||
|
||||
target = admission.build_identity(
|
||||
payload_receipt="sha256:" + "8" * 64,
|
||||
applied_at=NOW,
|
||||
declared_version="1.0.0",
|
||||
manifest_matches_payload=False,
|
||||
)
|
||||
|
||||
assert target.declared_version == "1.0.0"
|
||||
assert target.declared_metadata is not None
|
||||
assert target.declared_metadata.declaration_version == "2.0.0"
|
||||
assert target.declared_metadata.manifest_matches_payload is False
|
||||
|
||||
|
||||
def test_first_online_binding_uses_payload_commit_time() -> None:
|
||||
"""首次在线绑定在载荷提交时生效,不能早于身份创建时间。"""
|
||||
applied_at = NOW + timedelta(seconds=1)
|
||||
@@ -315,7 +367,7 @@ def test_legacy_identity_can_bind_explicit_online_source() -> None:
|
||||
payload_source_key=None,
|
||||
declared_version=None,
|
||||
package_generation=None,
|
||||
supports_v3=None,
|
||||
declared_metadata=None,
|
||||
payload_receipt=None,
|
||||
bound_at=None,
|
||||
payload_applied_at=None,
|
||||
|
||||
@@ -14,6 +14,7 @@ from app.application.plugin.admission import (
|
||||
PluginInstallAdmissionRequest,
|
||||
admit_plugin_install,
|
||||
)
|
||||
from app.application.plugin.declaration import PluginDeclaredMetadata
|
||||
from app.application.plugin.identity import (
|
||||
PluginBindingBasis,
|
||||
PluginIdentity,
|
||||
@@ -56,9 +57,11 @@ def _identity(*, version: str = "1.0.0", revision: int = 1) -> PluginIdentity:
|
||||
payload_source_key=SOURCE_KEY,
|
||||
declared_version=version,
|
||||
package_generation="v3",
|
||||
system_version=None,
|
||||
supports_v3=True,
|
||||
supports_v3t=False,
|
||||
declared_metadata=PluginDeclaredMetadata.from_package(
|
||||
{"name": "Demo", "v3": True, "v3t": False},
|
||||
declaration_version=version,
|
||||
manifest_matches_payload=True,
|
||||
),
|
||||
payload_receipt=RECEIPT,
|
||||
revision=revision,
|
||||
created_at=NOW,
|
||||
@@ -197,6 +200,8 @@ class _PersistenceSpy:
|
||||
self.calls.append("journal_commit")
|
||||
if self.commit_error:
|
||||
raise self.commit_error
|
||||
if identity_target is not None:
|
||||
self.identity = identity_target
|
||||
record = self.records[transaction_id]
|
||||
record = replace(
|
||||
record,
|
||||
@@ -624,6 +629,33 @@ async def test_precommit_failure_restores_files_and_runtime(
|
||||
rollback_reloader.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_precommit_failure_keeps_previous_declared_metadata_snapshot():
|
||||
"""提交前失败不得把新声明快照写成当前身份事实。"""
|
||||
previous = _identity(version="1.0.0", revision=3)
|
||||
persistence = _PersistenceSpy([], identity=previous)
|
||||
rollback_reloader = AsyncMock()
|
||||
target_reloader = AsyncMock(side_effect=RuntimeError("reload failed"))
|
||||
command, _, _ = _command(
|
||||
persistence=persistence,
|
||||
target_reloader=target_reloader,
|
||||
rollback_reloader=rollback_reloader,
|
||||
)
|
||||
|
||||
result = await _execute(
|
||||
command,
|
||||
admission=_admission(identity=previous),
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert result.failure_stage == "runtime_reload"
|
||||
assert result.rollback.journal_deleted is True
|
||||
assert persistence.identity is previous
|
||||
assert persistence.identity.declared_version == "1.0.0"
|
||||
assert persistence.identity.declared_metadata is previous.declared_metadata
|
||||
rollback_reloader.assert_awaited_once_with("DemoPlugin")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_database_commit_failure_restores_runtime_before_deleting_journal():
|
||||
"""数据库最终提交失败时,文件、运行态和 PREPARED journal 必须一起补偿。"""
|
||||
|
||||
@@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.plugin.declaration import PluginDeclaredMetadata
|
||||
from app.application.plugin.gateway import PluginInstallGateway
|
||||
from app.application.plugin.identity import (
|
||||
PluginBindingBasis,
|
||||
@@ -141,9 +142,11 @@ async def test_gateway_checks_compatibility_on_final_trusted_candidate() -> None
|
||||
payload_source_key=None,
|
||||
declared_version="9.9.9",
|
||||
package_generation="v3",
|
||||
system_version=None,
|
||||
supports_v3=True,
|
||||
supports_v3t=None,
|
||||
declared_metadata=PluginDeclaredMetadata.from_package(
|
||||
{"name": "Demo local", "v3": True},
|
||||
declaration_version="9.9.9",
|
||||
manifest_matches_payload=True,
|
||||
),
|
||||
payload_receipt="sha256:" + "1" * 64,
|
||||
revision=3,
|
||||
created_at=NOW,
|
||||
@@ -253,9 +256,11 @@ async def test_gateway_forwards_explicit_source_change_revision() -> None:
|
||||
payload_source_key="github:jxxghp/moviepilot-plugins",
|
||||
declared_version="1.0.0",
|
||||
package_generation="v3",
|
||||
system_version=None,
|
||||
supports_v3=True,
|
||||
supports_v3t=None,
|
||||
declared_metadata=PluginDeclaredMetadata.from_package(
|
||||
{"name": "Demo", "v3": True},
|
||||
declaration_version="1.0.0",
|
||||
manifest_matches_payload=True,
|
||||
),
|
||||
payload_receipt="sha256:" + "0" * 64,
|
||||
revision=4,
|
||||
created_at=NOW,
|
||||
|
||||
@@ -26,6 +26,7 @@ except ModuleNotFoundError:
|
||||
|
||||
POSTGRESQL_DIALECT = "postgresql+psycopg"
|
||||
|
||||
from app.application.plugin.declaration import PluginDeclaredMetadata
|
||||
from app.application.plugin.identity import (
|
||||
PluginBindingBasis,
|
||||
PluginIdentity,
|
||||
@@ -63,9 +64,11 @@ def _identity(
|
||||
payload_source_key="github:jxxghp/moviepilot-plugins",
|
||||
declared_version=version,
|
||||
package_generation="v3",
|
||||
system_version=None,
|
||||
supports_v3=True,
|
||||
supports_v3t=False,
|
||||
declared_metadata=PluginDeclaredMetadata.from_package(
|
||||
{"name": "Demo", "v3": True, "v3t": False},
|
||||
declaration_version=version,
|
||||
manifest_matches_payload=True,
|
||||
),
|
||||
payload_receipt="sha256:" + "0" * 64,
|
||||
revision=revision,
|
||||
created_at=NOW,
|
||||
@@ -238,8 +241,11 @@ def _identity_model(identity: PluginIdentity) -> PluginIdentityModel:
|
||||
payload_source_key=identity.payload_source_key,
|
||||
declared_version=identity.declared_version,
|
||||
package_generation=identity.package_generation,
|
||||
supports_v3=identity.supports_v3,
|
||||
supports_v3t=identity.supports_v3t,
|
||||
declared_metadata=(
|
||||
identity.declared_metadata.to_json()
|
||||
if identity.declared_metadata is not None
|
||||
else None
|
||||
),
|
||||
payload_receipt=identity.payload_receipt,
|
||||
revision=identity.revision,
|
||||
created_at=identity.created_at.isoformat(),
|
||||
@@ -610,6 +616,10 @@ def postgresql_installation_stores():
|
||||
connection,
|
||||
"database.versions.e4f7a1b2c3d5_3_0_10",
|
||||
)
|
||||
_upgrade_migration(
|
||||
connection,
|
||||
"database.versions.5f2a9c1e7b4d_3_0_12",
|
||||
)
|
||||
factory = sessionmaker(bind=engine, expire_on_commit=False)
|
||||
_set_config(factory, [])
|
||||
first = TransactionalPluginInstallationStore(
|
||||
|
||||
@@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.plugin.declaration import PluginDeclaredMetadata
|
||||
from app.application.plugin.identity import (
|
||||
PluginBindingBasis,
|
||||
PluginIdentity,
|
||||
@@ -38,9 +39,11 @@ def _identity(*, revision: int = 2, receipt: str = RECEIPT) -> PluginIdentity:
|
||||
payload_source_key="github:jxxghp/moviepilot-plugins",
|
||||
declared_version="2.0.0",
|
||||
package_generation="v3",
|
||||
system_version=None,
|
||||
supports_v3=True,
|
||||
supports_v3t=True,
|
||||
declared_metadata=PluginDeclaredMetadata.from_package(
|
||||
{"name": "Demo", "v3": True, "v3t": True},
|
||||
declaration_version="2.0.0",
|
||||
manifest_matches_payload=True,
|
||||
),
|
||||
payload_receipt=receipt,
|
||||
revision=revision,
|
||||
created_at=NOW,
|
||||
|
||||
@@ -64,9 +64,7 @@ def _identity(source_type: TrustedPluginSourceType, source_key: str) -> PluginId
|
||||
payload_source_key=None,
|
||||
declared_version=None,
|
||||
package_generation=None,
|
||||
system_version=None,
|
||||
supports_v3=None,
|
||||
supports_v3t=None,
|
||||
declared_metadata=None,
|
||||
payload_receipt=None,
|
||||
revision=1,
|
||||
created_at=now,
|
||||
|
||||
@@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.plugin.declaration import PluginDeclaredMetadata
|
||||
from app.application.plugin.gateway import PluginInstallGateway
|
||||
from app.application.plugin.identity import (
|
||||
PluginBindingBasis,
|
||||
@@ -165,9 +166,11 @@ async def test_market_sync_reuses_startup_lease_through_real_gateway(
|
||||
payload_source_key=None,
|
||||
declared_version="9.9.10",
|
||||
package_generation="v3",
|
||||
system_version=None,
|
||||
supports_v3=True,
|
||||
supports_v3t=None,
|
||||
declared_metadata=PluginDeclaredMetadata.from_package(
|
||||
{"name": "Demo local", "v3": True},
|
||||
declaration_version="9.9.10",
|
||||
manifest_matches_payload=True,
|
||||
),
|
||||
payload_receipt="sha256:" + "0" * 64,
|
||||
revision=1,
|
||||
created_at=datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc),
|
||||
|
||||
Reference in New Issue
Block a user