mirror of
https://github.com/lnbits/lnbits.git
synced 2026-08-13 12:42:47 +02:00
feat: custom wasm install dir (#4100)
This commit is contained in:
parent
5c1cf66ef3
commit
84da727282
16 changed files with 318 additions and 37 deletions
|
|
@ -80,6 +80,9 @@ FORWARDED_ALLOW_IPS="*"
|
|||
# Inside this directory the `extensions` and `upgrades` sub-directories will be created.
|
||||
# LNBITS_EXTENSIONS_PATH="/path/to/some/dir"
|
||||
|
||||
# Path where WASM extensions will be installed (defaults to `LNBITS_DATA_FOLDER/wasm_extensions`).
|
||||
# LNBITS_WASM_EXTENSIONS_PATH="/path/to/some/dir"
|
||||
|
||||
# ID of the super user. The user ID must exist.
|
||||
# SUPER_USER=""
|
||||
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ from lnbits.core.wasm_ext.routes.register import (
|
|||
)
|
||||
from lnbits.core.wasm_ext.wasm.events import dispatch_wasm_invoice_paid
|
||||
from lnbits.core.wasm_ext.wasm.loader import (
|
||||
is_wasm_extension_dir,
|
||||
is_wasm_extension_id,
|
||||
)
|
||||
from lnbits.exceptions import register_exception_handlers
|
||||
|
|
@ -312,7 +313,30 @@ async def build_all_installed_extensions_list( # noqa: C901
|
|||
installed_extensions = await get_installed_extensions()
|
||||
settings.lnbits_installed_extensions_ids = {e.id for e in installed_extensions}
|
||||
|
||||
for ext_dir in Path(settings.lnbits_extensions_path, "extensions").iterdir():
|
||||
settings.wasm_extensions_dir.mkdir(parents=True, exist_ok=True)
|
||||
for ext_dir in settings.wasm_extensions_dir.iterdir():
|
||||
try:
|
||||
if not ext_dir.is_dir() or not is_wasm_extension_dir(ext_dir):
|
||||
continue
|
||||
ext_id = ext_dir.name
|
||||
if ext_id in settings.lnbits_installed_extensions_ids:
|
||||
continue
|
||||
ext_info = InstallableExtension.from_wasm_ext_dir(ext_id)
|
||||
if not ext_info:
|
||||
continue
|
||||
|
||||
installed_extensions.append(ext_info)
|
||||
settings.lnbits_installed_extensions_ids.add(ext_id)
|
||||
await create_installed_extension(ext_info)
|
||||
current_version = await get_db_version(ext_id)
|
||||
await migrate_extension_database(ext_info, current_version)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
|
||||
ext_dir_path = Path(settings.lnbits_extensions_path, "extensions")
|
||||
existing_ext_dirs = ext_dir_path.iterdir() if ext_dir_path.is_dir() else []
|
||||
for ext_dir in existing_ext_dirs:
|
||||
try:
|
||||
if not ext_dir.is_dir():
|
||||
continue
|
||||
|
|
@ -367,14 +391,18 @@ async def build_all_installed_extensions_list( # noqa: C901
|
|||
|
||||
|
||||
async def check_installed_extension_files(ext: InstallableExtension) -> bool:
|
||||
if ext.has_installed_version:
|
||||
if ext.is_wasm or ext.has_installed_version:
|
||||
return True
|
||||
|
||||
zip_files = glob.glob(os.path.join(settings.lnbits_data_folder, "zips", "*.zip"))
|
||||
|
||||
if f"./{ext.zip_path!s}" not in zip_files:
|
||||
await ext.download_archive()
|
||||
ext.extract_archive()
|
||||
archive_config = ext.load_archive_config()
|
||||
if archive_config.get("extension_type") == "wasm":
|
||||
ext.extract_wasm_archive()
|
||||
else:
|
||||
ext.extract_archive()
|
||||
|
||||
return False
|
||||
|
||||
|
|
@ -415,6 +443,7 @@ def register_custom_extensions_path():
|
|||
extensions_dir = Path(settings.lnbits_extensions_path, "extensions")
|
||||
Path(extensions_dir).mkdir(parents=True, exist_ok=True)
|
||||
sys.path.append(str(extensions_dir))
|
||||
settings.wasm_extensions_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def register_new_ext_routes(app: FastAPI) -> Callable:
|
||||
|
|
|
|||
|
|
@ -544,6 +544,10 @@ class InstallableExtension(BaseModel):
|
|||
def ext_dir(self) -> Path:
|
||||
return Path(settings.lnbits_extensions_path, "extensions", self.id)
|
||||
|
||||
@property
|
||||
def wasm_ext_dir(self) -> Path:
|
||||
return Path(settings.wasm_extensions_dir, self.id)
|
||||
|
||||
@property
|
||||
def ext_upgrade_dir(self) -> Path:
|
||||
return Path(settings.lnbits_extensions_upgrade_path, f"{self.id}-{self.hash}")
|
||||
|
|
@ -577,7 +581,7 @@ class InstallableExtension(BaseModel):
|
|||
|
||||
@property
|
||||
def is_wasm(self) -> bool:
|
||||
config_path = Path(self.ext_dir, "config.json")
|
||||
config_path = Path(self.wasm_ext_dir, "config.json")
|
||||
if not config_path.is_file():
|
||||
return False
|
||||
try:
|
||||
|
|
@ -673,6 +677,37 @@ class InstallableExtension(BaseModel):
|
|||
shutil.copytree(Path(self.ext_upgrade_dir), Path(self.ext_dir))
|
||||
logger.info(f"Extension {self.name} ({self.installed_version}) extracted.")
|
||||
|
||||
def extract_wasm_archive(self):
|
||||
logger.info(f"Extracting extension {self.name} ({self.installed_version}).")
|
||||
|
||||
tmp_dir = Path(settings.lnbits_data_folder, "unzip-temp", self.hash)
|
||||
shutil.rmtree(tmp_dir, True)
|
||||
with zipfile.ZipFile(self.zip_path, "r") as zip_ref:
|
||||
zip_ref.extractall(tmp_dir)
|
||||
generated_dir_name = os.listdir(tmp_dir)[0]
|
||||
extracted_dir = Path(tmp_dir, generated_dir_name)
|
||||
|
||||
with open(Path(extracted_dir, "config.json"), "r+") as json_file:
|
||||
config_json = json.load(json_file)
|
||||
|
||||
self.name = config_json.get("name")
|
||||
self.short_description = config_json.get("short_description")
|
||||
|
||||
if (
|
||||
self.meta
|
||||
and self.meta.installed_release
|
||||
and self.meta.installed_release.is_github_release
|
||||
and config_json.get("tile")
|
||||
):
|
||||
self.icon = icon_to_github_url(
|
||||
self.meta.installed_release.source_repo, config_json.get("tile")
|
||||
)
|
||||
|
||||
shutil.rmtree(self.wasm_ext_dir, True)
|
||||
shutil.copytree(extracted_dir, self.wasm_ext_dir)
|
||||
shutil.rmtree(tmp_dir, True)
|
||||
logger.info(f"Extension {self.name} ({self.installed_version}) extracted.")
|
||||
|
||||
def clean_extension_files(self):
|
||||
# remove downloaded archive
|
||||
if self.zip_path.is_file():
|
||||
|
|
@ -683,6 +718,12 @@ class InstallableExtension(BaseModel):
|
|||
|
||||
shutil.rmtree(self.ext_upgrade_dir, True)
|
||||
|
||||
def clean_wasm_extension_files(self):
|
||||
if self.zip_path.is_file():
|
||||
os.remove(self.zip_path)
|
||||
|
||||
shutil.rmtree(self.wasm_ext_dir, True)
|
||||
|
||||
def check_release_updates(self, release: ExtensionRelease | None):
|
||||
self._check_latest_version(release)
|
||||
self._check_payment_link(release)
|
||||
|
|
@ -831,6 +872,41 @@ class InstallableExtension(BaseModel):
|
|||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def from_wasm_ext_dir(cls, ext_id: str) -> InstallableExtension | None:
|
||||
try:
|
||||
conf_path = Path(settings.wasm_extensions_dir, ext_id, "config.json")
|
||||
if not conf_path.is_file():
|
||||
return None
|
||||
with open(conf_path, "r+") as json_file:
|
||||
config_json = json.load(json_file)
|
||||
version = config_json.get("version", "0.0")
|
||||
|
||||
return InstallableExtension(
|
||||
id=ext_id,
|
||||
name=config_json.get("name", ext_id),
|
||||
active=True,
|
||||
version=version,
|
||||
short_description=config_json.get("short_description"),
|
||||
icon=config_json.get("tile"),
|
||||
permissions=ExtensionPermission.list_from_config(config_json),
|
||||
meta=ExtensionMeta(
|
||||
installed_release=ExtensionRelease(
|
||||
name=ext_id,
|
||||
version=version,
|
||||
archive=f"{conf_path}",
|
||||
source_repo=f"{conf_path}",
|
||||
min_lnbits_version=config_json.get("min_lnbits_version"),
|
||||
max_lnbits_version=config_json.get("max_lnbits_version"),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
async def get_installable_extensions(
|
||||
cls, post_refresh_cache: bool = False
|
||||
|
|
|
|||
|
|
@ -217,7 +217,10 @@ async def install_extension(
|
|||
allow_admin_policy_overrides=allow_admin_policy_overrides,
|
||||
)
|
||||
|
||||
ext_info.extract_archive()
|
||||
if extension_config.get("extension_type") == "wasm":
|
||||
ext_info.extract_wasm_archive()
|
||||
else:
|
||||
ext_info.extract_archive()
|
||||
|
||||
db_version = await get_db_version(ext_info.id)
|
||||
await migrate_extension_database(ext_info, db_version)
|
||||
|
|
@ -645,7 +648,10 @@ async def uninstall_extension(ext_id: str):
|
|||
|
||||
extension = await get_installed_extension(ext_id)
|
||||
if extension:
|
||||
extension.clean_extension_files()
|
||||
if extension.is_wasm:
|
||||
extension.clean_wasm_extension_files()
|
||||
else:
|
||||
extension.clean_extension_files()
|
||||
await delete_installed_extension(ext_id=ext_id)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -129,7 +129,14 @@ async def api_install_extension(data: CreateExtension):
|
|||
logger.warning(exc)
|
||||
etype, _, tb = sys.exc_info()
|
||||
traceback.print_exception(etype, exc, tb)
|
||||
ext_info.clean_extension_files()
|
||||
try:
|
||||
archive_config = ext_info.load_archive_config()
|
||||
except ValueError:
|
||||
archive_config = {}
|
||||
if archive_config.get("extension_type") == "wasm":
|
||||
ext_info.clean_wasm_extension_files()
|
||||
else:
|
||||
ext_info.clean_extension_files()
|
||||
detail = (
|
||||
str(exc)
|
||||
if isinstance(exc, (AssertionError, ValueError))
|
||||
|
|
@ -1054,7 +1061,8 @@ async def create_extension_review(
|
|||
|
||||
|
||||
def _load_installed_extension_config(extension: InstallableExtension) -> dict:
|
||||
config_path = extension.ext_dir / "config.json"
|
||||
ext_dir = extension.wasm_ext_dir if extension.is_wasm else extension.ext_dir
|
||||
config_path = ext_dir / "config.json"
|
||||
if not config_path.is_file():
|
||||
raise ValueError(f"Extension '{extension.id}' config file is missing.")
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -254,7 +254,7 @@ async def migrate_wasm_extension_database(
|
|||
ext: InstallableExtension,
|
||||
current_version: DbVersion | None = None,
|
||||
) -> None:
|
||||
migrations_dir = ext.ext_dir / "storage" / "migrations"
|
||||
migrations_dir = ext.wasm_ext_dir / "storage" / "migrations"
|
||||
migration_files = _migration_files(migrations_dir)
|
||||
if not migration_files:
|
||||
logger.debug(f"No storage migrations for WASM extension '{ext.id}'.")
|
||||
|
|
@ -433,13 +433,7 @@ def _load_table_schema(ext_id: str, table: str) -> dict[str, Any]:
|
|||
|
||||
|
||||
def _load_storage_schema(ext_id: str) -> dict[str, Any]:
|
||||
schema_path = (
|
||||
Path(settings.lnbits_extensions_path)
|
||||
/ "extensions"
|
||||
/ ext_id
|
||||
/ "storage"
|
||||
/ "schema.json"
|
||||
)
|
||||
schema_path = settings.wasm_extensions_dir / ext_id / "storage" / "schema.json"
|
||||
if not schema_path.is_file():
|
||||
raise ValueError(f"WASM extension '{ext_id}' has no storage schema.")
|
||||
return _load_json(schema_path)
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ class WasmExtension:
|
|||
|
||||
|
||||
def is_wasm_extension_id(ext_id: str) -> bool:
|
||||
ext_dir = Path(settings.lnbits_extensions_path, "extensions", ext_id)
|
||||
ext_dir = Path(settings.wasm_extensions_dir, ext_id)
|
||||
config = _load_json(ext_dir / "config.json")
|
||||
return bool(config and config.get("extension_type") == "wasm")
|
||||
|
||||
|
|
@ -38,7 +38,7 @@ def is_wasm_extension_dir(ext_dir: Path) -> bool:
|
|||
|
||||
|
||||
def load_wasm_extension_config(ext_id: str) -> WasmExtensionConfig | None:
|
||||
ext_dir = Path(settings.lnbits_extensions_path, "extensions", ext_id)
|
||||
ext_dir = Path(settings.wasm_extensions_dir, ext_id)
|
||||
config = _load_json(ext_dir / "config.json")
|
||||
if not config or config.get("extension_type") != "wasm":
|
||||
return None
|
||||
|
|
@ -46,7 +46,7 @@ def load_wasm_extension_config(ext_id: str) -> WasmExtensionConfig | None:
|
|||
|
||||
|
||||
def load_wasm_extension(ext_id: str) -> WasmExtension:
|
||||
ext_dir = Path(settings.lnbits_extensions_path, "extensions", ext_id)
|
||||
ext_dir = Path(settings.wasm_extensions_dir, ext_id)
|
||||
raw_config = _load_json(ext_dir / "config.json")
|
||||
if not raw_config:
|
||||
raise FileNotFoundError(f"Missing WASM extension config for '{ext_id}'.")
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ def main(
|
|||
Path(settings.lnbits_extensions_path, "extensions").mkdir(
|
||||
parents=True, exist_ok=True
|
||||
)
|
||||
settings.wasm_extensions_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
set_cli_settings(host=host, port=port, forwarded_allow_ips=forwarded_allow_ips)
|
||||
|
||||
|
|
|
|||
|
|
@ -1161,6 +1161,32 @@ class EnvSettings(LNbitsSettings):
|
|||
class PersistenceSettings(LNbitsSettings):
|
||||
lnbits_data_folder: str = Field(default="./data")
|
||||
lnbits_database_url: str | None = Field(default=None)
|
||||
lnbits_wasm_extensions_path: str = Field(default="")
|
||||
|
||||
@validator("lnbits_wasm_extensions_path", pre=True, always=True)
|
||||
@classmethod
|
||||
def validate_wasm_extensions_path(cls, value, values) -> str:
|
||||
if value:
|
||||
return str(value)
|
||||
return str(Path(values.get("lnbits_data_folder", "./data"), "wasm_extensions"))
|
||||
|
||||
@property
|
||||
def wasm_extensions_dir(self) -> Path:
|
||||
wasm_dir = Path(self.lnbits_wasm_extensions_path)
|
||||
importable_dirs = (
|
||||
Path(getattr(self, "lnbits_extensions_path", "lnbits"), "extensions"),
|
||||
Path(self.lnbits_data_folder, "upgrades"),
|
||||
)
|
||||
resolved_wasm_dir = wasm_dir.resolve()
|
||||
if any(
|
||||
resolved_dir == resolved_wasm_dir
|
||||
or resolved_dir in resolved_wasm_dir.parents
|
||||
for resolved_dir in (path.resolve() for path in importable_dirs)
|
||||
):
|
||||
raise ValueError(
|
||||
"WASM extensions path must be outside importable extension directories."
|
||||
)
|
||||
return wasm_dir
|
||||
|
||||
|
||||
class SuperUserSettings(LNbitsSettings):
|
||||
|
|
|
|||
|
|
@ -296,6 +296,7 @@ async def test_extension_api_installs_wasm_with_granted_permissions(
|
|||
)
|
||||
original_data_folder = settings.lnbits_data_folder
|
||||
original_extensions_path = settings.lnbits_extensions_path
|
||||
original_wasm_extensions_path = settings.lnbits_wasm_extensions_path
|
||||
register_wasm_routes_mock = mocker.patch(
|
||||
"lnbits.core.services.extensions.core_app_extra.register_new_wasm_ext_routes"
|
||||
)
|
||||
|
|
@ -313,6 +314,7 @@ async def test_extension_api_installs_wasm_with_granted_permissions(
|
|||
try:
|
||||
settings.lnbits_data_folder = str(tmp_path / "data")
|
||||
settings.lnbits_extensions_path = str(tmp_path / "code")
|
||||
settings.lnbits_wasm_extensions_path = str(tmp_path / "wasm_extensions")
|
||||
_write_wasm_extension_archive(ext_id, release.version, settings)
|
||||
|
||||
installed = await api_install_extension(create_data)
|
||||
|
|
@ -321,6 +323,7 @@ async def test_extension_api_installs_wasm_with_granted_permissions(
|
|||
await delete_installed_extension(ext_id=ext_id)
|
||||
settings.lnbits_data_folder = original_data_folder
|
||||
settings.lnbits_extensions_path = original_extensions_path
|
||||
settings.lnbits_wasm_extensions_path = original_wasm_extensions_path
|
||||
|
||||
assert installed.code == ext_id
|
||||
assert installed.is_wasm is True
|
||||
|
|
@ -351,10 +354,12 @@ async def test_extension_api_wasm_runtime_limits_and_catalog_use_installed_metad
|
|||
)
|
||||
]
|
||||
original_extensions_path = settings.lnbits_extensions_path
|
||||
original_wasm_extensions_path = settings.lnbits_wasm_extensions_path
|
||||
|
||||
try:
|
||||
settings.lnbits_extensions_path = str(tmp_path)
|
||||
_write_installed_wasm_config(ext_id, tmp_path)
|
||||
settings.lnbits_wasm_extensions_path = str(tmp_path / "wasm_extensions")
|
||||
_write_installed_wasm_config(ext_id, settings.wasm_extensions_dir)
|
||||
await create_installed_extension(
|
||||
InstallableExtension(
|
||||
id=ext_id,
|
||||
|
|
@ -396,6 +401,7 @@ async def test_extension_api_wasm_runtime_limits_and_catalog_use_installed_metad
|
|||
await delete_installed_extension(ext_id=ext_id)
|
||||
await delete_installed_extension(ext_id=py_ext_id)
|
||||
settings.lnbits_extensions_path = original_extensions_path
|
||||
settings.lnbits_wasm_extensions_path = original_wasm_extensions_path
|
||||
|
||||
assert wasm_info.wasm_runtime_limits == {"wasm_runtime_max_execution_ms": 1234}
|
||||
assert py_ext_id not in {info.id for info in runtime_extensions}
|
||||
|
|
@ -421,6 +427,7 @@ async def test_extension_api_admin_updates_wasm_extension_permission_limits(
|
|||
):
|
||||
ext_id = f"wasm_{uuid4().hex[:8]}"
|
||||
original_extensions_path = settings.lnbits_extensions_path
|
||||
original_wasm_extensions_path = settings.lnbits_wasm_extensions_path
|
||||
manifest_permissions = [
|
||||
{
|
||||
"id": "ext.storage.append_public",
|
||||
|
|
@ -456,9 +463,10 @@ async def test_extension_api_admin_updates_wasm_extension_permission_limits(
|
|||
|
||||
try:
|
||||
settings.lnbits_extensions_path = str(tmp_path)
|
||||
settings.lnbits_wasm_extensions_path = str(tmp_path / "wasm_extensions")
|
||||
_write_installed_wasm_config(
|
||||
ext_id,
|
||||
tmp_path,
|
||||
settings.wasm_extensions_dir,
|
||||
permissions=manifest_permissions,
|
||||
)
|
||||
await create_installed_extension(
|
||||
|
|
@ -479,6 +487,7 @@ async def test_extension_api_admin_updates_wasm_extension_permission_limits(
|
|||
finally:
|
||||
await delete_installed_extension(ext_id=ext_id)
|
||||
settings.lnbits_extensions_path = original_extensions_path
|
||||
settings.lnbits_wasm_extensions_path = original_wasm_extensions_path
|
||||
|
||||
assert response.extension_permissions == [
|
||||
ExtensionPermission(
|
||||
|
|
@ -784,10 +793,10 @@ def _write_wasm_extension_archive(
|
|||
|
||||
def _write_installed_wasm_config(
|
||||
ext_id: str,
|
||||
extensions_path,
|
||||
wasm_extensions_path,
|
||||
permissions: list[dict] | None = None,
|
||||
) -> None:
|
||||
config_dir = extensions_path / "extensions" / ext_id
|
||||
config_dir = wasm_extensions_path / ext_id
|
||||
config_dir.mkdir(parents=True)
|
||||
(config_dir / "config.json").write_text(
|
||||
json.dumps(_wasm_config(ext_id, permissions=permissions)),
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ def settings():
|
|||
lnbits_settings.auth_https_only = False
|
||||
lnbits_settings.lnbits_admin_extensions = []
|
||||
lnbits_settings.lnbits_data_folder = "./tests/data"
|
||||
lnbits_settings.lnbits_wasm_extensions_path = "./tests/data/wasm_extensions"
|
||||
lnbits_settings.lnbits_admin_ui = True
|
||||
lnbits_settings.lnbits_extensions_default_install = []
|
||||
lnbits_settings.lnbits_extensions_deactivate_all = True
|
||||
|
|
@ -363,6 +364,7 @@ def _settings_cleanup(settings: Settings):
|
|||
_restore_pure_settings(settings)
|
||||
settings.auth_https_only = False
|
||||
settings.lnbits_data_folder = "./tests/data"
|
||||
settings.lnbits_wasm_extensions_path = "./tests/data/wasm_extensions"
|
||||
settings.bundle_assets = True
|
||||
settings.lnbits_admin_ui = True
|
||||
settings.lnbits_extensions_default_install = []
|
||||
|
|
|
|||
71
tests/unit/test_app_extensions.py
Normal file
71
tests/unit/test_app_extensions.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pytest_mock.plugin import MockerFixture
|
||||
|
||||
from lnbits.app import build_all_installed_extensions_list
|
||||
from lnbits.settings import Settings
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wasm_extension_discovery_uses_configured_directory(
|
||||
tmp_path: Path,
|
||||
settings: Settings,
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
discovered_id = "discovered_wasm"
|
||||
original_extensions_path = settings.lnbits_extensions_path
|
||||
original_wasm_extensions_path = settings.lnbits_wasm_extensions_path
|
||||
original_installed_ids = set(settings.lnbits_installed_extensions_ids)
|
||||
|
||||
settings.lnbits_extensions_path = str(tmp_path / "code")
|
||||
settings.lnbits_wasm_extensions_path = str(tmp_path / "wasm_extensions")
|
||||
Path(settings.lnbits_extensions_path, "extensions").mkdir(parents=True)
|
||||
_write_wasm_config(settings.wasm_extensions_dir / discovered_id, discovered_id)
|
||||
|
||||
mocker.patch(
|
||||
"lnbits.app.get_installed_extensions",
|
||||
mocker.AsyncMock(return_value=[]),
|
||||
)
|
||||
create_mock = mocker.patch(
|
||||
"lnbits.app.create_installed_extension",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.app.get_db_version",
|
||||
mocker.AsyncMock(return_value=None),
|
||||
)
|
||||
migrate_mock = mocker.patch(
|
||||
"lnbits.app.migrate_extension_database",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
|
||||
try:
|
||||
installed = await build_all_installed_extensions_list()
|
||||
finally:
|
||||
settings.lnbits_extensions_path = original_extensions_path
|
||||
settings.lnbits_wasm_extensions_path = original_wasm_extensions_path
|
||||
settings.lnbits_installed_extensions_ids = original_installed_ids
|
||||
|
||||
assert [extension.id for extension in installed] == [discovered_id]
|
||||
create_mock.assert_awaited_once()
|
||||
assert create_mock.await_args is not None
|
||||
assert create_mock.await_args.args[0].id == discovered_id
|
||||
migrate_mock.assert_awaited_once()
|
||||
|
||||
|
||||
def _write_wasm_config(ext_dir: Path, ext_id: str) -> None:
|
||||
ext_dir.mkdir(parents=True)
|
||||
(ext_dir / "config.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"id": ext_id,
|
||||
"name": ext_id,
|
||||
"version": "1.0.0",
|
||||
"extension_type": "wasm",
|
||||
"wasm": {"module": "extension.wasm"},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
|
@ -164,6 +164,7 @@ async def test_install_wasm_extension_requires_permissions_and_skips_background_
|
|||
ext_info = make_installable_extension(ext_id)
|
||||
original_data_folder = settings.lnbits_data_folder
|
||||
original_extensions_path = settings.lnbits_extensions_path
|
||||
original_wasm_extensions_path = settings.lnbits_wasm_extensions_path
|
||||
start_mock = mocker.patch(
|
||||
"lnbits.core.services.extensions.start_extension_background_work",
|
||||
mocker.AsyncMock(return_value=True),
|
||||
|
|
@ -172,7 +173,11 @@ async def test_install_wasm_extension_requires_permissions_and_skips_background_
|
|||
try:
|
||||
settings.lnbits_data_folder = str(tmp_path / "data")
|
||||
settings.lnbits_extensions_path = str(tmp_path / "code")
|
||||
settings.lnbits_wasm_extensions_path = str(tmp_path / "wasm_extensions")
|
||||
_write_wasm_extension_archive(ext_info, _wasm_install_config(ext_id))
|
||||
wasm_ext_dir = settings.wasm_extensions_dir / ext_id
|
||||
py_ext_dir = ext_info.ext_dir
|
||||
upgrade_dir = ext_info.ext_upgrade_dir
|
||||
|
||||
with pytest.raises(ValueError, match="requires permission approval"):
|
||||
await install_extension(ext_info, skip_download=True)
|
||||
|
|
@ -193,6 +198,7 @@ async def test_install_wasm_extension_requires_permissions_and_skips_background_
|
|||
await delete_installed_extension(ext_id=ext_id)
|
||||
settings.lnbits_data_folder = original_data_folder
|
||||
settings.lnbits_extensions_path = original_extensions_path
|
||||
settings.lnbits_wasm_extensions_path = original_wasm_extensions_path
|
||||
|
||||
assert extension.code == ext_id
|
||||
assert extension.is_wasm is True
|
||||
|
|
@ -205,6 +211,9 @@ async def test_install_wasm_extension_requires_permissions_and_skips_background_
|
|||
)
|
||||
]
|
||||
start_mock.assert_not_awaited()
|
||||
assert wasm_ext_dir.is_dir()
|
||||
assert not py_ext_dir.exists()
|
||||
assert not upgrade_dir.exists()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
|
@ -269,16 +278,18 @@ async def test_uninstall_wasm_extension_unregisters_live_routes(
|
|||
ext_info = make_installable_extension(ext_id)
|
||||
original_data_folder = settings.lnbits_data_folder
|
||||
original_extensions_path = settings.lnbits_extensions_path
|
||||
original_wasm_extensions_path = settings.lnbits_wasm_extensions_path
|
||||
original_deactivated = set(settings.lnbits_deactivated_extensions)
|
||||
unregister_routes_mock = mocker.patch(
|
||||
"lnbits.core.services.extensions.core_app_extra.unregister_wasm_ext_routes"
|
||||
)
|
||||
clean_mock = mocker.patch.object(InstallableExtension, "clean_extension_files")
|
||||
clean_mock = mocker.patch.object(InstallableExtension, "clean_wasm_extension_files")
|
||||
|
||||
try:
|
||||
settings.lnbits_data_folder = str(tmp_path / "data")
|
||||
settings.lnbits_extensions_path = str(tmp_path / "code")
|
||||
ext_dir = tmp_path / "code" / "extensions" / ext_id
|
||||
settings.lnbits_wasm_extensions_path = str(tmp_path / "wasm_extensions")
|
||||
ext_dir = settings.wasm_extensions_dir / ext_id
|
||||
ext_dir.mkdir(parents=True, exist_ok=True)
|
||||
(ext_dir / "config.json").write_text(
|
||||
json.dumps(_wasm_install_config(ext_id)),
|
||||
|
|
@ -294,6 +305,7 @@ async def test_uninstall_wasm_extension_unregisters_live_routes(
|
|||
await delete_installed_extension(ext_id=ext_id)
|
||||
settings.lnbits_data_folder = original_data_folder
|
||||
settings.lnbits_extensions_path = original_extensions_path
|
||||
settings.lnbits_wasm_extensions_path = original_wasm_extensions_path
|
||||
settings.lnbits_deactivated_extensions = original_deactivated
|
||||
|
||||
unregister_routes_mock.assert_called_once_with(ext_id)
|
||||
|
|
@ -579,9 +591,11 @@ async def test_update_wasm_extension_runtime_limits_saves_sparse_overrides(
|
|||
):
|
||||
ext_id = "wasm_demo"
|
||||
original_extensions_path = settings.lnbits_extensions_path
|
||||
original_wasm_extensions_path = settings.lnbits_wasm_extensions_path
|
||||
try:
|
||||
settings.lnbits_extensions_path = str(tmp_path)
|
||||
config_dir = tmp_path / "extensions" / ext_id
|
||||
settings.lnbits_wasm_extensions_path = str(tmp_path / "wasm_extensions")
|
||||
config_dir = settings.wasm_extensions_dir / ext_id
|
||||
config_dir.mkdir(parents=True)
|
||||
(config_dir / "config.json").write_text(
|
||||
'{"extension_type": "wasm"}',
|
||||
|
|
@ -612,6 +626,7 @@ async def test_update_wasm_extension_runtime_limits_saves_sparse_overrides(
|
|||
)
|
||||
finally:
|
||||
settings.lnbits_extensions_path = original_extensions_path
|
||||
settings.lnbits_wasm_extensions_path = original_wasm_extensions_path
|
||||
|
||||
assert saved_limits == {
|
||||
"wasm_runtime_max_execution_ms": 15000,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
|
@ -214,6 +215,40 @@ def test_settings_keep_wasm_manifests_separate_from_extension_manifests():
|
|||
]
|
||||
|
||||
|
||||
def test_wasm_extensions_directory_defaults_to_data_folder_and_is_configurable(
|
||||
tmp_path: Path,
|
||||
):
|
||||
data_folder = tmp_path / "data"
|
||||
default_settings = Settings(
|
||||
lnbits_data_folder=str(data_folder),
|
||||
lnbits_wasm_extensions_path="",
|
||||
)
|
||||
custom_path = tmp_path / "custom-wasm"
|
||||
custom_settings = Settings(
|
||||
lnbits_data_folder=str(data_folder),
|
||||
lnbits_wasm_extensions_path=str(custom_path),
|
||||
)
|
||||
|
||||
assert default_settings.wasm_extensions_dir == data_folder / "wasm_extensions"
|
||||
assert default_settings.lnbits_wasm_extensions_path == str(
|
||||
data_folder / "wasm_extensions"
|
||||
)
|
||||
assert custom_settings.wasm_extensions_dir == custom_path
|
||||
|
||||
|
||||
def test_wasm_extensions_directory_must_not_be_importable(tmp_path: Path):
|
||||
settings = Settings(
|
||||
lnbits_data_folder=str(tmp_path / "data"),
|
||||
lnbits_extensions_path=str(tmp_path / "code"),
|
||||
lnbits_wasm_extensions_path=str(
|
||||
tmp_path / "code" / "extensions" / "wasm_extensions"
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="outside importable extension directories"):
|
||||
_ = settings.wasm_extensions_dir
|
||||
|
||||
|
||||
def test_exchange_rate_provider_convert_ticker():
|
||||
provider = ExchangeRateProvider(
|
||||
name="Provider",
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@ from lnbits.core.models.extensions import ExtensionPermission
|
|||
from lnbits.core.models.misc import WasmExtensionRegistry
|
||||
from lnbits.core.wasm_ext.api.permissions import validate_wasm_extension_permissions
|
||||
from lnbits.core.wasm_ext.wasm.config import parse_wasm_extension_config
|
||||
from lnbits.core.wasm_ext.wasm.loader import WasmExtension, load_wasm_extension
|
||||
from lnbits.core.wasm_ext.wasm.loader import (
|
||||
WasmExtension,
|
||||
load_wasm_extension,
|
||||
)
|
||||
from lnbits.settings import Settings
|
||||
from tests.helpers import make_installable_extension
|
||||
|
||||
|
|
@ -185,8 +188,8 @@ def _write_wasm_extension(
|
|||
*,
|
||||
config_id: str | None,
|
||||
) -> None:
|
||||
settings.lnbits_extensions_path = str(tmp_path)
|
||||
ext_dir = tmp_path / "extensions" / ext_id
|
||||
settings.lnbits_wasm_extensions_path = str(tmp_path / "wasm_extensions")
|
||||
ext_dir = settings.wasm_extensions_dir / ext_id
|
||||
ext_dir.mkdir(parents=True)
|
||||
(ext_dir / "extension.wasm").write_bytes(b"\0asm")
|
||||
config = {
|
||||
|
|
|
|||
|
|
@ -225,10 +225,12 @@ async def test_wasm_storage_migration_and_owner_scoped_crud(
|
|||
):
|
||||
ext_id = f"wasmstore_{uuid4().hex[:8]}"
|
||||
original_extensions_path = settings.lnbits_extensions_path
|
||||
original_wasm_extensions_path = settings.lnbits_wasm_extensions_path
|
||||
original_data_folder = settings.lnbits_data_folder
|
||||
try:
|
||||
settings.lnbits_data_folder = str(tmp_path / "data")
|
||||
settings.lnbits_extensions_path = str(tmp_path / "code")
|
||||
settings.lnbits_wasm_extensions_path = str(tmp_path / "wasm_extensions")
|
||||
Path(settings.lnbits_data_folder).mkdir(parents=True)
|
||||
ext_dir = _write_storage_extension(settings, ext_id)
|
||||
|
||||
|
|
@ -281,6 +283,7 @@ async def test_wasm_storage_migration_and_owner_scoped_crud(
|
|||
deleted = await storage_get_row(ext_id, "notes", "note-1", "owner-1")
|
||||
finally:
|
||||
settings.lnbits_extensions_path = original_extensions_path
|
||||
settings.lnbits_wasm_extensions_path = original_wasm_extensions_path
|
||||
settings.lnbits_data_folder = original_data_folder
|
||||
|
||||
assert ext_dir.is_dir()
|
||||
|
|
@ -304,10 +307,12 @@ async def test_wasm_storage_public_append_generates_id_and_counts_by_owner(
|
|||
):
|
||||
ext_id = f"wasmstore_{uuid4().hex[:8]}"
|
||||
original_extensions_path = settings.lnbits_extensions_path
|
||||
original_wasm_extensions_path = settings.lnbits_wasm_extensions_path
|
||||
original_data_folder = settings.lnbits_data_folder
|
||||
try:
|
||||
settings.lnbits_data_folder = str(tmp_path / "data")
|
||||
settings.lnbits_extensions_path = str(tmp_path / "code")
|
||||
settings.lnbits_wasm_extensions_path = str(tmp_path / "wasm_extensions")
|
||||
Path(settings.lnbits_data_folder).mkdir(parents=True)
|
||||
_write_storage_extension(settings, ext_id)
|
||||
|
||||
|
|
@ -339,6 +344,7 @@ async def test_wasm_storage_public_append_generates_id_and_counts_by_owner(
|
|||
message = await storage_get_row(ext_id, "messages", message_id, "owner-1")
|
||||
finally:
|
||||
settings.lnbits_extensions_path = original_extensions_path
|
||||
settings.lnbits_wasm_extensions_path = original_wasm_extensions_path
|
||||
settings.lnbits_data_folder = original_data_folder
|
||||
|
||||
assert message_id
|
||||
|
|
@ -357,10 +363,12 @@ async def test_wasm_storage_rejects_reserved_fields_and_invalid_identifiers(
|
|||
):
|
||||
ext_id = f"wasmstore_{uuid4().hex[:8]}"
|
||||
original_extensions_path = settings.lnbits_extensions_path
|
||||
original_wasm_extensions_path = settings.lnbits_wasm_extensions_path
|
||||
original_data_folder = settings.lnbits_data_folder
|
||||
try:
|
||||
settings.lnbits_data_folder = str(tmp_path / "data")
|
||||
settings.lnbits_extensions_path = str(tmp_path / "code")
|
||||
settings.lnbits_wasm_extensions_path = str(tmp_path / "wasm_extensions")
|
||||
Path(settings.lnbits_data_folder).mkdir(parents=True)
|
||||
_write_storage_extension(settings, ext_id)
|
||||
|
||||
|
|
@ -375,13 +383,7 @@ async def test_wasm_storage_rejects_reserved_fields_and_invalid_identifiers(
|
|||
"owner-1",
|
||||
)
|
||||
|
||||
schema_path = (
|
||||
Path(settings.lnbits_extensions_path)
|
||||
/ "extensions"
|
||||
/ ext_id
|
||||
/ "storage"
|
||||
/ "schema.json"
|
||||
)
|
||||
schema_path = settings.wasm_extensions_dir / ext_id / "storage" / "schema.json"
|
||||
schema = json.loads(schema_path.read_text(encoding="utf-8"))
|
||||
schema["tables"]["notes"]["fields"].append(
|
||||
{"name": OWNER_ID_FIELD, "type": "string"}
|
||||
|
|
@ -392,6 +394,7 @@ async def test_wasm_storage_rejects_reserved_fields_and_invalid_identifiers(
|
|||
await storage_get_row(ext_id, "notes", "note-1", "owner-1")
|
||||
finally:
|
||||
settings.lnbits_extensions_path = original_extensions_path
|
||||
settings.lnbits_wasm_extensions_path = original_wasm_extensions_path
|
||||
settings.lnbits_data_folder = original_data_folder
|
||||
|
||||
|
||||
|
|
@ -429,7 +432,7 @@ async def _temporary_core_crud_database(
|
|||
|
||||
|
||||
def _write_storage_extension(settings: Settings, ext_id: str) -> Path:
|
||||
ext_dir = Path(settings.lnbits_extensions_path) / "extensions" / ext_id
|
||||
ext_dir = settings.wasm_extensions_dir / ext_id
|
||||
storage_dir = ext_dir / "storage"
|
||||
migrations_dir = storage_dir / "migrations"
|
||||
migrations_dir.mkdir(parents=True)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue