""" Commandline interface for setting config items in config.yaml. Used as: tljh-config set firstlevel.second_level something tljh-config show tljh-config show firstlevel tljh-config show firstlevel.second_level """ import argparse import os import re import sys import time from collections.abc import Mapping, Sequence from copy import deepcopy import requests from .yaml import yaml INSTALL_PREFIX = os.environ.get("TLJH_INSTALL_PREFIX", "/opt/tljh") HUB_ENV_PREFIX = os.path.join(INSTALL_PREFIX, "hub") USER_ENV_PREFIX = os.path.join(INSTALL_PREFIX, "user") STATE_DIR = os.path.join(INSTALL_PREFIX, "state") CONFIG_DIR = os.path.join(INSTALL_PREFIX, "config") CONFIG_FILE = os.path.join(CONFIG_DIR, "config.yaml") def set_item_in_config(config, property_path, value): """ Set key at property_path to value in config & return new config. config is not mutated. property_path is a series of dot separated values. Any part of the path that does not exist is created. """ path_components = property_path.split(".") # Mutate a copy of the config, not config itself cur_part = config_copy = deepcopy(config) for i, cur_path in enumerate(path_components): cur_path = path_components[i] if i == len(path_components) - 1: # Final component cur_part[cur_path] = value else: # If we are asked to create new non-leaf nodes, we will always make them dicts # This means setting is *destructive* - will replace whatever is down there! if cur_path not in cur_part or not _is_dict(cur_part[cur_path]): cur_part[cur_path] = {} cur_part = cur_part[cur_path] return config_copy def unset_item_from_config(config, property_path): """ Unset key at property_path in config & return new config. config is not mutated. property_path is a series of dot separated values. """ path_components = property_path.split(".") # Mutate a copy of the config, not config itself cur_part = config_copy = deepcopy(config) def remove_empty_configs(configuration, path): """ Delete the keys that hold an empty dict. This might happen when we delete a config property that has no siblings from a multi-level config. """ if not path: return configuration conf_iter = configuration for cur_path in path: if conf_iter[cur_path] == {}: del conf_iter[cur_path] remove_empty_configs(configuration, path[:-1]) else: conf_iter = conf_iter[cur_path] for i, cur_path in enumerate(path_components): if i == len(path_components) - 1: if cur_path not in cur_part: raise ValueError(f"{property_path} does not exist in config!") del cur_part[cur_path] remove_empty_configs(config_copy, path_components[:-1]) break else: if cur_path not in cur_part: raise ValueError(f"{property_path} does not exist in config!") cur_part = cur_part[cur_path] return config_copy def add_item_to_config(config, property_path, value): """ Add an item to a list in config. """ path_components = property_path.split(".") # Mutate a copy of the config, not config itself cur_part = config_copy = deepcopy(config) for i, cur_path in enumerate(path_components): if i == len(path_components) - 1: # Final component, it must be a list and we append to it if cur_path not in cur_part or not _is_list(cur_part[cur_path]): cur_part[cur_path] = [] cur_part = cur_part[cur_path] cur_part.append(value) else: # If we are asked to create new non-leaf nodes, we will always make them dicts # This means setting is *destructive* - will replace whatever is down there! if cur_path not in cur_part or not _is_dict(cur_part[cur_path]): cur_part[cur_path] = {} cur_part = cur_part[cur_path] return config_copy def remove_item_from_config(config, property_path, value): """ Remove an item from a list in config. """ path_components = property_path.split(".") # Mutate a copy of the config, not config itself cur_part = config_copy = deepcopy(config) for i, cur_path in enumerate(path_components): if i == len(path_components) - 1: # Final component, it must be a list and we delete from it if cur_path not in cur_part or not _is_list(cur_part[cur_path]): raise ValueError(f"{property_path} is not a list") cur_part = cur_part[cur_path] cur_part.remove(value) else: if cur_path not in cur_part or not _is_dict(cur_part[cur_path]): raise ValueError(f"{property_path} does not exist in config!") cur_part = cur_part[cur_path] return config_copy def validate_config(config, validate): """ Validate changes to the config with tljh-config against the schema """ import jsonschema from .config_schema import config_schema try: jsonschema.validate(instance=config, schema=config_schema) except jsonschema.exceptions.ValidationError as e: if validate: print( f"Config validation error: {e.message}.\n" "You can still apply this change without validation by re-running your command with the --no-validate flag.\n" "If you think this validation error is incorrect, please report it to https://github.com/jupyterhub/the-littlest-jupyterhub/issues." ) exit() def show_config(config_path): """ Pretty print config from given config_path """ config = get_current_config(config_path) yaml.dump(config, sys.stdout) def set_config_value(config_path, key_path, value, validate=True): """ Set key at key_path in config_path to value """ from filelock import FileLock, Timeout lock_file = f"{config_path}.lock" lock = FileLock(lock_file) try: with lock.acquire(timeout=1): config = get_current_config(config_path) config = set_item_in_config(config, key_path, value) validate_config(config, validate) with open(config_path, "w") as f: yaml.dump(config, f) except Timeout: print(f"Another instance of tljh-config holds the lock {lock_file}") exit(1) def unset_config_value(config_path, key_path, validate=True): """ Unset key at key_path in config_path """ from filelock import FileLock, Timeout lock_file = f"{config_path}.lock" lock = FileLock(lock_file) try: with lock.acquire(timeout=1): config = get_current_config(config_path) config = unset_item_from_config(config, key_path) validate_config(config, validate) with open(config_path, "w") as f: yaml.dump(config, f) except Timeout: print(f"Another instance of tljh-config holds the lock {lock_file}") exit(1) def add_config_value(config_path, key_path, value, validate=True): """ Add value to list at key_path """ from filelock import FileLock, Timeout lock_file = f"{config_path}.lock" lock = FileLock(lock_file) try: with lock.acquire(timeout=1): config = get_current_config(config_path) config = add_item_to_config(config, key_path, value) validate_config(config, validate) with open(config_path, "w") as f: yaml.dump(config, f) except Timeout: print(f"Another instance of tljh-config holds the lock {lock_file}") exit(1) def remove_config_value(config_path, key_path, value, validate=True): """ Remove value from list at key_path """ from filelock import FileLock, Timeout lock_file = f"{config_path}.lock" lock = FileLock(lock_file) try: with lock.acquire(timeout=1): config = get_current_config(config_path) config = remove_item_from_config(config, key_path, value) validate_config(config, validate) with open(config_path, "w") as f: yaml.dump(config, f) except Timeout: print(f"Another instance of tljh-config holds the lock {lock_file}") exit(1) def get_current_config(config_path): """ Retrieve the current config at config_path """ try: with open(config_path) as f: return yaml.load(f) except FileNotFoundError: return {} def check_hub_ready(): """ Checks that hub is running. """ from .configurer import load_config base_url = load_config()["base_url"] base_url = base_url[:-1] if base_url[-1] == "/" else base_url http_address = load_config()["http"]["address"] http_port = load_config()["http"]["port"] # The default config is an empty address, so it binds on all interfaces. # Test the connectivity on the local address. if http_address == "": http_address = "127.0.0.1" try: r = requests.get( "http://%s:%d%s/hub/api" % (http_address, http_port, base_url), verify=False ) if r.status_code != 200: print(f"Hub not ready: (HTTP status {r.status_code})") return r.status_code == 200 except Exception as e: print(f"Hub not ready: {e}") return False def reload_component(component): """ Reload a TLJH component. component can be 'hub' or 'proxy'. """ # import here to avoid circular imports from tljh import systemd, traefik if component == "hub": systemd.restart_service("jupyterhub") # Ensure hub is back up while not systemd.check_service_active("jupyterhub"): time.sleep(1) while not check_hub_ready(): time.sleep(1) print("Hub reload with new configuration complete") elif component == "proxy": traefik.ensure_traefik_config(STATE_DIR) systemd.restart_service("traefik") while not systemd.check_service_active("traefik"): time.sleep(1) print("Proxy reload with new configuration complete") def parse_value(value_str): """Parse a value string""" if value_str is None: return value_str if re.match(r"^\d+$", value_str): return int(value_str) elif re.match(r"^\d+\.\d*$", value_str): return float(value_str) elif value_str.lower() == "true": return True elif value_str.lower() == "false": return False else: # it's a string return value_str def _is_dict(item): return isinstance(item, Mapping) def _is_list(item): return isinstance(item, Sequence) def main(argv=None): if os.geteuid() != 0: print("tljh-config needs root privileges to run", file=sys.stderr) print( "Try using sudo before the tljh-config command you wanted to run", file=sys.stderr, ) sys.exit(1) if argv is None: argv = sys.argv[1:] from .log import init_logging try: init_logging() except Exception as e: print(str(e)) print("Perhaps you didn't use `sudo -E`?") argparser = argparse.ArgumentParser() argparser.add_argument( "--config-path", default=CONFIG_FILE, help="Path to TLJH config.yaml file" ) argparser.add_argument( "--validate", action="store_true", help="Validate the TLJH config" ) argparser.add_argument( "--no-validate", dest="validate", action="store_false", help="Do not validate the TLJH config", ) argparser.set_defaults(validate=True) subparsers = argparser.add_subparsers(dest="action") show_parser = subparsers.add_parser("show", help="Show current configuration") unset_parser = subparsers.add_parser("unset", help="Unset a configuration property") unset_parser.add_argument( "key_path", help="Dot separated path to configuration key to unset" ) set_parser = subparsers.add_parser("set", help="Set a configuration property") set_parser.add_argument( "key_path", help="Dot separated path to configuration key to set" ) set_parser.add_argument("value", help="Value to set the configuration key to") add_item_parser = subparsers.add_parser( "add-item", help="Add a value to a list for a configuration property" ) add_item_parser.add_argument( "key_path", help="Dot separated path to configuration key to add value to" ) add_item_parser.add_argument("value", help="Value to add to the configuration key") remove_item_parser = subparsers.add_parser( "remove-item", help="Remove a value from a list for a configuration property" ) remove_item_parser.add_argument( "key_path", help="Dot separated path to configuration key to remove value from" ) remove_item_parser.add_argument("value", help="Value to remove from key_path") reload_parser = subparsers.add_parser( "reload", help="Reload a component to apply configuration change" ) reload_parser.add_argument( "component", choices=("hub", "proxy"), help="Which component to reload", default="hub", nargs="?", ) args = argparser.parse_args(argv) if args.action == "show": show_config(args.config_path) elif args.action == "set": set_config_value( args.config_path, args.key_path, parse_value(args.value), args.validate ) elif args.action == "unset": unset_config_value(args.config_path, args.key_path, args.validate) elif args.action == "add-item": add_config_value( args.config_path, args.key_path, parse_value(args.value), args.validate ) elif args.action == "remove-item": remove_config_value( args.config_path, args.key_path, parse_value(args.value), args.validate ) elif args.action == "reload": reload_component(args.component) else: argparser.print_help() if __name__ == "__main__": main()