""" 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 contextlib import contextmanager from copy import deepcopy import requests from filelock import FileLock, Timeout 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") @contextmanager def config_file_lock(config_path, timeout=1): """Context manager to acquire the config file lock""" lock_file = f"{config_path}.lock" try: with FileLock(lock_file).acquire(timeout=timeout): yield except Timeout: print( f"Another instance of tljh-config holds the lock {lock_file}.", file=sys.stderr, ) sys.exit(1) 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.", file=sys.stderr, ) sys.exit(1) 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 """ with config_file_lock(config_path): 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) def unset_config_value(config_path, key_path, validate=True): """ Unset key at key_path in config_path """ with config_file_lock(config_path): 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) def add_config_value(config_path, key_path, value, validate=True): """ Add value to list at key_path """ with config_file_lock(config_path): 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) def remove_config_value(config_path, key_path, value, validate=True): """ Remove value from list at key_path """ with config_file_lock(config_path): 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) 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()