Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Validate tljh specific config #962

Merged
merged 27 commits into from
Apr 4, 2024
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
d292457
Initial config setup
jrdnbradford Feb 3, 2024
78d4b7f
Test config setup
jrdnbradford Feb 3, 2024
fb01dea
Working schema path
jrdnbradford Feb 3, 2024
ef5c6c5
`print` human readable error message
jrdnbradford Feb 3, 2024
929536d
Run `pre-commit` hooks
jrdnbradford Feb 3, 2024
5a0de13
Do not allow `additionalProperties` for `Users`
jrdnbradford Feb 3, 2024
166eba6
Switch from JSON to py
jrdnbradford Feb 3, 2024
fa36365
Update schema based on values in `configurer.py`
jrdnbradford Feb 3, 2024
d0c9aa2
Remove `TLS` required properties
jrdnbradford Feb 3, 2024
4912cff
Fix `parse_value`
jrdnbradford Feb 3, 2024
9060267
Add `LetsEncrypt.staging`; run `pre-commit`
jrdnbradford Feb 3, 2024
1f7d6d1
Add doc string for `validate_config`
jrdnbradford Feb 5, 2024
48fe440
Add `--[no-]validation` flag for `tljh-config`
jrdnbradford Mar 20, 2024
743f729
Update `set_config_value` tests
jrdnbradford Mar 20, 2024
46e4045
Update `test_proxy` tests
jrdnbradford Mar 20, 2024
f921acc
Bump `ubuntu` image to `22.04`
jrdnbradford Mar 20, 2024
4ddd798
Add docstring from `config_schema.py`
jrdnbradford Mar 27, 2024
196208a
Remove `argparse.BooleanOptionalAction` for Python 3.8 compatibility
jrdnbradford Apr 3, 2024
51f8470
Fix unit tests with `pip`
jrdnbradford Apr 3, 2024
c578a7b
Update `step` `name`
jrdnbradford Apr 3, 2024
67dd3c8
Update tljh/config.py
jrdnbradford Apr 3, 2024
b94a281
Update tljh/config.py
jrdnbradford Apr 3, 2024
5ae31ce
Update tljh/config.py
jrdnbradford Apr 3, 2024
7474b87
Remove unneeded `validate` code
jrdnbradford Apr 3, 2024
9bcfa70
Update tljh/config.py
jrdnbradford Apr 3, 2024
38a01e8
Use default `true` for `validate`
jrdnbradford Apr 3, 2024
5469e21
Fix removal of `https.enabled`
jrdnbradford Apr 3, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/unit-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
jrdnbradford marked this conversation as resolved.
Show resolved Hide resolved
bzip2 \
git

Expand Down
8 changes: 4 additions & 4 deletions integration-tests/Dockerfile
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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"]
8 changes: 4 additions & 4 deletions integration-tests/test_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,9 @@ def test_manual_https(preserve_config):
"/CN=tljh.jupyer.org",
]
)
set_config_value(CONFIG_FILE, "https.enabled", True)
set_config_value(CONFIG_FILE, "https.tls.key", key)
set_config_value(CONFIG_FILE, "https.tls.cert", cert)
set_config_value(CONFIG_FILE, "https.enabled", True, True)
set_config_value(CONFIG_FILE, "https.tls.key", key, True)
set_config_value(CONFIG_FILE, "https.tls.cert", cert, True)
jrdnbradford marked this conversation as resolved.
Show resolved Hide resolved
reload_component("proxy")
for i in range(10):
time.sleep(i)
Expand All @@ -89,7 +89,7 @@ def test_manual_https(preserve_config):

# cleanup
shutil.rmtree(ssl_dir)
set_config_value(CONFIG_FILE, "https.enabled", False)
set_config_value(CONFIG_FILE, "https.enabled", False, True)

reload_component("proxy")

Expand Down
30 changes: 19 additions & 11 deletions tests/test_traefik.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,12 @@ def test_default_config(tmpdir, tljh_dir):

