mirror of
https://github.com/ChristianLight/tutor.git
synced 2025-01-22 13:18:24 +00:00
refactor: clarify configuration management
Previously, configuration management was very confusing because we kept mixing "base" and "defaults" configuration: - It was difficult to make the difference between core settings that were necessary (e.g: passwords) as opposed to others that could simply be defaulted to. - The order of settings in config.yml mattered: config entries that depended on other needed to be defined later. As a consequence, Tutor was not compatible with Python 3.5, where dict entries are not sorted.
This commit is contained in:
parent
b18c9dc4f8
commit
c40e682f5d
@ -2,6 +2,8 @@ import unittest
|
|||||||
from unittest.mock import Mock, patch
|
from unittest.mock import Mock, patch
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
from tutor import config as tutor_config
|
from tutor import config as tutor_config
|
||||||
from tutor import interactive
|
from tutor import interactive
|
||||||
from tutor.types import get_typed, Config
|
from tutor.types import get_typed, Config
|
||||||
@ -9,7 +11,7 @@ from tutor.types import get_typed, Config
|
|||||||
|
|
||||||
class ConfigTests(unittest.TestCase):
|
class ConfigTests(unittest.TestCase):
|
||||||
def test_version(self) -> None:
|
def test_version(self) -> None:
|
||||||
defaults = tutor_config.load_defaults()
|
defaults = tutor_config.get_defaults({})
|
||||||
self.assertNotIn("TUTOR_VERSION", defaults)
|
self.assertNotIn("TUTOR_VERSION", defaults)
|
||||||
|
|
||||||
def test_merge(self) -> None:
|
def test_merge(self) -> None:
|
||||||
@ -18,22 +20,21 @@ class ConfigTests(unittest.TestCase):
|
|||||||
tutor_config.merge(config1, config2)
|
tutor_config.merge(config1, config2)
|
||||||
self.assertEqual({"x": "y"}, config1)
|
self.assertEqual({"x": "y"}, config1)
|
||||||
|
|
||||||
def test_merge_render(self) -> None:
|
def test_merge_not_render(self) -> None:
|
||||||
config: Config = {}
|
config: Config = {}
|
||||||
defaults = tutor_config.load_defaults()
|
base = tutor_config.get_base({})
|
||||||
with patch.object(tutor_config.utils, "random_string", return_value="abcd"):
|
with patch.object(tutor_config.utils, "random_string", return_value="abcd"):
|
||||||
tutor_config.merge(config, defaults)
|
tutor_config.merge(config, base)
|
||||||
|
|
||||||
self.assertEqual("abcd", config["MYSQL_ROOT_PASSWORD"])
|
# Check that merge does not perform a rendering
|
||||||
|
self.assertNotEqual("abcd", config["MYSQL_ROOT_PASSWORD"])
|
||||||
|
|
||||||
@patch.object(tutor_config.fmt, "echo")
|
@patch.object(tutor_config.fmt, "echo")
|
||||||
def test_update_twice(self, _: Mock) -> None:
|
def test_save_load(self, _: Mock) -> None:
|
||||||
with tempfile.TemporaryDirectory() as root:
|
with tempfile.TemporaryDirectory() as root:
|
||||||
tutor_config.update(root)
|
config1 = tutor_config.load_minimal(root)
|
||||||
config1 = tutor_config.load_user(root)
|
tutor_config.save_config_file(root, config1)
|
||||||
|
config2 = tutor_config.load_minimal(root)
|
||||||
tutor_config.update(root)
|
|
||||||
config2 = tutor_config.load_user(root)
|
|
||||||
|
|
||||||
self.assertEqual(config1, config2)
|
self.assertEqual(config1, config2)
|
||||||
|
|
||||||
@ -44,28 +45,32 @@ class ConfigTests(unittest.TestCase):
|
|||||||
tutor_config.utils, "random_string"
|
tutor_config.utils, "random_string"
|
||||||
) as mock_random_string:
|
) as mock_random_string:
|
||||||
mock_random_string.return_value = "abcd"
|
mock_random_string.return_value = "abcd"
|
||||||
config1, _defaults1 = tutor_config.load_all(root)
|
config1 = tutor_config.load_full(root)
|
||||||
password1 = config1["MYSQL_ROOT_PASSWORD"]
|
password1 = config1["MYSQL_ROOT_PASSWORD"]
|
||||||
|
|
||||||
config1.pop("MYSQL_ROOT_PASSWORD")
|
config1.pop("MYSQL_ROOT_PASSWORD")
|
||||||
tutor_config.save_config_file(root, config1)
|
tutor_config.save_config_file(root, config1)
|
||||||
|
|
||||||
mock_random_string.return_value = "efgh"
|
mock_random_string.return_value = "efgh"
|
||||||
config2, _defaults2 = tutor_config.load_all(root)
|
config2 = tutor_config.load_full(root)
|
||||||
password2 = config2["MYSQL_ROOT_PASSWORD"]
|
password2 = config2["MYSQL_ROOT_PASSWORD"]
|
||||||
|
|
||||||
self.assertEqual("abcd", password1)
|
self.assertEqual("abcd", password1)
|
||||||
self.assertEqual("efgh", password2)
|
self.assertEqual("efgh", password2)
|
||||||
|
|
||||||
def test_interactive_load_all(self) -> None:
|
def test_interactive(self) -> None:
|
||||||
|
def mock_prompt(*_args: None, **kwargs: str) -> str:
|
||||||
|
return kwargs["default"]
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as rootdir:
|
with tempfile.TemporaryDirectory() as rootdir:
|
||||||
config, defaults = interactive.load_all(rootdir, interactive=False)
|
with patch.object(click, "prompt", new=mock_prompt):
|
||||||
|
with patch.object(click, "confirm", new=mock_prompt):
|
||||||
|
config = interactive.load_user_config(rootdir, interactive=True)
|
||||||
|
|
||||||
self.assertIn("MYSQL_ROOT_PASSWORD", config)
|
self.assertIn("MYSQL_ROOT_PASSWORD", config)
|
||||||
self.assertEqual(8, len(get_typed(config, "MYSQL_ROOT_PASSWORD", str)))
|
self.assertEqual(8, len(get_typed(config, "MYSQL_ROOT_PASSWORD", str)))
|
||||||
self.assertNotIn("LMS_HOST", config)
|
self.assertEqual("www.myopenedx.com", config["LMS_HOST"])
|
||||||
self.assertEqual("www.myopenedx.com", defaults["LMS_HOST"])
|
self.assertEqual("studio.www.myopenedx.com", config["CMS_HOST"])
|
||||||
self.assertEqual("studio.{{ LMS_HOST }}", defaults["CMS_HOST"])
|
|
||||||
|
|
||||||
def test_is_service_activated(self) -> None:
|
def test_is_service_activated(self) -> None:
|
||||||
config: Config = {"RUN_SERVICE1": True, "RUN_SERVICE2": False}
|
config: Config = {"RUN_SERVICE1": True, "RUN_SERVICE2": False}
|
||||||
|
@ -63,7 +63,10 @@ class EnvTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_render_file(self) -> None:
|
def test_render_file(self) -> None:
|
||||||
config: Config = {}
|
config: Config = {}
|
||||||
tutor_config.merge(config, tutor_config.load_defaults())
|
tutor_config.update_with_base(config)
|
||||||
|
tutor_config.update_with_defaults(config)
|
||||||
|
tutor_config.render_full(config)
|
||||||
|
|
||||||
config["MYSQL_ROOT_PASSWORD"] = "testpassword"
|
config["MYSQL_ROOT_PASSWORD"] = "testpassword"
|
||||||
rendered = env.render_file(config, "hooks", "mysql", "init")
|
rendered = env.render_file(config, "hooks", "mysql", "init")
|
||||||
self.assertIn("testpassword", rendered)
|
self.assertIn("testpassword", rendered)
|
||||||
@ -75,10 +78,8 @@ class EnvTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_save_full(self) -> None:
|
def test_save_full(self) -> None:
|
||||||
defaults = tutor_config.load_defaults()
|
|
||||||
with tempfile.TemporaryDirectory() as root:
|
with tempfile.TemporaryDirectory() as root:
|
||||||
config = tutor_config.load_current(root, defaults)
|
config = tutor_config.load_full(root)
|
||||||
tutor_config.merge(config, defaults)
|
|
||||||
with patch.object(fmt, "STDOUT"):
|
with patch.object(fmt, "STDOUT"):
|
||||||
env.save(root, config)
|
env.save(root, config)
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
@ -86,10 +87,8 @@ class EnvTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_save_full_with_https(self) -> None:
|
def test_save_full_with_https(self) -> None:
|
||||||
defaults = tutor_config.load_defaults()
|
|
||||||
with tempfile.TemporaryDirectory() as root:
|
with tempfile.TemporaryDirectory() as root:
|
||||||
config = tutor_config.load_current(root, defaults)
|
config = tutor_config.load_full(root)
|
||||||
tutor_config.merge(config, defaults)
|
|
||||||
config["ENABLE_HTTPS"] = True
|
config["ENABLE_HTTPS"] = True
|
||||||
with patch.object(fmt, "STDOUT"):
|
with patch.object(fmt, "STDOUT"):
|
||||||
env.save(root, config)
|
env.save(root, config)
|
||||||
|
@ -128,9 +128,6 @@ class PluginsTests(unittest.TestCase):
|
|||||||
self.assertEqual([], patches)
|
self.assertEqual([], patches)
|
||||||
|
|
||||||
def test_configure(self) -> None:
|
def test_configure(self) -> None:
|
||||||
config: Config = {"ID": "id"}
|
|
||||||
defaults: Config = {}
|
|
||||||
|
|
||||||
class plugin1:
|
class plugin1:
|
||||||
config: Config = {
|
config: Config = {
|
||||||
"add": {"PARAM1": "value1", "PARAM2": "value2"},
|
"add": {"PARAM1": "value1", "PARAM2": "value2"},
|
||||||
@ -143,37 +140,31 @@ class PluginsTests(unittest.TestCase):
|
|||||||
"iter_enabled",
|
"iter_enabled",
|
||||||
return_value=[plugins.BasePlugin("plugin1", plugin1)],
|
return_value=[plugins.BasePlugin("plugin1", plugin1)],
|
||||||
):
|
):
|
||||||
tutor_config.load_plugins(config, defaults)
|
base = tutor_config.get_base({})
|
||||||
|
defaults = tutor_config.get_defaults({})
|
||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(base["PARAM3"], "value3")
|
||||||
{
|
self.assertEqual(base["PLUGIN1_PARAM1"], "value1")
|
||||||
"ID": "id",
|
self.assertEqual(base["PLUGIN1_PARAM2"], "value2")
|
||||||
"PARAM3": "value3",
|
self.assertEqual(defaults["PLUGIN1_PARAM4"], "value4")
|
||||||
"PLUGIN1_PARAM1": "value1",
|
|
||||||
"PLUGIN1_PARAM2": "value2",
|
|
||||||
},
|
|
||||||
config,
|
|
||||||
)
|
|
||||||
self.assertEqual({"PLUGIN1_PARAM4": "value4"}, defaults)
|
|
||||||
|
|
||||||
def test_configure_set_does_not_override(self) -> None:
|
def test_configure_set_does_not_override(self) -> None:
|
||||||
config: Config = {"ID": "oldid"}
|
config: Config = {"ID1": "oldid"}
|
||||||
|
|
||||||
class plugin1:
|
class plugin1:
|
||||||
config: Config = {"set": {"ID": "newid"}}
|
config: Config = {"set": {"ID1": "newid", "ID2": "id2"}}
|
||||||
|
|
||||||
with patch.object(
|
with patch.object(
|
||||||
plugins.Plugins,
|
plugins.Plugins,
|
||||||
"iter_enabled",
|
"iter_enabled",
|
||||||
return_value=[plugins.BasePlugin("plugin1", plugin1)],
|
return_value=[plugins.BasePlugin("plugin1", plugin1)],
|
||||||
):
|
):
|
||||||
tutor_config.load_plugins(config, {})
|
tutor_config.update_with_base(config)
|
||||||
|
|
||||||
self.assertEqual({"ID": "oldid"}, config)
|
self.assertEqual("oldid", config["ID1"])
|
||||||
|
self.assertEqual("id2", config["ID2"])
|
||||||
|
|
||||||
def test_configure_set_random_string(self) -> None:
|
def test_configure_set_random_string(self) -> None:
|
||||||
config: Config = {}
|
|
||||||
|
|
||||||
class plugin1:
|
class plugin1:
|
||||||
config: Config = {"set": {"PARAM1": "{{ 128|random_string }}"}}
|
config: Config = {"set": {"PARAM1": "{{ 128|random_string }}"}}
|
||||||
|
|
||||||
@ -182,12 +173,13 @@ class PluginsTests(unittest.TestCase):
|
|||||||
"iter_enabled",
|
"iter_enabled",
|
||||||
return_value=[plugins.BasePlugin("plugin1", plugin1)],
|
return_value=[plugins.BasePlugin("plugin1", plugin1)],
|
||||||
):
|
):
|
||||||
tutor_config.load_plugins(config, {})
|
config = tutor_config.get_base({})
|
||||||
|
tutor_config.render_full(config)
|
||||||
|
|
||||||
self.assertEqual(128, len(get_typed(config, "PARAM1", str)))
|
self.assertEqual(128, len(get_typed(config, "PARAM1", str)))
|
||||||
|
|
||||||
def test_configure_default_value_with_previous_definition(self) -> None:
|
def test_configure_default_value_with_previous_definition(self) -> None:
|
||||||
config: Config = {}
|
config: Config = {"PARAM1": "value"}
|
||||||
defaults: Config = {"PARAM1": "value"}
|
|
||||||
|
|
||||||
class plugin1:
|
class plugin1:
|
||||||
config: Config = {"defaults": {"PARAM2": "{{ PARAM1 }}"}}
|
config: Config = {"defaults": {"PARAM2": "{{ PARAM1 }}"}}
|
||||||
@ -197,10 +189,10 @@ class PluginsTests(unittest.TestCase):
|
|||||||
"iter_enabled",
|
"iter_enabled",
|
||||||
return_value=[plugins.BasePlugin("plugin1", plugin1)],
|
return_value=[plugins.BasePlugin("plugin1", plugin1)],
|
||||||
):
|
):
|
||||||
tutor_config.load_plugins(config, defaults)
|
tutor_config.update_with_defaults(config)
|
||||||
self.assertEqual("{{ PARAM1 }}", defaults["PLUGIN1_PARAM2"])
|
self.assertEqual("{{ PARAM1 }}", config["PLUGIN1_PARAM2"])
|
||||||
|
|
||||||
def test_configure_add_twice(self) -> None:
|
def test_config_load_from_plugins(self) -> None:
|
||||||
config: Config = {}
|
config: Config = {}
|
||||||
|
|
||||||
class plugin1:
|
class plugin1:
|
||||||
@ -211,19 +203,12 @@ class PluginsTests(unittest.TestCase):
|
|||||||
"iter_enabled",
|
"iter_enabled",
|
||||||
return_value=[plugins.BasePlugin("plugin1", plugin1)],
|
return_value=[plugins.BasePlugin("plugin1", plugin1)],
|
||||||
):
|
):
|
||||||
tutor_config.load_plugins(config, {})
|
tutor_config.update_with_base(config)
|
||||||
|
tutor_config.update_with_defaults(config)
|
||||||
|
tutor_config.render_full(config)
|
||||||
value1 = get_typed(config, "PLUGIN1_PARAM1", str)
|
value1 = get_typed(config, "PLUGIN1_PARAM1", str)
|
||||||
with patch.object(
|
|
||||||
plugins.Plugins,
|
|
||||||
"iter_enabled",
|
|
||||||
return_value=[plugins.BasePlugin("plugin1", plugin1)],
|
|
||||||
):
|
|
||||||
tutor_config.load_plugins(config, {})
|
|
||||||
value2 = get_typed(config, "PLUGIN1_PARAM1", str)
|
|
||||||
|
|
||||||
self.assertEqual(10, len(value1))
|
self.assertEqual(10, len(value1))
|
||||||
self.assertEqual(10, len(value2))
|
|
||||||
self.assertEqual(value1, value2)
|
|
||||||
|
|
||||||
def test_hooks(self) -> None:
|
def test_hooks(self) -> None:
|
||||||
class plugin1:
|
class plugin1:
|
||||||
|
@ -50,16 +50,16 @@ def save(
|
|||||||
unset_vars: List[str],
|
unset_vars: List[str],
|
||||||
env_only: bool,
|
env_only: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
config, defaults = interactive_config.load_all(
|
config = interactive_config.load_user_config(context.root, interactive=interactive)
|
||||||
context.root, interactive=interactive
|
|
||||||
)
|
|
||||||
if set_vars:
|
if set_vars:
|
||||||
tutor_config.merge(config, dict(set_vars), force=True)
|
for key, value in dict(set_vars).items():
|
||||||
|
config[key] = env.render_unknown(config, value)
|
||||||
for key in unset_vars:
|
for key in unset_vars:
|
||||||
config.pop(key, None)
|
config.pop(key, None)
|
||||||
if not env_only:
|
if not env_only:
|
||||||
tutor_config.save_config_file(context.root, config)
|
tutor_config.save_config_file(context.root, config)
|
||||||
tutor_config.merge(config, defaults)
|
|
||||||
|
tutor_config.render_full(config)
|
||||||
env.save(context.root, config)
|
env.save(context.root, config)
|
||||||
|
|
||||||
|
|
||||||
@ -78,8 +78,8 @@ def save(
|
|||||||
def render(context: Context, extra_configs: List[str], src: str, dst: str) -> None:
|
def render(context: Context, extra_configs: List[str], src: str, dst: str) -> None:
|
||||||
config = tutor_config.load(context.root)
|
config = tutor_config.load(context.root)
|
||||||
for extra_config in extra_configs:
|
for extra_config in extra_configs:
|
||||||
tutor_config.merge(
|
config.update(
|
||||||
config, tutor_config.load_config_file(extra_config), force=True
|
env.render_unknown(config, tutor_config.get_yaml_file(extra_config))
|
||||||
)
|
)
|
||||||
|
|
||||||
renderer = env.Renderer(config, [src])
|
renderer = env.Renderer(config, [src])
|
||||||
|
@ -8,11 +8,11 @@ from .. import config as tutor_config
|
|||||||
from .. import env as tutor_env
|
from .. import env as tutor_env
|
||||||
from .. import exceptions
|
from .. import exceptions
|
||||||
from .. import fmt
|
from .. import fmt
|
||||||
from .. import interactive as interactive_config
|
|
||||||
from .. import jobs
|
from .. import jobs
|
||||||
from .. import serialize
|
from .. import serialize
|
||||||
from ..types import Config, get_typed
|
from ..types import Config, get_typed
|
||||||
from .. import utils
|
from .. import utils
|
||||||
|
from .config import save as config_save_command
|
||||||
from .context import Context
|
from .context import Context
|
||||||
|
|
||||||
|
|
||||||
@ -162,9 +162,13 @@ def k8s() -> None:
|
|||||||
@click.pass_context
|
@click.pass_context
|
||||||
def quickstart(context: click.Context, non_interactive: bool) -> None:
|
def quickstart(context: click.Context, non_interactive: bool) -> None:
|
||||||
click.echo(fmt.title("Interactive platform configuration"))
|
click.echo(fmt.title("Interactive platform configuration"))
|
||||||
config = interactive_config.update(
|
context.invoke(
|
||||||
context.obj.root, interactive=(not non_interactive)
|
config_save_command,
|
||||||
|
interactive=(not non_interactive),
|
||||||
|
set_vars=[],
|
||||||
|
unset_vars=[],
|
||||||
)
|
)
|
||||||
|
config = tutor_config.load(context.obj.root)
|
||||||
if not config["ENABLE_WEB_PROXY"]:
|
if not config["ENABLE_WEB_PROXY"]:
|
||||||
fmt.echo_alert(
|
fmt.echo_alert(
|
||||||
"Potentially invalid configuration: ENABLE_WEB_PROXY=false\n"
|
"Potentially invalid configuration: ENABLE_WEB_PROXY=false\n"
|
||||||
|
@ -109,7 +109,7 @@ Your Open edX platform is ready and can be accessed at the following urls:
|
|||||||
@click.option("-I", "--non-interactive", is_flag=True, help="Run non-interactively")
|
@click.option("-I", "--non-interactive", is_flag=True, help="Run non-interactively")
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def upgrade(context: click.Context, from_version: str, non_interactive: bool) -> None:
|
def upgrade(context: click.Context, from_version: str, non_interactive: bool) -> None:
|
||||||
config = tutor_config.load_no_check(context.obj.root)
|
config = tutor_config.load_full(context.obj.root)
|
||||||
|
|
||||||
if not non_interactive:
|
if not non_interactive:
|
||||||
question = """You are about to upgrade your Open edX platform. It is strongly recommended to make a backup before upgrading. To do so, run:
|
question = """You are about to upgrade your Open edX platform. It is strongly recommended to make a backup before upgrading. To do so, run:
|
||||||
|
@ -28,7 +28,7 @@ def plugins_command() -> None:
|
|||||||
@click.command(name="list", help="List installed plugins")
|
@click.command(name="list", help="List installed plugins")
|
||||||
@click.pass_obj
|
@click.pass_obj
|
||||||
def list_command(context: Context) -> None:
|
def list_command(context: Context) -> None:
|
||||||
config = tutor_config.load_user(context.root)
|
config = tutor_config.load_full(context.root)
|
||||||
for plugin in plugins.iter_installed():
|
for plugin in plugins.iter_installed():
|
||||||
status = "" if plugins.is_enabled(config, plugin.name) else " (disabled)"
|
status = "" if plugins.is_enabled(config, plugin.name) else " (disabled)"
|
||||||
print(
|
print(
|
||||||
@ -42,7 +42,7 @@ def list_command(context: Context) -> None:
|
|||||||
@click.argument("plugin_names", metavar="plugin", nargs=-1)
|
@click.argument("plugin_names", metavar="plugin", nargs=-1)
|
||||||
@click.pass_obj
|
@click.pass_obj
|
||||||
def enable(context: Context, plugin_names: List[str]) -> None:
|
def enable(context: Context, plugin_names: List[str]) -> None:
|
||||||
config = tutor_config.load_user(context.root)
|
config = tutor_config.load_full(context.root)
|
||||||
for plugin in plugin_names:
|
for plugin in plugin_names:
|
||||||
plugins.enable(config, plugin)
|
plugins.enable(config, plugin)
|
||||||
fmt.echo_info("Plugin {} enabled".format(plugin))
|
fmt.echo_info("Plugin {} enabled".format(plugin))
|
||||||
@ -59,7 +59,7 @@ def enable(context: Context, plugin_names: List[str]) -> None:
|
|||||||
@click.argument("plugin_names", metavar="plugin", nargs=-1)
|
@click.argument("plugin_names", metavar="plugin", nargs=-1)
|
||||||
@click.pass_obj
|
@click.pass_obj
|
||||||
def disable(context: Context, plugin_names: List[str]) -> None:
|
def disable(context: Context, plugin_names: List[str]) -> None:
|
||||||
config = tutor_config.load_user(context.root)
|
config = tutor_config.load_full(context.root)
|
||||||
disable_all = "all" in plugin_names
|
disable_all = "all" in plugin_names
|
||||||
for plugin in plugins.iter_enabled(config):
|
for plugin in plugins.iter_enabled(config):
|
||||||
if disable_all or plugin.name in plugin_names:
|
if disable_all or plugin.name in plugin_names:
|
||||||
|
247
tutor/config.py
247
tutor/config.py
@ -1,134 +1,178 @@
|
|||||||
import os
|
import os
|
||||||
from typing import Tuple
|
|
||||||
|
|
||||||
from . import env, exceptions, fmt, plugins, serialize, utils
|
from . import env, exceptions, fmt, plugins, serialize, utils
|
||||||
from .types import Config, cast_config
|
from .types import Config, cast_config
|
||||||
|
|
||||||
|
|
||||||
def update(root: str) -> Config:
|
|
||||||
"""
|
|
||||||
Load and save the configuration.
|
|
||||||
"""
|
|
||||||
config, defaults = load_all(root)
|
|
||||||
save_config_file(root, config)
|
|
||||||
merge(config, defaults)
|
|
||||||
return config
|
|
||||||
|
|
||||||
|
|
||||||
def load(root: str) -> Config:
|
def load(root: str) -> Config:
|
||||||
"""
|
"""
|
||||||
Load full configuration. This will raise an exception if there is no current
|
Load full configuration.
|
||||||
configuration in the project root.
|
|
||||||
|
This will raise an exception if there is no current configuration in the
|
||||||
|
project root. A warning will also be printed if the version from disk
|
||||||
|
differs from the package version.
|
||||||
"""
|
"""
|
||||||
check_existing_config(root)
|
if not os.path.exists(config_path(root)):
|
||||||
return load_no_check(root)
|
raise exceptions.TutorError(
|
||||||
|
"Project root does not exist. Make sure to generate the initial "
|
||||||
|
"configuration with `tutor config save --interactive` or `tutor local "
|
||||||
|
"quickstart` prior to running other commands."
|
||||||
|
)
|
||||||
|
env.check_is_up_to_date(root)
|
||||||
|
convert_json2yml(root)
|
||||||
|
return load_full(root)
|
||||||
|
|
||||||
|
|
||||||
def load_no_check(root: str) -> Config:
|
def load_minimal(root: str) -> Config:
|
||||||
config, defaults = load_all(root)
|
"""
|
||||||
merge(config, defaults)
|
Load a minimal configuration composed of the user and the base config.
|
||||||
|
|
||||||
|
This configuration is not suitable for rendering templates, as it is incomplete.
|
||||||
|
"""
|
||||||
|
config = get_user(root)
|
||||||
|
update_with_base(config)
|
||||||
|
render_full(config)
|
||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
def load_all(root: str) -> Tuple[Config, Config]:
|
def load_full(root: str) -> Config:
|
||||||
"""
|
"""
|
||||||
Return:
|
Load a full configuration, with user, base and defaults.
|
||||||
current (dict): params currently saved in config.yml
|
|
||||||
defaults (dict): default values of params which might be missing from the
|
|
||||||
current config
|
|
||||||
"""
|
"""
|
||||||
defaults = load_defaults()
|
config = get_user(root)
|
||||||
current = load_current(root, defaults)
|
update_with_base(config)
|
||||||
return current, defaults
|
update_with_defaults(config)
|
||||||
|
render_full(config)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
def merge(config: Config, defaults: Config, force: bool = False) -> None:
|
def update_with_base(config: Config) -> None:
|
||||||
"""
|
"""
|
||||||
Merge default values with user configuration and perform rendering of "{{...}}"
|
Add base configuration to the config object.
|
||||||
values.
|
|
||||||
|
Note that configuration entries are unrendered at this point.
|
||||||
"""
|
"""
|
||||||
for key, value in defaults.items():
|
base = get_base(config)
|
||||||
if force or key not in config:
|
merge(config, base)
|
||||||
config[key] = env.render_unknown(config, value)
|
|
||||||
|
|
||||||
|
|
||||||
def load_defaults() -> Config:
|
def update_with_defaults(config: Config) -> None:
|
||||||
config = serialize.load(env.read_template_file("config.yml"))
|
"""
|
||||||
|
Add default configuration to the config object.
|
||||||
|
|
||||||
|
Note that configuration entries are unrendered at this point.
|
||||||
|
"""
|
||||||
|
defaults = get_defaults(config)
|
||||||
|
merge(config, defaults)
|
||||||
|
|
||||||
|
|
||||||
|
def update_with_env(config: Config) -> None:
|
||||||
|
"""
|
||||||
|
Override config values from environment variables.
|
||||||
|
"""
|
||||||
|
overrides = {}
|
||||||
|
for k in config.keys():
|
||||||
|
env_var = "TUTOR_" + k
|
||||||
|
if env_var in os.environ:
|
||||||
|
overrides[k] = serialize.parse(os.environ[env_var])
|
||||||
|
config.update(overrides)
|
||||||
|
|
||||||
|
|
||||||
|
def get_user(root: str) -> Config:
|
||||||
|
"""
|
||||||
|
Get the user configuration from the tutor root.
|
||||||
|
|
||||||
|
Overrides from environment variables are loaded as well.
|
||||||
|
"""
|
||||||
|
path = config_path(root)
|
||||||
|
config = {}
|
||||||
|
if os.path.exists(path):
|
||||||
|
config = get_yaml_file(path)
|
||||||
|
upgrade_obsolete(config)
|
||||||
|
update_with_env(config)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def get_base(config: Config) -> Config:
|
||||||
|
"""
|
||||||
|
Load the base configuration.
|
||||||
|
|
||||||
|
Entries in this configuration are unrendered.
|
||||||
|
"""
|
||||||
|
base = get_template("base.yml")
|
||||||
|
|
||||||
|
# Load base values from plugins
|
||||||
|
for plugin in plugins.iter_enabled(config):
|
||||||
|
# Add new config key/values
|
||||||
|
for key, value in plugin.config_add.items():
|
||||||
|
new_key = plugin.config_key(key)
|
||||||
|
base[new_key] = value
|
||||||
|
|
||||||
|
# Set existing config key/values
|
||||||
|
for key, value in plugin.config_set.items():
|
||||||
|
base[key] = value
|
||||||
|
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def get_defaults(config: Config) -> Config:
|
||||||
|
"""
|
||||||
|
Get default configuration, including from plugins.
|
||||||
|
|
||||||
|
Entries in this configuration are unrendered.
|
||||||
|
"""
|
||||||
|
defaults = get_template("defaults.yml")
|
||||||
|
|
||||||
|
for plugin in plugins.iter_enabled(config):
|
||||||
|
# Create new defaults
|
||||||
|
for key, value in plugin.config_defaults.items():
|
||||||
|
defaults[plugin.config_key(key)] = value
|
||||||
|
|
||||||
|
update_with_env(defaults)
|
||||||
|
return defaults
|
||||||
|
|
||||||
|
|
||||||
|
def get_template(filename: str) -> Config:
|
||||||
|
"""
|
||||||
|
Get one of the configuration templates.
|
||||||
|
|
||||||
|
Entries in this configuration are unrendered.
|
||||||
|
"""
|
||||||
|
config = serialize.load(env.read_template_file("config", filename))
|
||||||
return cast_config(config)
|
return cast_config(config)
|
||||||
|
|
||||||
|
|
||||||
def load_config_file(path: str) -> Config:
|
def get_yaml_file(path: str) -> Config:
|
||||||
|
"""
|
||||||
|
Load config from yaml file.
|
||||||
|
"""
|
||||||
with open(path) as f:
|
with open(path) as f:
|
||||||
config = serialize.load(f.read())
|
config = serialize.load(f.read())
|
||||||
return cast_config(config)
|
return cast_config(config)
|
||||||
|
|
||||||
|
|
||||||
def load_current(root: str, defaults: Config) -> Config:
|
def merge(config: Config, base: Config) -> None:
|
||||||
"""
|
"""
|
||||||
Load the configuration currently stored on disk.
|
Merge base values with user configuration. Values are only added if not
|
||||||
Note: this modifies the defaults with the plugin default values.
|
already present.
|
||||||
|
|
||||||
|
Note that this function does not perform the rendering step of the
|
||||||
|
configuration entries.
|
||||||
"""
|
"""
|
||||||
convert_json2yml(root)
|
for key, value in base.items():
|
||||||
config = load_user(root)
|
|
||||||
load_env(config, defaults)
|
|
||||||
load_required(config, defaults)
|
|
||||||
load_plugins(config, defaults)
|
|
||||||
return config
|
|
||||||
|
|
||||||
|
|
||||||
def load_user(root: str) -> Config:
|
|
||||||
path = config_path(root)
|
|
||||||
if not os.path.exists(path):
|
|
||||||
return {}
|
|
||||||
|
|
||||||
config = load_config_file(path)
|
|
||||||
upgrade_obsolete(config)
|
|
||||||
return config
|
|
||||||
|
|
||||||
|
|
||||||
def load_env(config: Config, defaults: Config) -> None:
|
|
||||||
for k in defaults.keys():
|
|
||||||
env_var = "TUTOR_" + k
|
|
||||||
if env_var in os.environ:
|
|
||||||
config[k] = serialize.parse(os.environ[env_var])
|
|
||||||
|
|
||||||
|
|
||||||
def load_required(config: Config, defaults: Config) -> None:
|
|
||||||
"""
|
|
||||||
All these keys must be present in the user's config.yml. This includes all values
|
|
||||||
that are generated once and must be kept after that, such as passwords.
|
|
||||||
"""
|
|
||||||
for key in [
|
|
||||||
"OPENEDX_SECRET_KEY",
|
|
||||||
"MYSQL_ROOT_PASSWORD",
|
|
||||||
"OPENEDX_MYSQL_PASSWORD",
|
|
||||||
"ID",
|
|
||||||
"JWT_RSA_PRIVATE_KEY",
|
|
||||||
]:
|
|
||||||
if key not in config:
|
if key not in config:
|
||||||
config[key] = env.render_unknown(config, defaults[key])
|
config[key] = value
|
||||||
|
|
||||||
|
|
||||||
def load_plugins(config: Config, defaults: Config) -> None:
|
def render_full(config: Config) -> None:
|
||||||
"""
|
"""
|
||||||
Add, override and set new defaults from plugins.
|
Fill and render an existing configuration with defaults.
|
||||||
|
|
||||||
|
It is generally necessary to apply this function before rendering templates,
|
||||||
|
otherwise configuration entries may not be rendered.
|
||||||
"""
|
"""
|
||||||
for plugin in plugins.iter_enabled(config):
|
for key, value in config.items():
|
||||||
# Add new config key/values
|
config[key] = env.render_unknown(config, value)
|
||||||
for key, value in plugin.config_add.items():
|
|
||||||
new_key = plugin.config_key(key)
|
|
||||||
if new_key not in config:
|
|
||||||
config[new_key] = env.render_unknown(config, value)
|
|
||||||
|
|
||||||
# Create new defaults
|
|
||||||
for key, value in plugin.config_defaults.items():
|
|
||||||
defaults[plugin.config_key(key)] = value
|
|
||||||
|
|
||||||
# Set existing config key/values: here, we do not override existing values
|
|
||||||
# This must come last, as overridden values may depend on plugin defaults
|
|
||||||
for key, value in plugin.config_set.items():
|
|
||||||
if key not in config:
|
|
||||||
config[key] = env.render_unknown(config, value)
|
|
||||||
|
|
||||||
|
|
||||||
def is_service_activated(config: Config, service: str) -> bool:
|
def is_service_activated(config: Config, service: str) -> bool:
|
||||||
@ -194,7 +238,7 @@ def convert_json2yml(root: str) -> None:
|
|||||||
root
|
root
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
config = load_config_file(json_path)
|
config = get_yaml_file(json_path)
|
||||||
save_config_file(root, config)
|
save_config_file(root, config)
|
||||||
os.remove(json_path)
|
os.remove(json_path)
|
||||||
fmt.echo_info(
|
fmt.echo_info(
|
||||||
@ -210,18 +254,5 @@ def save_config_file(root: str, config: Config) -> None:
|
|||||||
fmt.echo_info("Configuration saved to {}".format(path))
|
fmt.echo_info("Configuration saved to {}".format(path))
|
||||||
|
|
||||||
|
|
||||||
def check_existing_config(root: str) -> None:
|
|
||||||
"""
|
|
||||||
Check there is a configuration on disk and the current environment is up-to-date.
|
|
||||||
"""
|
|
||||||
if not os.path.exists(config_path(root)):
|
|
||||||
raise exceptions.TutorError(
|
|
||||||
"Project root does not exist. Make sure to generate the initial "
|
|
||||||
"configuration with `tutor config save --interactive` or `tutor local "
|
|
||||||
"quickstart` prior to running other commands."
|
|
||||||
)
|
|
||||||
env.check_is_up_to_date(root)
|
|
||||||
|
|
||||||
|
|
||||||
def config_path(root: str) -> str:
|
def config_path(root: str) -> str:
|
||||||
return os.path.join(root, "config.yml")
|
return os.path.join(root, "config.yml")
|
||||||
|
@ -1,34 +1,24 @@
|
|||||||
from typing import List, Tuple
|
from typing import List
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
from . import config as tutor_config
|
from . import config as tutor_config
|
||||||
from . import env, exceptions, fmt
|
from . import env, exceptions, fmt
|
||||||
from .__about__ import __version__
|
|
||||||
from .types import Config, get_typed
|
from .types import Config, get_typed
|
||||||
|
|
||||||
|
|
||||||
def update(root: str, interactive: bool = True) -> Config:
|
def load_user_config(root: str, interactive: bool = True) -> Config:
|
||||||
"""
|
|
||||||
Load and save the configuration.
|
|
||||||
"""
|
|
||||||
config, defaults = load_all(root, interactive=interactive)
|
|
||||||
tutor_config.save_config_file(root, config)
|
|
||||||
tutor_config.merge(config, defaults)
|
|
||||||
return config
|
|
||||||
|
|
||||||
|
|
||||||
def load_all(root: str, interactive: bool = True) -> Tuple[Config, Config]:
|
|
||||||
"""
|
"""
|
||||||
Load configuration and interactively ask questions to collect param values from the user.
|
Load configuration and interactively ask questions to collect param values from the user.
|
||||||
"""
|
"""
|
||||||
config, defaults = tutor_config.load_all(root)
|
config = tutor_config.load_minimal(root)
|
||||||
if interactive:
|
if interactive:
|
||||||
ask_questions(config, defaults)
|
ask_questions(config)
|
||||||
return config, defaults
|
return config
|
||||||
|
|
||||||
|
|
||||||
def ask_questions(config: Config, defaults: Config) -> None:
|
def ask_questions(config: Config) -> None:
|
||||||
|
defaults = tutor_config.get_defaults(config)
|
||||||
run_for_prod = config.get("LMS_HOST") != "local.overhang.io"
|
run_for_prod = config.get("LMS_HOST") != "local.overhang.io"
|
||||||
run_for_prod = click.confirm(
|
run_for_prod = click.confirm(
|
||||||
fmt.question(
|
fmt.question(
|
||||||
|
6
tutor/templates/config/base.yml
Normal file
6
tutor/templates/config/base.yml
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
ID: "{{ 24|random_string }}"
|
||||||
|
JWT_RSA_PRIVATE_KEY: "{{ 2048|rsa_private_key }}"
|
||||||
|
MYSQL_ROOT_PASSWORD: "{{ 8|random_string }}"
|
||||||
|
OPENEDX_MYSQL_PASSWORD: "{{ 8|random_string }}"
|
||||||
|
OPENEDX_SECRET_KEY: "{{ 24|random_string }}"
|
@ -1,27 +1,10 @@
|
|||||||
---
|
---
|
||||||
# These configuration values must be stored in the user's config.yml.
|
# This file includes all Tutor setting defaults. Settings that do not have a
|
||||||
MYSQL_ROOT_PASSWORD: "{{ 8|random_string }}"
|
# default value, such as passwords, should be stored in base.yml.
|
||||||
OPENEDX_MYSQL_PASSWORD: "{{ 8|random_string }}"
|
|
||||||
OPENEDX_SECRET_KEY: "{{ 24|random_string }}"
|
|
||||||
ID: "{{ 24|random_string }}"
|
|
||||||
|
|
||||||
# This must be defined early
|
# This must be defined early
|
||||||
LMS_HOST: "www.myopenedx.com"
|
|
||||||
|
|
||||||
# The following are default values
|
|
||||||
RUN_LMS: true
|
|
||||||
RUN_CMS: true
|
|
||||||
RUN_ELASTICSEARCH: true
|
|
||||||
RUN_MONGODB: true
|
|
||||||
RUN_MYSQL: true
|
|
||||||
RUN_REDIS: true
|
|
||||||
RUN_SMTP: true
|
|
||||||
CADDY_HTTP_PORT: 80
|
CADDY_HTTP_PORT: 80
|
||||||
CMS_HOST: "studio.{{ LMS_HOST }}"
|
CMS_HOST: "studio.{{ LMS_HOST }}"
|
||||||
PREVIEW_LMS_HOST: "preview.{{ LMS_HOST }}"
|
|
||||||
CONTACT_EMAIL: "contact@{{ LMS_HOST }}"
|
CONTACT_EMAIL: "contact@{{ LMS_HOST }}"
|
||||||
OPENEDX_AWS_ACCESS_KEY: ""
|
|
||||||
OPENEDX_AWS_SECRET_ACCESS_KEY: ""
|
|
||||||
DEV_PROJECT_NAME: "{{ TUTOR_APP }}_dev"
|
DEV_PROJECT_NAME: "{{ TUTOR_APP }}_dev"
|
||||||
DOCKER_REGISTRY: "docker.io/"
|
DOCKER_REGISTRY: "docker.io/"
|
||||||
DOCKER_IMAGE_OPENEDX: "{{ DOCKER_REGISTRY }}overhangio/openedx:{{ TUTOR_VERSION }}"
|
DOCKER_IMAGE_OPENEDX: "{{ DOCKER_REGISTRY }}overhangio/openedx:{{ TUTOR_VERSION }}"
|
||||||
@ -35,7 +18,6 @@ DOCKER_IMAGE_NGINX: "{{ DOCKER_REGISTRY }}nginx:1.21.1"
|
|||||||
DOCKER_IMAGE_PERMISSIONS: "{{ DOCKER_REGISTRY }}overhangio/openedx-permissions:{{ TUTOR_VERSION }}"
|
DOCKER_IMAGE_PERMISSIONS: "{{ DOCKER_REGISTRY }}overhangio/openedx-permissions:{{ TUTOR_VERSION }}"
|
||||||
DOCKER_IMAGE_REDIS: "{{ DOCKER_REGISTRY }}redis:6.2.6"
|
DOCKER_IMAGE_REDIS: "{{ DOCKER_REGISTRY }}redis:6.2.6"
|
||||||
DOCKER_IMAGE_SMTP: "{{ DOCKER_REGISTRY }}devture/exim-relay:4.94.2-r0-4"
|
DOCKER_IMAGE_SMTP: "{{ DOCKER_REGISTRY }}devture/exim-relay:4.94.2-r0-4"
|
||||||
LOCAL_PROJECT_NAME: "{{ TUTOR_APP }}_local"
|
|
||||||
ELASTICSEARCH_HOST: "elasticsearch"
|
ELASTICSEARCH_HOST: "elasticsearch"
|
||||||
ELASTICSEARCH_PORT: 9200
|
ELASTICSEARCH_PORT: 9200
|
||||||
ELASTICSEARCH_SCHEME: "http"
|
ELASTICSEARCH_SCHEME: "http"
|
||||||
@ -45,14 +27,17 @@ ENABLE_WEB_PROXY: true
|
|||||||
JWT_COMMON_AUDIENCE: "openedx"
|
JWT_COMMON_AUDIENCE: "openedx"
|
||||||
JWT_COMMON_ISSUER: "{% if ENABLE_HTTPS %}https{% else %}http{% endif %}://{{ LMS_HOST }}/oauth2"
|
JWT_COMMON_ISSUER: "{% if ENABLE_HTTPS %}https{% else %}http{% endif %}://{{ LMS_HOST }}/oauth2"
|
||||||
JWT_COMMON_SECRET_KEY: "{{ OPENEDX_SECRET_KEY }}"
|
JWT_COMMON_SECRET_KEY: "{{ OPENEDX_SECRET_KEY }}"
|
||||||
JWT_RSA_PRIVATE_KEY: "{{ 2048|rsa_private_key }}"
|
|
||||||
K8S_NAMESPACE: "openedx"
|
K8S_NAMESPACE: "openedx"
|
||||||
LANGUAGE_CODE: "en"
|
LANGUAGE_CODE: "en"
|
||||||
|
LMS_HOST: "www.myopenedx.com"
|
||||||
|
LOCAL_PROJECT_NAME: "{{ TUTOR_APP }}_local"
|
||||||
MONGODB_HOST: "mongodb"
|
MONGODB_HOST: "mongodb"
|
||||||
MONGODB_DATABASE: "openedx"
|
MONGODB_DATABASE: "openedx"
|
||||||
MONGODB_PORT: 27017
|
MONGODB_PORT: 27017
|
||||||
MONGODB_USERNAME: ""
|
MONGODB_USERNAME: ""
|
||||||
MONGODB_PASSWORD: ""
|
MONGODB_PASSWORD: ""
|
||||||
|
OPENEDX_AWS_ACCESS_KEY: ""
|
||||||
|
OPENEDX_AWS_SECRET_ACCESS_KEY: ""
|
||||||
OPENEDX_CACHE_REDIS_DB: 1
|
OPENEDX_CACHE_REDIS_DB: 1
|
||||||
OPENEDX_CELERY_REDIS_DB: 0
|
OPENEDX_CELERY_REDIS_DB: 0
|
||||||
OPENEDX_CMS_UWSGI_WORKERS: 2
|
OPENEDX_CMS_UWSGI_WORKERS: 2
|
||||||
@ -73,6 +58,13 @@ REDIS_HOST: "redis"
|
|||||||
REDIS_PORT: 6379
|
REDIS_PORT: 6379
|
||||||
REDIS_USERNAME: ""
|
REDIS_USERNAME: ""
|
||||||
REDIS_PASSWORD: ""
|
REDIS_PASSWORD: ""
|
||||||
|
RUN_CMS: true
|
||||||
|
RUN_ELASTICSEARCH: true
|
||||||
|
RUN_LMS: true
|
||||||
|
RUN_MONGODB: true
|
||||||
|
RUN_MYSQL: true
|
||||||
|
RUN_REDIS: true
|
||||||
|
RUN_SMTP: true
|
||||||
SMTP_HOST: "smtp"
|
SMTP_HOST: "smtp"
|
||||||
SMTP_PORT: 8025
|
SMTP_PORT: 8025
|
||||||
SMTP_USERNAME: ""
|
SMTP_USERNAME: ""
|
Loading…
x
Reference in New Issue
Block a user