diff --git a/.github/workflows/unit-test.yaml b/.github/workflows/unit-test.yaml index 3b5f857..154bd1c 100644 --- a/.github/workflows/unit-test.yaml +++ b/.github/workflows/unit-test.yaml @@ -53,12 +53,13 @@ jobs: with: python-version: "${{ matrix.python_version }}" - - name: Install venv, git and setup venv + - name: Install venv, git, pip and setup venv run: | export DEBIAN_FRONTEND=noninteractive apt-get update apt-get install --yes \ python3-venv \ + python3-pip \ bzip2 \ git diff --git a/integration-tests/Dockerfile b/integration-tests/Dockerfile index c1c73d8..eb116eb 100644 --- a/integration-tests/Dockerfile +++ b/integration-tests/Dockerfile @@ -1,5 +1,5 @@ # Systemd inside a Docker container, for CI only -ARG BASE_IMAGE=ubuntu:20.04 +ARG BASE_IMAGE=ubuntu:22.04 FROM $BASE_IMAGE # DEBIAN_FRONTEND is set to avoid being asked for input and hang during build: @@ -29,8 +29,8 @@ RUN systemctl set-default multi-user.target STOPSIGNAL SIGRTMIN+3 # Uncomment these lines for a development install -#ENV TLJH_BOOTSTRAP_DEV=yes -#ENV TLJH_BOOTSTRAP_PIP_SPEC=/srv/src -#ENV PATH=/opt/tljh/hub/bin:${PATH} +# ENV TLJH_BOOTSTRAP_DEV=yes +# ENV TLJH_BOOTSTRAP_PIP_SPEC=/srv/src +# ENV PATH=/opt/tljh/hub/bin:${PATH} CMD ["/bin/bash", "-c", "exec /lib/systemd/systemd --log-target=journal 3>&1"] diff --git a/tljh/config.py b/tljh/config.py index d308e9e..633cc36 100644 --- a/tljh/config.py +++ b/tljh/config.py @@ -154,6 +154,26 @@ def remove_item_from_config(config, property_path, value): 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 @@ -167,30 +187,29 @@ def show_config(config_path): yaml.dump(config, sys.stdout) -def set_config_value(config_path, key_path, value): +def set_config_value(config_path, key_path, value, validate=True): """ Set key at key_path in config_path to value """ # FIXME: Have a file lock here - # FIXME: Validate schema here try: with open(config_path) as f: config = yaml.load(f) except FileNotFoundError: config = {} - 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): +def unset_config_value(config_path, key_path, validate=True): """ Unset key at key_path in config_path """ # FIXME: Have a file lock here - # FIXME: Validate schema here try: with open(config_path) as f: config = yaml.load(f) @@ -198,17 +217,17 @@ def unset_config_value(config_path, key_path): config = {} 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): +def add_config_value(config_path, key_path, value, validate=True): """ Add value to list at key_path """ # FIXME: Have a file lock here - # FIXME: Validate schema here try: with open(config_path) as f: config = yaml.load(f) @@ -216,17 +235,17 @@ def add_config_value(config_path, key_path, value): config = {} 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): +def remove_config_value(config_path, key_path, value, validate=True): """ Remove value from list at key_path """ # FIXME: Have a file lock here - # FIXME: Validate schema here try: with open(config_path) as f: config = yaml.load(f) @@ -234,6 +253,7 @@ def remove_config_value(config_path, key_path, value): config = {} config = remove_item_from_config(config, key_path, value) + validate_config(config, validate) with open(config_path, "w") as f: yaml.dump(config, f) @@ -336,6 +356,18 @@ def main(argv=None): 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") @@ -383,13 +415,19 @@ def main(argv=None): 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)) + 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) + 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)) + 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)) + remove_config_value( + args.config_path, args.key_path, parse_value(args.value), args.validate + ) elif args.action == "reload": reload_component(args.component) else: diff --git a/tljh/config_schema.py b/tljh/config_schema.py new file mode 100644 index 0000000..0b12c8f --- /dev/null +++ b/tljh/config_schema.py @@ -0,0 +1,117 @@ +""" +The schema against which the TLJH config file can be validated. + +Validation occurs when changing values with tljh-config. +""" + +config_schema = { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Littlest JupyterHub YAML config file", + "definitions": { + "BaseURL": { + "type": "string", + }, + "Users": { + "type": "object", + "additionalProperties": False, + "properties": { + "extra_user_groups": {"type": "object", "items": {"type": "string"}}, + "allowed": {"type": "array", "items": {"type": "string"}}, + "banned": {"type": "array", "items": {"type": "string"}}, + "admin": {"type": "array", "items": {"type": "string"}}, + }, + }, + "Services": { + "type": "object", + "properties": { + "cull": { + "type": "object", + "additionalProperties": False, + "properties": { + "enabled": {"type": "boolean"}, + "timeout": {"type": "integer"}, + "every": {"type": "integer"}, + "concurrency": {"type": "integer"}, + "users": {"type": "boolean"}, + "max_age": {"type": "integer"}, + "remove_named_servers": {"type": "boolean"}, + }, + } + }, + }, + "HTTP": { + "type": "object", + "additionalProperties": False, + "properties": { + "address": {"type": "string", "format": "ipv4"}, + "port": {"type": "integer"}, + }, + }, + "HTTPS": { + "type": "object", + "additionalProperties": False, + "properties": { + "enabled": {"type": "boolean"}, + "address": {"type": "string", "format": "ipv4"}, + "port": {"type": "integer"}, + "tls": {"$ref": "#/definitions/TLS"}, + "letsencrypt": {"$ref": "#/definitions/LetsEncrypt"}, + }, + }, + "LetsEncrypt": { + "type": "object", + "additionalProperties": False, + "properties": { + "email": {"type": "string", "format": "email"}, + "domains": { + "type": "array", + "items": {"type": "string", "format": "hostname"}, + }, + "staging": {"type": "boolean"}, + }, + }, + "TLS": { + "type": "object", + "additionalProperties": False, + "properties": {"key": {"type": "string"}, "cert": {"type": "string"}}, + }, + "Limits": { + "description": "User CPU and memory limits.", + "type": "object", + "additionalProperties": False, + "properties": {"memory": {"type": "string"}, "cpu": {"type": "integer"}}, + }, + "UserEnvironment": { + "type": "object", + "additionalProperties": False, + "properties": { + "default_app": { + "type": "string", + "enum": ["jupyterlab", "classic"], + "default": "jupyterlab", + } + }, + }, + "TraefikAPI": { + "type": "object", + "additionalProperties": False, + "properties": { + "ip": {"type": "string", "format": "ipv4"}, + "port": {"type": "integer"}, + "username": {"type": "string"}, + "password": {"type": "string"}, + }, + }, + }, + "properties": { + "additionalProperties": False, + "base_url": {"$ref": "#/definitions/BaseURL"}, + "user_environment": {"$ref": "#/definitions/UserEnvironment"}, + "users": {"$ref": "#/definitions/Users"}, + "limits": {"$ref": "#/definitions/Limits"}, + "https": {"$ref": "#/definitions/HTTPS"}, + "http": {"$ref": "#/definitions/HTTP"}, + "traefik_api": {"$ref": "#/definitions/TraefikAPI"}, + "services": {"$ref": "#/definitions/Services"}, + }, +}