Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/update-site-lockfile.yml

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall this workflow makes sense to me, but I'm slightly concerned that it won't catch build failures with the updated lockfile.

I know in theory things should stay compatible with our pinning strategy, but I've run into issues where dependencies unintentionally release a breaking change as minor (most recently openedx/paragon#4218 / openedx/paragon#4257)

With renovate PRs this gets caught by CI, but I don't see anything in .github/workflows/test.yml or the test target in the Makefile that would catch things like that.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, but the problem is that tutor mfe has never run CI against the actual build, probably because it's too expensive. Probably good for a follow-up conversation with the other maintainers.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Or it's just that this already runs as part of the Docker build process somewhere else not visible from here - which I think is the likely answer.)

Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: Update site lockfile

on:
schedule:
- cron: "0 2 * * *"
workflow_dispatch:

jobs:
update:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install tutor (main branch) and this plugin
run: |
pip install git+https://github.com/overhangio/tutor.git@main
pip install -e .
- name: Render tutor templates
run: |
tutor config save
- name: Regenerate @openedx/ scoped entries in site lockfile
run: >
tutor mfe update-site-lockfile
--scope @openedx/
--output tutormfe/templates/mfe/build/mfe/site/package-lock.json
- name: Open pull request
uses: peter-evans/create-pull-request@v6
with:
branch: auto/update-site-lockfile
title: "chore: update @openedx/ entries in site package-lock.json"
commit-message: "chore: update @openedx/ entries in site package-lock.json"
body: |
Automated update of `@openedx/`-scoped entries in the frontend-base site's `package-lock.json`, regenerated via `tutor mfe update-site-lockfile --scope @openedx/`.
440 changes: 431 additions & 9 deletions README.rst

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- [Feature] Add support for [frontend-base](https://github.com/openedx/frontend-base), a unified framework that bundles frontend apps into a single shell application. Introduces a new `FRONTEND_APPS` hook and ships four core apps (`authn`, `learner-dashboard`, `instructor-dashboard`, `notifications`), with the latter two enabled by default. (by @arbrandes and @holaontiveros)
Binary file added media/instructor-dashboard.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
112 changes: 112 additions & 0 deletions tutormfe/commands.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
from __future__ import annotations

import json
import os
import shutil
import tempfile

import click
from tutor import env, exceptions, fmt, images
from tutor.commands.context import Context


@click.group(name="mfe", help="Commands for the MFE plugin.")
def mfe_command() -> None:
pass


@mfe_command.command(
name="update-site-lockfile",
help=(
"Regenerate the frontend-base site's package-lock.json with the latest "
"versions allowed by the declared semver ranges. Requires `tutor config "
"save` to have been run first."
),
)
@click.option(
"-o",
"--output",
type=click.Path(dir_okay=False, writable=True, resolve_path=True),
default="package-lock.json",
show_default=True,
help="Path to write the refreshed lockfile to.",
)
@click.option(
"--scope",
"scopes",
multiple=True,
help=(
"npm scope prefix to limit updates to (e.g. '@openedx/'). May be "
"passed multiple times. If neither --scope nor --package is given, "
"all dependencies are updated."
),
)
@click.option(
"--package",
"packages",
multiple=True,
help=(
"Exact package name to update. May be passed multiple times and "
"combined with --scope."
),
)
@click.pass_obj
def update_site_lockfile(
context: Context,
output: str,
scopes: tuple[str, ...],
packages: tuple[str, ...],
) -> None:
build_path = env.pathjoin(context.root, "plugins", "mfe", "build", "mfe")
if not os.path.isdir(build_path):
raise exceptions.TutorError(
f"Rendered MFE build directory not found at {build_path}. "
"Run `tutor config save` first."
)
selected = _resolve_packages(build_path, scopes, packages)
with tempfile.TemporaryDirectory() as tmp:
images.build(
build_path,
"tutor-mfe-lockfile:refresh",
"--target=site-lockfile",
"--no-cache-filter=site-lockfile-builder",
f"--build-arg=NPM_UPDATE_PACKAGES={' '.join(selected)}",
f"--output=type=local,dest={tmp}",
)
src = os.path.join(tmp, "package-lock.json")
if not os.path.exists(src):
raise exceptions.TutorError(
"Docker build did not produce a package-lock.json. "
"Ensure at least one frontend app is enabled via FRONTEND_APPS."
)
shutil.copy(src, output)
fmt.echo_info(f"Refreshed lockfile written to {output}")


def _resolve_packages(
build_path: str, scopes: tuple[str, ...], packages: tuple[str, ...]
) -> list[str]:
if not scopes and not packages:
return []
pkg_path = os.path.join(build_path, "site", "package.json")
try:
with open(pkg_path, encoding="utf-8") as f:
pkg = json.load(f)
except FileNotFoundError as e:
raise exceptions.TutorError(
f"Rendered site package.json not found at {pkg_path}."
) from e
declared = {
*pkg.get("dependencies", {}).keys(),
*pkg.get("devDependencies", {}).keys(),
}
selected = {
name
for name in declared
if name in packages or any(name.startswith(s) for s in scopes)
}
if not selected:
raise exceptions.TutorError(
"No dependencies matched the given --scope / --package filters."
)
return sorted(selected)
12 changes: 12 additions & 0 deletions tutormfe/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,22 @@

from tutor.core.hooks import Filter

# TODO(legacy-mfe-removal): MFE_ATTRS_TYPE, MFE_APPS, and PLUGIN_SLOTS all go
# away with the legacy MFE cleanup. See the central TODO block in plugin.py for
# the full list.
MFE_ATTRS_TYPE = t.Dict[t.Literal["repository", "port", "version"], t.Union["str", int]]

FRONTEND_APP_ATTRS_TYPE = t.Dict[
t.Literal["npm_package", "npm_version", "enabled", "source"],
t.Union[str, bool],
]

MFE_APPS: Filter[dict[str, MFE_ATTRS_TYPE], []] = Filter()

FRONTEND_APPS: Filter[dict[str, FRONTEND_APP_ATTRS_TYPE], []] = Filter()

PLUGIN_SLOTS: Filter[list[tuple[str, str, str]], []] = Filter()

EXTERNAL_SCRIPTS: Filter[list[tuple[str, str]], []] = Filter()

FRONTEND_SLOTS: Filter[list[str], []] = Filter()
3 changes: 3 additions & 0 deletions tutormfe/patches/lms-env
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{% if is_frontend_app_enabled("notifications") %}
NOTIFICATIONS_DEFAULT_FROM_EMAIL: '{{ NOTIFICATIONS_DEFAULT_FROM_EMAIL }}'
{% endif %}
26 changes: 25 additions & 1 deletion tutormfe/patches/local-docker-compose-dev-services
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
{#- TODO(legacy-mfe-removal): drop mfe_data, the mfe_data.mounted loop below,
and the mfe_data.unmounted / legacy port-mapping block further down. Only
the mfe-dev site block survives. #}
{%- set mfe_data = MFEMountData(MOUNTS) %}
{%- set site_mounts = get_site_mounts(MOUNTS) %}

{%- for app_name, app, mounts in mfe_data.mounted %}
{{ app_name }}: # Work on this MFE for development
Expand All @@ -19,8 +23,25 @@
- "PORT={{ app['port'] }}"
{%- endfor %}

{%- if site_mounts %}
mfe-dev: # Work on the frontend site for development
image: "{{ MFE_DOCKER_IMAGE_DEV_PREFIX }}-mfe-dev:{{ MFE_VERSION }}"
ports:
- "{{ MFE_SITE_PORT }}:{{ MFE_SITE_PORT }}"
stdin_open: true
tty: true
volumes:
{%- for mount in site_mounts %}
- {{ mount }}
{%- endfor %}
restart: unless-stopped
depends_on:
- lms
environment:
- "PORT={{ MFE_SITE_PORT }}"
{%- endif %}

{% if mfe_data.unmounted|length > 0 or MFE_HOST_EXTRA_FILES %}
{% if mfe_data.unmounted|length > 0 or not site_mounts or MFE_HOST_EXTRA_FILES %}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Curious about this logic. I think the main thing that is making it seem confusing to me is that site_mounts (plural) implies we might have multiple sites. In the case where multiple sites exist, one is mounted and one isn't, then site_mounts would be truthy.

I see 2 possible solutions here:

  1. Rename get_site_mounts and site_mounts to get_site_mount and site_mount respectively. That would clarify the limitation of "we only support one site."
  2. Support multiple sites. One possible way to do this would be to follow the MFEMountData class pattern for a SiteMountData class.

@arbrandes arbrandes Apr 7, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

site_mounts (plural) implies we might have multiple sites

Not necessarily, if you take it to mean: "all of the site-related mounts that go into the mfe-dev service". This includes both the site itself, and/or the apps. So the name is fine, I think.

On the suggestions:

  1. Doesn't work, because there are really multiple mounts possible.
  2. That is explicitly a non-goal of this implementation: there is no scenario I can imagine where multiple sites is a desirable tutor-mfe feature. Users can already override the full site config if they want - I can see why somebody would want to do that - but multiple sites negates most (or at least, many) of the benefits of frontend-base.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gotcha. So with

        if is_frontend_app_enabled(app_name):
            mounts.append(("mfe", f"frontend-app-{app_name}-src"))
            mounts.append(("mfe-dev", f"frontend-app-{app_name}-src"))

the frontend-app-* mounts are "site mounts."

Seems like a classic "names are hard" problem. When I first saw get_site_mounts I read it as "get mounts for all the sites" instead of "get all the mounts for the one site."

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm actually looking into what we can do, here. Be back in a bit. :)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I don't have a better idea for the name (what I commented earlier holds), but you did help clarify the implementation:

78618ae

mfe:
ports:
{%- if MFE_HOST_EXTRA_FILES %}
Expand All @@ -29,4 +50,7 @@ mfe:
{%- for app_name, app in mfe_data.unmounted %}
- {{ app["port"] }}:8002 # {{ app_name }}
{%- endfor %}
{%- if not site_mounts and get_frontend_apps() %}
- {{ MFE_SITE_PORT }}:8002 # site
{%- endif %}
{% endif %}
5 changes: 1 addition & 4 deletions tutormfe/patches/openedx-lms-common-settings
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ ENABLE_MFE_CONFIG_API = True
MFE_CONFIG_API_CACHE_TIMEOUT = 1

# MFE-specific settings
{% if get_mfe("authn") %}
{% if get_mfe("authn") or is_frontend_app_enabled("authn") %}
FEATURES['ENABLE_AUTHN_MICROFRONTEND'] = True
{% endif %}
{% if get_mfe("catalog") %}
Expand All @@ -14,6 +14,3 @@ ENABLE_CATALOG_MICROFRONTEND = True
{% if get_mfe("communications") %}
FEATURES['ENABLE_NEW_BULK_EMAIL_EXPERIENCE'] = True
{% endif %}
{% if get_mfe("learner-dashboard") %}
LEARNER_HOME_MFE_REDIRECT_PERCENTAGE = 100
{% endif %}
40 changes: 37 additions & 3 deletions tutormfe/patches/openedx-lms-development-settings
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,33 @@ MFE_CONFIG = {
"ACCESS_TOKEN_COOKIE_NAME": "edx-jwt-cookie-header-payload",
}

FRONTEND_SITE_CONFIG = {
"baseUrl": "http://{{ MFE_HOST }}:{{ MFE_SITE_PORT }}",
"lmsBaseUrl": "http://{{ LMS_HOST }}:8000",
"loginUrl": "http://{{ LMS_HOST }}:8000/login",
"logoutUrl": "http://{{ LMS_HOST }}:8000/logout",
"externalRoutes": [
{"role": "org.openedx.frontend.role.logout", "url": "http://{{ LMS_HOST }}:8000/logout"},
],
"commonAppConfig": {},
}

# MFE-specific settings
{% if get_mfe("authn") %}
AUTHN_MICROFRONTEND_URL = "http://{{ MFE_HOST }}:{{ get_mfe("authn")["port"] }}/authn"
{% if get_mfe("authn") or is_frontend_app_enabled("authn") %}
AUTHN_MICROFRONTEND_DOMAIN = "{{ MFE_HOST }}/authn"
MFE_CONFIG["DISABLE_ENTERPRISE_LOGIN"] = True
FRONTEND_SITE_CONFIG["commonAppConfig"]["DISABLE_ENTERPRISE_LOGIN"] = True
{% if is_frontend_app_enabled("authn") %}
AUTHN_MICROFRONTEND_URL = "http://{{ MFE_HOST }}:{{ MFE_SITE_PORT }}/authn"
{% else %}
AUTHN_MICROFRONTEND_URL = "http://{{ MFE_HOST }}:{{ get_mfe("authn")["port"] }}/authn"
{% endif %}
{% endif %}

{% if get_mfe("account") %}
ACCOUNT_MICROFRONTEND_URL = "http://{{ MFE_HOST }}:{{ get_mfe("account")["port"] }}/account/"
MFE_CONFIG["ACCOUNT_SETTINGS_URL"] = ACCOUNT_MICROFRONTEND_URL
FRONTEND_SITE_CONFIG["externalRoutes"].append({"role": "org.openedx.frontend.role.account", "url": "http://{{ MFE_HOST }}:{{ get_mfe("account")["port"] }}/account/"})
{% endif %}

{% if get_mfe("authoring") %}
Expand All @@ -55,10 +72,16 @@ DISCUSSIONS_MFE_FEEDBACK_URL = None
WRITABLE_GRADEBOOK_URL = "http://{{ MFE_HOST }}:{{ get_mfe("gradebook")["port"] }}/gradebook"
{% endif %}

{% if get_mfe("learner-dashboard") %}
{% if is_frontend_app_enabled("learner-dashboard") %}
LEARNER_HOME_MICROFRONTEND_URL = "http://{{ MFE_HOST }}:{{ MFE_SITE_PORT }}/learner-dashboard/"
{% elif get_mfe("learner-dashboard") %}
LEARNER_HOME_MICROFRONTEND_URL = "http://{{ MFE_HOST }}:{{ get_mfe("learner-dashboard")["port"] }}/learner-dashboard/"
{% endif %}

{% if is_frontend_app_enabled("instructor-dashboard") %}
INSTRUCTOR_MICROFRONTEND_URL = "http://{{ MFE_HOST }}:{{ MFE_SITE_PORT }}/instructor-dashboard"
{% endif %}

{% if get_mfe("learning") %}
LEARNING_MICROFRONTEND_URL = "http://{{ MFE_HOST }}:{{ get_mfe("learning")["port"] }}/learning"
MFE_CONFIG["LEARNING_BASE_URL"] = "http://{{ MFE_HOST }}:{{ get_mfe("learning")["port"] }}/learning"
Expand All @@ -71,6 +94,7 @@ ORA_GRADING_MICROFRONTEND_URL = "http://{{ MFE_HOST }}:{{ get_mfe("ora-grading")
{% if get_mfe("profile") %}
PROFILE_MICROFRONTEND_URL = "http://{{ MFE_HOST }}:{{ get_mfe("profile")["port"] }}/profile/u/"
MFE_CONFIG["ACCOUNT_PROFILE_URL"] = "http://{{ MFE_HOST }}:{{ get_mfe("profile")["port"] }}/profile"
FRONTEND_SITE_CONFIG["externalRoutes"].append({"role": "org.openedx.frontend.role.profile", "url": "http://{{ MFE_HOST }}:{{ get_mfe("profile")["port"] }}/profile/"})
{% endif %}

{% if get_mfe("communications") %}
Expand All @@ -87,6 +111,10 @@ MFE_CONFIG["ADMIN_CONSOLE_URL"] = ADMIN_CONSOLE_MICROFRONTEND_URL
CATALOG_MICROFRONTEND_URL = "http://{{ MFE_HOST }}:{{ get_mfe("catalog")["port"] }}/catalog"
{% endif %}

{% if is_frontend_app_enabled("notifications") %}
NOTIFICATIONS_DEFAULT_FROM_EMAIL = ENV_TOKENS.get("NOTIFICATIONS_DEFAULT_FROM_EMAIL", ENV_TOKENS["CONTACT_EMAIL"])
{% endif %}

# Cors configuration
{% for app_name, app in iter_mfes() %}
# {{ app_name }} MFE
Expand All @@ -95,5 +123,11 @@ LOGIN_REDIRECT_WHITELIST.append("{{ MFE_HOST }}:{{ app["port"] }}")
CSRF_TRUSTED_ORIGINS.append("http://{{ MFE_HOST }}:{{ app["port"] }}")
{% endfor %}

{% if get_frontend_apps() %}
CORS_ORIGIN_WHITELIST.append("http://{{ MFE_HOST }}:{{ MFE_SITE_PORT }}")
LOGIN_REDIRECT_WHITELIST.append("{{ MFE_HOST }}:{{ MFE_SITE_PORT }}")
CSRF_TRUSTED_ORIGINS.append("http://{{ MFE_HOST }}:{{ MFE_SITE_PORT }}")
{% endif %}

{{ patch("mfe-lms-common-settings") }}
{{ patch("mfe-lms-development-settings") }}
26 changes: 24 additions & 2 deletions tutormfe/patches/openedx-lms-production-settings
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,30 @@ MFE_CONFIG = {
"ACCESS_TOKEN_COOKIE_NAME": "edx-jwt-cookie-header-payload",
}

FRONTEND_SITE_CONFIG = {
"baseUrl": "{{ "https" if ENABLE_HTTPS else "http" }}://{{ MFE_HOST }}",
"lmsBaseUrl": "{{ "https" if ENABLE_HTTPS else "http" }}://{{ LMS_HOST }}",
"loginUrl": "{{ "https" if ENABLE_HTTPS else "http" }}://{{ LMS_HOST }}/login",
"logoutUrl": "{{ "https" if ENABLE_HTTPS else "http" }}://{{ LMS_HOST }}/logout",
"externalRoutes": [
{"role": "org.openedx.frontend.role.logout", "url": "{{ "https" if ENABLE_HTTPS else "http" }}://{{ LMS_HOST }}/logout"},
],
"commonAppConfig": {},
}

# MFE-specific settings

{% if get_mfe("authn") %}
{% if get_mfe("authn") or is_frontend_app_enabled("authn") %}
AUTHN_MICROFRONTEND_URL = "{% if ENABLE_HTTPS %}https://{% else %}http://{% endif %}{{ MFE_HOST }}/authn"
AUTHN_MICROFRONTEND_DOMAIN = "{{ MFE_HOST }}/authn"
MFE_CONFIG["DISABLE_ENTERPRISE_LOGIN"] = True
FRONTEND_SITE_CONFIG["commonAppConfig"]["DISABLE_ENTERPRISE_LOGIN"] = True
{% endif %}

{% if get_mfe("account") %}
ACCOUNT_MICROFRONTEND_URL = "{% if ENABLE_HTTPS %}https://{% else %}http://{% endif %}{{ MFE_HOST }}/account/"
MFE_CONFIG["ACCOUNT_SETTINGS_URL"] = ACCOUNT_MICROFRONTEND_URL
FRONTEND_SITE_CONFIG["externalRoutes"].append({"role": "org.openedx.frontend.role.account", "url": "{{ "https" if ENABLE_HTTPS else "http" }}://{{ MFE_HOST }}/account/"})
{% endif %}

{% if get_mfe("authoring") %}
Expand All @@ -56,10 +69,14 @@ DISCUSSIONS_MFE_FEEDBACK_URL = None
WRITABLE_GRADEBOOK_URL = "{% if ENABLE_HTTPS %}https://{% else %}http://{% endif %}{{ MFE_HOST }}/gradebook"
{% endif %}

{% if get_mfe("learner-dashboard") %}
{% if get_mfe("learner-dashboard") or is_frontend_app_enabled("learner-dashboard") %}
LEARNER_HOME_MICROFRONTEND_URL = "{% if ENABLE_HTTPS %}https://{% else %}http://{% endif %}{{ MFE_HOST }}/learner-dashboard/"
{% endif %}

{% if is_frontend_app_enabled("instructor-dashboard") %}
INSTRUCTOR_MICROFRONTEND_URL = "{% if ENABLE_HTTPS %}https://{% else %}http://{% endif %}{{ MFE_HOST }}/instructor-dashboard"
{% endif %}

{% if get_mfe("learning") %}
LEARNING_MICROFRONTEND_URL = "{% if ENABLE_HTTPS %}https://{% else %}http://{% endif %}{{ MFE_HOST }}/learning"
MFE_CONFIG["LEARNING_BASE_URL"] = "{{ "https" if ENABLE_HTTPS else "http" }}://{{ MFE_HOST }}/learning"
Expand All @@ -72,6 +89,7 @@ ORA_GRADING_MICROFRONTEND_URL = "{% if ENABLE_HTTPS %}https://{% else %}http://{
{% if get_mfe("profile") %}
PROFILE_MICROFRONTEND_URL = "{% if ENABLE_HTTPS %}https://{% else %}http://{% endif %}{{ MFE_HOST }}/profile/u/"
MFE_CONFIG["ACCOUNT_PROFILE_URL"] = "{% if ENABLE_HTTPS %}https://{% else %}http://{% endif %}{{ MFE_HOST }}/profile"
FRONTEND_SITE_CONFIG["externalRoutes"].append({"role": "org.openedx.frontend.role.profile", "url": "{{ "https" if ENABLE_HTTPS else "http" }}://{{ MFE_HOST }}/profile/"})
{% endif %}

{% if get_mfe("communications") %}
Expand All @@ -88,6 +106,10 @@ MFE_CONFIG["ADMIN_CONSOLE_URL"] = ADMIN_CONSOLE_MICROFRONTEND_URL
CATALOG_MICROFRONTEND_URL = "{% if ENABLE_HTTPS %}https://{% else %}http://{% endif %}{{ MFE_HOST }}/catalog"
{% endif %}

{% if is_frontend_app_enabled("notifications") %}
NOTIFICATIONS_DEFAULT_FROM_EMAIL = ENV_TOKENS.get("NOTIFICATIONS_DEFAULT_FROM_EMAIL", ENV_TOKENS["CONTACT_EMAIL"])
{% endif %}

LOGIN_REDIRECT_WHITELIST.append("{{ MFE_HOST }}")
CORS_ORIGIN_WHITELIST.append("{% if ENABLE_HTTPS %}https://{% else %}http://{% endif %}{{ MFE_HOST }}")
CSRF_TRUSTED_ORIGINS.append("{% if ENABLE_HTTPS %}https://{% else %}http://{% endif %}{{ MFE_HOST }}")
Expand Down
Loading
Loading