diff --git a/api/addons/install.py b/api/addons/install.py index 1260c801..56a2e4c3 100644 --- a/api/addons/install.py +++ b/api/addons/install.py @@ -1,4 +1,3 @@ -import hashlib from datetime import datetime from typing import Literal @@ -10,6 +9,7 @@ from ayon_server.constraints import Constraints from ayon_server.events import dispatch_event, update_event from ayon_server.exceptions import ForbiddenException +from ayon_server.helpers.download_addon import download_addon from ayon_server.installer import background_installer from ayon_server.installer.addons import get_addon_zip_info from ayon_server.lib.postgres import Postgres @@ -43,39 +43,7 @@ async def upload_addon_zip_file( raise ForbiddenException("Only admins can install addons") if url: - hash = hashlib.sha256(f"addon_install_{url}".encode()).hexdigest() - - query = """ - SELECT id FROM events - WHERE topic = 'addon.install_from_url' - AND hash = $1 - """ - - summary = {"url": url} - if addonName and addonVersion: - summary["name"] = addonName - summary["version"] = addonVersion - - res = await Postgres.fetch(query, hash) - if res: - event_id = res[0]["id"] - await update_event( - event_id, - description="Reinstalling addon from URL", - summary=summary, - status="pending", - ) - else: - event_id = await dispatch_event( - "addon.install_from_url", - hash=hash, - description="Installing addon from URL", - summary=summary, - user=user.name, - finished=False, - ) - - await background_installer.enqueue(event_id) + event_id = await download_addon(url, addonName, addonVersion) return InstallAddonResponseModel(event_id=event_id) # Store the zip file in a temporary location diff --git a/api/bundles/migration.py b/api/bundles/migration.py index 46c1c90c..04913932 100644 --- a/api/bundles/migration.py +++ b/api/bundles/migration.py @@ -1,15 +1,11 @@ __all__ = ["migrate_settings"] -from typing import Any - from nxtools import logging -from ayon_server.addons.addon import BaseServerAddon from ayon_server.addons.library import AddonLibrary -from ayon_server.config import ayonconfig from ayon_server.events import EventStream from ayon_server.exceptions import NotFoundException -from ayon_server.helpers.project_list import get_project_list +from ayon_server.helpers.migrate_addon_settings import migrate_addon_settings from ayon_server.lib.postgres import Connection, Postgres AddonVersionsDict = dict[str, str] @@ -51,263 +47,6 @@ async def _get_bundles_addons( return source_addons, target_addons -async def _migrate_addon_settings( - source_addon: BaseServerAddon, - target_addon: BaseServerAddon, - source_variant: str, - target_variant: str, - with_projects: bool, - conn: Connection, -) -> list[dict[str, Any]]: - """Migrate settings from source to target addon. - - Returns a list of events that were created during migration. - """ - - # Studio settings - - # Load studio settings from source addon converted to the target version model - new_studio_overrides: dict[str, Any] - new_studio_overrides = await source_addon.get_studio_overrides( - variant=source_variant, - as_version=target_addon.version, - ) - - events: list[dict[str, Any]] = [] - event_head = f"{target_addon.name} {target_addon.version} {target_variant}" - - event_created = False - event_payload = {} - - if new_studio_overrides: - # fetch the original studio settings - res = await conn.fetch( - """ - SELECT data FROM settings - WHERE addon_name = $1 AND addon_version = $2 AND variant = $3 - """, - target_addon.name, - target_addon.version, - target_variant, - ) - - do_copy = False - - if res: - original_data = res[0]["data"] - if original_data != new_studio_overrides: - do_copy = True - if ayonconfig.audit_trail: - event_payload["originalValue"] = original_data - event_payload["newValue"] = new_studio_overrides - else: - do_copy = True - if ayonconfig.audit_trail: - event_payload["originalValue"] = {} - event_payload["newValue"] = new_studio_overrides - - if do_copy: - event_created = True - event_description = "studio overrides changed during migration" - - await conn.execute( - """ - INSERT INTO settings (addon_name, addon_version, variant, data) - VALUES ($1, $2, $3, $4) - ON CONFLICT (addon_name, addon_version, variant) - DO UPDATE SET data = $4 - """, - target_addon.name, - target_addon.version, - target_variant, - new_studio_overrides, - ) - else: - res = await conn.fetch( - """ - DELETE FROM settings - WHERE addon_name = $1 AND addon_version = $2 AND variant = $3 - RETURNING data - """, - target_addon.name, - target_addon.version, - target_variant, - ) - if res: - event_created = True - event_description = "studio overrides removed during migration" - if ayonconfig.audit_trail: - event_payload = {"originalValue": res[0]["data"], "newValue": {}} - - if event_created: - events.append( - { - "description": f"{event_head} {event_description}", - "summary": { - "addon_name": target_addon.name, - "addon_version": target_addon.version, - "variant": target_variant, - }, - "payload": event_payload, - } - ) - - if not with_projects: - return events - - # Project settings - - project_names = [project.name for project in await get_project_list()] - - for project_name in project_names: - event_created = False - event_payload = {} - - # Load project settings from source addon converted to the target version model - new_project_overrides: dict[str, Any] - new_project_overrides = await source_addon.get_project_overrides( - project_name=project_name, - variant=source_variant, - as_version=target_addon.version, - ) - - if new_project_overrides: - # fetch the original project settings - res = await conn.fetch( - f""" - SELECT data - FROM project_{project_name}.settings - WHERE addon_name = $1 AND addon_version = $2 AND variant = $3 - """, - target_addon.name, - target_addon.version, - target_variant, - ) - - do_copy = False - - if res: - original_data = res[0]["data"] - if original_data != new_project_overrides: - do_copy = True - if ayonconfig.audit_trail: - event_payload["originalValue"] = original_data - event_payload["newValue"] = new_project_overrides - else: - do_copy = True - if ayonconfig.audit_trail: - event_payload["originalValue"] = {} - event_payload["newValue"] = new_project_overrides - - if do_copy: - event_created = True - event_description = "project overrides changed during migration" - - await conn.execute( - f""" - INSERT INTO project_{project_name}.settings - (addon_name, addon_version, variant, data) - VALUES ($1, $2, $3, $4) - ON CONFLICT (addon_name, addon_version, variant) - DO UPDATE SET data = $4 - """, - target_addon.name, - target_addon.version, - target_variant, - new_project_overrides, - ) - else: - res = await conn.fetch( - f""" - DELETE FROM project_{project_name}.settings - WHERE addon_name = $1 AND addon_version = $2 AND variant = $3 - RETURNING data - """, - target_addon.name, - target_addon.version, - target_variant, - ) - - if res: - event_created = True - event_description = "project overrides removed during migration" - if ayonconfig.audit_trail: - event_payload = {"originalValue": res[0]["data"], "newValue": {}} - - if event_created: - events.append( - { - "description": f"{event_head}: {event_description}", - "summary": { - "addon_name": target_addon.name, - "addon_version": target_addon.version, - "variant": target_variant, - }, - "project": project_name, - "payload": event_payload, - } - ) - - # Project site settings - - site_info = await conn.fetch( - f""" - SELECT site_id, user_name, data - FROM project_{project_name}.project_site_settings - WHERE addon_name = $1 AND addon_version = $2 - """, - source_addon.name, - source_addon.version, - ) - for row in site_info: - if not row["data"]: - continue - site_id, user_name = row["site_id"], row["user_name"] - - # Load project site settings from source addon - # converted to the target version model - - new_site_overrides: dict[str, Any] - new_site_overrides = await source_addon.get_project_site_overrides( - project_name=project_name, - site_id=site_id, - user_name=user_name, - as_version=target_addon.version, - ) - - if new_site_overrides: - await conn.execute( - f""" - INSERT INTO project_{project_name}.project_site_settings - (addon_name, addon_version, site_id, user_name, data) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (addon_name, addon_version, site_id, user_name) - DO UPDATE SET data = $5 - """, - target_addon.name, - target_addon.version, - site_id, - user_name, - new_site_overrides, - ) - else: - await conn.execute( - f""" - DELETE FROM project_{project_name}.project_site_settings - WHERE addon_name = $1 - AND addon_version = $2 - AND site_id = $3 - AND user_name = $4 - """, - target_addon.name, - target_addon.version, - site_id, - user_name, - ) - - return events - - async def _migrate_settings_by_bundle( source_bundle: str, target_bundle: str, @@ -353,7 +92,7 @@ async def _migrate_settings_by_bundle( # perform migration of addon settings - events = await _migrate_addon_settings( + events = await migrate_addon_settings( source_addon, target_addon, source_variant, diff --git a/ayon_server/background/auto_update.py b/ayon_server/background/auto_update.py new file mode 100644 index 00000000..5df90e33 --- /dev/null +++ b/ayon_server/background/auto_update.py @@ -0,0 +1,169 @@ +import asyncio +import time + +import httpx +from nxtools import logging + +from ayon_server.addons.library import AddonLibrary +from ayon_server.background.background_worker import BackgroundWorker +from ayon_server.config import ayonconfig +from ayon_server.exceptions import NotFoundException +from ayon_server.helpers.cloud import get_cloud_api_headers +from ayon_server.helpers.download_addon import download_addon +from ayon_server.helpers.get_downloaded_addons import get_downloaded_addons +from ayon_server.helpers.migrate_addon_settings import migrate_addon_settings +from ayon_server.lib.postgres import Postgres +from ayon_server.version import __version__ as ayon_version + + +async def get_required_addons() -> list[dict[str, str]]: + url = f"{ayonconfig.ynput_cloud_api_url}/api/v1/me" + headers = await get_cloud_api_headers() + headers["X-Ayon-Version"] = ayon_version + try: + async with httpx.AsyncClient() as client: + response = await client.get(url, headers=headers) + data = response.json() + return data.get("requiredAddons", []) + except Exception: + logging.debug("Failed to fetch required addons list") + return [] + + +async def get_download_url(addon_name: str, addon_version: str) -> str: + headers = await get_cloud_api_headers() + headers["X-Ayon-Version"] = ayon_version + + async with httpx.AsyncClient(timeout=ayonconfig.http_timeout) as client: + endpoint = f"addons/{addon_name}/{addon_version}" + res = await client.get( + f"{ayonconfig.ynput_cloud_api_url}/api/v1/market/{endpoint}", + headers=headers, + ) + data = res.json() + return data["url"] + + +async def run_auto_update() -> None: + required_addons = await get_required_addons() + + if not required_addons: + return + + addon_library = AddonLibrary.getinstance() + + bundle_addons_patch = {} + + downloaded_addons = get_downloaded_addons() + for addon_name, addon_version in required_addons: + # Do we have the required addon downloaded? + + if (addon_name, addon_version) not in downloaded_addons: + try: + url = await get_download_url(addon_name, addon_version) + except Exception: + logging.debug( + f"Failed to get download URL for {addon_name} {addon_version}" + ) + continue + + await download_addon( + addon_name=addon_name, + addon_version=addon_version, + url=url, + ) + # Just download the addon. After restart, we'll be able to continue + continue + + # Addon is downloaded. Check if it's active + + try: + addon = addon_library.addon(addon_name, addon_version) + except NotFoundException: + # Addon is downloaded, but not active. Server restart is needed + continue + + # Addon is active. Check if it's in the production bundle + + if await addon.is_production(): + # Addon is active and in production we don't need to do anything + continue + + logging.debug( + f"Required addon {addon_name} {addon_version} is not in production" + ) + + # Get the current production version of the addon + + production_addon = await addon_library.get_production_addon(addon_name) + if production_addon is not None: + # There is a different version of the addon in production + + logging.debug( + f"Migrating {addon_name} settings " + f" from {addon_version} to {production_addon.version}" + ) + await migrate_addon_settings( + source_addon=production_addon, + target_addon=addon, + source_variant="production", + target_variant="production", + ) + + bundle_addons_patch[addon_name] = addon_version + + if not bundle_addons_patch: + return + + # Get the current production bundle + + q = "SELECT name, data FROM bundles WHERE is_production = TRUE" + production_bundle = await Postgres.fetchrow(q) + if production_bundle: + data = production_bundle["data"] + data["addons"].update(bundle_addons_patch) + + q = "UPDATE bundles SET data = $1 WHERE name = $2" + await Postgres.execute(q, data, production_bundle["name"]) + + for addon_name, addon_version in bundle_addons_patch.items(): + logging.info(f"Updated production bundle with {addon_name} {addon_version}") + + else: + ts = int(time.time()) + bundle_name = f"default_bundle_{ts}" + bundle_data = { + "addons": bundle_addons_patch, + "dependency_packages": {}, + "installer_version": None, + } + q = "INSERT INTO bundles (name, data, is_production) VALUES ($1, $2, TRUE)" + await Postgres.execute(q, bundle_name, bundle_data) + + for addon_name, addon_version in bundle_addons_patch.items(): + logging.info(f"Created production bundle with {addon_name} {addon_version}") + + +class AutoUpdate(BackgroundWorker): + """Auto-update server addons""" + + async def run(self): + await asyncio.sleep(20) + + while True: + try: + _ = get_cloud_api_headers() + except Exception: + # Not connected to Ynput cloud + # Do nothing + await asyncio.sleep(3600) + continue + + try: + await run_auto_update() + except Exception: + pass + await asyncio.sleep(3600) + + +auto_update = AutoUpdate() diff --git a/ayon_server/background/workers.py b/ayon_server/background/workers.py index 7ac84f2b..95e02715 100644 --- a/ayon_server/background/workers.py +++ b/ayon_server/background/workers.py @@ -1,5 +1,6 @@ from ayon_server.installer import background_installer +from .auto_update import auto_update from .background_worker import BackgroundWorker from .clean_up import clean_up from .log_collector import log_collector @@ -9,6 +10,7 @@ class BackgroundWorkers: def __init__(self): self.tasks: list[BackgroundWorker] = [ + auto_update, background_installer, log_collector, metrics_collector, diff --git a/ayon_server/helpers/download_addon.py b/ayon_server/helpers/download_addon.py new file mode 100644 index 00000000..05a42423 --- /dev/null +++ b/ayon_server/helpers/download_addon.py @@ -0,0 +1,61 @@ +import hashlib + +from nxtools import logging + +from ayon_server.constraints import Constraints +from ayon_server.events import EventStream +from ayon_server.exceptions import ForbiddenException +from ayon_server.installer import background_installer +from ayon_server.lib.postgres import Postgres + + +async def download_addon( + url: str, + addon_name: str | None = None, + addon_version: str | None = None, +) -> str: + if ( + allow_custom_addons := await Constraints.check("allowCustomAddons") + ) is not None: + if not allow_custom_addons: + allowed_prefixes = ["https://download.ynput.cloud"] + if not any(url.startswith(prefix) for prefix in allowed_prefixes): + raise ForbiddenException("Custom addons uploads are not allowed") + + hash = hashlib.sha256(f"addon_install_{url}".encode()).hexdigest() + + query = """ + SELECT id FROM events + WHERE topic = 'addon.install_from_url' + AND hash = $1 + """ + + summary = {"url": url} + if addon_name and addon_version: + summary["name"] = addon_name + summary["version"] = addon_version + + res = await Postgres.fetch(query, hash) + if res: + event_id = res[0]["id"] + await EventStream.update( + event_id, + description="Reinstalling addon from URL", + summary=summary, + status="pending", + ) + else: + event_id = await EventStream.dispatch( + "addon.install_from_url", + hash=hash, + description="Installing addon from URL", + summary=summary, + finished=False, + ) + + url_label = url[:50] + if url_label != url: + url_label += "..." + logging.debug(f"Downloading addon from {url_label}") + await background_installer.enqueue(event_id) + return event_id diff --git a/ayon_server/helpers/get_downloaded_addons.py b/ayon_server/helpers/get_downloaded_addons.py new file mode 100644 index 00000000..202f5409 --- /dev/null +++ b/ayon_server/helpers/get_downloaded_addons.py @@ -0,0 +1,54 @@ +import os + +import yaml + +from ayon_server.addons.utils import import_module +from ayon_server.config import ayonconfig + + +def get_addon_from_dir(addon_dir: str) -> tuple[str, str]: + """Return the addon name and version from the directory name""" + package_path = os.path.join(addon_dir, "package.py") + dirname = os.path.basename(addon_dir) + metadata = {} + if os.path.exists(package_path): + package_module_name = f"{dirname}-package" + package_module = import_module(package_module_name, package_path) + for key in ("name", "version"): + if hasattr(package_module, key): + metadata[key] = getattr(package_module, key) + + elif os.path.exists(os.path.join(addon_dir, "package.yml")): + with open(os.path.join(addon_dir, "package.yml")) as f: + metadata = yaml.safe_load(f) + + elif os.path.exists(os.path.join(addon_dir, "package.yaml")): + with open(os.path.join(addon_dir, "package.yaml")) as f: + metadata = yaml.safe_load(f) + + if "name" in metadata and "version" in metadata: + return metadata["name"], metadata["version"] + raise ValueError(f"Addon {dirname} is missing name or version") + + +def get_downloaded_addons() -> list[tuple[str, str]]: + """Return a list of all downloaded addons + + Returns a list of (addon_name, addon_version) tuples + regardless they are active or not. + """ + result = [] + for addon_name in os.listdir(ayonconfig.addons_dir): + addon_dir = os.path.join(ayonconfig.addons_dir, addon_name) + if not os.path.isdir(addon_dir): + continue + for addon_version in os.listdir(addon_dir): + addon_version_dir = os.path.join(addon_dir, addon_version) + if not os.path.isdir(addon_version_dir): + continue + try: + result.append(get_addon_from_dir(addon_version_dir)) + except ValueError: + pass + + return result diff --git a/ayon_server/helpers/migrate_addon_settings.py b/ayon_server/helpers/migrate_addon_settings.py new file mode 100644 index 00000000..bc2315cd --- /dev/null +++ b/ayon_server/helpers/migrate_addon_settings.py @@ -0,0 +1,300 @@ +from typing import TYPE_CHECKING, Any, Optional + +from ayon_server.config import ayonconfig +from ayon_server.helpers.project_list import get_project_list +from ayon_server.lib.postgres import Postgres + +if TYPE_CHECKING: + from ayon_server.addons.addon import BaseServerAddon + from ayon_server.lib.postgres import Connection + + +async def _migrate_addon_settings( + source_addon: "BaseServerAddon", + target_addon: "BaseServerAddon", + source_variant: str, + target_variant: str, + with_projects: bool, + conn: "Connection", +) -> list[dict[str, Any]]: + """Migrate settings from source to target addon. + + Returns a list of events that were created during migration. + """ + + # Studio settings + + # Load studio settings from source addon converted to the target version model + new_studio_overrides: dict[str, Any] + new_studio_overrides = await source_addon.get_studio_overrides( + variant=source_variant, + as_version=target_addon.version, + ) + + events: list[dict[str, Any]] = [] + event_head = f"{target_addon.name} {target_addon.version} {target_variant}" + + event_created = False + event_payload = {} + + if new_studio_overrides: + # fetch the original studio settings + res = await conn.fetch( + """ + SELECT data FROM settings + WHERE addon_name = $1 AND addon_version = $2 AND variant = $3 + """, + target_addon.name, + target_addon.version, + target_variant, + ) + + do_copy = False + + if res: + original_data = res[0]["data"] + if original_data != new_studio_overrides: + do_copy = True + if ayonconfig.audit_trail: + event_payload["originalValue"] = original_data + event_payload["newValue"] = new_studio_overrides + else: + do_copy = True + if ayonconfig.audit_trail: + event_payload["originalValue"] = {} + event_payload["newValue"] = new_studio_overrides + + if do_copy: + event_created = True + event_description = "studio overrides changed during migration" + + await conn.execute( + """ + INSERT INTO settings (addon_name, addon_version, variant, data) + VALUES ($1, $2, $3, $4) + ON CONFLICT (addon_name, addon_version, variant) + DO UPDATE SET data = $4 + """, + target_addon.name, + target_addon.version, + target_variant, + new_studio_overrides, + ) + else: + res = await conn.fetch( + """ + DELETE FROM settings + WHERE addon_name = $1 AND addon_version = $2 AND variant = $3 + RETURNING data + """, + target_addon.name, + target_addon.version, + target_variant, + ) + if res: + event_created = True + event_description = "studio overrides removed during migration" + if ayonconfig.audit_trail: + event_payload = {"originalValue": res[0]["data"], "newValue": {}} + + if event_created: + events.append( + { + "description": f"{event_head} {event_description}", + "summary": { + "addon_name": target_addon.name, + "addon_version": target_addon.version, + "variant": target_variant, + }, + "payload": event_payload, + } + ) + + if not with_projects: + return events + + # Project settings + + project_names = [project.name for project in await get_project_list()] + + for project_name in project_names: + event_created = False + event_payload = {} + + # Load project settings from source addon converted to the target version model + new_project_overrides: dict[str, Any] + new_project_overrides = await source_addon.get_project_overrides( + project_name=project_name, + variant=source_variant, + as_version=target_addon.version, + ) + + if new_project_overrides: + # fetch the original project settings + res = await conn.fetch( + f""" + SELECT data + FROM project_{project_name}.settings + WHERE addon_name = $1 AND addon_version = $2 AND variant = $3 + """, + target_addon.name, + target_addon.version, + target_variant, + ) + + do_copy = False + + if res: + original_data = res[0]["data"] + if original_data != new_project_overrides: + do_copy = True + if ayonconfig.audit_trail: + event_payload["originalValue"] = original_data + event_payload["newValue"] = new_project_overrides + else: + do_copy = True + if ayonconfig.audit_trail: + event_payload["originalValue"] = {} + event_payload["newValue"] = new_project_overrides + + if do_copy: + event_created = True + event_description = "project overrides changed during migration" + + await conn.execute( + f""" + INSERT INTO project_{project_name}.settings + (addon_name, addon_version, variant, data) + VALUES ($1, $2, $3, $4) + ON CONFLICT (addon_name, addon_version, variant) + DO UPDATE SET data = $4 + """, + target_addon.name, + target_addon.version, + target_variant, + new_project_overrides, + ) + else: + res = await conn.fetch( + f""" + DELETE FROM project_{project_name}.settings + WHERE addon_name = $1 AND addon_version = $2 AND variant = $3 + RETURNING data + """, + target_addon.name, + target_addon.version, + target_variant, + ) + + if res: + event_created = True + event_description = "project overrides removed during migration" + if ayonconfig.audit_trail: + event_payload = {"originalValue": res[0]["data"], "newValue": {}} + + if event_created: + events.append( + { + "description": f"{event_head}: {event_description}", + "summary": { + "addon_name": target_addon.name, + "addon_version": target_addon.version, + "variant": target_variant, + }, + "project": project_name, + "payload": event_payload, + } + ) + + # Project site settings + + site_info = await conn.fetch( + f""" + SELECT site_id, user_name, data + FROM project_{project_name}.project_site_settings + WHERE addon_name = $1 AND addon_version = $2 + """, + source_addon.name, + source_addon.version, + ) + for row in site_info: + if not row["data"]: + continue + site_id, user_name = row["site_id"], row["user_name"] + + # Load project site settings from source addon + # converted to the target version model + + new_site_overrides: dict[str, Any] + new_site_overrides = await source_addon.get_project_site_overrides( + project_name=project_name, + site_id=site_id, + user_name=user_name, + as_version=target_addon.version, + ) + + if new_site_overrides: + await conn.execute( + f""" + INSERT INTO project_{project_name}.project_site_settings + (addon_name, addon_version, site_id, user_name, data) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (addon_name, addon_version, site_id, user_name) + DO UPDATE SET data = $5 + """, + target_addon.name, + target_addon.version, + site_id, + user_name, + new_site_overrides, + ) + else: + await conn.execute( + f""" + DELETE FROM project_{project_name}.project_site_settings + WHERE addon_name = $1 + AND addon_version = $2 + AND site_id = $3 + AND user_name = $4 + """, + target_addon.name, + target_addon.version, + site_id, + user_name, + ) + + return events + + +async def migrate_addon_settings( + source_addon: "BaseServerAddon", + target_addon: "BaseServerAddon", + source_variant: str = "production", + target_variant: str = "production", + with_projects: bool = True, + conn: Optional["Connection"] = None, +) -> list[dict[str, Any]]: + """Migrate settings from source to target addon. + + Returns a list of events that were created during migration. + """ + + if conn is None: + async with Postgres.acquire() as conn: + return await _migrate_addon_settings( + source_addon, + target_addon, + source_variant, + target_variant, + with_projects, + conn, + ) + + return await _migrate_addon_settings( + source_addon, + target_addon, + source_variant, + target_variant, + with_projects, + conn, + ) diff --git a/setup/initial_bundle.py b/setup/initial_bundle.py index 8cffd707..f9fa6a83 100644 --- a/setup/initial_bundle.py +++ b/setup/initial_bundle.py @@ -60,6 +60,12 @@ async def create_initial_bundle(bundle_data: dict[str, Any]): log_name = f"{i+1} of {len(addons)}" if addon_url := addon.get("url"): + # Do not use helpers.download_addon here, as we need + # the underlying function and await the download, while + # helpers.download_addon just enqueues the download task + # and finishes immediately. BackgroundInstaller doesn't + # run at this point. + logging.info(f"Installing addon {log_name}") event_id = await EventStream.dispatch( "addon.install_from_url",