7
0
mirror of https://github.com/ChristianLight/tutor.git synced 2024-06-18 21:32:33 +00:00
tutor/tutor/commands/compose.py

450 lines
16 KiB
Python
Raw Normal View History

from __future__ import annotations
import os
import typing as t
import click
from tutor import bindmount
feat: migrate to plugins.v1 with filters & actions This is a very large refactoring which aims at making Tutor both more extendable and more generic. Historically, the Tutor plugin system was designed as an ad-hoc solution to allow developers to modify their own Open edX platforms without having to fork Tutor. The plugin API was simple, but limited, because of its ad-hoc nature. As a consequence, there were many things that plugin developers could not do, such as extending different parts of the CLI or adding custom template filters. Here, we refactor the whole codebase to make use of a generic plugin system. This system was inspired by the Wordpress plugin API and the Open edX "hooks and filters" API. The various components are added to a small core thanks to a set of actions and filters. Actions are callback functions that can be triggered at different points of the application lifecycle. Filters are functions that modify some data. Both actions and filters are collectively named as "hooks". Hooks can optionally be created within a certain context, which makes it easier to keep track of which application created which callback. This new hooks system allows us to provide a Python API that developers can use to extend their applications. The API reference is added to the documentation, along with a new plugin development tutorial. The plugin v0 API remains supported for backward compatibility of existing plugins. Done: - Do not load commands from plugins which are not enabled. - Load enabled plugins once on start. - Implement contexts for actions and filters, which allow us to keep track of the source of every hook. - Migrate patches - Migrate commands - Migrate plugin detection - Migrate templates_root - Migrate config - Migrate template environment globals and filters - Migrate hooks to tasks - Generate hook documentation - Generate patch reference documentation - Add the concept of action priority Close #499.
2022-02-07 17:11:43 +00:00
from tutor import config as tutor_config
from tutor import env as tutor_env
from tutor import fmt, hooks
from tutor import interactive as interactive_config
from tutor import utils
from tutor.commands import images, jobs
from tutor.commands.config import save as config_save_command
from tutor.commands.context import BaseTaskContext
from tutor.commands.upgrade import OPENEDX_RELEASE_NAMES
from tutor.commands.upgrade.compose import upgrade_from
from tutor.core.hooks import Filter # pylint: disable=unused-import
feat: migrate to plugins.v1 with filters & actions This is a very large refactoring which aims at making Tutor both more extendable and more generic. Historically, the Tutor plugin system was designed as an ad-hoc solution to allow developers to modify their own Open edX platforms without having to fork Tutor. The plugin API was simple, but limited, because of its ad-hoc nature. As a consequence, there were many things that plugin developers could not do, such as extending different parts of the CLI or adding custom template filters. Here, we refactor the whole codebase to make use of a generic plugin system. This system was inspired by the Wordpress plugin API and the Open edX "hooks and filters" API. The various components are added to a small core thanks to a set of actions and filters. Actions are callback functions that can be triggered at different points of the application lifecycle. Filters are functions that modify some data. Both actions and filters are collectively named as "hooks". Hooks can optionally be created within a certain context, which makes it easier to keep track of which application created which callback. This new hooks system allows us to provide a Python API that developers can use to extend their applications. The API reference is added to the documentation, along with a new plugin development tutorial. The plugin v0 API remains supported for backward compatibility of existing plugins. Done: - Do not load commands from plugins which are not enabled. - Load enabled plugins once on start. - Implement contexts for actions and filters, which allow us to keep track of the source of every hook. - Migrate patches - Migrate commands - Migrate plugin detection - Migrate templates_root - Migrate config - Migrate template environment globals and filters - Migrate hooks to tasks - Generate hook documentation - Generate patch reference documentation - Add the concept of action priority Close #499.
2022-02-07 17:11:43 +00:00
from tutor.exceptions import TutorError
from tutor.tasks import BaseComposeTaskRunner
feat: migrate to plugins.v1 with filters & actions This is a very large refactoring which aims at making Tutor both more extendable and more generic. Historically, the Tutor plugin system was designed as an ad-hoc solution to allow developers to modify their own Open edX platforms without having to fork Tutor. The plugin API was simple, but limited, because of its ad-hoc nature. As a consequence, there were many things that plugin developers could not do, such as extending different parts of the CLI or adding custom template filters. Here, we refactor the whole codebase to make use of a generic plugin system. This system was inspired by the Wordpress plugin API and the Open edX "hooks and filters" API. The various components are added to a small core thanks to a set of actions and filters. Actions are callback functions that can be triggered at different points of the application lifecycle. Filters are functions that modify some data. Both actions and filters are collectively named as "hooks". Hooks can optionally be created within a certain context, which makes it easier to keep track of which application created which callback. This new hooks system allows us to provide a Python API that developers can use to extend their applications. The API reference is added to the documentation, along with a new plugin development tutorial. The plugin v0 API remains supported for backward compatibility of existing plugins. Done: - Do not load commands from plugins which are not enabled. - Load enabled plugins once on start. - Implement contexts for actions and filters, which allow us to keep track of the source of every hook. - Migrate patches - Migrate commands - Migrate plugin detection - Migrate templates_root - Migrate config - Migrate template environment globals and filters - Migrate hooks to tasks - Generate hook documentation - Generate patch reference documentation - Add the concept of action priority Close #499.
2022-02-07 17:11:43 +00:00
from tutor.types import Config
class ComposeTaskRunner(BaseComposeTaskRunner):
def __init__(self, root: str, config: Config):
super().__init__(root, config)
self.project_name = ""
self.docker_compose_files: list[str] = []
self.docker_compose_job_files: list[str] = []
def docker_compose(self, *command: str) -> int:
"""
Run docker-compose with the right yml files.
"""
if "start" in command or "up" in command or "restart" in command:
# Note that we don't trigger the action on "run". That's because we
# don't want to trigger the action for every initialization script.
hooks.Actions.COMPOSE_PROJECT_STARTED.do(
self.root, self.config, self.project_name
)
args = []
for docker_compose_path in self.docker_compose_files:
if os.path.exists(docker_compose_path):
args += ["-f", docker_compose_path]
return utils.docker_compose(
*args, "--project-name", self.project_name, *command
)
def run_task(self, service: str, command: str) -> int:
"""
Run the "{{ service }}-job" service from local/docker-compose.jobs.yml with the
2021-11-02 17:24:38 +00:00
specified command.
"""
run_command = []
for docker_compose_path in self.docker_compose_job_files:
path = tutor_env.pathjoin(self.root, docker_compose_path)
if os.path.exists(path):
run_command += ["-f", path]
run_command += ["run", "--rm"]
if not utils.is_a_tty():
run_command += ["-T"]
job_service_name = f"{service}-job"
return self.docker_compose(
*run_command,
job_service_name,
"sh",
"-e",
"-c",
command,
)
class BaseComposeContext(BaseTaskContext):
NAME: t.Literal["local", "dev"]
def job_runner(self, config: Config) -> ComposeTaskRunner:
raise NotImplementedError
@click.command(help="Configure and run Open edX from scratch")
@click.option("-I", "--non-interactive", is_flag=True, help="Run non-interactively")
@click.option("-p", "--pullimages", is_flag=True, help="Update docker images")
@click.option("--skip-build", is_flag=True, help="Skip building Docker images")
@click.pass_context
def launch(
context: click.Context,
non_interactive: bool,
pullimages: bool,
skip_build: bool,
) -> None:
context_name = context.obj.NAME
run_for_prod = False if context_name == "dev" else None
utils.warn_macos_docker_memory()
# Upgrade has to run before configuration
interactive_upgrade(context, not non_interactive, run_for_prod=run_for_prod)
interactive_configuration(context, not non_interactive, run_for_prod=run_for_prod)
config = tutor_config.load(context.obj.root)
if not skip_build:
click.echo(fmt.title("Building Docker images"))
images_to_build = hooks.Filters.IMAGES_BUILD_REQUIRED.apply([], context_name)
if not images_to_build:
fmt.echo_info("No image to build")
context.invoke(images.build, image_names=images_to_build)
click.echo(fmt.title("Stopping any existing platform"))
context.invoke(stop)
if pullimages:
click.echo(fmt.title("Docker image updates"))
context.invoke(dc_command, command="pull")
click.echo(fmt.title("Starting the platform in detached mode"))
context.invoke(start, detach=True)
click.echo(fmt.title("Database creation and migrations"))
context.invoke(do.commands["init"])
# Print the urls of the user-facing apps
public_app_hosts = ""
for host in hooks.Filters.APP_PUBLIC_HOSTS.iterate(context_name):
public_app_host = tutor_env.render_str(
config, "{% if ENABLE_HTTPS %}https{% else %}http{% endif %}://" + host
)
public_app_hosts += f" {public_app_host}\n"
if public_app_hosts:
fmt.echo_info(
f"""The platform is now running and can be accessed at the following urls:
{public_app_hosts}"""
)
def interactive_upgrade(
context: click.Context, interactive: bool, run_for_prod: t.Optional[bool]
) -> None:
"""
Piece of code that is only used in launch.
"""
run_upgrade_from_release = tutor_env.should_upgrade_from_release(context.obj.root)
if run_upgrade_from_release is not None:
click.echo(fmt.title("Upgrading from an older release"))
if interactive:
to_release = tutor_env.get_current_open_edx_release_name()
question = f"""You are about to upgrade your Open edX platform from {run_upgrade_from_release.capitalize()} to {to_release.capitalize()}
It is strongly recommended to make a backup before upgrading. To do so, run:
tutor local stop # or 'tutor dev stop' in development
sudo rsync -avr "$(tutor config printroot)"/ /tmp/tutor-backup/
In case of problem, to restore your backup you will then have to run: sudo rsync -avr /tmp/tutor-backup/ "$(tutor config printroot)"/
Are you sure you want to continue?"""
click.confirm(
fmt.question(question), default=True, abort=True, prompt_suffix=" "
)
context.invoke(
upgrade,
from_release=run_upgrade_from_release,
)
# Update env and configuration
# Don't run in interactive mode, otherwise users gets prompted twice.
interactive_configuration(context, False, run_for_prod)
# Post upgrade
if interactive:
question = f"""Your platform is being upgraded from {run_upgrade_from_release.capitalize()}.
If you run custom Docker images, you must rebuild them now by running the following command in a different shell:
tutor images build all # list your custom images here
See the documentation for more information:
https://docs.tutor.edly.io/install.html#upgrading-to-a-new-open-edx-release
Press enter when you are ready to continue"""
click.confirm(
fmt.question(question), default=True, abort=True, prompt_suffix=" "
)
def interactive_configuration(
context: click.Context, interactive: bool, run_for_prod: t.Optional[bool] = None
) -> None:
click.echo(fmt.title("Interactive platform configuration"))
config = tutor_config.load_minimal(context.obj.root)
if interactive:
interactive_config.ask_questions(config, run_for_prod=run_for_prod)
tutor_config.save_config_file(context.obj.root, config)
config = tutor_config.load_full(context.obj.root)
tutor_env.save(context.obj.root, config)
@click.command(
short_help="Perform release-specific upgrade tasks",
help="Perform release-specific upgrade tasks. To perform a full upgrade remember to run `launch`.",
)
@click.option(
"--from",
"from_release",
type=click.Choice(OPENEDX_RELEASE_NAMES),
)
@click.pass_context
def upgrade(context: click.Context, from_release: t.Optional[str]) -> None:
fmt.echo_alert(
"This command only performs a partial upgrade of your Open edX platform. "
"To perform a full upgrade, you should run `tutor local launch` (or `tutor dev launch` "
"in development)."
)
if from_release is None:
from_release = tutor_env.get_env_release(context.obj.root)
if from_release is None:
fmt.echo_info("Your environment is already up-to-date")
else:
upgrade_from(context, from_release)
# We update the environment to update the version
context.invoke(config_save_command)
@click.command(
short_help="Run all or a selection of services.",
help="Run all or a selection of services. Docker images will be rebuilt where necessary.",
)
@click.option("--build", is_flag=True, help="Build images on start")
@click.option("-d", "--detach", is_flag=True, help="Start in daemon mode")
@click.argument("services", metavar="service", nargs=-1)
@click.pass_obj
feat: upgrade to Maple - A shared cookie domain between lms and cms is no longer recommended: https://github.com/edx/edx-platform/blob/master/docs/guides/studio_oauth.rst - refactor: clean mounted data folder in lms/cms. In Lilac, the bind-mounted lms/data and cms/data folders are a mess because new folders are created there for every new course organisation. These folders are empty. As far as we know they are useless... With this change we move these folders to a dedicated "modulestore" subdirectory; which corresponds better to the initial intent of the fs_root setting. - fix: frontend failure during login to the lms. See: https://github.com/openedx/build-test-release-wg/issues/104 - feat: move all forum-related code to a dedicated plugin. Forum is an optional feature, and as such it deserves its own plugin. Starting from Maple, users will be able to install the forum from https://github.com/overhangio/tutor-forum/ - migrate from DCS_* session cookie settings to SESSION_*. That's because edx-platform no longer depends on django-cookies-samesite. Close https://github.com/openedx/build-test-release-wg/issues/110 - get rid of tons of deprecation warnings in the lms/cms - feat: make it possible to point to themed assets. Cherry-picking this change makes it possible to point to themed assets with a theme-agnostic url, notably from MFEs. - Install all official plugins as part of the `tutor[full]` package. - Don't print error messages about loading plugins during autocompletion. - Prompt for image building when upgrading from one release to the next. - Add `tutor local start --skip-build` option to skip building Docker images. Close #450. Close #545.
2021-10-18 09:43:40 +00:00
def start(
context: BaseComposeContext,
build: bool,
detach: bool,
services: list[str],
feat: upgrade to Maple - A shared cookie domain between lms and cms is no longer recommended: https://github.com/edx/edx-platform/blob/master/docs/guides/studio_oauth.rst - refactor: clean mounted data folder in lms/cms. In Lilac, the bind-mounted lms/data and cms/data folders are a mess because new folders are created there for every new course organisation. These folders are empty. As far as we know they are useless... With this change we move these folders to a dedicated "modulestore" subdirectory; which corresponds better to the initial intent of the fs_root setting. - fix: frontend failure during login to the lms. See: https://github.com/openedx/build-test-release-wg/issues/104 - feat: move all forum-related code to a dedicated plugin. Forum is an optional feature, and as such it deserves its own plugin. Starting from Maple, users will be able to install the forum from https://github.com/overhangio/tutor-forum/ - migrate from DCS_* session cookie settings to SESSION_*. That's because edx-platform no longer depends on django-cookies-samesite. Close https://github.com/openedx/build-test-release-wg/issues/110 - get rid of tons of deprecation warnings in the lms/cms - feat: make it possible to point to themed assets. Cherry-picking this change makes it possible to point to themed assets with a theme-agnostic url, notably from MFEs. - Install all official plugins as part of the `tutor[full]` package. - Don't print error messages about loading plugins during autocompletion. - Prompt for image building when upgrading from one release to the next. - Add `tutor local start --skip-build` option to skip building Docker images. Close #450. Close #545.
2021-10-18 09:43:40 +00:00
) -> None:
command = ["up", "--remove-orphans"]
if build:
feat: upgrade to Maple - A shared cookie domain between lms and cms is no longer recommended: https://github.com/edx/edx-platform/blob/master/docs/guides/studio_oauth.rst - refactor: clean mounted data folder in lms/cms. In Lilac, the bind-mounted lms/data and cms/data folders are a mess because new folders are created there for every new course organisation. These folders are empty. As far as we know they are useless... With this change we move these folders to a dedicated "modulestore" subdirectory; which corresponds better to the initial intent of the fs_root setting. - fix: frontend failure during login to the lms. See: https://github.com/openedx/build-test-release-wg/issues/104 - feat: move all forum-related code to a dedicated plugin. Forum is an optional feature, and as such it deserves its own plugin. Starting from Maple, users will be able to install the forum from https://github.com/overhangio/tutor-forum/ - migrate from DCS_* session cookie settings to SESSION_*. That's because edx-platform no longer depends on django-cookies-samesite. Close https://github.com/openedx/build-test-release-wg/issues/110 - get rid of tons of deprecation warnings in the lms/cms - feat: make it possible to point to themed assets. Cherry-picking this change makes it possible to point to themed assets with a theme-agnostic url, notably from MFEs. - Install all official plugins as part of the `tutor[full]` package. - Don't print error messages about loading plugins during autocompletion. - Prompt for image building when upgrading from one release to the next. - Add `tutor local start --skip-build` option to skip building Docker images. Close #450. Close #545.
2021-10-18 09:43:40 +00:00
command.append("--build")
if detach:
command.append("-d")
# Start services
config = tutor_config.load(context.root)
context.job_runner(config).docker_compose(*command, *services)
@click.command(help="Stop a running platform")
@click.argument("services", metavar="service", nargs=-1)
@click.pass_obj
def stop(context: BaseComposeContext, services: list[str]) -> None:
config = tutor_config.load(context.root)
context.job_runner(config).docker_compose("stop", *services)
@click.command(
short_help="Reboot an existing platform",
help="This is more than just a restart: with reboot, the platform is fully stopped before being restarted again",
)
@click.option("-d", "--detach", is_flag=True, help="Start in daemon mode")
@click.argument("services", metavar="service", nargs=-1)
@click.pass_context
def reboot(context: click.Context, detach: bool, services: list[str]) -> None:
context.invoke(stop, services=services)
context.invoke(start, detach=detach, services=services)
@click.command(
short_help="Restart some components from a running platform.",
help="""Specify 'openedx' to restart the lms, cms and workers, or 'all' to
restart all services. Note that this performs a 'docker compose restart', so new images
may not be taken into account. It is useful for reloading settings, for instance. To
fully stop the platform, use the 'reboot' command.""",
)
@click.argument("services", metavar="service", nargs=-1)
@click.pass_obj
def restart(context: BaseComposeContext, services: list[str]) -> None:
config = tutor_config.load(context.root)
command = ["restart"]
if "all" in services:
pass
else:
for service in services:
if service == "openedx":
command += ["lms", "lms-worker", "cms", "cms-worker"]
else:
command.append(service)
context.job_runner(config).docker_compose(*command)
@jobs.do_group
def do() -> None:
"""
Run a custom job in the right container(s).
"""
@click.command(
short_help="Run a command in a new container",
help=(
"Run a command in a new container. This is a wrapper around `docker compose run`. Any option or argument passed"
" to this command will be forwarded to docker compose. Thus, you may use `-v` or `-p` to mount volumes and"
" expose ports."
),
context_settings={"ignore_unknown_options": True},
)
@click.argument("args", nargs=-1, required=True)
@click.pass_context
def run(
context: click.Context,
args: list[str],
) -> None:
extra_args = ["--rm"]
if not utils.is_a_tty():
extra_args.append("-T")
context.invoke(dc_command, command="run", args=[*extra_args, *args])
@click.command(
name="copyfrom",
help="Copy files/folders from a container directory to the local filesystem.",
)
@click.argument("service")
@click.argument("container_path")
@click.argument(
"host_path",
type=click.Path(dir_okay=True, file_okay=False, resolve_path=True),
)
@click.pass_obj
def copyfrom(
context: BaseComposeContext, service: str, container_path: str, host_path: str
) -> None:
# Path management
container_root_path = "/tmp/mount"
container_dst_path = container_root_path
if not os.path.exists(host_path):
# Emulate cp semantics, where if the destination path does not exist
# then we copy to its parent and rename to the destination folder
container_dst_path += "/" + os.path.basename(host_path)
host_path = os.path.dirname(host_path)
if not os.path.exists(host_path):
raise TutorError(
f"Cannot create directory {host_path}. No such file or directory."
)
# cp/mv commands
command = f"cp --recursive --preserve {container_path} {container_dst_path}"
config = tutor_config.load(context.root)
runner = context.job_runner(config)
runner.docker_compose(
"run",
"--rm",
"--no-deps",
"--user=0",
f"--volume={host_path}:{container_root_path}",
service,
"sh",
"-e",
"-c",
command,
)
@click.command(
short_help="Run a command in a running container",
help=(
"Run a command in a running container. This is a wrapper around `docker compose exec`. Any option or argument"
" passed to this command will be forwarded to docker-compose. Thus, you may use `-e` to manually define"
" environment variables."
),
context_settings={"ignore_unknown_options": True},
name="exec",
)
@click.argument("args", nargs=-1, required=True)
@click.pass_context
def execute(context: click.Context, args: list[str]) -> None:
context.invoke(dc_command, command="exec", args=args)
@click.command(
short_help="View output from containers",
help="View output from containers. This is a wrapper around `docker compose logs`.",
)
@click.option("-f", "--follow", is_flag=True, help="Follow log output")
@click.option("--tail", type=int, help="Number of lines to show from each container")
@click.argument("service", nargs=-1)
@click.pass_context
def logs(context: click.Context, follow: bool, tail: bool, service: str) -> None:
args = []
if follow:
args.append("--follow")
if tail is not None:
args += ["--tail", str(tail)]
args += service
context.invoke(dc_command, command="logs", args=args)
@click.command(help="Print status information for containers")
@click.pass_context
def status(context: click.Context) -> None:
context.invoke(dc_command, command="ps")
@click.command(
short_help="Direct interface to docker compose.",
help=(
"Direct interface to docker compose. This is a wrapper around `docker compose`. Most commands, options and"
" arguments passed to this command will be forwarded as-is to docker compose."
),
context_settings={"ignore_unknown_options": True},
name="dc",
)
@click.argument("command")
@click.argument("args", nargs=-1)
@click.pass_obj
def dc_command(
context: BaseComposeContext,
command: str,
args: list[str],
) -> None:
config = tutor_config.load(context.root)
context.job_runner(config).docker_compose(command, *args)
hooks.Filters.ENV_TEMPLATE_VARIABLES.add_item(("iter_mounts", bindmount.iter_mounts))
def add_commands(command_group: click.Group) -> None:
command_group.add_command(launch)
command_group.add_command(upgrade)
command_group.add_command(start)
command_group.add_command(stop)
command_group.add_command(restart)
command_group.add_command(reboot)
command_group.add_command(dc_command)
command_group.add_command(run)
command_group.add_command(copyfrom)
command_group.add_command(execute)
command_group.add_command(logs)
command_group.add_command(status)
@hooks.Actions.PLUGINS_LOADED.add()
def _add_do_commands() -> None:
jobs.add_job_commands(do)
command_group.add_command(do)