feat: wasm ext resource limits (#4052)

This commit is contained in:
Vlad Stan
2026-07-10 14:33:04 +03:00
committed by GitHub
parent 624f88bcf8
commit 7fc279b081
38 changed files with 3797 additions and 119 deletions
+270
View File
@@ -14,14 +14,21 @@ from lnbits.core.models.extensions import (
InstallableExtension,
ReleasePaymentInfo,
)
from lnbits.core.services import extensions as extension_services
from lnbits.core.services.extensions import (
activate_extension,
attach_wasm_invocation_runtime,
deactivate_extension,
finish_wasm_invocation,
get_current_wasm_invocations,
get_valid_extension,
get_valid_extensions,
install_extension,
record_wasm_invocation_host_call,
start_extension_background_work,
start_wasm_invocation,
stop_extension_background_work,
stop_wasm_invocation,
uninstall_extension,
)
from lnbits.settings import Settings
@@ -219,6 +226,269 @@ async def test_stop_extension_background_work_handles_missing_and_async_stops(
assert called["stop"] is True
@pytest.mark.anyio
async def test_wasm_invocation_tracking_counts_and_stops(mocker: MockerFixture):
_reset_wasm_invocation_state()
mocker.patch(
"lnbits.core.services.extensions.create_wasm_invocation",
mocker.AsyncMock(),
)
update_mock = mocker.patch(
"lnbits.core.services.extensions.update_wasm_invocation",
mocker.AsyncMock(),
)
mocker.patch(
"lnbits.core.services.extensions.get_wasm_invocation",
mocker.AsyncMock(return_value=None),
)
mocker.patch(
"lnbits.core.services.extensions.mark_stale_wasm_invocations",
mocker.AsyncMock(),
)
mocker.patch(
"lnbits.core.services.extensions.delete_old_wasm_invocations",
mocker.AsyncMock(),
)
invocation = await start_wasm_invocation(
extension_id="demoext",
export_name="render",
trigger_type="http",
method="POST",
path="/api/v1/ext/demoext/run",
context={"origin": "https://example.com"},
)
store = SimpleNamespace(deadline=None)
store.set_epoch_deadline = lambda deadline: setattr(store, "deadline", deadline)
engine = SimpleNamespace(increments=0)
def increment_epoch():
engine.increments += 1
engine.increment_epoch = increment_epoch
attach_wasm_invocation_runtime(invocation.id, engine=engine, store=store)
record_wasm_invocation_host_call(invocation.id, "http.request")
record_wasm_invocation_host_call(invocation.id, "storage.get")
assert await stop_wasm_invocation(invocation.id, reason="test stop") is True
current = get_current_wasm_invocations()
assert current[0].status == "stopping"
assert store.deadline == 1
assert engine.increments == 1
await finish_wasm_invocation(invocation.id, status="failed")
assert update_mock.await_args is not None
saved = update_mock.await_args.args[0]
assert saved.status == "stopped"
assert saved.stop_reason == "test stop"
assert saved.host_call_count == 2
assert saved.http_call_count == 1
assert saved.storage_call_count == 1
def _reset_wasm_invocation_state():
with extension_services._wasm_invocation_lock:
extension_services._wasm_invocation_handles.clear()
extension_services._wasm_invocations_marked_stale = False
extension_services._wasm_invocations_last_cleanup_at = None
def test_wasm_runtime_limits_merge_sparse_extension_overrides(settings: Settings):
original_execution_ms = settings.wasm_runtime_max_execution_ms
original_memory_bytes = settings.wasm_runtime_max_memory_bytes
try:
settings.wasm_runtime_max_execution_ms = 5_000
settings.wasm_runtime_max_memory_bytes = 64 * 1024 * 1024
extension = InstallableExtension(
id="wasm_demo",
name="WASM Demo",
version="1.0.0",
wasm_runtime_limits={
"wasm_runtime_max_execution_ms": 20_000,
"wasm_runtime_max_fuel": 0,
},
)
limits = extension_services.resolve_wasm_runtime_limits(extension)
assert limits["wasm_runtime_max_execution_ms"] == 20_000
assert limits["wasm_runtime_max_fuel"] == 0
assert limits["wasm_runtime_max_memory_bytes"] == 64 * 1024 * 1024
finally:
settings.wasm_runtime_max_execution_ms = original_execution_ms
settings.wasm_runtime_max_memory_bytes = original_memory_bytes
def test_wasm_runtime_limit_override_validation():
assert extension_services.validate_wasm_runtime_limit_overrides(
{
"wasm_runtime_max_execution_ms": "7000",
"wasm_runtime_max_fuel": 0,
"wasm_runtime_max_memory_bytes": "",
}
) == {
"wasm_runtime_max_execution_ms": 7000,
"wasm_runtime_max_fuel": 0,
}
with pytest.raises(ValueError, match="Unknown WASM runtime limit field"):
extension_services.validate_wasm_runtime_limit_overrides({"unknown": 1})
with pytest.raises(ValueError, match="cannot be negative"):
extension_services.validate_wasm_runtime_limit_overrides(
{"wasm_runtime_max_execution_ms": -1}
)
with pytest.raises(ValueError, match="must be an integer"):
extension_services.validate_wasm_runtime_limit_overrides(
{"wasm_runtime_max_execution_ms": True}
)
with pytest.raises(ValueError, match="must be an integer"):
extension_services.validate_wasm_runtime_limit_overrides(
{"wasm_runtime_max_execution_ms": 1.5}
)
@pytest.mark.anyio
async def test_update_wasm_extension_runtime_limits_saves_sparse_overrides(
tmp_path,
settings: Settings,
mocker: MockerFixture,
):
ext_id = "wasm_demo"
original_extensions_path = settings.lnbits_extensions_path
try:
settings.lnbits_extensions_path = str(tmp_path)
config_dir = tmp_path / "extensions" / ext_id
config_dir.mkdir(parents=True)
(config_dir / "config.json").write_text(
'{"extension_type": "wasm"}',
encoding="utf-8",
)
installed_extension = InstallableExtension(
id=ext_id,
name="WASM Demo",
version="1.0.0",
)
mocker.patch(
"lnbits.core.services.extensions.get_installed_extension",
mocker.AsyncMock(return_value=installed_extension),
)
update_mock = mocker.patch(
"lnbits.core.services.extensions."
"update_installed_extension_wasm_runtime_limits",
mocker.AsyncMock(),
)
saved_limits = await extension_services.update_wasm_extension_runtime_limits(
ext_id,
{
"wasm_runtime_max_execution_ms": "15000",
"wasm_runtime_max_fuel": 0,
"wasm_runtime_max_memory_bytes": "",
},
)
finally:
settings.lnbits_extensions_path = original_extensions_path
assert saved_limits == {
"wasm_runtime_max_execution_ms": 15000,
"wasm_runtime_max_fuel": 0,
}
update_mock.assert_awaited_once_with(ext_id=ext_id, limits=saved_limits)
@pytest.mark.anyio
async def test_wasm_invocation_concurrency_limits(mocker: MockerFixture):
_reset_wasm_invocation_state()
mocker.patch(
"lnbits.core.services.extensions.create_wasm_invocation",
mocker.AsyncMock(),
)
mocker.patch(
"lnbits.core.services.extensions.update_wasm_invocation",
mocker.AsyncMock(),
)
mocker.patch(
"lnbits.core.services.extensions.get_wasm_invocation",
mocker.AsyncMock(return_value=None),
)
mocker.patch(
"lnbits.core.services.extensions.mark_stale_wasm_invocations",
mocker.AsyncMock(),
)
mocker.patch(
"lnbits.core.services.extensions.delete_old_wasm_invocations",
mocker.AsyncMock(),
)
limits = extension_services.wasm_runtime_limit_defaults()
limits.update(
{
"wasm_runtime_max_concurrent_invocations": 1,
"wasm_runtime_max_concurrent_invocations_per_extension": 1,
"wasm_runtime_max_concurrent_invocations_per_user": 1,
}
)
invocation = await start_wasm_invocation(
extension_id="demoext",
export_name="render",
user_id="user-id",
runtime_limits=limits,
)
with pytest.raises(ValueError, match="too many active invocations"):
await start_wasm_invocation(
extension_id="demoext",
export_name="render",
user_id="user-id",
runtime_limits=limits,
)
await finish_wasm_invocation(invocation.id, status="completed")
@pytest.mark.anyio
async def test_wasm_invocation_host_call_limits(mocker: MockerFixture):
_reset_wasm_invocation_state()
mocker.patch(
"lnbits.core.services.extensions.create_wasm_invocation",
mocker.AsyncMock(),
)
mocker.patch(
"lnbits.core.services.extensions.update_wasm_invocation",
mocker.AsyncMock(),
)
mocker.patch(
"lnbits.core.services.extensions.get_wasm_invocation",
mocker.AsyncMock(return_value=None),
)
mocker.patch(
"lnbits.core.services.extensions.mark_stale_wasm_invocations",
mocker.AsyncMock(),
)
mocker.patch(
"lnbits.core.services.extensions.delete_old_wasm_invocations",
mocker.AsyncMock(),
)
limits = extension_services.wasm_runtime_limit_defaults()
limits["wasm_runtime_max_host_calls"] = 1
invocation = await start_wasm_invocation(
extension_id="demoext",
export_name="render",
runtime_limits=limits,
)
record_wasm_invocation_host_call(invocation.id, "http.request")
with pytest.raises(ValueError, match="host call limit"):
record_wasm_invocation_host_call(invocation.id, "storage.get")
await finish_wasm_invocation(invocation.id, status="failed")
@pytest.mark.anyio
async def test_start_extension_background_work_handles_missing_and_sync_starts(
mocker: MockerFixture,
+223
View File
@@ -0,0 +1,223 @@
import json
from pathlib import Path
from typing import Any
import pytest
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.settings import Settings
from tests.helpers import make_installable_extension
def test_load_wasm_extension_rejects_missing_config_id(
tmp_path: Path, settings: Settings
):
ext_id = "demoext"
_write_wasm_extension(settings, tmp_path, ext_id, config_id=None)
with pytest.raises(ValueError, match="config must define id"):
load_wasm_extension(ext_id)
def test_load_wasm_extension_rejects_mismatched_config_id(
tmp_path: Path, settings: Settings
):
ext_id = "demoext"
_write_wasm_extension(settings, tmp_path, ext_id, config_id="otherext")
with pytest.raises(ValueError, match="id mismatch"):
load_wasm_extension(ext_id)
def test_load_wasm_extension_uses_canonical_extension_id(
tmp_path: Path, settings: Settings
):
ext_id = "demoext"
_write_wasm_extension(settings, tmp_path, ext_id, config_id=ext_id)
extension = load_wasm_extension(ext_id)
assert extension.id == ext_id
def test_wasm_extension_config_ignores_unknown_fields():
config = _wasm_config("demoext")
config["unexpected"] = True
parsed = parse_wasm_extension_config("demoext", config)
assert not hasattr(parsed, "unexpected")
def test_wasm_extension_config_rejects_coerced_scalar_types():
config = _wasm_config("demoext")
config["wasm"] = {"module": 123}
with pytest.raises(ValueError, match="str type expected"):
parse_wasm_extension_config("demoext", config)
@pytest.mark.parametrize(
"config_update",
[
{"wasm": {"module": "extension.wasm", "host_api": "custom.HostAPI"}},
{
"wasm": {
"module": "extension.wasm",
"resource_limits": {"max_response_bytes": 1024},
}
},
{"build": {"source": "dev", "command": "npm run build"}},
],
)
def test_wasm_extension_config_ignores_removed_extension_control_fields(
config_update: dict[str, Any],
):
config = _wasm_config("demoext")
config.update(config_update)
parsed = parse_wasm_extension_config("demoext", config)
assert parsed.wasm.module == "extension.wasm"
assert not hasattr(parsed.wasm, "host_api")
assert not hasattr(parsed.wasm, "resource_limits")
assert not hasattr(parsed, "build")
def test_wasm_extension_config_accepts_supported_optional_sections():
config = _wasm_config("demoext")
config.update(
{
"tile": "static/icon.png",
"min_lnbits_version": "1.0.0",
"max_lnbits_version": "2.0.0",
"wasm": {
"module": "wasm/module.wasm",
"wit": "wasm/lnbits-extension.wit",
"world": "lnbits-extension",
"exports": [
{"name": "render", "visibility": "public"},
{"name": "on_invoice_paid", "visibility": "event"},
],
},
"events": {"onInvoicePaid": "on_invoice_paid"},
"ui": {"entrypoint": "static/index.html", "sandbox": True},
"sdk": {"frontend_js": "static/lnbits-extension-sdk.js"},
"ui_routes": [
{
"path": "/demo/{item_id}",
"entrypoint": "static/index.html",
"auth": "user",
"path_params": {"item_id": "str"},
}
],
"api_routes": [
{
"method": "GET",
"path": "/api/demo/{item_id}",
"export": "render",
"auth": "public",
"path_params": {"item_id": "str"},
}
],
"permissions": [{"id": "utils.basic", "description": "Basic utils"}],
}
)
parsed = parse_wasm_extension_config("demoext", config)
assert parsed.events.on_invoice_paid == "on_invoice_paid"
assert parsed.wasm.world == "lnbits-extension"
def test_wasm_extension_config_ignores_unknown_permission_fields():
config = _wasm_config("demoext")
config["permissions"] = [
{"id": "utils.basic", "label": "Basic utilities", "unknown": True}
]
parsed = parse_wasm_extension_config("demoext", config)
assert parsed.permissions == [ExtensionPermission(id="utils.basic")]
def test_install_time_permission_validation_rejects_config_id_mismatch():
ext_info = make_installable_extension("demoext")
extension_config = {
"id": "otherext",
"extension_type": "wasm",
"permissions": [{"id": "utils.basic"}],
}
with pytest.raises(ValueError, match="id mismatch"):
validate_wasm_extension_permissions(
ext_info,
[ExtensionPermission(id="utils.basic")],
extension_config,
)
def test_wasm_extension_registry_rejects_same_id_from_different_root(tmp_path: Path):
registry = WasmExtensionRegistry()
first = _wasm_extension("demoext", tmp_path / "one")
second_same_root = _wasm_extension("demoext", tmp_path / "one")
second_different_root = _wasm_extension("demoext", tmp_path / "two")
registry.register(first)
registry.register(second_same_root)
with pytest.raises(ValueError, match="already registered"):
registry.register(second_different_root)
def _write_wasm_extension(
settings: Settings,
tmp_path: Path,
ext_id: str,
*,
config_id: str | None,
) -> None:
settings.lnbits_extensions_path = str(tmp_path)
ext_dir = tmp_path / "extensions" / ext_id
ext_dir.mkdir(parents=True)
(ext_dir / "extension.wasm").write_bytes(b"\0asm")
config = {
"name": "Demo",
"short_description": "Demo extension",
"version": "1.0.0",
"extension_type": "wasm",
"wasm": {"module": "extension.wasm"},
}
if config_id is not None:
config["id"] = config_id
(ext_dir / "config.json").write_text(json.dumps(config), encoding="utf-8")
def _wasm_extension(ext_id: str, root_path: Path) -> WasmExtension:
config = parse_wasm_extension_config(ext_id, _wasm_config(ext_id))
return WasmExtension(
id=ext_id,
name=ext_id,
version="1.0.0",
root_path=root_path,
module_path=root_path / "extension.wasm",
wit_path=None,
world="",
exports=[],
config=config,
)
def _wasm_config(ext_id: str) -> dict[str, Any]:
return {
"id": ext_id,
"name": ext_id,
"short_description": "Demo extension",
"version": "1.0.0",
"extension_type": "wasm",
"wasm": {"module": "extension.wasm"},
}
@@ -0,0 +1,210 @@
from types import SimpleNamespace
from typing import Any, cast
import pytest
from pytest_mock.plugin import MockerFixture
from lnbits.core.models.extensions import ExtensionPermission
from lnbits.core.wasm_ext.api.permissions import validate_wasm_extension_permissions
from lnbits.core.wasm_ext.wasm.events import _wasm_invoice_paid_owner_id
from lnbits.core.wasm_ext.wasm.invoke import _active_installed_extension
from tests.helpers import make_installable_extension
def test_validate_wasm_permissions_rejects_broader_policy_grant():
ext_info = make_installable_extension("demoext")
extension_config = _wasm_config(
"demoext",
[
{
"id": "http.request",
"policies": [{"host": "https://api.example.com"}],
}
],
)
with pytest.raises(ValueError, match="broader policies"):
validate_wasm_extension_permissions(
ext_info,
[
ExtensionPermission(
id="http.request",
policies=[
{"host": "https://api.example.com"},
{"host": "https://evil.example.com"},
],
)
],
extension_config,
)
def test_validate_wasm_permissions_stores_narrower_policy_grant():
ext_info = make_installable_extension("demoext")
extension_config = _wasm_config(
"demoext",
[
{
"id": "ext.storage.read_public",
"description": "Read public storage.",
"policies": [
{
"table_name": "tip_jars",
"public_fields": ["id", "title", "description"],
}
],
}
],
)
permissions = validate_wasm_extension_permissions(
ext_info,
[
ExtensionPermission(
id="ext.storage.read_public",
policies=[
{
"table_name": "tip_jars",
"public_fields": ["id", "title"],
}
],
)
],
extension_config,
)
assert permissions == [
ExtensionPermission(
id="ext.storage.read_public",
description="Read public storage.",
policies=[
{
"table_name": "tip_jars",
"public_fields": ["id", "title"],
}
],
)
]
def test_validate_wasm_permissions_rejects_broader_extension_api_access():
ext_info = make_installable_extension("demoext")
extension_config = _wasm_config(
"demoext",
[
{
"id": "extension.api.request",
"policies": [{"id": "targetext", "access": ["read"]}],
}
],
)
with pytest.raises(ValueError, match="broader policies"):
validate_wasm_extension_permissions(
ext_info,
[
ExtensionPermission(
id="extension.api.request",
policies=[{"id": "targetext", "access": ["read", "write"]}],
)
],
extension_config,
)
def test_validate_wasm_permissions_allows_empty_grant():
ext_info = make_installable_extension("demoext")
extension_config = _wasm_config(
"demoext",
[
{
"id": "wallet.create_invoice_public",
"policies": [{"table": "tip_jars", "wallet_field": "wallet_id"}],
}
],
)
assert validate_wasm_extension_permissions(ext_info, [], extension_config) == []
def test_validate_wasm_permissions_rejects_unrequested_permission_grant():
ext_info = make_installable_extension("demoext")
extension_config = _wasm_config("demoext", [{"id": "utils.basic"}])
with pytest.raises(ValueError, match="unrequested permissions"):
validate_wasm_extension_permissions(
ext_info,
[
ExtensionPermission(id="utils.basic"),
ExtensionPermission(id="wallet.list"),
],
extension_config,
)
@pytest.mark.anyio
async def test_invoice_paid_owner_lookup_uses_stored_granted_policies(
mocker: MockerFixture,
):
extension = SimpleNamespace(
id="demoext",
config=_wasm_config(
"demoext",
[
{
"id": "wallet.create_invoice_public",
"policies": [
{"table": "requested_table", "wallet_field": "wallet_id"}
],
}
],
),
)
payment = SimpleNamespace(extra={"source_id": "source-1"})
installed_extension = SimpleNamespace(
permissions=[
ExtensionPermission(
id="wallet.create_invoice_public",
policies=[{"table": "granted_table", "wallet_field": "wallet_id"}],
)
]
)
mocker.patch(
"lnbits.core.wasm_ext.wasm.events.get_installed_extension",
mocker.AsyncMock(return_value=installed_extension),
)
storage_mock = mocker.patch(
"lnbits.core.wasm_ext.wasm.events.storage_get_row_owner_id",
mocker.AsyncMock(return_value="owner-1"),
)
owner_id = await _wasm_invoice_paid_owner_id(extension, payment)
assert owner_id == "owner-1"
storage_mock.assert_awaited_once_with("demoext", "granted_table", "source-1")
@pytest.mark.anyio
async def test_wasm_invocation_requires_installed_active_extension(
mocker: MockerFixture,
):
extension = SimpleNamespace(id="demoext")
mocker.patch(
"lnbits.core.wasm_ext.wasm.invoke.get_installed_extension",
mocker.AsyncMock(return_value=None),
)
with pytest.raises(PermissionError, match="deactivated"):
await _active_installed_extension(cast(Any, extension))
def _wasm_config(ext_id: str, permissions: list[dict]) -> dict:
return {
"id": ext_id,
"name": ext_id,
"short_description": "Demo extension",
"version": "1.0.0",
"extension_type": "wasm",
"wasm": {"module": "extension.wasm"},
"permissions": permissions,
}