Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add do command to update the authentication plugin of MySQL users #1097

Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- [Improvement] Add a do command to update the authentication plugin of existing MySQL users from mysql_native_password to caching_sha2_password for compatibility with MySQL v8.4.0 and above. (by @Danyal-Faheem)
18 changes: 18 additions & 0 deletions docs/local.rst
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,24 @@ By default, only the tables in the openedx database are changed. For upgrading t

tutor local do convert-mysql-utf8mb4-charset --database=discovery

.. _update_mysql_authentication_plugin:

Updating the authentication plugin of MySQL users
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

As of MySQL v8.4.0, the ``mysql_native_password`` authentication plugin has been deprecated. Users created with this authentication plugin should ideally be updated to use the latest ``caching_sha2_password`` authentication plugin.

Tutor makes it easy do so with this handy command::

tutor local do update-mysql-authentication-plugin myuser
Danyal-Faheem marked this conversation as resolved.
Show resolved Hide resolved

The password will be required to be entered interactively. Optionally, the password can also be provided as part of the command::

tutor local do update-mysql-authentication-plugin myuser --password=mypassword
Danyal-Faheem marked this conversation as resolved.
Show resolved Hide resolved

Tutor may prompt you with some warnings if the entered password is suspected to be wrong. To avoid these prompts, use the non-interactive option::

tutor local do update-mysql-authentication-plugin myuser --password=mypassword --non-interactive
Danyal-Faheem marked this conversation as resolved.
Show resolved Hide resolved
Running arbitrary ``manage.py`` commands
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Expand Down
7 changes: 7 additions & 0 deletions docs/troubleshooting.rst
Original file line number Diff line number Diff line change
Expand Up @@ -216,3 +216,10 @@ NPM Dependency Conflict When overriding ``@edx/frontend-component-header`` or ``
----------------------------------------------------------------------------------------------------------------

The detailed steps are mentioned in `tutor-mfe <https://github.com/overhangio/tutor-mfe?tab=readme-ov-file#npm-dependency-conflict-when-overriding-edxfrontend-component-header-or-edxfrontend-component-footer>`__ documentation.

"Plugin 'mysql_native_password' is not loaded"
----------------------------------------------

This issue can occur when Tutor is upgraded from v15 (Olive) or earlier to v18 (Redwood) because the users created in Tutor v15 utilize the mysql_native_password authentication plugin by default. This plugin has been deprecated as of MySQL v8.4.0 which is the default MySQL server used in Tutor v18.

The handy :ref:`update-mysql-authentication-plugin <update_mysql_authentication_plugin>` do command in tutor can be used to fix this issue.
Copy link
Contributor

Choose a reason for hiding this comment

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

What is the command that should be run for a vanilla installation?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I've added the two commands necessary for a vanilla installation in the troubleshooting section as well

24 changes: 24 additions & 0 deletions tests/commands/test_jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,3 +165,27 @@ def test_convert_mysql_utf8mb4_charset_exclude_tables(self) -> None:
self.assertIn("NOT", dc_args[-1])
self.assertIn("course", dc_args[-1])
self.assertIn("auth", dc_args[-1])

def test_update_mysql_authentication_plugin(self) -> None:
with temporary_root() as root:
self.invoke_in_root(root, ["config", "save"])
with patch("tutor.utils.docker_compose") as mock_docker_compose:
result = self.invoke_in_root(
root,
[
"local",
"do",
"update-mysql-authentication-plugin",
"openedx",
"--password=password",
"--non-interactive",
],
)
dc_args, _dc_kwargs = mock_docker_compose.call_args

self.assertIsNone(result.exception)
self.assertEqual(0, result.exit_code)
self.assertIn("lms-job", dc_args)
self.assertIn("caching_sha2_password", dc_args[-1])
self.assertIn("openedx", dc_args[-1])
self.assertIn("password", dc_args[-1])
136 changes: 80 additions & 56 deletions tutor/commands/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,13 @@
from tutor import config as tutor_config
from tutor import env, fmt, hooks
from tutor.commands.context import Context
from tutor.commands.jobs_utils import get_mysql_change_charset_query
from tutor.commands.jobs_utils import (
create_user_template,
get_mysql_change_charset_query,
set_theme_template,
)
from tutor.hooks import priorities
from tutor.types import get_typed


