diff --git a/tutormfe/hooks.py b/tutormfe/hooks.py index d88c7b44..3ad50c1a 100644 --- a/tutormfe/hooks.py +++ b/tutormfe/hooks.py @@ -10,8 +10,17 @@ from tutor.core.hooks import Filter -MFE_ATTRS_TYPE = t.Dict[t.Literal["repository", "port", "version"], t.Union["str", int]] +MFE_ATTRS_TYPE = t.Dict[ + t.Literal["repository", "port", "version"], t.Union["str", int] +] +FRONTEND_TEMPLATE_SITE_ATTRS_TYPE = t.Dict[ + t.Literal["repository", "version"], t.Union["str", int] +] MFE_APPS: Filter[dict[str, MFE_ATTRS_TYPE], []] = Filter() +# TODO: This will hold the list of which apps are "enabled" so we can switch between mfe +# and frontend-base ones +FRONTEND_APPS: Filter[dict[str, FRONTEND_TEMPLATE_SITE_ATTRS_TYPE], []] = Filter() + PLUGIN_SLOTS: Filter[list[tuple[str, str, str]], []] = Filter() diff --git a/tutormfe/plugin.py b/tutormfe/plugin.py index 798f2c72..fcd0c320 100644 --- a/tutormfe/plugin.py +++ b/tutormfe/plugin.py @@ -5,7 +5,7 @@ from glob import glob import importlib_resources -from tutor import fmt +from tutor import fmt, config as tutor_config from tutor import hooks as tutor_hooks from tutor.__about__ import __version_suffix__ from tutor.bindmount import iter_mounts @@ -13,7 +13,7 @@ from tutor.types import Config, get_typed from .__about__ import __version__ -from .hooks import MFE_APPS, MFE_ATTRS_TYPE, PLUGIN_SLOTS +from .hooks import MFE_APPS, MFE_ATTRS_TYPE, FRONTEND_APPS, FRONTEND_TEMPLATE_SITE_ATTRS_TYPE, PLUGIN_SLOTS # Handle version suffix in main mode, just like tutor core if __version_suffix__: @@ -76,6 +76,11 @@ "repository": "https://github.com/openedx/frontend-app-profile.git", "port": 1995, }, + # "template-site": { + # "repository": "https://github.com/WGU-Open-edX/frontend-template-site.git", + # "version": "initial", + # "port": 8080, + # } } @@ -95,6 +100,49 @@ def get_mfes() -> dict[str, MFE_ATTRS_TYPE]: return MFE_APPS.apply({}) +# List will need +## Apps that are only frontend-apps +## Apps that are only MFEs +## Apps with unique ones (all old mfes + instruct) +## 1 and 2 with 1 having something like a different identifier + + +@tutor_hooks.lru_cache +def get_frontend_apps(apps_to_build: bool = False) -> dict[str, FRONTEND_TEMPLATE_SITE_ATTRS_TYPE]: + """ + This function is cached for performance. + """ + all_frontend_apps = FRONTEND_APPS.apply({}) + + if not apps_to_build: + return all_frontend_apps + + # When returning apps to build we only return the ones that have a repository defined + # (those are the ones to be built) and we prefix the name + # with "frontend-app-" to avoid conflicts with MFE names + return { + f"frontend-app-{name}": attrs + for name, attrs in all_frontend_apps.items() + if "repository" in attrs + } + + +@tutor_hooks.lru_cache +def get_all_apps() -> dict[str, t.Union[MFE_ATTRS_TYPE, FRONTEND_TEMPLATE_SITE_ATTRS_TYPE]]: + """ + This function is cached for performance. + """ + # IMPORTANT: Make a copy to avoid mutating the cached result from get_frontend_apps() + all_apps = get_frontend_apps(apps_to_build=True).copy() + mfes = get_mfes() + all_apps.update(mfes) + + # ensure frontend-template-site is the last one on the all_apps dict + if "template-site" in all_apps: + all_apps["template-site"] = all_apps.pop("template-site") + + return all_apps + class MFEMountData: """Stores categorized mounted and unmounted MFEs.""" @@ -129,6 +177,50 @@ def iter_mfes() -> t.Iterable[tuple[str, MFE_ATTRS_TYPE]]: """ yield from get_mfes().items() +# Iter throgh all mfes and adds the unique frontend apps, +# so we can have a list of all the things that are unique that needs +# to be added to Caddyfile, for example instructor dashboard that was +# created as frontend-base app but didn't exist as a MFE before +# so it returns the whole mfes list plus the unique frontend apps that are not in the mfe list +def iter_unique_apps() -> t.Iterable[tuple[str, FRONTEND_TEMPLATE_SITE_ATTRS_TYPE]]: + """ + Yield: + + (name, dict) + """ + mfes = get_mfes() + frontend_apps = get_frontend_apps() + + # First yield all MFEs + for name, attrs in mfes.items(): + yield (name, attrs) + + # Then yield frontend apps that are not already MFEs + for name, attrs in frontend_apps.items(): + if name not in mfes: + yield (name, attrs) + +# Iters through all apps that will be built +def iter_all_apps() -> t.Iterable[tuple[str, t.Union[MFE_ATTRS_TYPE, FRONTEND_TEMPLATE_SITE_ATTRS_TYPE]]]: + """ + Yield: + + (name, dict) + """ + all_apps = get_all_apps() + for name, attrs in all_apps.items(): + yield (name, attrs) + +def iter_frontend_apps_to_build() -> t.Iterable[tuple[str, FRONTEND_TEMPLATE_SITE_ATTRS_TYPE]]: + """ + Yield: + + (name, dict) + """ + frontend_apps = get_frontend_apps(apps_to_build=True) + for name, attrs in frontend_apps.items(): + yield (name, attrs) + def iter_plugin_slots(mfe_name: str) -> t.Iterable[tuple[str, str]]: """ @@ -142,6 +234,11 @@ def iter_plugin_slots(mfe_name: str) -> t.Iterable[tuple[str, str]]: def is_mfe_enabled(mfe_name: str) -> bool: return mfe_name in get_mfes() +def is_frontend_app_enabled(app_name: str) -> bool: + return app_name in get_frontend_apps() + +def is_frontend_app_to_build(app_name: str) -> bool: + return app_name in get_frontend_apps(apps_to_build=True) def get_mfe(mfe_name: str) -> t.Union[MFE_ATTRS_TYPE, t.Any]: return get_mfes().get(mfe_name, {}) @@ -152,8 +249,13 @@ def get_mfe(mfe_name: str) -> t.Union[MFE_ATTRS_TYPE, t.Any]: [ ("get_mfe", get_mfe), ("iter_mfes", iter_mfes), + ("iter_unique_apps", iter_unique_apps), + ("iter_all_apps", iter_all_apps), + ("iter_frontend_apps_to_build", iter_frontend_apps_to_build), ("iter_plugin_slots", iter_plugin_slots), ("is_mfe_enabled", is_mfe_enabled), + ("is_frontend_app_enabled", is_frontend_app_enabled), + ("is_frontend_app_to_build", is_frontend_app_to_build), ("MFEMountData", MFEMountData), ] ) @@ -217,6 +319,8 @@ def _mounted_mfe_image_management() -> None: tutor_hooks.Filters.CLI_DO_INIT_TASKS.add_item(("lms", task_file.read())) REPO_PREFIX = "frontend-app-" +# TODO: for now leave this and then find better semantic namings +FRONTEND_TEMPLATE_SITE_PREFIX = "frontend-" @tutor_hooks.Filters.COMPOSE_MOUNTS.add() @@ -229,12 +333,12 @@ def _mount_frontend_apps( in dev mode, because in production, all MFEs are built and hosted on the singular 'mfe' service container. """ - if path_basename.startswith(REPO_PREFIX): + if path_basename.startswith(REPO_PREFIX) or path_basename.startswith(FRONTEND_TEMPLATE_SITE_PREFIX): # Assumption: # For each repo named frontend-app-APPNAME, there is an associated # docker-compose service named APPNAME. If this assumption is broken, # then Tutor will try to mount the repo in a service that doesn't exist. - app_name = path_basename[len(REPO_PREFIX) :] + app_name = path_basename[len(REPO_PREFIX) :] if path_basename.startswith(REPO_PREFIX) else path_basename[len(FRONTEND_TEMPLATE_SITE_PREFIX) :] volumes += [(app_name, "/openedx/app")] return volumes @@ -244,9 +348,9 @@ def _mount_frontend_apps_on_build( mounts: list[tuple[str, str]], host_path: str ) -> list[tuple[str, str]]: path_basename = os.path.basename(host_path) - if path_basename.startswith(REPO_PREFIX): + if path_basename.startswith(REPO_PREFIX) or path_basename.startswith(FRONTEND_TEMPLATE_SITE_PREFIX): # Bind-mount repo at build-time, both for prod and dev images - app_name = path_basename[len(REPO_PREFIX) :] + app_name = path_basename[len(REPO_PREFIX) :] if path_basename.startswith(REPO_PREFIX) else path_basename[len(FRONTEND_TEMPLATE_SITE_PREFIX) :] mounts.append(("mfe", f"{app_name}-src")) mounts.append((f"{app_name}-dev", f"{app_name}-src")) return mounts diff --git a/tutormfe/templates/mfe/apps/mfe/Caddyfile b/tutormfe/templates/mfe/apps/mfe/Caddyfile index 115d57c5..e7c5ff71 100644 --- a/tutormfe/templates/mfe/apps/mfe/Caddyfile +++ b/tutormfe/templates/mfe/apps/mfe/Caddyfile @@ -26,15 +26,22 @@ redir @authoring /authoring/{re.authoring.1} permanent {% endif %} - {% for app_name, app in iter_mfes() %} + {% for app_name, app in iter_unique_apps() %} @mfe_{{ app_name }} { path /{{ app_name }} /{{ app_name }}/* } handle @mfe_{{ app_name }} { uri strip_prefix /{{ app_name }} + {%- if is_frontend_app_enabled(app_name) %} + # {{ app_name }} - using frontend-apps approach + root * /openedx/dist/template-site + {%- else %} + # {{ app_name }} - using traditional MFE approach root * /openedx/dist/{{ app_name }} + {%- endif %} try_files /{path} /index.html file_server } + {% endfor %} } diff --git a/tutormfe/templates/mfe/build/mfe/Dockerfile b/tutormfe/templates/mfe/build/mfe/Dockerfile index 7a470822..c83357fd 100644 --- a/tutormfe/templates/mfe/build/mfe/Dockerfile +++ b/tutormfe/templates/mfe/build/mfe/Dockerfile @@ -22,7 +22,7 @@ ENV PATH=/openedx/app/node_modules/.bin:${PATH} {{ patch("mfe-dockerfile-base") }} -{% for app_name, app in iter_mfes() %} +{% for app_name, app in iter_all_apps() %} ####################### {{ app_name }} MFE ######## {{ app_name }} (git) FROM base AS {{ app_name }}-git @@ -50,9 +50,11 @@ RUN --mount=type=cache,target=/root/.npm,sharing=shared npm clean-install --no-a {{ patch("mfe-dockerfile-post-npm-install-{}".format(app_name)) }} COPY --from={{ app_name }}-src / /openedx/app +# While we figure out how translations will be managed in template site +# if it's either template-site or starts with frontend-app, we skip pulling translations +{% if app_name != "template-site" and not app_name.startswith("frontend-app") %} RUN make OPENEDX_ATLAS_PULL=true ATLAS_OPTIONS="--repository={{ ATLAS_REPOSITORY }} --revision={{ ATLAS_REVISION }} {{ ATLAS_OPTIONS }}" pull_translations - -EXPOSE {{ app['port'] }} +{% endif %} # Configuration needed at build time ENV APP_ID={{ app_name }} @@ -66,6 +68,12 @@ COPY env.config.jsx /openedx/app {{ patch("mfe-dockerfile-pre-npm-build") }} {{ patch("mfe-dockerfile-pre-npm-build-{}".format(app_name)) }} +{% if is_frontend_app_to_build(app_name) %} +RUN npm pack && mv *.tgz {{app_name}}.tgz +{% else %} +EXPOSE {{ app['port'] }} +{% endif %} + ######## {{ app_name }} (dev) FROM {{ app_name }}-common AS {{ app_name }}-dev ENV NODE_ENV=development @@ -76,6 +84,16 @@ CMD ["/bin/bash", "-c", "npm run start --- --config ./webpack.dev-tutor.config.j {%- for app_name, app in iter_mfes() %} ######## {{ app_name }} (production) FROM {{ app_name }}-common AS {{ app_name }}-prod + +{% if app_name == "template-site" %} + RUN mkdir -p /openedx/app/pack + {%- for app_name, app in iter_frontend_apps_to_build() %} + COPY --from={{ app_name }}-common /openedx/app/{{app_name}}.tgz /openedx/app/pack/ + RUN npm install /openedx/app/pack/{{app_name}}.tgz --no-audit --no-fund + {% endfor %} + RUN npm ci +{% endif %} + ENV NODE_ENV=production RUN npm run build {{ patch("mfe-dockerfile-post-npm-build") }}