def test_letsencrypt_config(tljh_dir):
state_dir = config.STATE_DIR
config.set_config_value(config.CONFIG_FILE, "https.enabled", True)
config.set_config_value(config.CONFIG_FILE, "https.enabled", True, True)
config.set_config_value(
config.CONFIG_FILE, "https.letsencrypt.email", "[email protected]"
config.CONFIG_FILE, "https.letsencrypt.email", "[email protected]", True
)
config.set_config_value(
config.CONFIG_FILE, "https.letsencrypt.domains", ["testing.jovyan.org"]
config.CONFIG_FILE, "https.letsencrypt.domains", ["testing.jovyan.org"], True
)
traefik.ensure_traefik_config(str(state_dir))

Expand Down Expand Up @@ -138,9 +138,13 @@ def test_letsencrypt_config(tljh_dir):

def test_manual_ssl_config(tljh_dir):
state_dir = config.STATE_DIR
config.set_config_value(config.CONFIG_FILE, "https.enabled", True)
config.set_config_value(config.CONFIG_FILE, "https.tls.key", "/path/to/ssl.key")
config.set_config_value(config.CONFIG_FILE, "https.tls.cert", "/path/to/ssl.cert")
config.set_config_value(config.CONFIG_FILE, "https.enabled", True, True)
config.set_config_value(
config.CONFIG_FILE, "https.tls.key", "/path/to/ssl.key", True
)
config.set_config_value(
config.CONFIG_FILE, "https.tls.cert", "/path/to/ssl.cert", True
)
traefik.ensure_traefik_config(str(state_dir))

cfg = _read_static_config(state_dir)
Expand Down Expand Up @@ -244,12 +248,16 @@ def test_extra_config(tmpdir, tljh_dir):

def test_listen_address(tmpdir, tljh_dir):
state_dir = config.STATE_DIR
config.set_config_value(config.CONFIG_FILE, "https.enabled", True)
config.set_config_value(config.CONFIG_FILE, "https.tls.key", "/path/to/ssl.key")
config.set_config_value(config.CONFIG_FILE, "https.tls.cert", "/path/to/ssl.cert")
config.set_config_value(config.CONFIG_FILE, "https.enabled", True, True)
config.set_config_value(
config.CONFIG_FILE, "https.tls.key", "/path/to/ssl.key", True
)
config.set_config_value(
config.CONFIG_FILE, "https.tls.cert", "/path/to/ssl.cert", True
)

config.set_config_value(config.CONFIG_FILE, "http.address", "127.0.0.1")
config.set_config_value(config.CONFIG_FILE, "https.address", "127.0.0.1")
config.set_config_value(config.CONFIG_FILE, "http.address", "127.0.0.1", True)
config.set_config_value(config.CONFIG_FILE, "https.address", "127.0.0.1", True)

traefik.ensure_traefik_config(str(state_dir))

Expand Down
67 changes: 54 additions & 13 deletions tljh/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -167,73 +187,73 @@ 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):
jrdnbradford marked this conversation as resolved.
Show resolved Hide resolved
"""
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):
jrdnbradford marked this conversation as resolved.
Show resolved Hide resolved
"""
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)
except FileNotFoundError:
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):
jrdnbradford marked this conversation as resolved.
Show resolved Hide resolved
"""
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)
except FileNotFoundError:
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):
jrdnbradford marked this conversation as resolved.
Show resolved Hide resolved
"""
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)
except FileNotFoundError:
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)
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -380,16 +412,25 @@ def main(argv=None):

args = argparser.parse_args(argv)

if args.validate == None:
args.validate = True
jrdnbradford marked this conversation as resolved.
Show resolved Hide resolved

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:
Expand Down
117 changes: 117 additions & 0 deletions tljh/config_schema.py
Original file line number Diff line number Diff line change
@@ -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"},
},
}
Loading