mirror of
https://github.com/ChristianLight/tutor.git
synced 2024-09-27 19:59:02 +00:00
b51fdb46f3
User was no longer asked whether they wanted to run on prod or not. In other words, it was not convenient to run as local.overhang.io.
482 lines
16 KiB
Python
482 lines
16 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
import typing as t
|
|
|
|
import click
|
|
|
|
from tutor import bindmount
|
|
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
|
|
from tutor.exceptions import TutorError
|
|
from tutor.tasks import BaseComposeTaskRunner
|
|
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
|
|
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.overhang.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
|
|
def start(
|
|
context: BaseComposeContext,
|
|
build: bool,
|
|
detach: bool,
|
|
services: list[str],
|
|
) -> None:
|
|
command = ["up", "--remove-orphans"]
|
|
if build:
|
|
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.COMPOSE_MOUNTS.add()
|
|
def _mount_edx_platform(
|
|
volumes: list[tuple[str, str]], name: str
|
|
) -> list[tuple[str, str]]:
|
|
"""
|
|
When mounting edx-platform with `tutor mounts add /path/to/edx-platform`,
|
|
bind-mount the host repo in the lms/cms containers.
|
|
"""
|
|
if name == "edx-platform":
|
|
path = "/openedx/edx-platform"
|
|
volumes += [
|
|
("lms", path),
|
|
("cms", path),
|
|
("lms-worker", path),
|
|
("cms-worker", path),
|
|
("lms-job", path),
|
|
("cms-job", path),
|
|
]
|
|
return volumes
|
|
|
|
|
|
@hooks.Filters.APP_PUBLIC_HOSTS.add()
|
|
def _edx_platform_public_hosts(
|
|
hosts: list[str], context_name: t.Literal["local", "dev"]
|
|
) -> list[str]:
|
|
if context_name == "dev":
|
|
hosts += ["{{ LMS_HOST }}:8000", "{{ CMS_HOST }}:8001"]
|
|
else:
|
|
hosts += ["{{ LMS_HOST }}", "{{ CMS_HOST }}"]
|
|
return hosts
|
|
|
|
|
|
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)
|