class DoGroup(click.Group):
Expand Down Expand Up @@ -109,24 +114,6 @@ def createuser(
yield ("lms", create_user_template(superuser, staff, name, email, password))


def create_user_template(
superuser: str, staff: bool, username: str, email: str, password: str
) -> str:
opts = ""
if superuser:
opts += " --superuser"
if staff:
opts += " --staff"
return f"""
./manage.py lms manage_user {opts} {username} {email}
./manage.py lms shell -c "
from django.contrib.auth import get_user_model
u = get_user_model().objects.get(username='{username}')
u.set_password('{password}')
u.save()"
"""


@click.command(help="Import the demo course")
@click.option(
"-r",
Expand Down Expand Up @@ -273,43 +260,6 @@ def settheme(domains: list[str], theme_name: str) -> t.Iterable[tuple[str, str]]
yield ("lms", set_theme_template(theme_name, domains))


def set_theme_template(theme_name: str, domain_names: list[str]) -> str:
"""
For each domain, get or create a Site object and assign the selected theme.
"""
# Note that there are no double quotes " in this piece of code
python_command = """
import sys
from django.contrib.sites.models import Site
def assign_theme(name, domain):
print('Assigning theme', name, 'to', domain)
if len(domain) > 50:
sys.stderr.write(
'Assigning a theme to a site with a long (> 50 characters) domain name.'
' The displayed site name will be truncated to 50 characters.\\n'
)
site, _ = Site.objects.get_or_create(domain=domain)
if not site.name:
name_max_length = Site._meta.get_field('name').max_length
site.name = domain[:name_max_length]
site.save()
site.themes.all().delete()
if name != 'default':
site.themes.create(theme_dir_name=name)
"""
domain_names = domain_names or [
"{{ LMS_HOST }}",
"{{ LMS_HOST }}:8000",
"{{ CMS_HOST }}",
"{{ CMS_HOST }}:8001",
"{{ PREVIEW_LMS_HOST }}",
"{{ PREVIEW_LMS_HOST }}:8000",
]
for domain_name in domain_names:
python_command += f"assign_theme('{theme_name}', '{domain_name}')\n"
return f'./manage.py lms shell -c "{python_command}"'


@click.command(context_settings={"ignore_unknown_options": True})
@click.argument("args", nargs=-1)
def sqlshell(args: list[str]) -> t.Iterable[tuple[str, str]]:
Expand Down Expand Up @@ -428,6 +378,79 @@ def generate_query_to_append(tables: list[str], exclude: bool = False) -> str:
fmt.echo_info("MySQL charset and collation successfully upgraded")


@click.command(
short_help="Update the authentication plugin of a mysql user to caching_sha2_password.",
help=(
"Update the authentication plugin of a mysql user to caching_sha2_password from mysql_native_password. You can specify either specific users to update or all to update all users."
Danyal-Faheem marked this conversation as resolved.
Show resolved Hide resolved
),
)
@click.option(
"-p",
"--password",
help="Specify password from the command line. If undefined, you will be prompted to input a password",
prompt=True,
hide_input=True,
)
@click.argument(
"user",
nargs=1,
Danyal-Faheem marked this conversation as resolved.
Show resolved Hide resolved
)
@click.option("-I", "--non-interactive", is_flag=True, help="Run non-interactively")
@click.pass_obj
def update_mysql_authentication_plugin(
context: Context, user: str, password: str, non_interactive: bool
) -> t.Iterable[tuple[str, str]]:
"""
Update the authentication plugin of MySQL users from mysql_native_password to caching_sha2_password
Handy command utilized when upgrading to v8.4 of MySQL which deprecates mysql_native_password
"""

config = tutor_config.load(context.root)

if not config["RUN_MYSQL"]:
fmt.echo_info(
"You are not running MySQL (RUN_MYSQL=False). It is your "
"responsibility to update the authentication plugin of mysql users."
)
return

conventional_password_key = f"{user.upper()}_MYSQL_PASSWORD"
Copy link
Contributor

Choose a reason for hiding this comment

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

This part about automatically loading password from config should be removed. In particular, with the current implementation, a warning will be printed even after user inputs a password.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Regarding this,

The warnings will only be printed if the password entered by a user does not match the conventional format that tutor expects or if the value in config of the conventional key does not match the one entered by the user. This is just done to make sure no one accidentally updates the password of their mysql users and is left wondering why their platform doesn't work now.

There is also an option of using the command non-interactively.

Do you still think we should remove this? I understand its not intuitive at all but I added it just in case someone doesn't accidentally update their passwords.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah, I think we should remove this, as it will cause more confusion than resolve anything. If the password is incorrect, the command will fail, right? (which is good)

Copy link
Contributor Author

@Danyal-Faheem Danyal-Faheem Jan 24, 2025

Choose a reason for hiding this comment

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

If the password is incorrect, the command will fail, right? (which is good)

Unfortunately, it wont fail. Instead, mysql will just update the password of the user to the incorrectly entered password and then the platform will be unable to connect because the password has been updated. This was my main motivation for adding these checks.

It only throws a warning if the username is incorrect, which makes sense cause then the user wouldn't exist anyway.

Copy link
Contributor

@regisb regisb Jan 24, 2025

Choose a reason for hiding this comment

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

OK, I understand. Here's what I suggest:

  1. Get rid of the interactive option, remove the corresponding warning.
  2. Remove prompt=True in the password option definition.
  3. In the command definition, build a dict of known username/passwords:
known_mysql_credentials = {}
for k, v in [("OPENEDX_MYSQL_USERNAME", "OPENEDX_MYSQL_PASSWORD"), ("NOTES_MYSQL_USERNAME", "NOTES_MYSQL_PASSWORD"), ...]:
    if username := config.get(k):
        known_mysql_credentials[username] = config[v]
if not password:
    password = known_mysql_credentials.get(username)
if not password:
    # prompt for password

In other words, the password is automatically detected if it matches a known username. Optionally, we can build known_mysql_credentials from a new filter.

  1. Change the documentation to tell users to run:

    tutor local do update-mysql-authentication-plugin $(tutor config printvalue OPENEDX_MYSQL_USERNAME)
    tutor local do update-mysql-authentication-plugin $(tutor config printvalue NOTES_MYSQL_USERNAME)
    ...
    

Would that work?

Sorry about the back and forth on this feature. I'm aware that this PR has been open for 6+ months, and I'm eager to merge it.

Copy link
Contributor Author

@Danyal-Faheem Danyal-Faheem Jan 27, 2025

Choose a reason for hiding this comment

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

I like this idea as well.

Optionally, we can build known_mysql_credentials from a new filter.

Since this is a one time command, lets not go this route. We can hardcode the values and if someone is using a custom plugin, they can just use the username password option.

I've updated the rest of the code and docs as per the method you've mentioned here.


# Prompt for confirmation to move forward if password not present in config with the conventional format USER_MYSQL_PASSWORD
if not non_interactive and not conventional_password_key in config:
if not click.confirm(
fmt.question(
f"""Password for user {user} could not be verified. The entered password is: {password}
Would you still like to continue with the upgrade process? Note: a wrong password would update the password for the user."""
)
):
return
# Prompt for confirmation to move forward is password is present in config with the conventional format USER_MYSQL_PASSWORD
# but it is not the same as the value of that config variable
elif (
not non_interactive
and get_typed(config, conventional_password_key, str, "") != password
):
if not click.confirm(
fmt.question(
f"""Password for user {user} is suspected to be wrong. The entered password is: {password} while the password suspected to be the correct one is {config[conventional_password_key]}
Would you still like to continue with the upgrade process? Note: a wrong password would update the password for the user."""
)
):
return

host = "%"

query = f"ALTER USER IF EXISTS '{user}'@'{host}' IDENTIFIED with caching_sha2_password BY '{password}';"

mysql_command = (
"mysql --user={{ MYSQL_ROOT_USERNAME }} --password={{ MYSQL_ROOT_PASSWORD }} --host={{ MYSQL_HOST }} --port={{ MYSQL_PORT }} --database={{ OPENEDX_MYSQL_DATABASE }} --show-warnings "
+ shlex.join(["-e", query])
Copy link
Contributor

Choose a reason for hiding this comment

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

I think the following will be more robust:

 yield ("lms", shlex.join([
    "mysql",
    "--user={{ MYSQL_ROOT_USERNAME }}",
    "--password={{ MYSQL_ROOT_PASSWORD }}",
    "--host={{ MYSQL_HOST }}",
    "--port={{ MYSQL_PORT }}",
    "--database={{ OPENEDX_MYSQL_DATABASE }}",
    "--show-warnings",
    "-e",
    query,
]))

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This certainly looks better. I've updated the original one to this.

)

yield ("lms", mysql_command)


def add_job_commands(do_command_group: click.Group) -> None:
"""
This is meant to be called with the `local/dev/k8s do` group commands, to add the
Expand Down Expand Up @@ -511,5 +534,6 @@ def do_callback(service_commands: t.Iterable[tuple[str, str]]) -> None:
print_edx_platform_setting,
settheme,
sqlshell,
update_mysql_authentication_plugin,
]
)
59 changes: 59 additions & 0 deletions tutor/commands/jobs_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,29 @@
This module provides utility methods for tutor `do` commands

Methods:
- `create_user_template`: Generates the necessary commands to create a user in openedx.
- `get_mysql_change_charset_query`: Generates MySQL queries to upgrade the charset and collation of columns, tables, and databases.
- `set_theme_template`: Generates the necessary commands to set a theme on a specific domain in openedx.
Copy link
Contributor

Choose a reason for hiding this comment

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

Let's not adopt the habit of documenting the functions at the top of the file. Can you please move the function short descriptions to the function docstrings? (in triple quotes)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I did it as @DawoudSheraz requested me to add file docstrings. But, I guess we're not following that convention for now. So, I've removed the file docstring in favor of function docstrings.

"""

from __future__ import annotations


def create_user_template(
superuser: str, staff: bool, username: str, email: str, password: str
) -> str:
opts = ""
if superuser:
opts += " --superuser"
if staff:
opts += " --staff"
return f"""
./manage.py lms manage_user {opts} {username} {email}
./manage.py lms shell -c "
from django.contrib.auth import get_user_model
u = get_user_model().objects.get(username='{username}')
u.set_password('{password}')
u.save()"
"""


Expand Down Expand Up @@ -130,3 +152,40 @@ def get_mysql_change_charset_query(
CALL UpdateColumns();
CALL UpdateTables();
"""


def set_theme_template(theme_name: str, domain_names: list[str]) -> str:
"""
For each domain, get or create a Site object and assign the selected theme.
"""
# Note that there are no double quotes " in this piece of code
python_command = """
import sys
from django.contrib.sites.models import Site
def assign_theme(name, domain):
print('Assigning theme', name, 'to', domain)
if len(domain) > 50:
sys.stderr.write(
'Assigning a theme to a site with a long (> 50 characters) domain name.'
' The displayed site name will be truncated to 50 characters.\\n'
)
site, _ = Site.objects.get_or_create(domain=domain)
if not site.name:
name_max_length = Site._meta.get_field('name').max_length
site.name = domain[:name_max_length]
site.save()
site.themes.all().delete()
if name != 'default':
site.themes.create(theme_dir_name=name)
"""
domain_names = domain_names or [
"{{ LMS_HOST }}",
"{{ LMS_HOST }}:8000",
"{{ CMS_HOST }}",
"{{ CMS_HOST }}:8001",
"{{ PREVIEW_LMS_HOST }}",
"{{ PREVIEW_LMS_HOST }}:8000",
]
for domain_name in domain_names:
python_command += f"assign_theme('{theme_name}', '{domain_name}')\n"
return f'./manage.py lms shell -c "{python_command}"'
Loading